first commit

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