first commit
This commit is contained in:
@@ -0,0 +1,338 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
message,
|
||||
Row,
|
||||
Col,
|
||||
Spin,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { ThunderboltOutlined } from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {getAiList, getAiConfig, saveAiConfig} from '@/api/system/sysAi.ts';
|
||||
import type {AiLab, AiList} from '@/domain/iAi.ts';
|
||||
import type { FormColumn } from '@/components/XinFormField/FieldRender';
|
||||
import XinForm, { type XinFormRef } from '@/components/XinForm';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
interface AiResponse {
|
||||
default: string;
|
||||
providers: Record<string, Record<string, any>>;
|
||||
}
|
||||
|
||||
const AiConfig: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const formRef = useRef<XinFormRef>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [aiList, setAiList] = useState<AiList>({
|
||||
default: []
|
||||
});
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<string | null>(null);
|
||||
const [testError, setTestError] = useState<string | null>(null);
|
||||
|
||||
const dependency = (lab: AiLab): FormColumn<AiResponse>['dependency'] => ({
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === lab
|
||||
})
|
||||
|
||||
const apikeyColumn = (lab: AiLab): FormColumn<AiResponse> => ({
|
||||
dataIndex: ['providers', lab, 'key'],
|
||||
valueType: 'password',
|
||||
title: t('system.ai.provider.api_key', { lab: lab }),
|
||||
fieldProps: { placeholder: 'sk-...' },
|
||||
colProps: { span: 16 },
|
||||
dependency: dependency(lab)
|
||||
})
|
||||
|
||||
const urlColumn = (lab: AiLab, url: string = ''): FormColumn<AiResponse> => ({
|
||||
dataIndex: ['providers', lab, 'url'],
|
||||
valueType: 'text',
|
||||
title: t('system.ai.provider.url', { lab: lab }),
|
||||
fieldProps: { placeholder: t('system.ai.provider.url.placeholder', {url: url}) },
|
||||
colProps: { span: 8 },
|
||||
dependency: dependency(lab)
|
||||
})
|
||||
|
||||
const anthropicColumns: FormColumn<AiResponse>[] = [
|
||||
apikeyColumn('anthropic'),
|
||||
urlColumn('anthropic', 'https://api.anthropic.com/v1')
|
||||
];
|
||||
|
||||
const openaiColumns: FormColumn<AiResponse>[] = [
|
||||
apikeyColumn('openai'),
|
||||
urlColumn('openai', 'https://api.openai.com/v1'),
|
||||
];
|
||||
|
||||
const geminiColumns: FormColumn<AiResponse>[] = [
|
||||
apikeyColumn('gemini'),
|
||||
urlColumn('gemini', 'https://generativelanguage.googleapis.com/v1beta/')
|
||||
];
|
||||
|
||||
const ollamaColumns: FormColumn<AiResponse>[] = [
|
||||
apikeyColumn('ollama'),
|
||||
urlColumn('ollama', 'http://localhost:11434')
|
||||
];
|
||||
|
||||
const azureColumns: FormColumn<AiResponse>[] = [
|
||||
apikeyColumn('azure'),
|
||||
urlColumn('azure', 'https://<resource>.openai.azure.com'),
|
||||
{
|
||||
dataIndex: ['providers', 'azure', 'api_version'],
|
||||
valueType: 'text',
|
||||
title: t('system.ai.azure.api_version'),
|
||||
fieldProps: { placeholder: '2025-04-01-preview' },
|
||||
colProps: { span: 8 },
|
||||
dependency: dependency('azure')
|
||||
},
|
||||
{
|
||||
dataIndex: ['providers', 'azure', 'deployment'],
|
||||
valueType: 'text',
|
||||
title: t('system.ai.azure.deployment'),
|
||||
fieldProps: { placeholder: 'gpt-4o' },
|
||||
colProps: { span: 8 },
|
||||
dependency: dependency('azure')
|
||||
},
|
||||
{
|
||||
dataIndex: ['providers', 'azure', 'embedding_deployment'],
|
||||
valueType: 'text',
|
||||
title: t('system.ai.azure.embedding_deployment'),
|
||||
fieldProps: { placeholder: 'text-embedding-3-small' },
|
||||
colProps: { span: 8 },
|
||||
dependency: dependency('azure')
|
||||
},
|
||||
{
|
||||
dataIndex: ['providers', 'azure', 'image_deployment'],
|
||||
valueType: 'text',
|
||||
title: t('system.ai.azure.image_deployment'),
|
||||
fieldProps: { placeholder: 'gpt-image-1' },
|
||||
colProps: { span: 12 },
|
||||
dependency: dependency('azure')
|
||||
},
|
||||
]
|
||||
|
||||
const bedrockColumns: FormColumn<AiResponse>[] = [
|
||||
{
|
||||
dataIndex: ['providers', 'bedrock', 'region'],
|
||||
valueType: 'text',
|
||||
title: t('system.ai.bedrock.region'),
|
||||
fieldProps: { placeholder: 'us-east-1' },
|
||||
colProps: { span: 12 },
|
||||
dependency: dependency('bedrock')
|
||||
},
|
||||
apikeyColumn('bedrock'),
|
||||
{
|
||||
dataIndex: ['providers', 'bedrock', 'access_key_id'],
|
||||
valueType: 'password',
|
||||
title: t('system.ai.bedrock.access_key_id'),
|
||||
fieldProps: { placeholder: t('system.ai.provider.api_key.placeholder') },
|
||||
colProps: { span: 12 },
|
||||
dependency: dependency('bedrock')
|
||||
},
|
||||
{
|
||||
dataIndex: ['providers', 'bedrock', 'secret_access_key'],
|
||||
valueType: 'password',
|
||||
title: t('system.ai.bedrock.secret_access_key'),
|
||||
fieldProps: { placeholder: t('system.ai.provider.api_key.placeholder') },
|
||||
colProps: { span: 12 },
|
||||
dependency: dependency('bedrock')
|
||||
},
|
||||
{
|
||||
dataIndex: ['providers', 'bedrock', 'session_token'],
|
||||
valueType: 'password',
|
||||
title: t('system.ai.bedrock.session_token'),
|
||||
fieldProps: { placeholder: t('system.ai.provider.api_key.placeholder') },
|
||||
colProps: { span: 24 },
|
||||
dependency: dependency('bedrock')
|
||||
},
|
||||
]
|
||||
|
||||
const columns: FormColumn<AiResponse>[] = [
|
||||
{
|
||||
dataIndex: 'default',
|
||||
valueType: 'radio',
|
||||
title: t('system.ai.default'),
|
||||
tooltip: t('system.ai.default.tooltip'),
|
||||
colProps: { span: 24 },
|
||||
fieldProps: {
|
||||
options: aiList.default.map(item => ({
|
||||
label: item,
|
||||
value: item
|
||||
}))
|
||||
}
|
||||
},
|
||||
...anthropicColumns,
|
||||
...openaiColumns,
|
||||
...geminiColumns,
|
||||
...azureColumns,
|
||||
...bedrockColumns,
|
||||
...ollamaColumns,
|
||||
apikeyColumn('cohere'),
|
||||
apikeyColumn('deepseek'),
|
||||
apikeyColumn('eleven'),
|
||||
apikeyColumn('groq'),
|
||||
apikeyColumn('jina'),
|
||||
apikeyColumn('mistral'),
|
||||
apikeyColumn('openrouter'),
|
||||
apikeyColumn('voyageai'),
|
||||
apikeyColumn('xai'),
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
getAiList().then(res => (
|
||||
res.data.data && setAiList(res.data.data)
|
||||
))
|
||||
}, []);
|
||||
|
||||
const loadConfig = async () => {
|
||||
const { data } = await getAiConfig();
|
||||
if (data.success) {
|
||||
form.setFieldsValue(data.data);
|
||||
}
|
||||
};
|
||||
|
||||
const onFinish = async (values: any) => {
|
||||
await saveAiConfig(values);
|
||||
message.success(t('system.ai.save.success'));
|
||||
};
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
setTestError(null);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const baseUrl = import.meta.env.VITE_BASE_URL || '';
|
||||
const response = await fetch(`${baseUrl}/system/ai/test`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Accept': 'text/event-stream',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null);
|
||||
throw new Error(errorData?.msg || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error('Stream not supported');
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let resultText = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
// Keep the last (potentially incomplete) line in buffer
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
const payload = line.slice(6).trim();
|
||||
if (payload === '[DONE]') continue;
|
||||
|
||||
try {
|
||||
const event = JSON.parse(payload);
|
||||
if (event.type === 'text_delta' && event.delta) {
|
||||
resultText += event.delta;
|
||||
setTestResult(resultText);
|
||||
}
|
||||
} catch {
|
||||
// Skip unparseable lines
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
message.success(t('system.ai.test.success'));
|
||||
} catch (err: any) {
|
||||
const msg = err?.message || 'Connection test failed';
|
||||
setTestError(msg);
|
||||
message.error(msg);
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={'mb-5'}>
|
||||
<Title level={3}>{t('system.ai.page.title')}</Title>
|
||||
<Text type="secondary">{t('system.ai.page.description')}</Text>
|
||||
</div>
|
||||
<Row gutter={24}>
|
||||
<Col span={16}>
|
||||
<Card variant={'borderless'}>
|
||||
<XinForm
|
||||
columns={columns}
|
||||
layout="vertical"
|
||||
formRef={formRef}
|
||||
form={form}
|
||||
grid={true}
|
||||
onFinish={onFinish}
|
||||
rowProps={{
|
||||
gutter: [10, 0],
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card title={t('system.ai.test.title')}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Text type="secondary">{t('system.ai.test.hint')}</Text>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
block
|
||||
icon={<ThunderboltOutlined />}
|
||||
loading={testing}
|
||||
onClick={handleTestConnection}
|
||||
>
|
||||
{testing ? t('system.ai.test.testing') : t('system.ai.test.button')}
|
||||
</Button>
|
||||
|
||||
{testing && !testResult && (
|
||||
<div className="flex justify-center py-8">
|
||||
<Spin description={t('system.ai.test.testing')} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{testResult && (
|
||||
<Alert
|
||||
type="success"
|
||||
title={t('system.ai.test.success')}
|
||||
description={testResult}
|
||||
showIcon
|
||||
/>
|
||||
)}
|
||||
|
||||
{testError && (
|
||||
<Alert
|
||||
type="error"
|
||||
title={t('system.ai.test.error')}
|
||||
description={testError}
|
||||
showIcon
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AiConfig;
|
||||
@@ -0,0 +1,144 @@
|
||||
import XinTable from '@/components/XinTable';
|
||||
import { Badge, Image, Typography } from 'antd';
|
||||
import type { ICarousel } from '@/domain/iCarousel';
|
||||
import type { XinTableColumn } from '@/components/XinTable/typings';
|
||||
import type { ISysFileInfo } from '@/domain/iSysFile';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
/**
|
||||
* 从 image 字段中提取预览 URL
|
||||
* image 字段可能是 ISysFileInfo 对象或 URL 字符串
|
||||
*/
|
||||
function getImageUrl(image: ISysFileInfo | string | null | undefined): string {
|
||||
if (!image) return '';
|
||||
if (typeof image === 'string') return image;
|
||||
return image.preview_url || image.file_url || '';
|
||||
}
|
||||
|
||||
/** 首页轮播图管理 */
|
||||
export default function CarouselPage() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const columns: XinTableColumn<ICarousel>[] = [
|
||||
{
|
||||
title: t('system.carousel.id'),
|
||||
dataIndex: 'id',
|
||||
hideInForm: true,
|
||||
width: 80,
|
||||
sorter: true,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: t('system.carousel.title'),
|
||||
dataIndex: 'title',
|
||||
valueType: 'text',
|
||||
colProps: { span: 12 },
|
||||
rules: [{ required: true, message: t('system.carousel.title.required') }],
|
||||
},
|
||||
{
|
||||
title: t('system.carousel.image'),
|
||||
dataIndex: 'image_id',
|
||||
valueType: 'image',
|
||||
colProps: { span: 24 },
|
||||
rules: [{ required: true, message: t('system.carousel.image.required') }],
|
||||
hideInSearch: true,
|
||||
fieldProps: {
|
||||
action: '/system/file/list/upload',
|
||||
mode: 'single',
|
||||
maxCount: 1,
|
||||
changeType: "id"
|
||||
},
|
||||
render: (_value: ISysFileInfo | string, record: ICarousel) => {
|
||||
const url = getImageUrl(record.image);
|
||||
if (!url) return '-';
|
||||
return (
|
||||
<Image
|
||||
src={url}
|
||||
width={120}
|
||||
height={60}
|
||||
style={{ objectFit: 'cover', borderRadius: 4 }}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('system.carousel.link'),
|
||||
dataIndex: 'link',
|
||||
valueType: 'text',
|
||||
colProps: { span: 12 },
|
||||
hideInSearch: true,
|
||||
fieldProps: {
|
||||
placeholder: t('system.carousel.link.placeholder'),
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('system.carousel.status'),
|
||||
dataIndex: 'status',
|
||||
valueType: 'select',
|
||||
filters: [
|
||||
{ text: t('system.carousel.status.normal'), value: 0 },
|
||||
{ text: t('system.carousel.status.disabled'), value: 1 },
|
||||
],
|
||||
colProps: { span: 12 },
|
||||
rules: [{ required: true, message: t('system.carousel.status.required') }],
|
||||
fieldProps: {
|
||||
options: [
|
||||
{ label: t('system.carousel.status.normal'), value: 0 },
|
||||
{ label: t('system.carousel.status.disabled'), value: 1 },
|
||||
],
|
||||
},
|
||||
render: (value: number) => {
|
||||
return value === 0
|
||||
? <Badge status="success" text={t('system.carousel.status.normal')} />
|
||||
: <Badge status="error" text={t('system.carousel.status.disabled')} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('system.carousel.sort'),
|
||||
dataIndex: 'sort',
|
||||
valueType: 'digit',
|
||||
colProps: { span: 12 },
|
||||
hideInSearch: true,
|
||||
initialValue: 0,
|
||||
fieldProps: {
|
||||
min: 0,
|
||||
style: { width: '100%' },
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('system.carousel.createdAt'),
|
||||
dataIndex: 'created_at',
|
||||
render: (value: string) => (value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '-'),
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
width: 160,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-5">
|
||||
<Title level={3}>{t('system.carousel.page.title')}</Title>
|
||||
<Text type="secondary">{t('system.carousel.page.description')}</Text>
|
||||
</div>
|
||||
<XinTable<ICarousel>
|
||||
api="/system/carousel"
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
accessName="system.carousel"
|
||||
formProps={{
|
||||
grid: true,
|
||||
colProps: { span: 12 },
|
||||
rowProps: { gutter: [30, 0] },
|
||||
layout: 'vertical',
|
||||
}}
|
||||
modalProps={{ width: 800 }}
|
||||
searchProps={false}
|
||||
scroll={{ x: 1000 }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,655 @@
|
||||
import React, {useEffect, useRef, useState} from 'react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Col,
|
||||
Divider,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Menu,
|
||||
message,
|
||||
Popconfirm,
|
||||
Radio,
|
||||
Row,
|
||||
Space,
|
||||
Spin,
|
||||
Switch,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
SaveOutlined,
|
||||
SettingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import XinForm from '@/components/XinForm';
|
||||
import type { XinFormRef } from '@/components/XinForm/typings';
|
||||
import type { FormColumn } from '@/components/XinFormField/FieldRender/typings';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import type {IConfigGroup} from '@/domain/iConfigGroup.ts';
|
||||
import type {IConfigItem} from '@/domain/iConfigItem.ts';
|
||||
import {
|
||||
createConfigGroup,
|
||||
createConfigItem,
|
||||
deleteConfigGroup,
|
||||
deleteConfigItem,
|
||||
getConfigGroupList,
|
||||
getConfigItemList, refreshCache,
|
||||
saveConfigItems,
|
||||
updateConfigGroup,
|
||||
updateConfigItem,
|
||||
} from '@/api/system/sys_config.ts';
|
||||
|
||||
const { TextArea } = Input;
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const ConfigManagement: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
/** 表单组件类型选项 */
|
||||
const FORM_COMPONENT_OPTIONS = [
|
||||
{ label: t('system.config.component.Input'), value: 'Input' },
|
||||
{ label: t('system.config.component.TextArea'), value: 'TextArea' },
|
||||
{ label: t('system.config.component.InputNumber'), value: 'InputNumber' },
|
||||
{ label: t('system.config.component.Switch'), value: 'Switch' },
|
||||
{ label: t('system.config.component.Radio'), value: 'Radio' },
|
||||
{ label: t('system.config.component.Checkbox'), value: 'Checkbox' },
|
||||
];
|
||||
const [configGroups, setConfigGroups] = useState<IConfigGroup[]>([]);
|
||||
const [selectedGroupId, setSelectedGroupId] = useState<number>();
|
||||
const [selectedGroupKey, setSelectedGroupKey] = useState<string>();
|
||||
const [configItems, setConfigItems] = useState<IConfigItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [itemsLoading, setItemsLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 设置组表单
|
||||
const [editingGroup, setEditingGroup] = useState<IConfigGroup | null>(null);
|
||||
const groupFormRef = useRef<XinFormRef>(null);
|
||||
|
||||
// 设置项表单
|
||||
const [editingItem, setEditingItem] = useState<IConfigItem | null>(null);
|
||||
const itemFormRef = useRef<XinFormRef>(null);
|
||||
|
||||
// 设置项值表单
|
||||
const [valuesForm] = Form.useForm();
|
||||
|
||||
/** 加载设置组列表 */
|
||||
const loadConfigGroups = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const { data } = await getConfigGroupList();
|
||||
const groups = data.data || [];
|
||||
setConfigGroups(groups);
|
||||
|
||||
// 如果当前没有选中的组,选中第一个
|
||||
if (!selectedGroupId && groups.length > 0) {
|
||||
setSelectedGroupId(groups[0].id);
|
||||
setSelectedGroupKey(groups[0].key);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** 加载设置项列表 */
|
||||
const loadConfigItems = async (groupId?: number) => {
|
||||
if (!groupId) return;
|
||||
|
||||
try {
|
||||
setItemsLoading(true);
|
||||
const { data } = await getConfigItemList(groupId);
|
||||
const items = data.data || [];
|
||||
setConfigItems(items);
|
||||
|
||||
// 初始化表单值
|
||||
const initialValues: { [key: string]: any } = {};
|
||||
items.forEach(item => {
|
||||
if(!item.type || ['Input', 'TextArea', 'Radio'].includes(item.type)) {
|
||||
initialValues[`item_${item.id}`] = String(item.values);
|
||||
}
|
||||
if(item.type && item.type === 'InputNumber') {
|
||||
initialValues[`item_${item.id}`] = Number(item.values);
|
||||
}
|
||||
if(item.type && item.type === 'Switch') {
|
||||
if(item.values === '0' || item.values === 'false') {
|
||||
initialValues[`item_${item.id}`] = false;
|
||||
}else {
|
||||
initialValues[`item_${item.id}`] = Boolean(item.values);
|
||||
}
|
||||
}
|
||||
if(item.type && item.type === 'Checkbox') {
|
||||
try {
|
||||
initialValues[`item_${item.id}`] = JSON.parse(item.values || '[]');
|
||||
} catch {
|
||||
initialValues[`item_${item.id}`] = [];
|
||||
}
|
||||
}
|
||||
});
|
||||
console.log(initialValues)
|
||||
valuesForm.setFieldsValue(initialValues);
|
||||
} finally {
|
||||
setItemsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadConfigGroups();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedGroupId) {
|
||||
loadConfigItems(selectedGroupId);
|
||||
}
|
||||
}, [selectedGroupId]);
|
||||
|
||||
/** 打开新增设置组对话框 */
|
||||
const handleAddGroup = () => {
|
||||
setEditingGroup(null);
|
||||
groupFormRef.current?.resetFields();
|
||||
groupFormRef.current?.open();
|
||||
};
|
||||
|
||||
/** 打开编辑设置组对话框 */
|
||||
const handleEditGroup = (group: IConfigGroup) => {
|
||||
setEditingGroup(group);
|
||||
groupFormRef.current?.setFieldsValue(group);
|
||||
groupFormRef.current?.open();
|
||||
};
|
||||
|
||||
/** 保存设置组 */
|
||||
const handleSaveGroup = async (values: IConfigGroup) => {
|
||||
try {
|
||||
if (editingGroup) {
|
||||
await updateConfigGroup(editingGroup.id!, values);
|
||||
message.success(t('system.config.group.updateSuccess'));
|
||||
} else {
|
||||
await createConfigGroup(values);
|
||||
message.success(t('system.config.group.createSuccess'));
|
||||
}
|
||||
groupFormRef.current?.close();
|
||||
await loadConfigGroups();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/** 删除设置组 */
|
||||
const handleDeleteGroup = async (id: number) => {
|
||||
try {
|
||||
await deleteConfigGroup(id);
|
||||
message.success(t('system.config.group.deleteSuccess'));
|
||||
if (selectedGroupId === id) {
|
||||
setSelectedGroupId(undefined);
|
||||
setSelectedGroupKey(undefined);
|
||||
}
|
||||
await loadConfigGroups();
|
||||
} catch (error) {
|
||||
console.error('删除设置组失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/** 打开新增设置项对话框 */
|
||||
const handleAddItem = () => {
|
||||
setEditingItem(null);
|
||||
itemFormRef.current?.resetFields();
|
||||
itemFormRef.current?.setFieldsValue({ group_id: selectedGroupId });
|
||||
itemFormRef.current?.open();
|
||||
};
|
||||
|
||||
/** 打开编辑设置项对话框 */
|
||||
const handleEditItem = (item: IConfigItem) => {
|
||||
setEditingItem(item);
|
||||
itemFormRef.current?.setFieldsValue(item);
|
||||
itemFormRef.current?.open();
|
||||
};
|
||||
|
||||
/** 保存设置项 */
|
||||
const handleSaveItem = async (values: IConfigItem) => {
|
||||
try {
|
||||
if (editingItem) {
|
||||
await updateConfigItem(editingItem.id!, { ...values, group_id: selectedGroupId });
|
||||
message.success(t('system.config.item.updateSuccess'));
|
||||
} else {
|
||||
await createConfigItem({ ...values, group_id: selectedGroupId });
|
||||
message.success(t('system.config.item.createSuccess'));
|
||||
}
|
||||
itemFormRef.current?.close();
|
||||
await loadConfigItems(selectedGroupId);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/** 删除设置项 */
|
||||
const handleDeleteItem = async (id: number) => {
|
||||
try {
|
||||
await deleteConfigItem(id);
|
||||
message.success(t('system.config.item.deleteSuccess'));
|
||||
await loadConfigItems(selectedGroupId);
|
||||
} catch (error) {
|
||||
console.error('删除设置项失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/** 批量保存所有设置项值 */
|
||||
const handleSaveAll = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
const values = valuesForm.getFieldsValue();
|
||||
const configs = configItems.map(item => {
|
||||
const value = values[`item_${item.id}`];
|
||||
if(!item.type || ['Input', 'TextArea', 'Radio'].includes(item.type)) {
|
||||
return { id: item.id!, value: String(value) };
|
||||
}
|
||||
if(item.type && item.type === 'InputNumber') {
|
||||
return { id: item.id!, value: String(value) };
|
||||
}
|
||||
if(item.type && item.type === 'Switch') {
|
||||
if(value === '0' || value === 'false' || !!value) {
|
||||
return { id: item.id!, value: 'false' };
|
||||
}else {
|
||||
return { id: item.id!, value: 'true' };
|
||||
}
|
||||
}
|
||||
if(item.type && item.type === 'Checkbox') {
|
||||
return { id: item.id!, value: JSON.stringify(value) };
|
||||
}
|
||||
return { id: item.id!, value: value };
|
||||
});
|
||||
await saveConfigItems(configs);
|
||||
message.success(t('system.config.item.updateSuccess'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
await refreshCache();
|
||||
message.success(t('system.config.item.refreshCacheSuccess'))
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
/** 渲染设置项的表单组件 */
|
||||
const renderConfigItemComponent = (item: IConfigItem) => {
|
||||
const fieldName = `item_${item.id}`;
|
||||
const componentType = item.type || 'Input';
|
||||
const usage = `site_config('${selectedGroupKey}.${item.key}')`;
|
||||
|
||||
// 解析options
|
||||
let options: any[] = [];
|
||||
if (item.options_json) {
|
||||
try {
|
||||
options = JSON.parse(item.options_json);
|
||||
} catch {
|
||||
console.error('options解析失败:', item.options_json);
|
||||
}
|
||||
}
|
||||
|
||||
// 解析props
|
||||
let componentProps: any = {};
|
||||
if (item.props_json) {
|
||||
try {
|
||||
componentProps = JSON.parse(item.props_json);
|
||||
} catch {
|
||||
console.error('props解析失败:', item.props_json);
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义Label组件
|
||||
const CustomLabel = () => (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Space>
|
||||
<Text strong>{item.title}</Text>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleEditItem(item)}
|
||||
/>
|
||||
<Popconfirm
|
||||
title={t('system.config.item.deleteConfirm')}
|
||||
onConfirm={() => handleDeleteItem(item.id!)}
|
||||
okText={t('system.config.confirm.ok')}
|
||||
cancelText={t('system.config.confirm.cancel')}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
/>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{item.describe} <Text code copyable>{usage}</Text>
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
let component;
|
||||
switch (componentType) {
|
||||
case 'Input':
|
||||
component = (
|
||||
<Input
|
||||
placeholder={item.describe || t('system.config.form.placeholder.input')}
|
||||
{...componentProps}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'TextArea':
|
||||
component = (
|
||||
<TextArea
|
||||
placeholder={item.describe || t('system.config.form.placeholder.input')}
|
||||
rows={4}
|
||||
{...componentProps}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'InputNumber':
|
||||
component = (
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
placeholder={item.describe || t('system.config.form.placeholder.input')}
|
||||
{...componentProps}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'Switch':
|
||||
component = <Switch {...componentProps} />;
|
||||
break;
|
||||
case 'Radio':
|
||||
component = <Radio.Group options={options} {...componentProps} />;
|
||||
break;
|
||||
case 'Checkbox':
|
||||
component = <Checkbox.Group options={options} {...componentProps} />;
|
||||
break;
|
||||
default:
|
||||
component = <Input placeholder={item.describe || t('system.config.form.placeholder.input')} {...componentProps} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<CustomLabel />
|
||||
<Form.Item name={fieldName} noStyle>
|
||||
{component}
|
||||
</Form.Item>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** 设置组表单列 */
|
||||
const groupColumns: FormColumn<IConfigGroup>[] = [
|
||||
{
|
||||
title: t('system.config.group.field.title'),
|
||||
dataIndex: 'title',
|
||||
valueType: 'text',
|
||||
rules: [{ required: true, message: t('system.config.group.field.title.required') }],
|
||||
},
|
||||
{
|
||||
title: t('system.config.group.field.key'),
|
||||
dataIndex: 'key',
|
||||
valueType: 'text',
|
||||
rules: [{ required: true, message: t('system.config.group.field.key.required') }],
|
||||
},
|
||||
{
|
||||
title: t('system.config.group.field.remark'),
|
||||
dataIndex: 'remark',
|
||||
valueType: 'textarea',
|
||||
colProps: {span: 24},
|
||||
},
|
||||
];
|
||||
|
||||
/** 设置项表单列 */
|
||||
const itemColumns: FormColumn<IConfigItem>[] = [
|
||||
{
|
||||
title: t('system.config.item.field.key'),
|
||||
dataIndex: 'key',
|
||||
valueType: 'text',
|
||||
rules: [{ required: true, message: t('system.config.item.field.key.required') }],
|
||||
},
|
||||
{
|
||||
title: t('system.config.item.field.title'),
|
||||
dataIndex: 'title',
|
||||
valueType: 'text',
|
||||
rules: [{ required: true, message: t('system.config.item.field.title.required') }],
|
||||
},
|
||||
{
|
||||
title: t('system.config.item.field.type'),
|
||||
dataIndex: 'type',
|
||||
valueType: 'select',
|
||||
fieldProps: {
|
||||
options: FORM_COMPONENT_OPTIONS,
|
||||
},
|
||||
rules: [{ required: true, message: t('system.config.item.field.type.required') }],
|
||||
},
|
||||
{
|
||||
title: t('system.config.item.field.sort'),
|
||||
dataIndex: 'sort',
|
||||
valueType: 'digit',
|
||||
initialValue: 0
|
||||
},
|
||||
{
|
||||
title: t('system.config.item.field.describe'),
|
||||
dataIndex: 'describe',
|
||||
valueType: 'textarea',
|
||||
colProps: { span: 24 },
|
||||
},
|
||||
{
|
||||
title: t('system.config.item.field.options'),
|
||||
dataIndex: 'options',
|
||||
valueType: 'textarea',
|
||||
tooltip: t('system.config.item.field.options.tooltip'),
|
||||
},
|
||||
{
|
||||
title: t('system.config.item.field.props'),
|
||||
dataIndex: 'props',
|
||||
valueType: 'textarea',
|
||||
tooltip: t('system.config.item.field.props.tooltip'),
|
||||
},
|
||||
{
|
||||
title: t('system.config.item.field.values'),
|
||||
dataIndex: 'values',
|
||||
valueType: 'text',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={'mb-5'}>
|
||||
<Title level={3}>{t('system.config.page.title')}</Title>
|
||||
<Text type="secondary">{t('system.config.page.description')}</Text>
|
||||
</div>
|
||||
<Row gutter={[16, 16]}>
|
||||
{/* 左侧设置组菜单 */}
|
||||
<Col xs={24} lg={4}>
|
||||
<Card
|
||||
styles={{
|
||||
header: { paddingInline: 16, paddingBlock: 0, minHeight: 48 },
|
||||
body: { padding: 16, minHeight: 52 },
|
||||
}}
|
||||
title={(
|
||||
<Space>
|
||||
<SettingOutlined />
|
||||
{t('system.config.group.title')}
|
||||
</Space>
|
||||
)}
|
||||
extra={
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleAddGroup}
|
||||
>
|
||||
{t('system.config.group.add')}
|
||||
</Button>
|
||||
}
|
||||
loading={loading}
|
||||
>
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={selectedGroupId ? [String(selectedGroupId)] : []}
|
||||
items={configGroups.map(group => ({
|
||||
key: String(group.id),
|
||||
label: (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>{group.title}</span>
|
||||
<Space size="small">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEditGroup(group);
|
||||
}}
|
||||
/>
|
||||
<Popconfirm
|
||||
title={t('system.config.group.deleteConfirm')}
|
||||
description={t('system.config.group.deleteWarning')}
|
||||
onConfirm={(e) => {
|
||||
e?.stopPropagation();
|
||||
handleDeleteGroup(group.id!);
|
||||
}}
|
||||
okText={t('system.config.confirm.ok')}
|
||||
cancelText={t('system.config.confirm.cancel')}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</div>
|
||||
),
|
||||
onClick: () => {
|
||||
setSelectedGroupId(group.id);
|
||||
setSelectedGroupKey(group.key);
|
||||
},
|
||||
}))}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* 右侧设置项 */}
|
||||
<Col xs={24} lg={20}>
|
||||
<Card
|
||||
styles={{
|
||||
header: { paddingInline: 16, paddingBlock: 0, minHeight: 48 },
|
||||
body: { padding: 16, minHeight: 52 },
|
||||
}}
|
||||
title={t('system.config.item.title')}
|
||||
extra={
|
||||
selectedGroupId && (
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
{t('system.config.refresh.button')}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
loading={saving}
|
||||
onClick={handleSaveAll}
|
||||
>
|
||||
{t('system.config.save.button')}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleAddItem}
|
||||
>
|
||||
{t('system.config.item.add')}
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
>
|
||||
{!selectedGroupId ? (
|
||||
<div style={{ textAlign: 'center', padding: '60px 0', color: '#999' }}>
|
||||
{t('system.config.item.selectGroup')}
|
||||
</div>
|
||||
) : (
|
||||
<Spin spinning={itemsLoading}>
|
||||
<Form form={valuesForm} layout="vertical">
|
||||
{configItems.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '60px 0', color: '#999' }}>
|
||||
{t('system.config.item.empty')}
|
||||
</div>
|
||||
) : (
|
||||
configItems.map((item, index) => (
|
||||
<div key={item.id} style={{ position: 'relative' }}>
|
||||
<div style={{ paddingRight: 80 }}>
|
||||
{renderConfigItemComponent(item)}
|
||||
</div>
|
||||
{index < configItems.length - 1 && <Divider />}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</Form>
|
||||
</Spin>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* 设置组表单弹窗 */}
|
||||
<XinForm<IConfigGroup>
|
||||
formRef={groupFormRef}
|
||||
layoutType="ModalForm"
|
||||
columns={groupColumns}
|
||||
onFinish={handleSaveGroup}
|
||||
modalProps={{
|
||||
title: editingGroup ? t('system.config.group.edit') : t('system.config.group.create'),
|
||||
onCancel: () => groupFormRef.current?.close(),
|
||||
forceRender: true,
|
||||
width: 800
|
||||
}}
|
||||
grid
|
||||
layout={'vertical'}
|
||||
colProps={{span: 8}}
|
||||
rowProps={{gutter: [30, 0]}}
|
||||
trigger={<span style={{display: 'none'}} />}
|
||||
/>
|
||||
|
||||
{/* 设置项表单弹窗 */}
|
||||
<XinForm<IConfigItem>
|
||||
formRef={itemFormRef}
|
||||
layoutType="ModalForm"
|
||||
columns={itemColumns}
|
||||
onFinish={handleSaveItem}
|
||||
grid={true}
|
||||
colProps={{ span: 12 }}
|
||||
rowProps={{gutter: 30}}
|
||||
layout={'vertical'}
|
||||
modalProps={{
|
||||
title: editingItem ? t('system.config.item.edit') : t('system.config.item.create'),
|
||||
onCancel: () => itemFormRef.current?.close(),
|
||||
forceRender: true,
|
||||
width: 800,
|
||||
}}
|
||||
trigger={<span style={{display: 'none'}} />}
|
||||
/>
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfigManagement;
|
||||
@@ -0,0 +1,452 @@
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
type CardProps,
|
||||
Col,
|
||||
message,
|
||||
Popconfirm,
|
||||
Row,
|
||||
Space, Table, type TableProps, Tag,
|
||||
Tree,
|
||||
type TreeDataNode, type TreeProps, Typography
|
||||
} from "antd";
|
||||
import {BankOutlined, DeleteOutlined, PlusOutlined, TeamOutlined, UserOutlined} from "@ant-design/icons";
|
||||
import {useEffect, useRef, useState} from "react";
|
||||
import {queryDept, createDept, updateDept, deleteDept, deptUsers} from "@/api/system/sys_dept.ts";
|
||||
import type {ISysDept} from "@/domain/iSysDept.ts";
|
||||
import {isArray, omit} from 'lodash';
|
||||
import type {FormColumn} from "@/components/XinFormField/FieldRender/typings.ts";
|
||||
import XinForm, {type XinFormRef} from "@/components/XinForm";
|
||||
import * as React from "react";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import AuthButton from "@/components/AuthButton";
|
||||
import useAuth from "@/hooks/useAuth.ts";
|
||||
import type ISysUser from "@/domain/iSysUser.ts";
|
||||
|
||||
const deptMap = new Map<string, ISysDept>();
|
||||
|
||||
interface TableParams {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
const Dept = () => {
|
||||
const {t} = useTranslation();
|
||||
const {auth} = useAuth();
|
||||
/** 部门信息表单 */
|
||||
const formRef = useRef<XinFormRef<ISysDept>>(null);
|
||||
/** 新增表单 */
|
||||
const modalFormRef = useRef<XinFormRef<ISysDept>>(null);
|
||||
/** 当前选中的部门 key */
|
||||
const [selectKey, setSelectKey] = useState<string>('');
|
||||
/** 当前(多选框)选择的部门 keys */
|
||||
const [checkedKeys , setCheckedKeys] = useState<React.Key[]>([]);
|
||||
/** 标签页 KEY */
|
||||
const [tabKey, setTabKey] = useState<string>('info');
|
||||
/** 标签页 Tab */
|
||||
const tabList: CardProps['tabList'] = [
|
||||
{
|
||||
key: 'info',
|
||||
label: t("system.dept.tab.info")
|
||||
},
|
||||
{
|
||||
key: 'users',
|
||||
label: t("system.dept.tab.users"),
|
||||
disabled: !auth("system.dept.users")
|
||||
},
|
||||
];
|
||||
/** 部门数据 */
|
||||
const [deptData, setDeptData] = useState<TreeDataNode[]>([]);
|
||||
/** 部门用户列表数据 */
|
||||
const [users, setUsers] = useState<ISysUser[]>([]);
|
||||
/** 部门用户列表表格参数 */
|
||||
const [tableParams, setTableParams] = useState<TableParams>({ page: 1, pageSize: 10, total: 0 });
|
||||
/** 加载状态 */
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [tableLoading, setTableLoading] = useState<boolean>(false);
|
||||
/** 获取部门用户列表 */
|
||||
const selectUsers = async (id: number, params?: TableParams) => {
|
||||
try {
|
||||
setTableLoading(true);
|
||||
const data = await deptUsers(id, omit(params || tableParams, 'total'));
|
||||
setUsers(data.data.data!.data);
|
||||
setTableParams({
|
||||
...tableParams,
|
||||
total: data.data.data!.total
|
||||
})
|
||||
} finally {
|
||||
setTableLoading(false);
|
||||
}
|
||||
}
|
||||
/** 刷新部门数据 */
|
||||
const refreshDept = async () => {
|
||||
setLoading(true);
|
||||
/** 转换部门类型 */
|
||||
const convertDeptToTreeData = (depts: ISysDept[]): TreeDataNode[] => {
|
||||
if (!depts?.length) return [];
|
||||
return depts.map(dept => {
|
||||
deptMap.set(dept.id!.toString(), omit(dept, 'children'));
|
||||
return {
|
||||
title: dept.name || t("system.dept.tab.users"),
|
||||
key: dept.id?.toString() || '',
|
||||
icon: dept.type === 0 ? <BankOutlined /> : dept.type === 1 ? <TeamOutlined /> : <UserOutlined />,
|
||||
children: convertDeptToTreeData(dept.children || [])
|
||||
}
|
||||
});
|
||||
};
|
||||
try {
|
||||
const res = await queryDept();
|
||||
if(res.data.data && res.data.data.length > 0) {
|
||||
deptMap.clear();
|
||||
setDeptData(convertDeptToTreeData(res.data.data));
|
||||
if(!selectKey || !deptMap.has(selectKey)) {
|
||||
setSelectKey(res.data.data[0].id!.toString());
|
||||
formRef.current?.setFieldsValue(omit(res.data.data[0], 'children'));
|
||||
if(auth("system.dept.users")) {
|
||||
await selectUsers(res.data.data[0].id!);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
/** 新增部门点击事件 */
|
||||
const addChange = (children: boolean = false) => {
|
||||
modalFormRef.current?.resetFields();
|
||||
modalFormRef.current?.setFieldsValue({
|
||||
parent_id: children ? Number(selectKey) : 0,
|
||||
sort: 0,
|
||||
status: 0,
|
||||
type: 0
|
||||
})
|
||||
modalFormRef.current?.open();
|
||||
}
|
||||
/** 部门选择事件 */
|
||||
const onSelect: TreeProps['onSelect'] = (key) => {
|
||||
if(key && key.length >= 1) {
|
||||
setSelectKey(key[0].toString());
|
||||
const deptInfo = deptMap.get(key[0].toString());
|
||||
if (deptInfo) {
|
||||
formRef.current?.setFieldsValue(deptInfo);
|
||||
}
|
||||
selectUsers(Number(key[0])).then();
|
||||
}
|
||||
}
|
||||
/** 部门多选框选中事件 */
|
||||
const onCheck: TreeProps['onCheck'] = (checkedKeys) => {
|
||||
if(isArray(checkedKeys)) {
|
||||
setCheckedKeys(checkedKeys);
|
||||
}else {
|
||||
setCheckedKeys(checkedKeys.checked);
|
||||
}
|
||||
}
|
||||
/** 表单提交 */
|
||||
const onSubmit = async (data: ISysDept, update: boolean = false) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
if(!update) {
|
||||
await createDept(data);
|
||||
message.success(t("system.dept.createSuccess"));
|
||||
modalFormRef.current?.close();
|
||||
}else {
|
||||
await updateDept(Number(selectKey), data);
|
||||
message.success(t("system.dept.updateSuccess"));
|
||||
}
|
||||
await refreshDept();
|
||||
}finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
/** 批量删除 */
|
||||
const onDeleteConfirm = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await deleteDept(checkedKeys);
|
||||
await refreshDept();
|
||||
setCheckedKeys([]);
|
||||
message.success(t("system.dept.deleteSuccess"));
|
||||
}finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
/** 表单列数据 */
|
||||
const columns: FormColumn<ISysDept>[] = [
|
||||
{
|
||||
title: t("system.dept.column.name"),
|
||||
valueType: 'text',
|
||||
dataIndex: 'name',
|
||||
rules: [{required: true, message: t("system.dept.column.name.required")}],
|
||||
},
|
||||
{
|
||||
title: t("system.dept.column.code"),
|
||||
valueType: 'text',
|
||||
dataIndex: 'code',
|
||||
rules: [{required: true, message: t("system.dept.column.code.required")}],
|
||||
},
|
||||
{
|
||||
title: t("system.dept.column.type"),
|
||||
valueType: 'radioButton',
|
||||
dataIndex: 'type',
|
||||
fieldProps: {
|
||||
options: [
|
||||
{ value: 0, label: t("system.dept.column.type.0") },
|
||||
{ value: 1, label: t("system.dept.column.type.1") },
|
||||
{ value: 2, label: t("system.dept.column.type.2") },
|
||||
],
|
||||
},
|
||||
rules: [{required: true, message: t("system.dept.column.type.required")}],
|
||||
},
|
||||
{
|
||||
title: t("system.dept.column.parent"),
|
||||
valueType: 'treeSelect',
|
||||
dataIndex: 'parent_id',
|
||||
fieldProps: {
|
||||
treeData: [
|
||||
{
|
||||
title: t("system.dept.column.parent.0"),
|
||||
value: 0,
|
||||
children: deptData
|
||||
}
|
||||
],
|
||||
fieldNames: { label: 'title', value: 'key' },
|
||||
disabled: true
|
||||
},
|
||||
rules: [{required: true, message: t("system.dept.column.parent.required")}],
|
||||
},
|
||||
{
|
||||
title: t("system.dept.column.email"),
|
||||
valueType: 'text',
|
||||
dataIndex: 'email',
|
||||
},
|
||||
{
|
||||
title: t("system.dept.column.address"),
|
||||
valueType: 'text',
|
||||
dataIndex: 'address',
|
||||
},
|
||||
{
|
||||
title: t("system.dept.column.phone"),
|
||||
valueType: 'text',
|
||||
dataIndex: 'phone',
|
||||
},
|
||||
{
|
||||
title: t("system.dept.column.sort"),
|
||||
valueType: 'digit',
|
||||
dataIndex: 'sort',
|
||||
rules: [{required: true, message: t("system.dept.column.sort.required")}],
|
||||
},
|
||||
{
|
||||
title: t("system.dept.column.status"),
|
||||
valueType: 'radioButton',
|
||||
dataIndex: 'status',
|
||||
fieldProps: {
|
||||
options: [
|
||||
{ value: 0, label: t("system.dept.column.status.0") },
|
||||
{ value: 1, label: t("system.dept.column.status.1") },
|
||||
]
|
||||
},
|
||||
rules: [{required: true, message: t("system.dept.column.status.required")}],
|
||||
},
|
||||
{
|
||||
title: t("system.dept.column.remark"),
|
||||
valueType: 'textarea',
|
||||
dataIndex: 'remark',
|
||||
},
|
||||
];
|
||||
/** 部门用户列表表格列 */
|
||||
const usersColumns: TableProps<ISysUser>['columns'] = [
|
||||
{
|
||||
title: t("system.dept.users.column.id"),
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: t("system.dept.users.column.username"),
|
||||
dataIndex: 'username',
|
||||
key: 'username',
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: t("system.dept.users.column.nickname"),
|
||||
dataIndex: 'nickname',
|
||||
key: 'nickname',
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: t("system.dept.users.column.nickname"),
|
||||
dataIndex: 'email',
|
||||
key: 'email',
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: t("system.dept.users.column.mobile"),
|
||||
dataIndex: 'mobile',
|
||||
key: 'mobile',
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: t("system.dept.users.column.status"),
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
align: 'center',
|
||||
render: (value) => (
|
||||
<>
|
||||
{value === 1 && <Tag color={'success'}>{t("system.dept.users.column.status.0")}</Tag>}
|
||||
{value === 0 && <Tag color={'error'}>{t("system.dept.users.column.status.1")}</Tag>}
|
||||
</>
|
||||
)
|
||||
},
|
||||
];
|
||||
/** 初始化 */
|
||||
useEffect(() => { refreshDept() }, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={'mb-5'}>
|
||||
<Typography.Title level={3}>{t("system.dept.page.title")}</Typography.Title>
|
||||
<Typography.Text type="secondary">{t("system.dept.page.description")}</Typography.Text>
|
||||
</div>
|
||||
<Row gutter={[20, 20]}>
|
||||
<Col xxl={12} lg={12} xs={24}>
|
||||
<XinForm<ISysDept>
|
||||
layoutType={'ModalForm'}
|
||||
formRef={modalFormRef}
|
||||
modalProps={{
|
||||
title: t("system.dept.createModalTitle"),
|
||||
styles: { body: { paddingTop: 20} },
|
||||
width: 800
|
||||
}}
|
||||
onFinish={async (data: ISysDept) => {
|
||||
await onSubmit(data, false);
|
||||
return true;
|
||||
}}
|
||||
columns={columns}
|
||||
layout={'vertical'}
|
||||
grid
|
||||
rowProps={{gutter: [30, 0]}}
|
||||
colProps={{ span: 12 }}
|
||||
/>
|
||||
<Card
|
||||
title={(
|
||||
<Space>
|
||||
<AuthButton auth={"system.dept.create"}>
|
||||
<Button
|
||||
loading={loading}
|
||||
children={t("system.dept.createButton")}
|
||||
icon={<PlusOutlined />}
|
||||
type={'primary'}
|
||||
onClick={() => addChange()}
|
||||
/>
|
||||
</AuthButton>
|
||||
<AuthButton auth={"system.dept.create"}>
|
||||
<Button
|
||||
loading={loading}
|
||||
children={t("system.dept.createChildrenButton")}
|
||||
icon={<PlusOutlined />}
|
||||
type={'primary'}
|
||||
onClick={() => addChange(true)}
|
||||
/>
|
||||
</AuthButton>
|
||||
</Space>
|
||||
)}
|
||||
variant={'borderless'}
|
||||
loading={loading}
|
||||
styles={{ body: { minHeight: '70vh' } }}
|
||||
>
|
||||
{checkedKeys.length > 0 && (
|
||||
<Alert
|
||||
style={{ marginBottom: 20 }}
|
||||
description={t("system.dept.checkedMessage", {checked: checkedKeys.length})}
|
||||
type="info"
|
||||
action={
|
||||
<Space>
|
||||
<Button size="small" type="primary" onClick={()=> setCheckedKeys([])}>
|
||||
{t("system.dept.unselect")}
|
||||
</Button>
|
||||
<AuthButton auth={"system.dept.delete"}>
|
||||
<Popconfirm
|
||||
okText={t("system.dept.delete.ok")}
|
||||
cancelText={t("system.dept.delete.cancel")}
|
||||
title={t("system.dept.delete.title")}
|
||||
description={t("system.dept.delete.description")}
|
||||
onConfirm={() => onDeleteConfirm()}
|
||||
>
|
||||
<Button type="primary" icon={<DeleteOutlined />} size={'small'} danger loading={loading}/>
|
||||
</Popconfirm>
|
||||
</AuthButton>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Tree
|
||||
checkable
|
||||
treeData={deptData}
|
||||
showIcon={true}
|
||||
checkStrictly={true}
|
||||
selectedKeys={[selectKey]}
|
||||
defaultExpandedKeys={[selectKey]}
|
||||
onSelect={onSelect}
|
||||
checkedKeys={checkedKeys}
|
||||
onCheck={onCheck}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xxl={12} lg={12} xs={24}>
|
||||
<Card
|
||||
variant={'borderless'}
|
||||
tabList={tabList}
|
||||
tabProps={{ accessKey: tabKey }}
|
||||
onTabChange={setTabKey}
|
||||
styles={{ body: { minHeight: '70vh' } }}
|
||||
>
|
||||
<XinForm<ISysDept>
|
||||
formRef={formRef}
|
||||
onFinish={async (data: ISysDept) => {
|
||||
await onSubmit(data, true);
|
||||
return true;
|
||||
}}
|
||||
columns={columns}
|
||||
layout={'horizontal'}
|
||||
style={{ display: tabKey === 'info' ? "block" : "none" }}
|
||||
submitter={{
|
||||
render: (dom) => (
|
||||
<AuthButton auth={"system.dept.update"}>
|
||||
{dom['submit']}
|
||||
</AuthButton>
|
||||
),
|
||||
submitText: t("system.dept.saveInfo")
|
||||
}}
|
||||
/>
|
||||
<AuthButton auth={"system.dept.users"}>
|
||||
<Table<ISysUser>
|
||||
style={{ display: tabKey === 'users' ? "block" : "none" }}
|
||||
dataSource={users}
|
||||
bordered={true}
|
||||
columns={usersColumns}
|
||||
loading={tableLoading}
|
||||
size={'small'}
|
||||
pagination={{
|
||||
current: tableParams.page,
|
||||
pageSize: tableParams.pageSize,
|
||||
total: tableParams.total,
|
||||
showSizeChanger: true,
|
||||
onChange: (page, pageSize) => {
|
||||
const params = { total: tableParams.total, pageSize, page }
|
||||
setTableParams(params);
|
||||
selectUsers(Number(selectKey), params).then();
|
||||
}
|
||||
}}
|
||||
scroll={{x: 600}}
|
||||
/>
|
||||
</AuthButton>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dept;
|
||||
@@ -0,0 +1,154 @@
|
||||
import XinTable from '@/components/XinTable';
|
||||
import {Badge, Button, Tooltip, Typography} from 'antd';
|
||||
import {UnorderedListOutlined} from '@ant-design/icons';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
import {type IDict} from '@/domain/iDict';
|
||||
import type {XinTableColumn} from '@/components/XinTable/typings';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {useNavigate} from 'react-router';
|
||||
import dayjs from 'dayjs';
|
||||
import useDictStore from '@/stores/dict';
|
||||
|
||||
/** 字典类型管理 */
|
||||
export default function DictPage() {
|
||||
const {t} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const initDict = useDictStore(state => state.initDict);
|
||||
|
||||
const columns: XinTableColumn<IDict>[] = [
|
||||
{
|
||||
title: t('system.dict.id'),
|
||||
dataIndex: 'id',
|
||||
hideInForm: true,
|
||||
width: 80,
|
||||
sorter: true,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: t('system.dict.name'),
|
||||
dataIndex: 'name',
|
||||
valueType: 'text',
|
||||
colProps: {span: 12},
|
||||
rules: [{required: true, message: t('system.dict.name.required')}],
|
||||
},
|
||||
{
|
||||
title: t('system.dict.code'),
|
||||
dataIndex: 'code',
|
||||
valueType: 'text',
|
||||
colProps: {span: 12},
|
||||
rules: [{required: true, message: t('system.dict.code.required')}],
|
||||
},
|
||||
{
|
||||
title: t('system.dict.status'),
|
||||
dataIndex: 'status',
|
||||
valueType: 'select',
|
||||
filters: [
|
||||
{text: t('system.dict.status.normal'), value: 0},
|
||||
{text: t('system.dict.status.disabled'), value: 1},
|
||||
],
|
||||
colProps: {span: 12},
|
||||
rules: [{required: true, message: t('system.dict.status.required')}],
|
||||
fieldProps: {
|
||||
options: [
|
||||
{label: t('system.dict.status.normal'), value: 0},
|
||||
{label: t('system.dict.status.disabled'), value: 1},
|
||||
],
|
||||
},
|
||||
render: (value: number) => {
|
||||
return value === 0
|
||||
? <Badge status="success" text={t('system.dict.status.normal')}/>
|
||||
: <Badge status="error" text={t('system.dict.status.disabled')}/>;
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('system.dict.sort'),
|
||||
dataIndex: 'sort',
|
||||
valueType: 'digit',
|
||||
colProps: {span: 12},
|
||||
hideInSearch: true,
|
||||
fieldProps: {
|
||||
min: 0,
|
||||
style: {width: '100%'}
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('system.dict.describe'),
|
||||
dataIndex: 'describe',
|
||||
valueType: 'textarea',
|
||||
colProps: {span: 24},
|
||||
hideInSearch: true,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: t('system.dict.createdAt'),
|
||||
dataIndex: 'created_at',
|
||||
render: (value: string) => value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '-',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
width: 160,
|
||||
},
|
||||
];
|
||||
|
||||
// 刷新字典缓存
|
||||
const handleRefreshCache = async () => {
|
||||
await initDict();
|
||||
window.$message?.success(t('system.dict.refreshSuccess'));
|
||||
};
|
||||
|
||||
// 跳转到字典项页面
|
||||
const handleGoToItems = (record: IDict) => {
|
||||
navigate(`/system/dict/item?dictId=${record.id}&dictName=${encodeURIComponent(record.name || '')}&dictCode=${record.code}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={'mb-5'}>
|
||||
<Title level={3}>{t('system.dict.page.title')}</Title>
|
||||
<Text type="secondary">{t('system.dict.page.description')}</Text>
|
||||
</div>
|
||||
<XinTable<IDict>
|
||||
api={'/system/dict/list'}
|
||||
columns={columns}
|
||||
rowKey={'id'}
|
||||
accessName={'system.dict.list'}
|
||||
searchProps={false}
|
||||
formProps={{
|
||||
grid: true,
|
||||
colProps: {span: 12},
|
||||
rowProps: {gutter: [30, 0]},
|
||||
layout: 'vertical'
|
||||
}}
|
||||
modalProps={{ width: 800 }}
|
||||
actionBarRender={(dom) => [
|
||||
dom.add,
|
||||
<Button
|
||||
type="primary"
|
||||
key="refresh"
|
||||
onClick={handleRefreshCache}
|
||||
>
|
||||
{t('system.dict.refreshCache')}
|
||||
</Button>,
|
||||
dom.keywordSearch,
|
||||
]}
|
||||
operateProps={{
|
||||
fixed: 'right',
|
||||
width: 180,
|
||||
}}
|
||||
scroll={{x: 1000}}
|
||||
operateRender={(record, dom) => [
|
||||
<Tooltip title={t('system.dict.manageItems')}>
|
||||
<Button
|
||||
type="default"
|
||||
icon={<UnorderedListOutlined/>}
|
||||
size="small"
|
||||
onClick={() => handleGoToItems(record)}
|
||||
/>
|
||||
</Tooltip>,
|
||||
dom.edit,
|
||||
dom.del
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import XinTable from '@/components/XinTable';
|
||||
import {Badge, Button, Space, Tag, Typography} from 'antd';
|
||||
import type {IDictItem} from '@/domain/iDictItem';
|
||||
import {COLORS} from '@/domain/iDictItem';
|
||||
import type {XinTableColumn} from '@/components/XinTable/typings';
|
||||
import {useEffect, useState} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {useNavigate, useSearchParams} from 'react-router';
|
||||
import {Create, Update} from "@/api/common/table.ts";
|
||||
import dayjs from 'dayjs';
|
||||
import {LeftOutlined} from "@ant-design/icons";
|
||||
|
||||
/** 字典数据管理 */
|
||||
export default function DictItemPage() {
|
||||
const {t} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
// 从 URL 参数获取字典信息
|
||||
const dictId = searchParams.get('dictId');
|
||||
const dictName = searchParams.get('dictName') || '';
|
||||
const dictCode = searchParams.get('dictCode') || '';
|
||||
|
||||
const [currentDict, setCurrentDict] = useState({
|
||||
id: dictId ? parseInt(dictId) : 0,
|
||||
name: decodeURIComponent(dictName),
|
||||
code: dictCode,
|
||||
});
|
||||
|
||||
// 监听 URL 参数变化
|
||||
useEffect(() => {
|
||||
if (dictId) {
|
||||
setCurrentDict({
|
||||
id: parseInt(dictId),
|
||||
name: decodeURIComponent(dictName),
|
||||
code: dictCode,
|
||||
});
|
||||
}
|
||||
}, [dictId, dictName, dictCode]);
|
||||
|
||||
const columns: XinTableColumn<IDictItem>[] = [
|
||||
{
|
||||
title: t('system.system.dict.item.id'),
|
||||
dataIndex: 'id',
|
||||
hideInForm: true,
|
||||
width: 80,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: t('system.system.dict.item.label'),
|
||||
dataIndex: 'label',
|
||||
valueType: 'text',
|
||||
rules: [{required: true, message: t('system.system.dict.item.label.required')}],
|
||||
},
|
||||
{
|
||||
title: t('system.system.dict.item.value'),
|
||||
dataIndex: 'value',
|
||||
valueType: 'text',
|
||||
rules: [{required: true, message: t('system.system.dict.item.value.required')}],
|
||||
},
|
||||
{
|
||||
title: t('system.system.dict.item.color'),
|
||||
dataIndex: 'color',
|
||||
valueType: 'select',
|
||||
colProps: {span: 12},
|
||||
initialValue: 'default',
|
||||
fieldProps: {
|
||||
options: COLORS.map(item => ({
|
||||
label: <Tag color={item.value}>{item.label}</Tag>,
|
||||
value: item.value
|
||||
})),
|
||||
},
|
||||
render: (value: string) => {
|
||||
const item = COLORS.find(i => i.value === value);
|
||||
return <Tag color={value}>{item?.label || value}</Tag>;
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('system.system.dict.item.isDefault'),
|
||||
dataIndex: 'is_default',
|
||||
valueType: 'select',
|
||||
colProps: {span: 12},
|
||||
initialValue: 0,
|
||||
rules: [{required: true, message: t('system.system.dict.item.isDefault.required')}],
|
||||
fieldProps: {
|
||||
options: [
|
||||
{label: t('system.system.dict.item.isDefault.yes'), value: 1},
|
||||
{label: t('system.system.dict.item.isDefault.no'), value: 0},
|
||||
],
|
||||
},
|
||||
render: (value: number) => {
|
||||
return value === 1
|
||||
? <Tag color="blue">{t('system.system.dict.item.isDefault.yes')}</Tag>
|
||||
: t('system.system.dict.item.isDefault.no');
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('system.system.dict.item.sort'),
|
||||
dataIndex: 'sort',
|
||||
valueType: 'digit',
|
||||
colProps: {span: 12},
|
||||
hideInSearch: true,
|
||||
initialValue: 0,
|
||||
fieldProps: {
|
||||
min: 0,
|
||||
style: {width: '100%'}
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('system.system.dict.item.status'),
|
||||
dataIndex: 'status',
|
||||
valueType: 'select',
|
||||
colProps: {span: 12},
|
||||
initialValue: 0,
|
||||
rules: [{required: true, message: t('system.system.dict.item.status.required')}],
|
||||
fieldProps: {
|
||||
options: [
|
||||
{label: t('system.system.dict.item.status.normal'), value: 0},
|
||||
{label: t('system.system.dict.item.status.disabled'), value: 1},
|
||||
],
|
||||
},
|
||||
render: (value: number) => {
|
||||
return value === 0
|
||||
? <Badge status="success" text={t('system.system.dict.item.status.normal')}/>
|
||||
: <Badge status="error" text={t('system.system.dict.item.status.disabled')}/>;
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('system.system.dict.item.createTime'),
|
||||
dataIndex: 'created_at',
|
||||
render: (value: string) => value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '-',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
width: 160,
|
||||
},
|
||||
];
|
||||
|
||||
// 返回字典列表
|
||||
const handleGoBack = () => {
|
||||
navigate('/system/dict');
|
||||
};
|
||||
|
||||
// 如果没有字典ID,显示提示
|
||||
if (!currentDict.id) {
|
||||
return (
|
||||
<div style={{padding: 50, textAlign: 'center'}}>
|
||||
<div style={{marginTop: 50, color: '#999'}}>
|
||||
{t('system.dict.selectDictFirst')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Space orientation={'vertical'} style={{width: '100%'}}>
|
||||
<div>
|
||||
<Button type={'link'} onClick={handleGoBack} icon={<LeftOutlined/>} classNames={{ root: 'p-0 mb-2' }}>
|
||||
{t('system.dict.backToList')}
|
||||
</Button>
|
||||
<Typography.Title level={3}>
|
||||
<span className={'mr-2'}>{t('system.dict.itemManagement')} - {currentDict.name}</span>
|
||||
<Typography.Text type="secondary">{currentDict.code}</Typography.Text>
|
||||
</Typography.Title>
|
||||
|
||||
</div>
|
||||
<XinTable<IDictItem>
|
||||
api={'/system/dict/item'}
|
||||
columns={columns}
|
||||
rowKey={'id'}
|
||||
accessName={'system.dict.item'}
|
||||
formProps={{
|
||||
grid: true,
|
||||
colProps: {span: 12},
|
||||
layout: 'vertical',
|
||||
rowProps: {gutter: [30, 0]}
|
||||
}}
|
||||
modalProps={{ width: 600 }}
|
||||
requestParams={(params) => {
|
||||
return {
|
||||
...params,
|
||||
dict_id: currentDict.id,
|
||||
}
|
||||
}}
|
||||
searchShow={false}
|
||||
handleFinish={async (values, mode, _form, defaultValue) => {
|
||||
if (mode === 'create') {
|
||||
await Create('/system/dict/item', {
|
||||
...values,
|
||||
dict_id: currentDict.id,
|
||||
});
|
||||
window.$message?.success(t('system.dict.item.createSuccess'));
|
||||
} else {
|
||||
await Update('/system/dict/item/' + defaultValue?.id, {
|
||||
...values,
|
||||
dict_id: currentDict.id,
|
||||
});
|
||||
window.$message?.success(t('system.dict.item.updateSuccess'));
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,144 @@
|
||||
import XinTable from '@/components/XinTable';
|
||||
import { Badge, Image, Typography } from 'antd';
|
||||
import type { IGridNav } from '@/domain/iGridNav';
|
||||
import type { XinTableColumn } from '@/components/XinTable/typings';
|
||||
import type { ISysFileInfo } from '@/domain/iSysFile';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
/**
|
||||
* 从 image 字段中提取预览 URL
|
||||
* image 字段可能是 ISysFileInfo 对象或 URL 字符串
|
||||
*/
|
||||
function getImageUrl(image: ISysFileInfo | string | null | undefined): string {
|
||||
if (!image) return '';
|
||||
if (typeof image === 'string') return image;
|
||||
return image.preview_url || image.file_url || '';
|
||||
}
|
||||
|
||||
/** 首页宫格导航管理 */
|
||||
export default function GridNavPage() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const columns: XinTableColumn<IGridNav>[] = [
|
||||
{
|
||||
title: t('system.gridNav.id'),
|
||||
dataIndex: 'id',
|
||||
hideInForm: true,
|
||||
width: 80,
|
||||
sorter: true,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: t('system.gridNav.title'),
|
||||
dataIndex: 'title',
|
||||
valueType: 'text',
|
||||
colProps: { span: 12 },
|
||||
rules: [{ required: true, message: t('system.gridNav.title.required') }],
|
||||
},
|
||||
{
|
||||
title: t('system.gridNav.image'),
|
||||
dataIndex: 'image_id',
|
||||
valueType: 'image',
|
||||
colProps: { span: 24 },
|
||||
rules: [{ required: true, message: t('system.gridNav.image.required') }],
|
||||
hideInSearch: true,
|
||||
fieldProps: {
|
||||
action: '/system/file/list/upload',
|
||||
mode: 'single',
|
||||
maxCount: 1,
|
||||
changeType: 'id'
|
||||
},
|
||||
render: (_value: ISysFileInfo | string, record: IGridNav) => {
|
||||
const url = getImageUrl(record.image);
|
||||
if (!url) return '-';
|
||||
return (
|
||||
<Image
|
||||
src={url}
|
||||
width={60}
|
||||
height={60}
|
||||
style={{ objectFit: 'cover', borderRadius: 4 }}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('system.gridNav.link'),
|
||||
dataIndex: 'link',
|
||||
valueType: 'text',
|
||||
colProps: { span: 12 },
|
||||
hideInSearch: true,
|
||||
fieldProps: {
|
||||
placeholder: t('system.gridNav.link.placeholder'),
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('system.gridNav.status'),
|
||||
dataIndex: 'status',
|
||||
valueType: 'select',
|
||||
filters: [
|
||||
{ text: t('system.gridNav.status.normal'), value: 0 },
|
||||
{ text: t('system.gridNav.status.disabled'), value: 1 },
|
||||
],
|
||||
colProps: { span: 12 },
|
||||
rules: [{ required: true, message: t('system.gridNav.status.required') }],
|
||||
fieldProps: {
|
||||
options: [
|
||||
{ label: t('system.gridNav.status.normal'), value: 0 },
|
||||
{ label: t('system.gridNav.status.disabled'), value: 1 },
|
||||
],
|
||||
},
|
||||
render: (value: number) => {
|
||||
return value === 0
|
||||
? <Badge status="success" text={t('system.gridNav.status.normal')} />
|
||||
: <Badge status="error" text={t('system.gridNav.status.disabled')} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('system.gridNav.sort'),
|
||||
dataIndex: 'sort',
|
||||
valueType: 'digit',
|
||||
colProps: { span: 12 },
|
||||
hideInSearch: true,
|
||||
initialValue: 0,
|
||||
fieldProps: {
|
||||
min: 0,
|
||||
style: { width: '100%' },
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('system.gridNav.createdAt'),
|
||||
dataIndex: 'created_at',
|
||||
render: (value: string) => (value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '-'),
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
width: 160,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-5">
|
||||
<Title level={3}>{t('system.gridNav.page.title')}</Title>
|
||||
<Text type="secondary">{t('system.gridNav.page.description')}</Text>
|
||||
</div>
|
||||
<XinTable<IGridNav>
|
||||
api="/system/grid-nav"
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
accessName="system.grid-nav"
|
||||
formProps={{
|
||||
grid: true,
|
||||
colProps: { span: 12 },
|
||||
rowProps: { gutter: [30, 0] },
|
||||
layout: 'vertical',
|
||||
}}
|
||||
modalProps={{ width: 800 }}
|
||||
searchProps={false}
|
||||
scroll={{ x: 1000 }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { Card, Divider, Tag, Timeline, Avatar, Typography } from 'antd';
|
||||
import {
|
||||
InfoCircleOutlined,
|
||||
LinkOutlined,
|
||||
HistoryOutlined,
|
||||
UserOutlined,
|
||||
FileTextOutlined,
|
||||
GithubOutlined,
|
||||
QqOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
const SystemInfoPage = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 系统基本信息
|
||||
const systemInfo = [
|
||||
{ label: t('system.info.label.name'), value: 'XinAdmin' },
|
||||
{ label: t('system.info.label.version'), value: 'v2.0' },
|
||||
{ label: t('system.info.label.build'), value: 'Vite 7.1.5' },
|
||||
{ label: t('system.info.label.frontend'), value: 'React 19.1.0' },
|
||||
{ label: t('system.info.label.ui'), value: 'Ant Design 5.27.1' },
|
||||
{ label: t('system.info.label.css'), value: 'TailwindCSS 4.1.11' },
|
||||
{ label: t('system.info.label.router'), value: 'React Router 7.8.2' },
|
||||
{ label: t('system.info.label.state'), value: 'Zustand 5.0.8' },
|
||||
{ label: t('system.info.label.ts'), value: '5.8.3' },
|
||||
];
|
||||
|
||||
// 项目地址
|
||||
const projectLinks = [
|
||||
{ name: t('system.info.link.github'), url: 'https://github.com/xin-admin/xin-admin', icon: <GithubOutlined /> },
|
||||
{ name: t('system.info.link.docs'), url: 'https://xinadmin.cn/ui/intro', icon: <FileTextOutlined /> },
|
||||
{ name: t('system.info.link.demo'), url: 'https://ui.xinadmin.cn', icon: <LinkOutlined /> },
|
||||
{ name: t('system.info.link.issues'), url: 'https://github.com/xin-admin/xin-admin/issues', icon: <InfoCircleOutlined /> },
|
||||
];
|
||||
|
||||
// 更新日志
|
||||
const changelog = [
|
||||
{ time: '2025-11', version: 'v2.0', content: t('system.info.log.content') },
|
||||
];
|
||||
|
||||
// 作者信息
|
||||
const authorInfo = {
|
||||
name: t('system.info.author.name'),
|
||||
avatar: 'https://file.xinadmin.cn/file/favicons.ico',
|
||||
role: t('system.info.author.role'),
|
||||
contact: [
|
||||
{ type: t('system.info.contact.github'), value: 'https://github.com/xin-admin/xin-admin', icon: <GithubOutlined /> },
|
||||
{ type: t('system.info.contact.group'), value: 'Xin Admin Official Community', icon: <QqOutlined /> },
|
||||
{ type: t('system.info.contact.discuss'), value: 'https://github.com/xin-admin/xin-admin/discussions', icon: <LinkOutlined /> },
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<div className={'mb-5'}>
|
||||
<Title level={3}>{t('system.info.title')}</Title>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{/* 系统基本信息卡片 */}
|
||||
<Card
|
||||
variant={'borderless'}
|
||||
title={
|
||||
<div className="flex items-center">
|
||||
<InfoCircleOutlined className="mr-2 text-blue-500" />
|
||||
<span>{t('system.info.basic.title')}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{systemInfo.map((item, index) => (
|
||||
<div key={index} className="flex justify-between items-center py-1">
|
||||
<span className="text-gray-600 text-sm">{item.label}:</span>
|
||||
<span className="font-medium text-sm">{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Divider className="my-4" />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Tag color="blue">React 19</Tag>
|
||||
<Tag color="geekblue">Ant Design</Tag>
|
||||
<Tag color="cyan">TailwindCSS</Tag>
|
||||
<Tag color="purple">TypeScript</Tag>
|
||||
<Tag color="green">Vite 7</Tag>
|
||||
<Tag color="orange">Zustand</Tag>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 项目地址卡片 */}
|
||||
<Card
|
||||
variant={'borderless'}
|
||||
title={
|
||||
<div className="flex items-center">
|
||||
<LinkOutlined className="mr-2 text-green-500" />
|
||||
<span>{t('system.info.project.title')}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{projectLinks.map((link, index) => (
|
||||
<div key={index} className="flex items-center">
|
||||
<span className="mr-2 text-gray-500">{link.icon}</span>
|
||||
<span className="text-gray-600 mr-2">{link.name}:</span>
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:underline"
|
||||
>
|
||||
{link.url}
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Divider className="my-4" />
|
||||
<div className="text-sm text-gray-500">
|
||||
{t('system.info.join')}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 更新日志卡片 */}
|
||||
<Card
|
||||
variant={'borderless'}
|
||||
title={
|
||||
<div className="flex items-center">
|
||||
<HistoryOutlined className="mr-2 text-orange-500" />
|
||||
<span>{t('system.info.changelog.title')}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Timeline mode="left" className="mt-4">
|
||||
{changelog.map((log, index) => (
|
||||
<Timeline.Item key={index}>
|
||||
<div className="font-medium">{log.version}<span className="ml-2 text-gray-400 font-normal">{log.time} </span></div>
|
||||
<div className="text-gray-600">{log.content}</div>
|
||||
</Timeline.Item>
|
||||
))}
|
||||
</Timeline>
|
||||
</Card>
|
||||
|
||||
{/* 作者介绍卡片 */}
|
||||
<Card
|
||||
variant={'borderless'}
|
||||
title={
|
||||
<div className="flex items-center">
|
||||
<UserOutlined className="mr-2 text-purple-500" />
|
||||
<span>{t('system.info.author.title')}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex items-center mb-4">
|
||||
<Avatar
|
||||
src={authorInfo.avatar}
|
||||
size={64}
|
||||
className="mr-4 border-2 border-purple-200"
|
||||
/>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold">{authorInfo.name}</h3>
|
||||
<p className="text-gray-600">{authorInfo.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Divider className="my-4" />
|
||||
<div className="space-y-3">
|
||||
{authorInfo.contact.map((item, index) => (
|
||||
<div key={index} className="flex items-center">
|
||||
<span className="mr-2 text-gray-500">{item.icon}</span>
|
||||
<span className="w-16 text-gray-600">{item.type}:</span>
|
||||
<span className="font-medium text-sm">
|
||||
{item.value.startsWith('http') ? (
|
||||
<a
|
||||
href={item.value}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:underline"
|
||||
>
|
||||
{item.value}
|
||||
</a>
|
||||
) : (
|
||||
item.value
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 系统描述卡片 */}
|
||||
<Card
|
||||
variant={'borderless'}
|
||||
title={
|
||||
<div className="flex items-center">
|
||||
<FileTextOutlined className="mr-2 text-indigo-500" />
|
||||
<span>{t('system.info.desc.title')}</span>
|
||||
</div>
|
||||
}
|
||||
className="md:col-span-2 lg:col-span-1"
|
||||
>
|
||||
<div className="prose max-w-none">
|
||||
<p className="text-gray-700 leading-relaxed whitespace-pre-line">
|
||||
{t('system.info.desc.content')}
|
||||
</p>
|
||||
</div>
|
||||
<Divider className="my-4" />
|
||||
<div className="flex justify-center flex-wrap gap-2">
|
||||
<Tag color="magenta">{t('system.info.tag.enterprise')}</Tag>
|
||||
<Tag color="red">{t('system.info.tag.ready')}</Tag>
|
||||
<Tag color="volcano">{t('system.info.tag.scalable')}</Tag>
|
||||
<Tag color="gold">{t('system.info.tag.modern')}</Tag>
|
||||
<Tag color="blue">{t('system.info.tag.i18n')}</Tag>
|
||||
<Tag color="green">{t('system.info.tag.auth')}</Tag>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SystemInfoPage;
|
||||
@@ -0,0 +1,514 @@
|
||||
import React, {useEffect, useRef, useState} from 'react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
message,
|
||||
Row,
|
||||
Col,
|
||||
Typography,
|
||||
Divider
|
||||
} from 'antd';
|
||||
import { SendOutlined } from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getMailConfig, saveMailConfig, sendTestMail } from '@/api/system/sysMail.ts';
|
||||
import type {FormColumn} from "@/components/XinFormField/FieldRender";
|
||||
import XinForm, {type XinFormRef} from "@/components/XinForm";
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
interface MailResponse {
|
||||
other: {
|
||||
mode: 'single' | 'failover' | 'roundrobin',
|
||||
mailers: string[];
|
||||
},
|
||||
mail: {
|
||||
default: string;
|
||||
mailers: {
|
||||
smtp: {
|
||||
url: string;
|
||||
host: string;
|
||||
port: string;
|
||||
username: string;
|
||||
password: string;
|
||||
},
|
||||
log: {
|
||||
channel: string;
|
||||
}
|
||||
},
|
||||
from: {
|
||||
address: string;
|
||||
name: string;
|
||||
}
|
||||
},
|
||||
services: {
|
||||
postmark: {
|
||||
token: string;
|
||||
},
|
||||
ses: {
|
||||
key: string;
|
||||
secret: string;
|
||||
region: string;
|
||||
token: string;
|
||||
},
|
||||
resend: {
|
||||
key: string;
|
||||
},
|
||||
mailgun: {
|
||||
domain: string;
|
||||
secret: string;
|
||||
endpoint: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const MailConfig: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const formRef = useRef<XinFormRef>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [testForm] = Form.useForm();
|
||||
const [testing, setTesting] = useState(false);
|
||||
|
||||
const driverOptions = [
|
||||
{ label: t('system.mail.driver.smtp'), value: 'smtp' },
|
||||
{ label: t('system.mail.driver.ses'), value: 'ses' },
|
||||
{ label: t('system.mail.driver.mailgun'), value: 'mailgun' },
|
||||
{ label: t('system.mail.driver.postmark'), value: 'postmark' },
|
||||
{ label: t('system.mail.driver.resend'), value: 'resend' },
|
||||
{ label: t('system.mail.driver.log'), value: 'log' },
|
||||
{ label: t('system.mail.driver.array'), value: 'array' },
|
||||
];
|
||||
|
||||
const columns: FormColumn<MailResponse>[] = [
|
||||
{
|
||||
dataIndex: ['other', 'mode'],
|
||||
valueType: 'radio',
|
||||
title: t('system.mail.mode'),
|
||||
fieldProps: {
|
||||
options: [
|
||||
{ label: t('system.mail.mode.single'), value: 'single'},
|
||||
{ label: t('system.mail.mode.failover'), value: 'failover'},
|
||||
{ label: t('system.mail.mode.roundrobin'), value: 'roundrobin'},
|
||||
]
|
||||
},
|
||||
required: true,
|
||||
colProps: {span: 24}
|
||||
},
|
||||
{
|
||||
dataIndex: ['mail', 'default'],
|
||||
valueType: 'radioButton',
|
||||
title: t('system.mail.driver'),
|
||||
fieldProps: {
|
||||
options: driverOptions
|
||||
},
|
||||
required: true,
|
||||
colProps: {span: 24},
|
||||
dependency: {
|
||||
dependencies: ['other'],
|
||||
visible: (values) => {
|
||||
return values.other?.mode === 'single'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['other', 'mailers'],
|
||||
valueType: 'checkbox',
|
||||
title: t('system.mail.driver'),
|
||||
fieldProps: { options: driverOptions},
|
||||
colProps: {span: 24},
|
||||
dependency: {
|
||||
dependencies: ['other'],
|
||||
visible: (values) => {
|
||||
return values.other?.mode !== 'single'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['mail', 'from', 'address'],
|
||||
valueType: 'text',
|
||||
title: t('system.mail.from_address'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.mail.from_address.placeholder'),
|
||||
},
|
||||
required: true,
|
||||
colProps: {span: 12}
|
||||
},
|
||||
{
|
||||
dataIndex: ['mail', 'from', 'name'],
|
||||
valueType: 'text',
|
||||
title: t('system.mail.from_name'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.mail.from_name.placeholder'),
|
||||
},
|
||||
required: true,
|
||||
colProps: {span: 12}
|
||||
},
|
||||
{
|
||||
fieldRender: () => <Divider>{t('system.mail.smtp.title')}</Divider>,
|
||||
dependency: {
|
||||
dependencies: ['mail', 'other'],
|
||||
visible: (values) => {
|
||||
return values.mail?.default === 'smtp' || !!values.other?.mailers?.includes('smtp')
|
||||
}
|
||||
},
|
||||
colProps: {span: 24},
|
||||
noStyle: true
|
||||
},
|
||||
{
|
||||
dataIndex: ['mail', 'mailers', 'smtp', 'host'],
|
||||
valueType: 'text',
|
||||
title: t('system.mail.host'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.mail.host.placeholder'),
|
||||
},
|
||||
required: true,
|
||||
colProps: {span: 16},
|
||||
dependency: {
|
||||
dependencies: ['mail', 'other'],
|
||||
visible: (values) => {
|
||||
return values.mail?.default === 'smtp' || !!values.other?.mailers?.includes('smtp')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['mail', 'mailers', 'smtp', 'port'],
|
||||
valueType: 'digit',
|
||||
title: t('system.mail.port'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.mail.port.placeholder'),
|
||||
},
|
||||
required: true,
|
||||
colProps: {span: 8},
|
||||
dependency: {
|
||||
dependencies: ['mail', 'other'],
|
||||
visible: (values) => {
|
||||
return values.mail?.default === 'smtp' || !!values.other?.mailers?.includes('smtp')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['mail', 'mailers', 'smtp', 'username'],
|
||||
valueType: 'text',
|
||||
title: t('system.mail.username'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.mail.username.placeholder'),
|
||||
},
|
||||
required: true,
|
||||
colProps: {span: 12},
|
||||
dependency: {
|
||||
dependencies: ['mail', 'other'],
|
||||
visible: (values) => {
|
||||
return values.mail?.default === 'smtp' || !!values.other?.mailers?.includes('smtp')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['mail', 'mailers', 'smtp', 'password'],
|
||||
valueType: 'password',
|
||||
title: t('system.mail.password'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.mail.password.placeholder'),
|
||||
},
|
||||
required: true,
|
||||
colProps: {span: 12},
|
||||
dependency: {
|
||||
dependencies: ['mail', 'other'],
|
||||
visible: (values) => {
|
||||
return values.mail?.default === 'smtp' || !!values.other?.mailers?.includes('smtp')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
fieldRender: () => <Divider>{t('system.mail.log.title')}</Divider>,
|
||||
dependency: {
|
||||
dependencies: ['mail', 'other'],
|
||||
visible: (values) => {
|
||||
return values.mail?.default === 'log' || !!values.other?.mailers?.includes('log')
|
||||
}
|
||||
},
|
||||
colProps: {span: 24},
|
||||
noStyle: true
|
||||
},
|
||||
{
|
||||
dataIndex: ['mail', 'mailers', 'log', 'channel'],
|
||||
valueType: 'text',
|
||||
title: t('system.mail.log.channel'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.mail.log.channel.placeholder'),
|
||||
},
|
||||
required: true,
|
||||
colProps: {span: 24},
|
||||
dependency: {
|
||||
dependencies: ['mail', 'other'],
|
||||
visible: (values) => {
|
||||
return values.mail?.default === 'log' || !!values.other?.mailers?.includes('log')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
fieldRender: () => <Divider>{t('system.mail.postmark.title')}</Divider>,
|
||||
dependency: {
|
||||
dependencies: ['mail', 'other'],
|
||||
visible: (values) => {
|
||||
return values.mail?.default === 'postmark' || !!values.other?.mailers?.includes('postmark')
|
||||
}
|
||||
},
|
||||
colProps: {span: 24},
|
||||
noStyle: true
|
||||
},
|
||||
{
|
||||
dataIndex: ['services', 'postmark', 'token'],
|
||||
valueType: 'text',
|
||||
title: t('system.mail.postmark.token'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.mail.postmark.token.placeholder'),
|
||||
},
|
||||
required: true,
|
||||
colProps: {span: 24},
|
||||
dependency: {
|
||||
dependencies: ['mail', 'other'],
|
||||
visible: (values) => {
|
||||
return values.mail?.default === 'postmark' || !!values.other?.mailers?.includes('postmark')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
fieldRender: () => <Divider>{t('system.mail.resend.title')}</Divider>,
|
||||
dependency: {
|
||||
dependencies: ['mail', 'other'],
|
||||
visible: (values) => {
|
||||
return values.mail?.default === 'resend' || !!values.other?.mailers?.includes('resend')
|
||||
}
|
||||
},
|
||||
colProps: {span: 24},
|
||||
noStyle: true
|
||||
},
|
||||
{
|
||||
dataIndex: ['services', 'resend', 'key'],
|
||||
valueType: 'text',
|
||||
title: t('system.mail.resend.key'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.mail.resend.key.placeholder'),
|
||||
},
|
||||
required: true,
|
||||
colProps: {span: 24},
|
||||
dependency: {
|
||||
dependencies: ['mail', 'other'],
|
||||
visible: (values) => {
|
||||
return values.mail?.default === 'resend' || !!values.other?.mailers?.includes('resend')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
fieldRender: () => <Divider>{t('system.mail.mailgun.title')}</Divider>,
|
||||
dependency: {
|
||||
dependencies: ['mail', 'other'],
|
||||
visible: (values) => {
|
||||
return values.mail?.default === 'mailgun' || !!values.other?.mailers?.includes('mailgun')
|
||||
}
|
||||
},
|
||||
colProps: {span: 24},
|
||||
noStyle: true
|
||||
},
|
||||
{
|
||||
dataIndex: ['services', 'mailgun', 'domain'],
|
||||
valueType: 'text',
|
||||
title: t('system.mail.mailgun.domain'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.mail.mailgun.domain.placeholder'),
|
||||
},
|
||||
required: true,
|
||||
colProps: {span: 12},
|
||||
dependency: {
|
||||
dependencies: ['mail', 'other'],
|
||||
visible: (values) => {
|
||||
return values.mail?.default === 'mailgun' || !!values.other?.mailers?.includes('mailgun')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['services', 'mailgun', 'secret'],
|
||||
valueType: 'text',
|
||||
title: t('system.mail.mailgun.secret'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.mail.mailgun.secret.placeholder'),
|
||||
},
|
||||
required: true,
|
||||
colProps: {span: 12},
|
||||
dependency: {
|
||||
dependencies: ['mail', 'other'],
|
||||
visible: (values) => {
|
||||
return values.mail?.default === 'mailgun' || !!values.other?.mailers?.includes('mailgun')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['services', 'mailgun', 'endpoint'],
|
||||
valueType: 'text',
|
||||
title: t('system.mail.mailgun.endpoint'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.mail.mailgun.endpoint.placeholder'),
|
||||
},
|
||||
required: true,
|
||||
colProps: {span: 12},
|
||||
dependency: {
|
||||
dependencies: ['mail', 'other'],
|
||||
visible: (values) => {
|
||||
return values.mail?.default === 'mailgun' || !!values.other?.mailers?.includes('mailgun')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
fieldRender: () => <Divider>{t('system.mail.ses.title')}</Divider>,
|
||||
dependency: {
|
||||
dependencies: ['mail', 'other'],
|
||||
visible: (values) => {
|
||||
return values.mail?.default === 'ses' || !!values.other?.mailers?.includes('ses')
|
||||
}
|
||||
},
|
||||
colProps: {span: 24},
|
||||
noStyle: true
|
||||
},
|
||||
{
|
||||
dataIndex: ['services', 'ses', 'key'],
|
||||
valueType: 'text',
|
||||
title: t('system.mail.ses.key'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.mail.ses.key.placeholder'),
|
||||
},
|
||||
required: true,
|
||||
colProps: {span: 12},
|
||||
dependency: {
|
||||
dependencies: ['mail', 'other'],
|
||||
visible: (values) => {
|
||||
return values.mail?.default === 'ses' || !!values.other?.mailers?.includes('ses')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['services', 'ses', 'secret'],
|
||||
valueType: 'text',
|
||||
title: t('system.mail.ses.secret'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.mail.ses.secret.placeholder'),
|
||||
},
|
||||
required: true,
|
||||
colProps: {span: 12},
|
||||
dependency: {
|
||||
dependencies: ['mail', 'other'],
|
||||
visible: (values) => {
|
||||
return values.mail?.default === 'ses' || !!values.other?.mailers?.includes('ses')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['services', 'ses', 'region'],
|
||||
valueType: 'text',
|
||||
title: t('system.mail.ses.region'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.mail.ses.region.placeholder'),
|
||||
},
|
||||
required: true,
|
||||
colProps: {span: 12},
|
||||
dependency: {
|
||||
dependencies: ['mail', 'other'],
|
||||
visible: (values) => {
|
||||
return values.mail?.default === 'ses' || !!values.other?.mailers?.includes('ses')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['services', 'ses', 'token'],
|
||||
valueType: 'text',
|
||||
title: t('system.mail.ses.token'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.mail.ses.token.placeholder'),
|
||||
},
|
||||
required: true,
|
||||
colProps: {span: 12},
|
||||
dependency: {
|
||||
dependencies: ['mail', 'other'],
|
||||
visible: (values) => {
|
||||
return values.mail?.default === 'ses' || !!values.other?.mailers?.includes('ses')
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
}, []);
|
||||
|
||||
const loadConfig = async () => {
|
||||
const { data } = await getMailConfig();
|
||||
if (data.success) {
|
||||
form.setFieldsValue(data.data);
|
||||
}
|
||||
};
|
||||
|
||||
const onFinish = async (values: any) => {
|
||||
await saveMailConfig(values);
|
||||
message.success(t('system.mail.save.success'));
|
||||
};
|
||||
|
||||
const onTestFinish = async (values: any) => {
|
||||
try {
|
||||
setTesting(true);
|
||||
await sendTestMail(values.to);
|
||||
message.success(t('system.mail.test.success'));
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={'mb-5'}>
|
||||
<Title level={3}>{t('system.mail.page.title')}</Title>
|
||||
<Text type="secondary">{t('system.mail.page.description')}</Text>
|
||||
</div>
|
||||
<Row gutter={24}>
|
||||
<Col span={16}>
|
||||
<Card variant={'borderless'}>
|
||||
<XinForm
|
||||
columns={columns}
|
||||
layout="vertical"
|
||||
formRef={formRef}
|
||||
form={form}
|
||||
grid={true}
|
||||
onFinish={onFinish}
|
||||
rowProps={{
|
||||
gutter: [10, 0]
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={8}>
|
||||
<Card title={t('system.mail.test.title')}>
|
||||
<Form
|
||||
form={testForm}
|
||||
layout="vertical"
|
||||
onFinish={onTestFinish}
|
||||
>
|
||||
<Form.Item
|
||||
name="to"
|
||||
label={t('system.mail.test.to')}
|
||||
rules={[{ required: true, type: 'email' }]}
|
||||
>
|
||||
<Input placeholder={t('system.mail.test.to.placeholder')} />
|
||||
</Form.Item>
|
||||
|
||||
<Button type="primary" htmlType="submit" block icon={<SendOutlined />} loading={testing}>
|
||||
{t('system.mail.test.send')}
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MailConfig;
|
||||
@@ -0,0 +1,427 @@
|
||||
import XinTable from "@/components/XinTable";
|
||||
import type {XinTableColumn} from "@/components/XinTable/typings.ts";
|
||||
import {Button, Card, type CardProps, Col, message, Row, Switch, Table, type TableProps, Tag, Tooltip, Tree, type TreeProps, Typography} from "antd";
|
||||
import {type RuleFieldsList, rulesList, setRule, statusRole, users as usersApi} from "@/api/system/sys_role";
|
||||
import type {ISysRole} from "@/domain/iSysRole.ts";
|
||||
import React, {useEffect, useRef, useState} from "react";
|
||||
import type ISysUser from "@/domain/iSysUser.ts";
|
||||
import {KeyOutlined, SaveOutlined, SmileOutlined, TeamOutlined} from "@ant-design/icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isArray } from "lodash";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
const Role = () => {
|
||||
const {t} = useTranslation();
|
||||
// 状态管理
|
||||
const [selectedRoleId, setSelectedRoleId] = useState<number | undefined>();
|
||||
const [activeTab, setActiveTab] = useState('users');
|
||||
const [roleUsers, setRoleUsers] = useState<ISysUser[]>([]);
|
||||
const [roleUsersTotal, setRoleUsersTotal] = useState<number>(0);
|
||||
const [isLoadingUsers, setIsLoadingUsers] = useState<boolean>(false);
|
||||
const [ruleFields, setRuleFields] = useState<RuleFieldsList[]>([]);
|
||||
const [checkedRuleKeys, setCheckedRuleKeys] = useState<React.Key[]>([]);
|
||||
const [isSavingRules, setIsSavingRules] = useState<boolean>(false);
|
||||
// 树展开状态
|
||||
const [expandedKeys, setExpandedKeys] = useState<React.Key[]>([]);
|
||||
// 树引用
|
||||
const treeRef = useRef<any>(null);
|
||||
// Tab 配置
|
||||
const tabList: CardProps['tabList'] = [
|
||||
{
|
||||
key: "users",
|
||||
icon: <TeamOutlined />,
|
||||
label: t('system.role.tab.users'),
|
||||
},
|
||||
{
|
||||
key: "rules",
|
||||
icon: <KeyOutlined />,
|
||||
label: t('system.role.tab.rules')
|
||||
}
|
||||
];
|
||||
// 角色表格列配置
|
||||
const roleColumns: XinTableColumn<ISysRole>[] = [
|
||||
{
|
||||
title: 'id',
|
||||
dataIndex: "id",
|
||||
valueType: "text",
|
||||
align: 'center',
|
||||
width: 80,
|
||||
hideInForm: true
|
||||
},
|
||||
{
|
||||
title: t('system.role.table.roleName'),
|
||||
dataIndex: "name",
|
||||
valueType: "text",
|
||||
align: "center",
|
||||
required: true,
|
||||
colProps: {span: 8},
|
||||
rules: [{ required: true, message: t('system.role.table.roleName.required') }],
|
||||
render: (value: any, record: ISysRole) => (
|
||||
<>
|
||||
<Tooltip title={record.description}>
|
||||
<Tag variant="filled" color="blue">{value}</Tag>
|
||||
</Tooltip>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('system.role.table.sort'),
|
||||
dataIndex: "sort",
|
||||
valueType: "digit",
|
||||
hideInSearch: true,
|
||||
align: "center",
|
||||
required: true,
|
||||
colProps: {span: 8},
|
||||
rules: [{ required: true, message: t('system.role.table.sort.required') }],
|
||||
render: (value: number) => <Tag variant="filled" color="purple">{value}</Tag>,
|
||||
},
|
||||
{
|
||||
title: t('system.role.table.userCount'),
|
||||
dataIndex: "countUser",
|
||||
valueType: "text",
|
||||
hideInForm: true,
|
||||
align: "center",
|
||||
render: (value: number) => <a><u>{value}{t('system.role.userTable.person')}</u></a>,
|
||||
},
|
||||
{
|
||||
title: t('system.role.table.status'),
|
||||
dataIndex: "status",
|
||||
valueType: "switch",
|
||||
align: "center",
|
||||
colProps: {span: 8},
|
||||
filters: [
|
||||
{ text: t('system.role.table.status.disable'), value: 0 },
|
||||
{ text: t('system.role.table.status.enable'), value: 1 },
|
||||
],
|
||||
hideInSearch: true,
|
||||
required: true,
|
||||
rules: [{ required: true, message: t('system.role.table.status.required') }],
|
||||
render: (_, record: ISysRole) => (
|
||||
<Switch
|
||||
disabled={record.id === 1}
|
||||
checked={record.status === 1}
|
||||
checkedChildren={t('system.role.table.status.enable')}
|
||||
unCheckedChildren={t('system.role.table.status.disable')}
|
||||
onChange={async (_, event) => {
|
||||
event.stopPropagation();
|
||||
try {
|
||||
await statusRole(record.id!);
|
||||
message.success(t('system.role.message.statusUpdateSuccess'));
|
||||
} catch (error) {
|
||||
message.error(t('system.role.message.statusUpdateFailed'));
|
||||
console.error('Failed to update role status:', error);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
valueType: 'textarea',
|
||||
title: t('system.role.table.description'),
|
||||
dataIndex: 'description',
|
||||
colProps: {span: 24},
|
||||
hideInTable: true,
|
||||
},
|
||||
{
|
||||
title: t('system.role.table.createdAt'),
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
dataIndex: 'created_at',
|
||||
align: 'center',
|
||||
render: (value: string) => value ? dayjs(value).fromNow() : '-',
|
||||
},
|
||||
{
|
||||
title: t('system.role.table.updatedAt'),
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
dataIndex: 'updated_at',
|
||||
align: 'center',
|
||||
render: (value: string) => value ? dayjs(value).fromNow() : '-',
|
||||
},
|
||||
];
|
||||
// 用户列表表格列配置
|
||||
const userColumns: TableProps<ISysUser>['columns'] = [
|
||||
{
|
||||
title: t('system.role.userTable.userId'),
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
align: 'center',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: t('system.role.userTable.username'),
|
||||
dataIndex: 'username',
|
||||
key: 'username',
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: t('system.role.userTable.nickname'),
|
||||
dataIndex: 'nickname',
|
||||
key: 'nickname',
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: t('system.role.userTable.email'),
|
||||
dataIndex: 'email',
|
||||
key: 'email',
|
||||
align: 'center',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: t('system.role.userTable.mobile'),
|
||||
dataIndex: 'mobile',
|
||||
key: 'mobile',
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: t('system.role.userTable.status'),
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
align: 'center',
|
||||
render: (value: number) => {
|
||||
const status = value === 0
|
||||
? { color: 'error', text: t('system.role.userTable.status.banned') }
|
||||
: { color: 'success', text: t('system.role.userTable.status.normal') };
|
||||
return <Tag color={status.color}>{status.text}</Tag>;
|
||||
},
|
||||
width: 80,
|
||||
},
|
||||
];
|
||||
// 获取角色用户列表
|
||||
const fetchRoleUsers = async (page: number = 1, pageSize: number = 10) => {
|
||||
if (!selectedRoleId) return;
|
||||
setIsLoadingUsers(true);
|
||||
try {
|
||||
const response = await usersApi(selectedRoleId, { page, pageSize });
|
||||
const { data, total } = response.data.data!;
|
||||
setRoleUsers(data);
|
||||
setRoleUsersTotal(total);
|
||||
} finally {
|
||||
setIsLoadingUsers(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
rulesList().then(response => {
|
||||
setRuleFields(response.data.data!);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 当selectedRoleId变化时,重新获取用户列表
|
||||
useEffect(() => { fetchRoleUsers(1, 10) }, [selectedRoleId]);
|
||||
|
||||
// 事件处理函数
|
||||
const handleRoleSelect = (record: ISysRole) => {
|
||||
setSelectedRoleId(record.id!);
|
||||
// 回显当前角色的权限
|
||||
if (record.ruleIds && record.ruleIds.length > 0) {
|
||||
setCheckedRuleKeys(record.ruleIds);
|
||||
} else {
|
||||
setCheckedRuleKeys([]);
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染权限树节点
|
||||
const renderRuleTreeNode = (node: RuleFieldsList) => {
|
||||
if (node.local) {
|
||||
return (
|
||||
<>
|
||||
{t(node.local)} - <span style={{ color: '#00000040' }}>{node.title}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <>{node.title}</>;
|
||||
};
|
||||
|
||||
// 处理权限树勾选变化
|
||||
const handleRuleCheck: TreeProps['onCheck'] = (checkedKeys) => {
|
||||
if(isArray(checkedKeys)) {
|
||||
setCheckedRuleKeys(checkedKeys);
|
||||
}else {
|
||||
setCheckedRuleKeys(checkedKeys.checked);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理树展开状态变化
|
||||
const handleTreeExpand = (keys: React.Key[]) => {
|
||||
setExpandedKeys(keys);
|
||||
};
|
||||
|
||||
// 获取所有节点的key
|
||||
const getAllNodeKeys = (nodes: RuleFieldsList[]): React.Key[] => {
|
||||
let keys: React.Key[] = [];
|
||||
nodes.forEach(node => {
|
||||
keys.push(node.key);
|
||||
if (node.children && node.children.length > 0) {
|
||||
keys = [...keys, ...getAllNodeKeys(node.children)];
|
||||
}
|
||||
});
|
||||
return keys;
|
||||
};
|
||||
|
||||
// 展开全部节点
|
||||
const expandAll = () => {
|
||||
const allKeys = getAllNodeKeys(ruleFields);
|
||||
setExpandedKeys(allKeys);
|
||||
};
|
||||
|
||||
// 折叠全部节点
|
||||
const collapseAll = () => {
|
||||
setExpandedKeys([]);
|
||||
};
|
||||
|
||||
// 选择全部节点
|
||||
const selectAll = () => {
|
||||
const allKeys = getAllNodeKeys(ruleFields);
|
||||
setCheckedRuleKeys(allKeys);
|
||||
};
|
||||
|
||||
// 取消选择全部节点
|
||||
const deselectAll = () => {
|
||||
setCheckedRuleKeys([]);
|
||||
};
|
||||
|
||||
// 反选节点
|
||||
const invertSelection = () => {
|
||||
const allKeys = getAllNodeKeys(ruleFields);
|
||||
const currentCheckedSet = new Set(checkedRuleKeys);
|
||||
const invertedKeys = allKeys.filter(key => !currentCheckedSet.has(key));
|
||||
setCheckedRuleKeys(invertedKeys);
|
||||
};
|
||||
|
||||
// 保存权限设置
|
||||
const handleSaveRules = async () => {
|
||||
if (!selectedRoleId) {
|
||||
message.warning(t('system.role.message.selectRoleFirst'));
|
||||
return;
|
||||
}
|
||||
setIsSavingRules(true);
|
||||
try {
|
||||
const ruleIds = checkedRuleKeys.map(key => Number(key));
|
||||
await setRule(selectedRoleId, ruleIds);
|
||||
message.success(t('system.role.message.rulesSaveSuccess'));
|
||||
} finally {
|
||||
setIsSavingRules(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={'mb-5'}>
|
||||
<Title level={3}>{t('system.role.page.title')}</Title>
|
||||
<Text type="secondary">{t('system.role.page.description')}</Text>
|
||||
</div>
|
||||
<Row gutter={[20, 20]}>
|
||||
{/* 角色列表 */}
|
||||
<Col xxl={14} lg={12} xs={24}>
|
||||
<XinTable<ISysRole>
|
||||
api="/system/role"
|
||||
accessName="system.role"
|
||||
columns={roleColumns}
|
||||
rowKey="id"
|
||||
searchShow={false}
|
||||
searchProps={false}
|
||||
scroll={{x: 800}}
|
||||
editShow={(row) => row.id !== 1}
|
||||
deleteShow={(row) => row.id !== 1}
|
||||
formProps={{
|
||||
grid: true,
|
||||
rowProps: { gutter: [20, 0] },
|
||||
layout: 'vertical'
|
||||
}}
|
||||
modalProps={{
|
||||
width: 800
|
||||
}}
|
||||
operateProps={{
|
||||
fixed: 'right'
|
||||
}}
|
||||
rowSelection={{
|
||||
type: 'radio',
|
||||
selectedRowKeys: selectedRoleId ? [selectedRoleId] : [],
|
||||
onChange: (_, rows) => handleRoleSelect(rows[0])
|
||||
}}
|
||||
onRow ={(record) => ({
|
||||
onClick: () => handleRoleSelect(record)
|
||||
})}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
{/* 用户列表和权限管理 */}
|
||||
<Col xxl={10} lg={12} xs={24}>
|
||||
<Card
|
||||
tabList={tabList}
|
||||
onTabChange={setActiveTab}
|
||||
activeTabKey={activeTab}
|
||||
styles={{ body: { minHeight: '70vh' } }}
|
||||
>
|
||||
{ selectedRoleId ? (
|
||||
activeTab === 'users' ? (
|
||||
<Table<ISysUser>
|
||||
loading={isLoadingUsers}
|
||||
dataSource={roleUsers}
|
||||
bordered={true}
|
||||
columns={userColumns}
|
||||
size="small"
|
||||
pagination={{
|
||||
total: roleUsersTotal,
|
||||
showSizeChanger: true,
|
||||
onChange: fetchRoleUsers
|
||||
}}
|
||||
scroll={{x: 600}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ height: '600px', overflow: 'auto', marginBottom: 12 }}>
|
||||
<Tree
|
||||
ref={treeRef}
|
||||
checkable
|
||||
treeData={ruleFields}
|
||||
checkedKeys={checkedRuleKeys}
|
||||
onCheck={handleRuleCheck}
|
||||
titleRender={renderRuleTreeNode}
|
||||
checkStrictly={true}
|
||||
expandedKeys={expandedKeys}
|
||||
onExpand={handleTreeExpand}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span style={{ color: '#666', fontSize: 12 }}>
|
||||
{t('system.role.permission.selectedCount', { count: checkedRuleKeys.length })}
|
||||
</span>
|
||||
<Button size="small" onClick={expandAll}>{t('system.role.button.expandAll')}</Button>
|
||||
<Button size="small" onClick={collapseAll}>{t('system.role.button.collapseAll')}</Button>
|
||||
<Button size="small" onClick={selectAll}>{t('system.role.button.selectAll')}</Button>
|
||||
<Button size="small" onClick={deselectAll}>{t('system.role.button.clearAll')}</Button>
|
||||
<Button size="small" onClick={invertSelection}>{t('system.role.button.invertSelection')}</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
loading={isSavingRules}
|
||||
onClick={handleSaveRules}
|
||||
size="small"
|
||||
disabled={selectedRoleId === 1}
|
||||
>
|
||||
{t('system.role.button.saveRules')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', color: '#00000040' }}>
|
||||
<SmileOutlined style={{ fontSize: 40, marginBottom: 12 }} />
|
||||
<p>{t('system.role.placeholder.selectRole')}</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Role;
|
||||
@@ -0,0 +1,300 @@
|
||||
import type {ISysRule} from "@/domain/iSysRule.ts";
|
||||
import {listRule, ruleParent, showRule, statusRule} from "@/api/system/sys_rule.ts";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import IconFont from "@/components/IconFont";
|
||||
import XinTable from "@/components/XinTable";
|
||||
import type {XinTableColumn, XinTableInstance} from "@/components/XinTable/typings.ts";
|
||||
import {message, Switch, Tag, Typography} from "antd";
|
||||
import {useEffect, useRef, useState} from "react";
|
||||
import useAuth from "@/hooks/useAuth.ts";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import useDictStore from "@/stores/dict";
|
||||
import DictTag from "@/components/DictTag";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
interface RuleParent {
|
||||
id: number;
|
||||
name: string;
|
||||
children?: RuleParent[];
|
||||
}
|
||||
|
||||
const Rule = () => {
|
||||
const {t} = useTranslation();
|
||||
const {auth} = useAuth();
|
||||
const [parentOptions, setParentOptions] = useState<RuleParent[]>([]);
|
||||
const tableRef = useRef<XinTableInstance<ISysRule>>(null);
|
||||
const getOptions = useDictStore(state => state.getOptions);
|
||||
|
||||
// 加载父级选项
|
||||
useEffect(() => {
|
||||
ruleParent().then(res => {
|
||||
const children = (res.data.data || []).map((item: any) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
children: item.children
|
||||
}));
|
||||
setParentOptions([
|
||||
{
|
||||
name: t("system.rule.parent.0"),
|
||||
id: 0,
|
||||
children
|
||||
}
|
||||
]);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const columns: XinTableColumn<ISysRule>[] = [
|
||||
/** ----------------- 表单使用的 Column ------------------- */
|
||||
{
|
||||
title: t("system.rule.type"),
|
||||
dataIndex: 'type',
|
||||
valueType: 'radioButton',
|
||||
hideInTable: true,
|
||||
hideInSearch: true,
|
||||
colProps: { span: 24 },
|
||||
rules: [{ required: true, message: t("system.rule.type.required") }],
|
||||
fieldProps: {
|
||||
options: getOptions('sys_rule_type'),
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t("system.rule.parent"),
|
||||
dataIndex: 'parent_id',
|
||||
hideInTable: true,
|
||||
hideInSearch: true,
|
||||
valueType: 'treeSelect',
|
||||
rules: [{ required: true, message: t("system.rule.parent.required") }],
|
||||
fieldProps: {
|
||||
treeData: parentOptions,
|
||||
fieldNames: { label: 'name', value: 'id' },
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t("system.rule.order"),
|
||||
hideInTable: true,
|
||||
hideInSearch: true,
|
||||
dataIndex: 'order',
|
||||
valueType: 'digit',
|
||||
rules: [{ required: true, message: t("system.rule.order.required") }],
|
||||
},
|
||||
{
|
||||
title: t("system.rule.name"),
|
||||
hideInTable: true,
|
||||
hideInSearch: true,
|
||||
dataIndex: 'name',
|
||||
valueType: 'text',
|
||||
rules: [{ required: true, message: t("system.rule.name.required") }],
|
||||
},
|
||||
{
|
||||
title: t("system.rule.key"),
|
||||
valueType: 'text',
|
||||
dataIndex: 'key',
|
||||
hideInTable: true,
|
||||
hideInSearch: true,
|
||||
rules: [{ required: true, message: t("system.rule.key.required") }],
|
||||
},
|
||||
{
|
||||
title: t("system.rule.link"),
|
||||
dataIndex: 'link',
|
||||
valueType: 'radio',
|
||||
hideInTable: true,
|
||||
hideInSearch: true,
|
||||
dependency: {
|
||||
dependencies: ['type'],
|
||||
visible: values => values.type === 'route'
|
||||
},
|
||||
fieldProps: {
|
||||
options: [
|
||||
{label: t("system.rule.link.0"), value: 0},
|
||||
{label: t("system.rule.link.1"), value: 1},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t("system.rule.routePath"),
|
||||
dataIndex: 'path',
|
||||
valueType: 'text',
|
||||
hideInTable: true,
|
||||
hideInSearch: true,
|
||||
tooltip: t("system.rule.routePath.tooltip"),
|
||||
dependency: {
|
||||
dependencies: ['type'],
|
||||
visible: values => values.type === 'route'
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t("system.rule.icon"),
|
||||
dataIndex: 'icon',
|
||||
valueType: 'icon',
|
||||
hideInTable: true,
|
||||
hideInSearch: true,
|
||||
dependency: {
|
||||
dependencies: ['type'],
|
||||
visible: values => values.type === 'route' || values.type === 'menu'
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t("system.rule.local"),
|
||||
dataIndex: 'local',
|
||||
valueType: 'text',
|
||||
hideInTable: true,
|
||||
hideInSearch: true,
|
||||
dependency: {
|
||||
dependencies: ['type'],
|
||||
visible: values => values.type === 'route' || values.type === 'menu'
|
||||
},
|
||||
},
|
||||
/** ------------------ 表格使用的 Column ---------------- */
|
||||
{
|
||||
title: t("system.rule.name"),
|
||||
ellipsis: true,
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
dataIndex: 'name',
|
||||
},
|
||||
{
|
||||
ellipsis: true,
|
||||
align: 'center',
|
||||
title: t("system.rule.local.show"),
|
||||
dataIndex: 'local',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
render: (data: string) => data ? t(data) : '-'
|
||||
},
|
||||
{
|
||||
title: t("system.rule.icon"),
|
||||
dataIndex: 'icon',
|
||||
align: 'center',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
render: (data: string) => data ? <IconFont name={data} /> : '-'
|
||||
},
|
||||
{
|
||||
title: t("system.rule.type"),
|
||||
dataIndex: 'type',
|
||||
align: 'center',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
render: (value: string) => <DictTag value={value} renderType={'tag'} code={'sys_rule_type'}/>
|
||||
},
|
||||
{
|
||||
title: t("system.rule.order"),
|
||||
align: 'center',
|
||||
dataIndex: 'order',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
render: (value: number) => <Tag variant="filled" color={'purple'}>{value}</Tag>,
|
||||
},
|
||||
{
|
||||
title: t("system.rule.key"),
|
||||
align: 'center',
|
||||
dataIndex: 'key',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
render: (value: string) => <Tag variant="filled" color={'geekblue'}>{value}</Tag>,
|
||||
},
|
||||
{
|
||||
title: t("system.rule.hidden"),
|
||||
align: 'center',
|
||||
dataIndex: 'hidden',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
tooltip: t("system.rule.hidden.tooltip"),
|
||||
render: (_, data: ISysRule) => {
|
||||
if (data.type === 'rule') { return '-' }
|
||||
return (
|
||||
<Switch
|
||||
defaultValue={data.hidden === 1}
|
||||
disabled={!auth("system.rule.show")}
|
||||
checkedChildren={t("system.rule.hidden.1")}
|
||||
unCheckedChildren={t("system.rule.hidden.0")}
|
||||
onChange={ async (_, event) => {
|
||||
event.stopPropagation();
|
||||
await showRule(data.id!);
|
||||
message.success(t("system.rule.hidden.updateSuccess"));
|
||||
}}
|
||||
/>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t("system.rule.status"),
|
||||
dataIndex: 'status',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
tooltip: t("system.rule.status.tooltip"),
|
||||
align: 'center',
|
||||
render: (_, data: ISysRule) => {
|
||||
return (
|
||||
<Switch
|
||||
defaultChecked={data.status === 1}
|
||||
disabled={!auth("system.rule.status")}
|
||||
checkedChildren={t("system.rule.status.1")}
|
||||
unCheckedChildren={t("system.rule.status.0")}
|
||||
onChange={async (_, event) => {
|
||||
event.stopPropagation();
|
||||
await statusRule(data.id!);
|
||||
message.success(t("system.rule.status.updateSuccess"));
|
||||
}}
|
||||
/>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t("system.rule.created_at"),
|
||||
dataIndex: 'created_at',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
align: 'center',
|
||||
render: (value: string) => value ? dayjs(value).fromNow() : '-',
|
||||
},
|
||||
{
|
||||
title: t("system.rule.updated_at"),
|
||||
dataIndex: 'updated_at',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
align: 'center',
|
||||
render: (value: string) => value ? dayjs(value).fromNow() : '-',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={'mb-5'}>
|
||||
<Title level={3}>{t("system.rule.title")}</Title>
|
||||
<Text type="secondary">{t("system.rule.description")}</Text>
|
||||
</div>
|
||||
<XinTable<ISysRule>
|
||||
handleRequest={async () => {
|
||||
const { data } = await listRule();
|
||||
return {
|
||||
data: data.data || [],
|
||||
total: data.data?.length || 0
|
||||
};
|
||||
}}
|
||||
tableRef={tableRef}
|
||||
searchShow={false}
|
||||
searchProps={false}
|
||||
paginationShow={false}
|
||||
scroll={{x: 1200}}
|
||||
formProps={{
|
||||
grid: true,
|
||||
rowProps: {gutter: [20, 0]},
|
||||
colProps: {span: 12},
|
||||
layout: 'vertical'
|
||||
}}
|
||||
modalProps={{ width: 800 }}
|
||||
columns={columns}
|
||||
api={'/system/rule'}
|
||||
rowKey={"id"}
|
||||
accessName={"system.rule"}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Rule;
|
||||
@@ -0,0 +1,547 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
message,
|
||||
Row,
|
||||
Col,
|
||||
Typography,
|
||||
Divider,
|
||||
Select
|
||||
} from 'antd';
|
||||
import { CloudUploadOutlined, CheckCircleOutlined } from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getStorageConfig, saveStorageConfig, testStorageConnection } from '@/api/system/sysStorage.ts';
|
||||
import type { FormColumn } from "@/components/XinFormField/FieldRender";
|
||||
import XinForm, { type XinFormRef } from "@/components/XinForm";
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
interface StorageResponse {
|
||||
default: string;
|
||||
local: {
|
||||
root: string;
|
||||
url: string;
|
||||
visibility: string;
|
||||
};
|
||||
s3: {
|
||||
key: string;
|
||||
secret: string;
|
||||
region: string;
|
||||
bucket: string;
|
||||
url: string;
|
||||
endpoint: string;
|
||||
use_path_style_endpoint: boolean;
|
||||
};
|
||||
ftp: {
|
||||
host: string;
|
||||
username: string;
|
||||
password: string;
|
||||
port: number;
|
||||
root: string;
|
||||
passive: boolean;
|
||||
ssl: boolean;
|
||||
timeout: number;
|
||||
};
|
||||
sftp: {
|
||||
host: string;
|
||||
username: string;
|
||||
password: string;
|
||||
port: number;
|
||||
root: string;
|
||||
timeout: number;
|
||||
private_key: string;
|
||||
passphrase: string;
|
||||
};
|
||||
}
|
||||
|
||||
const StorageConfig: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const formRef = useRef<XinFormRef>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [selectedDisk, setSelectedDisk] = useState<string>('local');
|
||||
|
||||
const driverOptions = [
|
||||
{ label: t('system.storage.driver.local'), value: 'local' },
|
||||
{ label: t('system.storage.driver.s3'), value: 's3' },
|
||||
{ label: t('system.storage.driver.ftp'), value: 'ftp' },
|
||||
{ label: t('system.storage.driver.sftp'), value: 'sftp' },
|
||||
];
|
||||
|
||||
const columns: FormColumn<StorageResponse>[] = [
|
||||
{
|
||||
dataIndex: 'default',
|
||||
valueType: 'radioButton',
|
||||
title: t('system.storage.driver'),
|
||||
fieldProps: {
|
||||
options: driverOptions
|
||||
},
|
||||
required: true,
|
||||
colProps: { span: 24 }
|
||||
},
|
||||
// 本地存储配置
|
||||
{
|
||||
fieldRender: () => <Divider>{t('system.storage.local.title')}</Divider>,
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 'local'
|
||||
},
|
||||
colProps: { span: 24 },
|
||||
noStyle: true
|
||||
},
|
||||
{
|
||||
dataIndex: ['local', 'url'],
|
||||
valueType: 'text',
|
||||
title: t('system.storage.local.url'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.storage.local.url.placeholder'),
|
||||
},
|
||||
tooltip: t('system.storage.local.url.tooltip'),
|
||||
colProps: { span: 24 },
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 'local'
|
||||
}
|
||||
},
|
||||
// S3 配置
|
||||
{
|
||||
fieldRender: () => <Divider>{t('system.storage.s3.title')}</Divider>,
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 's3'
|
||||
},
|
||||
colProps: { span: 24 },
|
||||
noStyle: true
|
||||
},
|
||||
{
|
||||
dataIndex: ['s3', 'key'],
|
||||
valueType: 'text',
|
||||
title: t('system.storage.s3.key'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.storage.s3.key.placeholder'),
|
||||
},
|
||||
required: true,
|
||||
colProps: { span: 12 },
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 's3'
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['s3', 'secret'],
|
||||
valueType: 'password',
|
||||
title: t('system.storage.s3.secret'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.storage.s3.secret.placeholder'),
|
||||
},
|
||||
required: true,
|
||||
colProps: { span: 12 },
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 's3'
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['s3', 'region'],
|
||||
valueType: 'text',
|
||||
title: t('system.storage.s3.region'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.storage.s3.region.placeholder'),
|
||||
},
|
||||
required: true,
|
||||
colProps: { span: 12 },
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 's3'
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['s3', 'bucket'],
|
||||
valueType: 'text',
|
||||
title: t('system.storage.s3.bucket'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.storage.s3.bucket.placeholder'),
|
||||
},
|
||||
required: true,
|
||||
colProps: { span: 12 },
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 's3'
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['s3', 'endpoint'],
|
||||
valueType: 'text',
|
||||
title: t('system.storage.s3.endpoint'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.storage.s3.endpoint.placeholder'),
|
||||
},
|
||||
tooltip: t('system.storage.s3.endpoint.tooltip'),
|
||||
colProps: { span: 12 },
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 's3'
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['s3', 'url'],
|
||||
valueType: 'text',
|
||||
title: t('system.storage.s3.url'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.storage.s3.url.placeholder'),
|
||||
},
|
||||
tooltip: t('system.storage.s3.url.tooltip'),
|
||||
colProps: { span: 12 },
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 's3'
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['s3', 'use_path_style_endpoint'],
|
||||
valueType: 'switch',
|
||||
title: t('system.storage.s3.path_style'),
|
||||
tooltip: t('system.storage.s3.path_style.tooltip'),
|
||||
colProps: { span: 12 },
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 's3'
|
||||
}
|
||||
},
|
||||
// FTP 配置
|
||||
{
|
||||
fieldRender: () => <Divider>{t('system.storage.ftp.title')}</Divider>,
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 'ftp'
|
||||
},
|
||||
colProps: { span: 24 },
|
||||
noStyle: true
|
||||
},
|
||||
{
|
||||
dataIndex: ['ftp', 'host'],
|
||||
valueType: 'text',
|
||||
title: t('system.storage.ftp.host'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.storage.ftp.host.placeholder'),
|
||||
},
|
||||
required: true,
|
||||
colProps: { span: 16 },
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 'ftp'
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['ftp', 'port'],
|
||||
valueType: 'digit',
|
||||
title: t('system.storage.ftp.port'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.storage.ftp.port.placeholder'),
|
||||
},
|
||||
colProps: { span: 8 },
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 'ftp'
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['ftp', 'username'],
|
||||
valueType: 'text',
|
||||
title: t('system.storage.ftp.username'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.storage.ftp.username.placeholder'),
|
||||
},
|
||||
required: true,
|
||||
colProps: { span: 12 },
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 'ftp'
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['ftp', 'password'],
|
||||
valueType: 'password',
|
||||
title: t('system.storage.ftp.password'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.storage.ftp.password.placeholder'),
|
||||
},
|
||||
required: true,
|
||||
colProps: { span: 12 },
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 'ftp'
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['ftp', 'root'],
|
||||
valueType: 'text',
|
||||
title: t('system.storage.ftp.root'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.storage.ftp.root.placeholder'),
|
||||
},
|
||||
tooltip: t('system.storage.ftp.root.tooltip'),
|
||||
colProps: { span: 12 },
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 'ftp'
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['ftp', 'timeout'],
|
||||
valueType: 'digit',
|
||||
title: t('system.storage.ftp.timeout'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.storage.ftp.timeout.placeholder'),
|
||||
addonAfter: t('system.storage.ftp.timeout.suffix')
|
||||
},
|
||||
colProps: { span: 12 },
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 'ftp'
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['ftp', 'passive'],
|
||||
valueType: 'switch',
|
||||
title: t('system.storage.ftp.passive'),
|
||||
tooltip: t('system.storage.ftp.passive.tooltip'),
|
||||
colProps: { span: 8 },
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 'ftp'
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['ftp', 'ssl'],
|
||||
valueType: 'switch',
|
||||
title: t('system.storage.ftp.ssl'),
|
||||
tooltip: t('system.storage.ftp.ssl.tooltip'),
|
||||
colProps: { span: 8 },
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 'ftp'
|
||||
}
|
||||
},
|
||||
// SFTP 配置
|
||||
{
|
||||
fieldRender: () => <Divider>{t('system.storage.sftp.title')}</Divider>,
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 'sftp'
|
||||
},
|
||||
colProps: { span: 24 },
|
||||
noStyle: true
|
||||
},
|
||||
{
|
||||
dataIndex: ['sftp', 'host'],
|
||||
valueType: 'text',
|
||||
title: t('system.storage.sftp.host'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.storage.sftp.host.placeholder'),
|
||||
},
|
||||
required: true,
|
||||
colProps: { span: 16 },
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 'sftp'
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['sftp', 'port'],
|
||||
valueType: 'digit',
|
||||
title: t('system.storage.sftp.port'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.storage.sftp.port.placeholder'),
|
||||
},
|
||||
colProps: { span: 8 },
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 'sftp'
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['sftp', 'username'],
|
||||
valueType: 'text',
|
||||
title: t('system.storage.sftp.username'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.storage.sftp.username.placeholder'),
|
||||
},
|
||||
required: true,
|
||||
colProps: { span: 12 },
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 'sftp'
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['sftp', 'password'],
|
||||
valueType: 'password',
|
||||
title: t('system.storage.sftp.password'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.storage.sftp.password.placeholder'),
|
||||
},
|
||||
tooltip: t('system.storage.sftp.password.tooltip'),
|
||||
colProps: { span: 12 },
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 'sftp'
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['sftp', 'root'],
|
||||
valueType: 'text',
|
||||
title: t('system.storage.sftp.root'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.storage.sftp.root.placeholder'),
|
||||
},
|
||||
tooltip: t('system.storage.sftp.root.tooltip'),
|
||||
colProps: { span: 12 },
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 'sftp'
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['sftp', 'timeout'],
|
||||
valueType: 'digit',
|
||||
title: t('system.storage.sftp.timeout'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.storage.sftp.timeout.placeholder'),
|
||||
addonAfter: t('system.storage.sftp.timeout.suffix')
|
||||
},
|
||||
colProps: { span: 12 },
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 'sftp'
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['sftp', 'private_key'],
|
||||
valueType: 'textarea',
|
||||
title: t('system.storage.sftp.private_key'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.storage.sftp.private_key.placeholder'),
|
||||
rows: 4
|
||||
},
|
||||
tooltip: t('system.storage.sftp.private_key.tooltip'),
|
||||
colProps: { span: 24 },
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 'sftp'
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['sftp', 'passphrase'],
|
||||
valueType: 'password',
|
||||
title: t('system.storage.sftp.passphrase'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.storage.sftp.passphrase.placeholder'),
|
||||
},
|
||||
tooltip: t('system.storage.sftp.passphrase.tooltip'),
|
||||
colProps: { span: 24 },
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 'sftp'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
}, []);
|
||||
|
||||
const loadConfig = async () => {
|
||||
const { data } = await getStorageConfig();
|
||||
if (data.success) {
|
||||
const configData = data.data as StorageResponse;
|
||||
form.setFieldsValue(configData);
|
||||
setSelectedDisk(configData.default || 'local');
|
||||
}
|
||||
};
|
||||
|
||||
const onFinish = async (values: any) => {
|
||||
await saveStorageConfig(values);
|
||||
message.success(t('system.storage.save.success'));
|
||||
};
|
||||
|
||||
const onValuesChange = (changedValues: any) => {
|
||||
if (changedValues.default) {
|
||||
setSelectedDisk(changedValues.default);
|
||||
}
|
||||
};
|
||||
|
||||
const onTestConnection = async () => {
|
||||
try {
|
||||
setTesting(true);
|
||||
await testStorageConnection(selectedDisk);
|
||||
message.success(t('system.storage.test.success'));
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={'mb-5'}>
|
||||
<Title level={3}>{t('system.storage.page.title')}</Title>
|
||||
<Text type="secondary">{t('system.storage.page.description')}</Text>
|
||||
</div>
|
||||
<Row gutter={24}>
|
||||
<Col span={16}>
|
||||
<Card variant={'borderless'}>
|
||||
<XinForm
|
||||
columns={columns}
|
||||
layout="vertical"
|
||||
formRef={formRef}
|
||||
form={form}
|
||||
grid={true}
|
||||
onFinish={onFinish}
|
||||
onValuesChange={onValuesChange}
|
||||
rowProps={{
|
||||
gutter: [10, 0]
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={8}>
|
||||
<Card title={t('system.storage.test.title')}>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label={t('system.storage.test.current_driver')}>
|
||||
<Select
|
||||
value={selectedDisk}
|
||||
onChange={setSelectedDisk}
|
||||
options={driverOptions}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
block
|
||||
icon={<CheckCircleOutlined />}
|
||||
loading={testing}
|
||||
onClick={onTestConnection}
|
||||
>
|
||||
{t('system.storage.test.button')}
|
||||
</Button>
|
||||
</Form>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div style={{ fontSize: 12, color: '#666' }}>
|
||||
<Title level={5}><CloudUploadOutlined /> {t('system.storage.help.title')}</Title>
|
||||
<p><strong>{t('system.storage.help.local')}</strong>{t('system.storage.help.local.desc')}</p>
|
||||
<p><strong>{t('system.storage.help.s3')}</strong>{t('system.storage.help.s3.desc')}</p>
|
||||
<p><strong>{t('system.storage.help.ftp')}</strong>{t('system.storage.help.ftp.desc')}</p>
|
||||
<p><strong>{t('system.storage.help.sftp')}</strong>{t('system.storage.help.sftp.desc')}</p>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default StorageConfig;
|
||||
@@ -0,0 +1,332 @@
|
||||
import {Avatar, Button, Form, Input, message, Modal, Tag, Tooltip, Typography} from 'antd';
|
||||
import React, {useEffect, useMemo, useState} from 'react';
|
||||
import XinTable from '@/components/XinTable';
|
||||
import type {XinTableColumn, XinTableProps} from "@/components/XinTable/typings.ts";
|
||||
import type ISysUser from "@/domain/iSysUser.ts";
|
||||
import AuthButton from "@/components/AuthButton";
|
||||
import type {DeptFieldType, ResetPasswordType, RoleFieldType} from "@/api/system/sys_user";
|
||||
import {deptField, resetPassword, roleField} from "@/api/system/sys_user";
|
||||
import {RedoOutlined} from "@ant-design/icons";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import useAuth from "@/hooks/useAuth.ts";
|
||||
const { Title, Text } = Typography;
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
const Table: React.FC = () => {
|
||||
const {t} = useTranslation();
|
||||
const { auth } = useAuth();
|
||||
/** 角色选项数据 */
|
||||
const [roles, setRoles] = useState<RoleFieldType[]>([]);
|
||||
/** 部门选项数据 */
|
||||
const [departments, setDepartments] = useState<DeptFieldType[]>([]);
|
||||
/** 部门选项数据映射 */
|
||||
const departmentMap: Map<number, string> = useMemo(() => {
|
||||
const map: Map<number, string> = new Map();
|
||||
const buildMap = (dept: DeptFieldType[]) => {
|
||||
dept.forEach(item => {
|
||||
map.set(item.dept_id, item.name);
|
||||
if (item.children && item.children.length > 0) {
|
||||
buildMap(item.children);
|
||||
}
|
||||
})
|
||||
}
|
||||
buildMap(departments);
|
||||
return map;
|
||||
}, [departments]);
|
||||
|
||||
/** 高级表格列配置 */
|
||||
const columns: XinTableColumn<ISysUser>[] = [
|
||||
{
|
||||
title: t("system.user.id"),
|
||||
dataIndex: 'id',
|
||||
hideInForm: true,
|
||||
sorter: true,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: t("system.user.username"),
|
||||
dataIndex: 'username',
|
||||
valueType: 'text',
|
||||
align: 'center',
|
||||
required: true,
|
||||
rules: [{required: true, message: t("system.user.username.required")}],
|
||||
},
|
||||
{
|
||||
title: t("system.user.nickname"),
|
||||
dataIndex: 'nickname',
|
||||
valueType: 'text',
|
||||
align: 'center',
|
||||
required: true,
|
||||
rules: [{required: true, message: t("system.user.nickname.required")}],
|
||||
},
|
||||
{
|
||||
title: t("system.user.sex"),
|
||||
dataIndex: 'sex',
|
||||
valueType: 'radio',
|
||||
filters: [
|
||||
{ text: t("system.user.sex.0"), value: 0 },
|
||||
{ text: t("system.user.sex.1"), value: 1 },
|
||||
],
|
||||
align: 'center',
|
||||
hideInSearch: true,
|
||||
fieldProps: {
|
||||
options: [
|
||||
{ value: 0, label: t("system.user.sex.0") },
|
||||
{ value: 1, label: t("system.user.sex.1") },
|
||||
]
|
||||
},
|
||||
render: (value: number) => {
|
||||
return value === 0 ? t("system.user.sex.0") : t("system.user.sex.1");
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t("system.user.email"),
|
||||
dataIndex: 'email',
|
||||
valueType: 'text',
|
||||
align: 'center',
|
||||
required: true,
|
||||
rules: [{required: true, message: t("system.user.email.required")}],
|
||||
},
|
||||
{
|
||||
title: t("system.user.role"),
|
||||
dataIndex: 'role_id',
|
||||
valueType: 'select',
|
||||
align: 'center',
|
||||
hideInSearch: true,
|
||||
required: true,
|
||||
rules: [{required: true, message: t("system.user.role.required")}],
|
||||
render: (value: number[]) => (
|
||||
<>
|
||||
{value.map(item => (
|
||||
<Tag color={'magenta'}>
|
||||
{roles.find(r => r.role_id === item)?.name || '-'}
|
||||
</Tag>
|
||||
))}
|
||||
</>
|
||||
),
|
||||
fieldProps: {
|
||||
mode: 'multiple',
|
||||
options: roles.map(r => ({ label: r.name, value: r.role_id })),
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t("system.user.dept"),
|
||||
dataIndex: 'dept_id',
|
||||
valueType: 'treeSelect',
|
||||
align: 'center',
|
||||
required: true,
|
||||
rules: [{required: true, message: t("system.user.dept.required")}],
|
||||
render: (value) => (
|
||||
// 嵌套寻找部门名称
|
||||
<Tag color={'volcano'}>{departmentMap.get(value) || '-'}</Tag>
|
||||
),
|
||||
fieldProps: {
|
||||
treeData: departments,
|
||||
fieldNames: {label: 'name', value: 'dept_id'}
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t("system.user.status"),
|
||||
dataIndex: 'status',
|
||||
valueType: 'radioButton',
|
||||
fieldProps: {
|
||||
options: [
|
||||
{ value: 0, label: t("system.user.status.0") },
|
||||
{ value: 1, label: t("system.user.status.1") },
|
||||
]
|
||||
},
|
||||
render: (value: number) => {
|
||||
return value === 1
|
||||
? <Tag color="success">{t("system.user.status.1")}</Tag>
|
||||
: <Tag color="error">{t("system.user.status.0")}</Tag>;
|
||||
},
|
||||
required: true,
|
||||
rules: [{required: true, message: t("system.user.status.required")}],
|
||||
filters: [
|
||||
{ text: t("system.user.status.0"), value: 0 },
|
||||
{ text: t("system.user.status.1"), value: 1 },
|
||||
],
|
||||
hideInSearch: true,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: t("system.user.mobile"),
|
||||
dataIndex: 'mobile',
|
||||
valueType: 'text',
|
||||
required: true,
|
||||
rules: [{required: true, message: t("system.user.mobile.required")}],
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: t("system.user.avatar"),
|
||||
dataIndex: 'avatar_url',
|
||||
hideInSearch: true,
|
||||
hideInForm: true,
|
||||
render: (_, entity: ISysUser) => <Avatar size={'small'} src={entity.avatar_url}></Avatar>,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: t("system.user.password"),
|
||||
dataIndex: 'password',
|
||||
valueType: 'password',
|
||||
hideInTable: true,
|
||||
hideInSearch: true,
|
||||
hideInUpdate: true,
|
||||
rules: [{required: true, message: t("system.user.password.required")}],
|
||||
fieldProps: {autoComplete: 'new-password'},
|
||||
},
|
||||
{
|
||||
title: t("system.user.rePassword"),
|
||||
dataIndex: 'rePassword',
|
||||
valueType: 'password',
|
||||
hideInTable: true,
|
||||
hideInSearch: true,
|
||||
hideInUpdate: true,
|
||||
rules: [{required: true, message: t("system.user.rePassword.required")}],
|
||||
fieldProps: {autoComplete: 'new-password'},
|
||||
},
|
||||
{
|
||||
title: t("system.user.created_at"),
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
dataIndex: 'created_at',
|
||||
align: 'center',
|
||||
render: (value: string) => value ? dayjs(value).fromNow() : '-',
|
||||
},
|
||||
{
|
||||
title: t("system.user.updated_at"),
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
dataIndex: 'updated_at',
|
||||
align: 'center',
|
||||
render: (value: string) => value ? dayjs(value).fromNow() : '-',
|
||||
},
|
||||
];
|
||||
|
||||
/** 修改密码相关状态 */
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [resetUserId, setResetUserId] = useState<number>();
|
||||
const [buttonLoading, setButtonLoading] = useState<boolean>(false);
|
||||
|
||||
/** 打开重置密码框 */
|
||||
const showRedoModal = (id: number) => {
|
||||
setIsModalOpen(true);
|
||||
setResetUserId(id);
|
||||
};
|
||||
|
||||
/** 提交重置密码 */
|
||||
const handleRedoSubmit = async (values: ResetPasswordType) => {
|
||||
try {
|
||||
setButtonLoading(true);
|
||||
await resetPassword({...values, id: resetUserId!});
|
||||
setIsModalOpen(false);
|
||||
message.success(t("system.user.resetSuccess"));
|
||||
} finally {
|
||||
setButtonLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if(auth('system.user.dept')) {
|
||||
deptField().then(res => setDepartments(res.data.data!));
|
||||
}
|
||||
if(auth('system.user.role')) {
|
||||
roleField().then(res => setRoles(res.data.data!));
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** 操作栏之后渲染 */
|
||||
const operateRender: XinTableProps['operateRender'] = (record, dom) => ([
|
||||
<>
|
||||
{record.id !== 1 &&
|
||||
<AuthButton auth={'system.user.resetPassword'}>
|
||||
<Tooltip title={t("system.user.resetPassword")}>
|
||||
<Button
|
||||
variant={'solid'}
|
||||
color={'pink'}
|
||||
icon={<RedoOutlined/>}
|
||||
size={'small'}
|
||||
onClick={() => showRedoModal(record.id!)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</AuthButton>
|
||||
}
|
||||
</>,
|
||||
dom.del,
|
||||
dom.edit
|
||||
]);
|
||||
|
||||
/** 表格配置 */
|
||||
const tableProps: XinTableProps<ISysUser> = {
|
||||
api: '/system/user',
|
||||
columns,
|
||||
rowKey: 'id',
|
||||
accessName: 'system.user',
|
||||
operateRender,
|
||||
scroll: { x: 1400 },
|
||||
editShow: (i) => i.id !== 1,
|
||||
deleteShow: (i) => i.id !== 1,
|
||||
formProps: {
|
||||
grid: true,
|
||||
colProps: {span: 12},
|
||||
rowProps: {gutter: [30, 0]},
|
||||
layout: 'vertical'
|
||||
},
|
||||
modalProps: {
|
||||
width: 800,
|
||||
},
|
||||
cardProps: {
|
||||
variant: 'borderless'
|
||||
},
|
||||
pagination: {
|
||||
size: 'small',
|
||||
style: {
|
||||
marginBottom: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={'mb-5'}>
|
||||
<Title level={3}>{t("system.user.page.title")}</Title>
|
||||
<Text type="secondary">{t("system.user.page.description")}</Text>
|
||||
</div>
|
||||
<XinTable<ISysUser> {...tableProps} />
|
||||
<Modal
|
||||
title={t("system.user.resetPassword")}
|
||||
closable={{'aria-label': 'Custom Close Button'}}
|
||||
open={isModalOpen}
|
||||
footer={null}
|
||||
styles={{body: {paddingTop: 20}}}
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
>
|
||||
<Form<ResetPasswordType> autoComplete="off" layout={'vertical'} onFinish={handleRedoSubmit}>
|
||||
<Form.Item
|
||||
label={t("system.user.password")}
|
||||
name="password"
|
||||
rules={[{required: true, message: t("system.user.password.required")}]}
|
||||
>
|
||||
<Input.Password/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("system.user.rePassword")}
|
||||
name="rePassword"
|
||||
rules={[{required: true, message: t("system.user.rePassword.required")}]}
|
||||
>
|
||||
<Input.Password/>
|
||||
</Form.Item>
|
||||
<Form.Item label={null} style={{marginTop: 30}}>
|
||||
<Button type="primary" block size={'large'} htmlType="submit" loading={buttonLoading}>
|
||||
{t("system.user.resetButton")}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Table;
|
||||
Reference in New Issue
Block a user