mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-05-21 00:00:18 +08:00
feat(storage): implement storage management API and enhance storage mode handling
This commit is contained in:
@@ -20,6 +20,13 @@ export interface PaginatedResult<T> {
|
||||
code: number;
|
||||
}
|
||||
|
||||
// 通用批量删除结果
|
||||
export interface BatchDeleteResult {
|
||||
successCount: number;
|
||||
failedCount: number;
|
||||
failedIds?: number[];
|
||||
}
|
||||
|
||||
export const BASE_URL = import.meta.env.PROD ? '/api' : 'http://localhost:5153/api';
|
||||
|
||||
export async function fetchApi<T = any>(
|
||||
|
||||
@@ -8,4 +8,5 @@ export * from './pictureApi';
|
||||
export * from './pictureManagementApi';
|
||||
export * from './tagApi';
|
||||
export * from './userManagementApi';
|
||||
export * from './vectorDbApi';
|
||||
export * from './vectorDbApi';
|
||||
export * from './storageManagementApi';
|
||||
@@ -1,5 +1,4 @@
|
||||
import { fetchApi, type BaseResult, type PaginatedResult } from './fetchClient';
|
||||
import { type BatchDeleteResult } from './userManagementApi';
|
||||
import { fetchApi, type BaseResult, type BatchDeleteResult, type PaginatedResult } from './fetchClient';
|
||||
|
||||
|
||||
// 日志级别枚举
|
||||
|
||||
@@ -40,6 +40,7 @@ export interface PictureResponse {
|
||||
permission: number;
|
||||
albumId?: number;
|
||||
albumName?: string;
|
||||
storageModeName:string;
|
||||
}
|
||||
|
||||
// 收藏请求
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { fetchApi, type BaseResult, type PaginatedResult } from './fetchClient';
|
||||
import { fetchApi, type BaseResult, type BatchDeleteResult, type PaginatedResult } from './fetchClient';
|
||||
import { type PictureResponse } from './pictureApi';
|
||||
import { type BatchDeleteResult } from './userManagementApi';
|
||||
|
||||
|
||||
// 获取图片列表
|
||||
|
||||
156
Web/src/api/storageManagementApi.ts
Normal file
156
Web/src/api/storageManagementApi.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { fetchApi, type BaseResult, type BatchDeleteResult, type PaginatedResult } from './fetchClient';
|
||||
|
||||
export enum StorageTypeEnum {
|
||||
Local = 0,
|
||||
Telegram = 1,
|
||||
S3 = 2,
|
||||
Cos = 3,
|
||||
WebDAV = 4,
|
||||
}
|
||||
export const StorageTypeLabels: Record<StorageTypeEnum, string> = {
|
||||
[StorageTypeEnum.Local]: "本地存储",
|
||||
[StorageTypeEnum.Telegram]: "Telegram",
|
||||
[StorageTypeEnum.S3]: "S3 对象存储",
|
||||
[StorageTypeEnum.Cos]: "腾讯云 COS",
|
||||
[StorageTypeEnum.WebDAV]: "WebDAV",
|
||||
};
|
||||
|
||||
export interface StorageModeResponse {
|
||||
id: number;
|
||||
name: string;
|
||||
storageType: StorageTypeEnum;
|
||||
storageTypeName: string;
|
||||
configurationJson?: string;
|
||||
isEnabled: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface CreateStorageModeRequest {
|
||||
name: string;
|
||||
storageType: StorageTypeEnum;
|
||||
configurationJson?: string;
|
||||
isEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateStorageModeRequest {
|
||||
id: number;
|
||||
name: string;
|
||||
storageType: StorageTypeEnum;
|
||||
configurationJson?: string;
|
||||
isEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface StorageTypeResponse {
|
||||
value: StorageTypeEnum;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface StorageModeFilterRequest {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
searchQuery?: string;
|
||||
storageType?: StorageTypeEnum;
|
||||
isEnabled?: boolean;
|
||||
}
|
||||
|
||||
// 获取存储模式列表
|
||||
export const getStorageModes = async (
|
||||
filters: StorageModeFilterRequest = {}
|
||||
): Promise<PaginatedResult<StorageModeResponse>> => {
|
||||
const { page = 1, pageSize = 10, searchQuery, storageType, isEnabled } = filters;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
pageSize: pageSize.toString(),
|
||||
});
|
||||
|
||||
if (searchQuery) params.append('searchQuery', searchQuery);
|
||||
if (storageType !== undefined) params.append('storageType', storageType.toString());
|
||||
if (isEnabled !== undefined) params.append('isEnabled', isEnabled.toString());
|
||||
|
||||
const response = await fetchApi(`/management/storage/get_modes?${params.toString()}`);
|
||||
return response as PaginatedResult<StorageModeResponse>;
|
||||
};
|
||||
|
||||
// 根据ID获取单个存储模式
|
||||
export const getStorageModeById = async (id: number): Promise<BaseResult<StorageModeResponse>> => {
|
||||
return fetchApi<StorageModeResponse>(
|
||||
`/management/storage/get_mode/${id}`,
|
||||
{ method: 'GET' }
|
||||
);
|
||||
};
|
||||
|
||||
// 创建存储模式
|
||||
export const createStorageMode = async (
|
||||
data: CreateStorageModeRequest
|
||||
): Promise<BaseResult<StorageModeResponse>> => {
|
||||
return fetchApi<StorageModeResponse>(
|
||||
'/management/storage/create_mode',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// 更新存储模式
|
||||
export const updateStorageMode = async (
|
||||
data: UpdateStorageModeRequest
|
||||
): Promise<BaseResult<StorageModeResponse>> => {
|
||||
return fetchApi<StorageModeResponse>(
|
||||
'/management/storage/update_mode',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// 删除存储模式
|
||||
export const deleteStorageMode = async (id: number): Promise<BaseResult<boolean>> => {
|
||||
return fetchApi<boolean>(
|
||||
'/management/storage/delete_mode',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(id) // 后端期望body中直接是id
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// 批量删除存储模式
|
||||
export const batchDeleteStorageModes = async (
|
||||
ids: number[]
|
||||
): Promise<BaseResult<BatchDeleteResult>> => {
|
||||
return fetchApi<BatchDeleteResult>(
|
||||
'/management/storage/batch_delete_modes',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(ids)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// 获取所有可用的存储类型
|
||||
export const getStorageTypes = async (): Promise<BaseResult<StorageTypeResponse[]>> => {
|
||||
return fetchApi<StorageTypeResponse[]>(
|
||||
'/management/storage/get_storage_types',
|
||||
{ method: 'GET' }
|
||||
);
|
||||
};
|
||||
|
||||
// 获取默认存储模式ID
|
||||
export const getDefaultStorageModeId = async (): Promise<BaseResult<number | null>> => {
|
||||
return fetchApi<number | null>(
|
||||
'/management/storage/get_default_mode_id',
|
||||
{ method: 'GET' }
|
||||
);
|
||||
};
|
||||
|
||||
// 设置默认存储模式
|
||||
export const setDefaultStorageMode = async (id: number): Promise<BaseResult<boolean>> => {
|
||||
return fetchApi<boolean>(
|
||||
`/management/storage/set_default_mode/${id}`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fetchApi, type BaseResult, type PaginatedResult } from './fetchClient';
|
||||
import { fetchApi, type BaseResult, type BatchDeleteResult, type PaginatedResult } from './fetchClient';
|
||||
|
||||
export type UserRole = "Administrator" | "User" | "";
|
||||
|
||||
@@ -34,13 +34,6 @@ export interface AdminUpdateUserRequest {
|
||||
role?: string;
|
||||
}
|
||||
|
||||
// 批量删除结果
|
||||
export interface BatchDeleteResult {
|
||||
successCount: number;
|
||||
failedCount: number;
|
||||
failedIds?: number[];
|
||||
}
|
||||
|
||||
// 用户筛选请求参数
|
||||
export interface UserFilterRequest {
|
||||
page?: number;
|
||||
|
||||
@@ -91,10 +91,17 @@ const ImageCard: React.FC<ImageCardProps> = ({
|
||||
{!selectable && (
|
||||
<>
|
||||
<div className="custom-card-indicators">
|
||||
<div className="custom-card-permission" style={{
|
||||
backgroundColor: permissionTypeMap[image.permission]?.color || 'rgba(0, 0, 0, 0.6)'
|
||||
}}>
|
||||
{permissionTypeMap[image.permission]?.icon} {permissionTypeMap[image.permission]?.label || '公开'}
|
||||
<div className="custom-card-left-indicators">
|
||||
<div className="custom-card-permission" style={{
|
||||
backgroundColor: permissionTypeMap[image.permission]?.color || 'rgba(0, 0, 0, 0.6)'
|
||||
}}>
|
||||
{permissionTypeMap[image.permission]?.icon} {permissionTypeMap[image.permission]?.label || '公开'}
|
||||
</div>
|
||||
{image.storageModeName && (
|
||||
<div className="custom-card-storage-mode">
|
||||
{image.storageModeName}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="custom-card-metadata">
|
||||
|
||||
@@ -121,12 +121,19 @@
|
||||
right: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center; /* 确保垂直对齐 */
|
||||
padding: 0 8px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.35s ease;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.custom-card-left-indicators { /* 新增样式 */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px; /* 指示器之间的间距 */
|
||||
}
|
||||
|
||||
.custom-card:hover .custom-card-indicators {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -145,6 +152,20 @@
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.custom-card-storage-mode { /* 新增样式 */
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
color: white;
|
||||
border-radius: 12px;
|
||||
padding: 3px 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
backdrop-filter: blur(4px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.custom-card-metadata {
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
color: white;
|
||||
|
||||
571
Web/src/pages/admin/storage/StorageManagement.tsx
Normal file
571
Web/src/pages/admin/storage/StorageManagement.tsx
Normal file
@@ -0,0 +1,571 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Table, Button, Card, Input, Space, Modal, Form, message, Tag, Typography, Popconfirm, Row, Col, Select, Switch, Tooltip, Alert} from 'antd';
|
||||
import {
|
||||
DeleteOutlined, EditOutlined, SearchOutlined, ExclamationCircleOutlined, ReloadOutlined,
|
||||
PlusOutlined, DatabaseOutlined, FilterOutlined, ClearOutlined, StarOutlined, StarFilled
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
getStorageModes, deleteStorageMode, createStorageMode, updateStorageMode, batchDeleteStorageModes, getStorageTypes,
|
||||
getDefaultStorageModeId, setDefaultStorageMode,
|
||||
type StorageModeResponse, type CreateStorageModeRequest, type UpdateStorageModeRequest, type StorageModeFilterRequest,
|
||||
type StorageTypeResponse, StorageTypeEnum, StorageTypeLabels
|
||||
} from '../../../api';
|
||||
import { useOutletContext } from 'react-router';
|
||||
import type { Breakpoint } from 'antd';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { Option } = Select;
|
||||
const { confirm } = Modal;
|
||||
|
||||
const StorageManagementPage: React.FC = () => {
|
||||
const { isMobile } = useOutletContext<{ isMobile: boolean }>();
|
||||
|
||||
const [storageModes, setStorageModes] = useState<StorageModeResponse[]>([]);
|
||||
const [availableStorageTypes, setAvailableStorageTypes] = useState<StorageTypeResponse[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||
const [defaultStorageModeId, setDefaultStorageModeId] = useState<number | null>(null);
|
||||
const [, setIsLoadingDefault] = useState(false);
|
||||
const [settingDefaultModeId, setSettingDefaultModeId] = useState<number | null>(null);
|
||||
|
||||
const [filters, setFilters] = useState<StorageModeFilterRequest>({});
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [filterForm] = Form.useForm();
|
||||
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
const [modalTitle, setModalTitle] = useState('');
|
||||
const [editingMode, setEditingMode] = useState<StorageModeResponse | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [currentStorageTypeForHelp, setCurrentStorageTypeForHelp] = useState<StorageTypeEnum | null>(null);
|
||||
|
||||
const fetchStorageTypes = useCallback(async () => {
|
||||
try {
|
||||
const response = await getStorageTypes();
|
||||
if (response.success && response.data) {
|
||||
setAvailableStorageTypes(response.data);
|
||||
} else {
|
||||
message.error(response.message || '获取存储类型失败');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('获取存储类型失败,请检查网络');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchDefaultStorageModeId = useCallback(async () => {
|
||||
try {
|
||||
setIsLoadingDefault(true);
|
||||
const response = await getDefaultStorageModeId();
|
||||
if (response.success) {
|
||||
setDefaultStorageModeId(response.data ?? null);
|
||||
} else {
|
||||
message.error(response.message || '获取默认存储模式失败');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('获取默认存储模式失败,请检查网络');
|
||||
} finally {
|
||||
setIsLoadingDefault(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchStorageModes = useCallback(async (page = currentPage, size = pageSize, filterParams = filters) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getStorageModes({
|
||||
page,
|
||||
pageSize: size,
|
||||
...filterParams
|
||||
});
|
||||
if (response.success && response.data) {
|
||||
setStorageModes(response.data.map(m => ({...m, createdAt: new Date(m.createdAt), updatedAt: new Date(m.updatedAt) })));
|
||||
setTotal(response.totalCount || 0);
|
||||
} else {
|
||||
message.error(response.message || '获取存储模式列表失败');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('获取存储模式列表失败,请检查网络连接');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [currentPage, pageSize, filters]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStorageTypes();
|
||||
fetchStorageModes();
|
||||
fetchDefaultStorageModeId();
|
||||
}, [fetchStorageModes, fetchStorageTypes, fetchDefaultStorageModeId]);
|
||||
|
||||
const handlePageChange = (page: number, size?: number) => {
|
||||
setCurrentPage(page);
|
||||
if (size) setPageSize(size);
|
||||
fetchStorageModes(page, size || pageSize, filters);
|
||||
};
|
||||
|
||||
const handleFilter = async () => {
|
||||
const values = await filterForm.validateFields();
|
||||
const newFilters: StorageModeFilterRequest = {
|
||||
searchQuery: values.searchQuery,
|
||||
storageType: values.storageType,
|
||||
isEnabled: typeof values.isEnabled === 'boolean' ? values.isEnabled : undefined,
|
||||
};
|
||||
setFilters(newFilters);
|
||||
setCurrentPage(1);
|
||||
fetchStorageModes(1, pageSize, newFilters);
|
||||
};
|
||||
|
||||
const handleClearFilters = () => {
|
||||
filterForm.resetFields();
|
||||
setFilters({});
|
||||
setCurrentPage(1);
|
||||
fetchStorageModes(1, pageSize, {});
|
||||
};
|
||||
|
||||
const handleQuickSearch = (searchQuery: string) => {
|
||||
const newFilters = { ...filters, searchQuery };
|
||||
setFilters(newFilters);
|
||||
setCurrentPage(1);
|
||||
fetchStorageModes(1, pageSize, newFilters);
|
||||
};
|
||||
|
||||
const showCreateModal = () => {
|
||||
setModalTitle('创建新存储模式');
|
||||
setEditingMode(null);
|
||||
setCurrentStorageTypeForHelp(null);
|
||||
form.resetFields(); // This will clear all fields, including any 'configuration' fields
|
||||
form.setFieldsValue({ isEnabled: true });
|
||||
setIsModalVisible(true);
|
||||
};
|
||||
|
||||
const showEditModal = (mode: StorageModeResponse) => {
|
||||
setModalTitle('编辑存储模式');
|
||||
setEditingMode(mode);
|
||||
setCurrentStorageTypeForHelp(mode.storageType);
|
||||
|
||||
let parsedConfig = {};
|
||||
if (mode.configurationJson) {
|
||||
try {
|
||||
parsedConfig = JSON.parse(mode.configurationJson);
|
||||
} catch (e) {
|
||||
message.error('解析现有配置JSON失败,请检查数据格式。');
|
||||
parsedConfig = {};
|
||||
}
|
||||
}
|
||||
|
||||
form.setFieldsValue({
|
||||
name: mode.name,
|
||||
storageType: mode.storageType,
|
||||
isEnabled: mode.isEnabled,
|
||||
configuration: parsedConfig,
|
||||
});
|
||||
setIsModalVisible(true);
|
||||
};
|
||||
|
||||
const handleModalOk = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const { name, storageType, isEnabled, configuration } = values;
|
||||
const configToSave = configuration || {};
|
||||
const commonData = {
|
||||
name,
|
||||
storageType,
|
||||
configurationJson: JSON.stringify(configToSave),
|
||||
isEnabled,
|
||||
};
|
||||
|
||||
let response;
|
||||
if (editingMode) {
|
||||
const updateData: UpdateStorageModeRequest = { id: editingMode.id, ...commonData };
|
||||
response = await updateStorageMode(updateData);
|
||||
} else {
|
||||
const createData: CreateStorageModeRequest = commonData;
|
||||
response = await createStorageMode(createData);
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
message.success(editingMode ? '存储模式更新成功' : '存储模式创建成功');
|
||||
fetchStorageModes(editingMode ? currentPage : 1);
|
||||
setIsModalVisible(false);
|
||||
} else {
|
||||
message.error(response.message || (editingMode ? '更新失败' : '创建失败'));
|
||||
}
|
||||
} catch (errorInfo) {
|
||||
console.error('Form validation failed:', errorInfo);
|
||||
message.error('请检查表单输入。');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
const response = await deleteStorageMode(id);
|
||||
if (response.success) {
|
||||
message.success('存储模式删除成功');
|
||||
fetchStorageModes(); // Refresh
|
||||
} else {
|
||||
message.error(response.message || '删除失败');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('删除失败,请检查网络');
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
if (selectedRowKeys.length === 0) {
|
||||
message.warning('请选择要删除的存储模式');
|
||||
return;
|
||||
}
|
||||
confirm({
|
||||
title: `确定要删除 ${selectedRowKeys.length} 个存储模式吗?`,
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
content: '此操作不可逆。如果存储模式仍被图片使用,则无法删除。',
|
||||
okText: '确认',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
async onOk() {
|
||||
try {
|
||||
const response = await batchDeleteStorageModes(selectedRowKeys as number[]);
|
||||
if (response.success && response.data) {
|
||||
message.success(`成功删除 ${response.data.successCount} 个存储模式`);
|
||||
if (response.data.failedCount > 0) {
|
||||
message.warning(`${response.data.failedCount} 个存储模式删除失败 (可能仍在使用中)。失败ID: ${response.data.failedIds?.join(', ')}`);
|
||||
}
|
||||
setSelectedRowKeys([]);
|
||||
fetchStorageModes(); // Refresh
|
||||
} else {
|
||||
message.error(response.message || '批量删除失败');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('批量删除失败,请检查网络');
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleSetDefault = async (id: number) => {
|
||||
try {
|
||||
setSettingDefaultModeId(id); // 开始为此特定项目设置默认
|
||||
const response = await setDefaultStorageMode(id);
|
||||
if (response.success) {
|
||||
message.success('默认存储模式设置成功');
|
||||
setDefaultStorageModeId(id);
|
||||
// 可选: 如果默认状态会影响列表显示方式(除了星星图标),则重新获取列表
|
||||
// fetchStorageModes(currentPage, pageSize, filters);
|
||||
} else {
|
||||
message.error(response.message || '设置默认存储模式失败');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('设置默认存储模式失败,请检查网络');
|
||||
} finally {
|
||||
setSettingDefaultModeId(null); // 清除此特定项目的加载状态
|
||||
}
|
||||
};
|
||||
|
||||
const renderDynamicConfigFields = (storageType: StorageTypeEnum | null) => {
|
||||
if (storageType === null || storageType === undefined) return null;
|
||||
switch (storageType) {
|
||||
case StorageTypeEnum.Local:
|
||||
return (
|
||||
<>
|
||||
<Form.Item name={['configuration', 'BasePath']} label="基础路径 (BasePath)" rules={[{ required: true, message: '请输入基础路径' }]}>
|
||||
<Input placeholder="例如: /path/to/your/uploads" />
|
||||
</Form.Item>
|
||||
<Form.Item name={['configuration', 'ServerUrl']} label="服务器URL (ServerUrl)" rules={[{ required: true, message: '请输入服务器URL' }]}>
|
||||
<Input placeholder="例如: http://localhost:5000" />
|
||||
</Form.Item>
|
||||
<Form.Item name={['configuration', 'PublicBasePath']} label="公共基础路径 (PublicBasePath)" rules={[{ required: true, message: '请输入公共基础路径'}]}>
|
||||
<Input placeholder="例如: /Uploads (用于拼接图片URL)" />
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
case StorageTypeEnum.Telegram:
|
||||
return (
|
||||
<>
|
||||
<Form.Item name={['configuration', 'BotToken']} label="机器人Token (BotToken)" rules={[{ required: true, message: '请输入机器人Token' }]}>
|
||||
<Input.Password placeholder="您的Telegram机器人Token" />
|
||||
</Form.Item>
|
||||
<Form.Item name={['configuration', 'ChatId']} label="聊天ID (ChatId)" rules={[{ required: true, message: '请输入聊天ID' }]}>
|
||||
<Input placeholder="目标聊天或频道的ID" />
|
||||
</Form.Item>
|
||||
<Form.Item name={['configuration', 'ProxyAddress']} label="代理地址 (ProxyAddress) (可选)">
|
||||
<Input placeholder="例如: 127.0.0.1" />
|
||||
</Form.Item>
|
||||
<Form.Item name={['configuration', 'ProxyPort']} label="代理端口 (ProxyPort) (可选)">
|
||||
<Input placeholder="例如: 1080" type="number" />
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
case StorageTypeEnum.S3:
|
||||
return (
|
||||
<>
|
||||
<Form.Item name={['configuration', 'AccessKey']} label="AccessKey" rules={[{ required: true, message: '请输入AccessKey' }]}>
|
||||
<Input placeholder="您的S3 AccessKey" />
|
||||
</Form.Item>
|
||||
<Form.Item name={['configuration', 'SecretKey']} label="SecretKey" rules={[{ required: true, message: '请输入SecretKey' }]}>
|
||||
<Input.Password placeholder="您的S3 SecretKey" />
|
||||
</Form.Item>
|
||||
<Form.Item name={['configuration', 'Endpoint']} label="Endpoint" rules={[{ required: true, message: '请输入Endpoint' }]}>
|
||||
<Input placeholder="例如: s3.us-west-2.amazonaws.com" />
|
||||
</Form.Item>
|
||||
<Form.Item name={['configuration', 'Region']} label="区域 (Region)" rules={[{ required: true, message: '请输入区域' }]}>
|
||||
<Input placeholder="例如: us-west-2" />
|
||||
</Form.Item>
|
||||
<Form.Item name={['configuration', 'BucketName']} label="存储桶名称 (BucketName)" rules={[{ required: true, message: '请输入存储桶名称' }]}>
|
||||
<Input placeholder="您的S3存储桶名称" />
|
||||
</Form.Item>
|
||||
<Form.Item name={['configuration', 'UsePathStyleUrls']} label="使用路径样式URL (UsePathStyleUrls)" valuePropName="checked">
|
||||
<Switch checkedChildren="是" unCheckedChildren="否" />
|
||||
</Form.Item>
|
||||
<Form.Item name={['configuration', 'CdnUrl']} label="CDN URL (可选)">
|
||||
<Input placeholder="例如: https://cdn.example.com" />
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
case StorageTypeEnum.Cos:
|
||||
return (
|
||||
<>
|
||||
<Form.Item name={['configuration', 'Region']} label="区域 (Region)" rules={[{ required: true, message: '请输入区域' }]}>
|
||||
<Input placeholder="例如: ap-guangzhou" />
|
||||
</Form.Item>
|
||||
<Form.Item name={['configuration', 'SecretId']} label="SecretId" rules={[{ required: true, message: '请输入SecretId' }]}>
|
||||
<Input placeholder="您的腾讯云SecretId" />
|
||||
</Form.Item>
|
||||
<Form.Item name={['configuration', 'SecretKey']} label="SecretKey" rules={[{ required: true, message: '请输入SecretKey' }]}>
|
||||
<Input.Password placeholder="您的腾讯云SecretKey" />
|
||||
</Form.Item>
|
||||
<Form.Item name={['configuration', 'BucketName']} label="存储桶名称 (BucketName)" rules={[{ required: true, message: '请输入存储桶名称' }]}>
|
||||
<Input placeholder="格式: your-bucket-appid" />
|
||||
</Form.Item>
|
||||
<Form.Item name={['configuration', 'CdnUrl']} label="CDN URL (可选)">
|
||||
<Input placeholder="例如: https://cdn.example.com" />
|
||||
</Form.Item>
|
||||
<Form.Item name={['configuration', 'PublicRead']} label="公共读 (PublicRead)" valuePropName="checked">
|
||||
<Switch checkedChildren="是" unCheckedChildren="否" />
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
case StorageTypeEnum.WebDAV:
|
||||
return (
|
||||
<>
|
||||
<Form.Item name={['configuration', 'ServerUrl']} label="服务器URL (ServerUrl)" rules={[{ required: true, message: '请输入WebDAV服务器URL' }]}>
|
||||
<Input placeholder="例如: https://dav.example.com" />
|
||||
</Form.Item>
|
||||
<Form.Item name={['configuration', 'BasePath']} label="基础路径 (BasePath)">
|
||||
<Input placeholder="例如: uploads (在服务器上的相对路径)" />
|
||||
</Form.Item>
|
||||
<Form.Item name={['configuration', 'UserName']} label="用户名 (UserName)" rules={[{ required: true, message: '请输入用户名' }]}>
|
||||
<Input placeholder="WebDAV用户名" />
|
||||
</Form.Item>
|
||||
<Form.Item name={['configuration', 'Password']} label="密码 (Password)" rules={[{ required: true, message: '请输入密码' }]}>
|
||||
<Input.Password placeholder="WebDAV密码" />
|
||||
</Form.Item>
|
||||
<Form.Item name={['configuration', 'PublicUrl']} label="公共URL (PublicUrl) (可选)">
|
||||
<Input placeholder="例如: https://public.example.com/dav (如果WebDAV内容可通过不同URL公开访问)" />
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
default:
|
||||
return <Alert message="此存储类型可能不需要额外配置,或配置界面暂未实现。" type="info" showIcon />;
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID', dataIndex: 'id', key: 'id', responsive: ['md'] as Breakpoint[] },
|
||||
{
|
||||
title: '名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (name: string, record: StorageModeResponse) => (
|
||||
<Space>
|
||||
{record.id === defaultStorageModeId && (
|
||||
<Tooltip title="默认存储模式">
|
||||
<StarFilled style={{ color: '#faad14' }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
{name}
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '类型', dataIndex: 'storageType', key: 'storageType',
|
||||
render: (type: StorageTypeEnum) => <Tag color="blue">{StorageTypeLabels[type] || type}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '配置 (JSON)', dataIndex: 'configurationJson', key: 'configurationJson', responsive: ['lg'] as Breakpoint[],
|
||||
render: (json?: string) => json ? <Tooltip title={json}><Text style={{ maxWidth: 200 }} ellipsis>{json}</Text></Tooltip> : <Text type="secondary">无</Text>,
|
||||
},
|
||||
{
|
||||
title: '启用状态', dataIndex: 'isEnabled', key: 'isEnabled',
|
||||
render: (enabled: boolean) => <Tag color={enabled ? 'green' : 'red'}>{enabled ? '已启用' : '已禁用'}</Tag>,
|
||||
},
|
||||
{ title: '更新时间', dataIndex: 'updatedAt', key: 'updatedAt', responsive: ['lg'] as Breakpoint[], render: (date: Date) => date.toLocaleString() },
|
||||
{
|
||||
title: '操作', key: 'action',
|
||||
render: (_: any, record: StorageModeResponse) => (
|
||||
<Space size="small">
|
||||
{record.isEnabled && record.id !== defaultStorageModeId && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<StarOutlined />}
|
||||
onClick={() => handleSetDefault(record.id)}
|
||||
loading={settingDefaultModeId === record.id} // 使用新的行特定加载状态
|
||||
title="设为默认"
|
||||
>
|
||||
{isMobile ? '' : '设为默认'}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="text" icon={<EditOutlined />} onClick={() => showEditModal(record)}>{isMobile ? '' : '编辑'}</Button>
|
||||
<Popconfirm
|
||||
title="确定删除此存储模式吗?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
disabled={record.id === defaultStorageModeId}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
disabled={record.id === defaultStorageModeId}
|
||||
>
|
||||
{isMobile ? '' : '删除'}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Row gutter={[16, 16]} align="middle" justify="space-between">
|
||||
<Col>
|
||||
<Space align="center">
|
||||
<DatabaseOutlined style={{ fontSize: 24 }} />
|
||||
<Title level={2} style={{ margin: 0 }}>存储模式管理</Title>
|
||||
</Space>
|
||||
<Text type="secondary" style={{ marginTop: 8, display: 'block' }}>
|
||||
管理系统中的各种文件存储方式及其配置
|
||||
</Text>
|
||||
</Col>
|
||||
<Col>
|
||||
{defaultStorageModeId && (
|
||||
<Alert
|
||||
type="info"
|
||||
message={
|
||||
<Space>
|
||||
<StarFilled style={{ color: '#faad14' }} />
|
||||
<span>当前默认存储模式ID: {defaultStorageModeId}</span>
|
||||
</Space>
|
||||
}
|
||||
style={{ padding: '4px 12px' }}
|
||||
/>
|
||||
)}
|
||||
{!defaultStorageModeId && (
|
||||
<Alert
|
||||
type="warning"
|
||||
message="未设置默认存储模式"
|
||||
description="请选择一个启用的存储模式设为默认"
|
||||
showIcon
|
||||
/>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card style={{ marginTop: 16 }}>
|
||||
<Row gutter={[16, 16]} justify="space-between" style={{ marginBottom: 16 }}>
|
||||
<Col xs={24} sm={14} md={16}>
|
||||
<Space wrap>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={showCreateModal}>创建模式</Button>
|
||||
<Button danger icon={<DeleteOutlined />} onClick={handleBatchDelete} disabled={selectedRowKeys.length === 0}>批量删除</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => fetchStorageModes(currentPage, pageSize, filters)}>刷新</Button>
|
||||
<Button icon={<FilterOutlined />} onClick={() => setShowFilters(!showFilters)} type={showFilters ? 'primary' : 'default'}>高级筛选</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col xs={24} sm={10} md={8}>
|
||||
<Input.Search placeholder="搜索模式名称" allowClear enterButton={<SearchOutlined />} onSearch={handleQuickSearch} />
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{showFilters && (
|
||||
<Card size="small" style={{ marginBottom: 16, backgroundColor: '#fafafa' }}>
|
||||
<Form form={filterForm} layout="inline" onFinish={handleFilter}>
|
||||
<Form.Item name="searchQuery" label="名称">
|
||||
<Input placeholder="模式名称" style={{ width: 150 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="storageType" label="类型">
|
||||
<Select placeholder="选择类型" style={{ width: 150 }} allowClear>
|
||||
{availableStorageTypes.map(st => <Option key={st.value} value={st.value}>{st.name}</Option>)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item name="isEnabled" label="状态">
|
||||
<Select placeholder="选择状态" style={{ width: 120 }} allowClear>
|
||||
<Option value={true}>已启用</Option>
|
||||
<Option value={false}>已禁用</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>筛选</Button>
|
||||
<Button icon={<ClearOutlined />} onClick={handleClearFilters}>清除</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={storageModes}
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: currentPage, pageSize: pageSize, total: total,
|
||||
showSizeChanger: true, showQuickJumper: true, onChange: handlePageChange,
|
||||
showTotal: (t) => `共 ${t} 条记录`,
|
||||
}}
|
||||
rowSelection={{ selectedRowKeys, onChange: (keys) => setSelectedRowKeys(keys) }}
|
||||
size={isMobile ? "small" : "middle"}
|
||||
scroll={{ x: 'max-content' }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={modalTitle}
|
||||
open={isModalVisible}
|
||||
onOk={handleModalOk}
|
||||
onCancel={() => setIsModalVisible(false)}
|
||||
okText={editingMode ? "更新" : "创建"}
|
||||
cancelText="取消"
|
||||
width={isMobile ? '90%' : 700}
|
||||
>
|
||||
<Form form={form} layout="vertical" initialValues={{ isEnabled: true, configuration: {} }}>
|
||||
<Form.Item name="name" label="模式名称" rules={[{ required: true, message: '请输入模式名称' }]}>
|
||||
<Input placeholder="例如:主图片存储、备份存储等" />
|
||||
</Form.Item>
|
||||
<Form.Item name="storageType" label="存储类型" rules={[{ required: true, message: '请选择存储类型' }]}>
|
||||
<Select
|
||||
placeholder="选择一个存储类型"
|
||||
onChange={(value) => {
|
||||
setCurrentStorageTypeForHelp(value as StorageTypeEnum);
|
||||
form.setFieldsValue({ configuration: {} });
|
||||
}}
|
||||
>
|
||||
{availableStorageTypes.map(st => <Option key={st.value} value={st.value}>{StorageTypeLabels[st.value as StorageTypeEnum] || st.name}</Option>)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
|
||||
{renderDynamicConfigFields(currentStorageTypeForHelp)}
|
||||
|
||||
<Form.Item name="isEnabled" label="启用状态" valuePropName="checked">
|
||||
<Switch checkedChildren="已启用" unCheckedChildren="已禁用" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StorageManagementPage;
|
||||
@@ -1,16 +1,16 @@
|
||||
import React from 'react';
|
||||
import { Tabs, Form, Input, Button, Select, Space, Divider, Typography } from 'antd';
|
||||
import { Tabs, Form, Input, Button, Space, Divider, Slider, InputNumber } from 'antd'; // InputNumber added
|
||||
import {
|
||||
ApiOutlined, RocketOutlined, PictureOutlined, SaveOutlined,
|
||||
SafetyCertificateOutlined, LockOutlined, GlobalOutlined, SettingOutlined,
|
||||
CloudServerOutlined, DatabaseOutlined, UploadOutlined} from '@ant-design/icons';
|
||||
DatabaseOutlined, UploadOutlined
|
||||
} from '@ant-design/icons';
|
||||
import ConfigFormItem from './ConfigFormItem';
|
||||
import ConfigSection from './ConfigSection';
|
||||
import VectorDbConfig from './VectorDbConfig';
|
||||
import VectorDbConfig from './VectorDbConfig';
|
||||
|
||||
const { TabPane } = Tabs;
|
||||
const { Option } = Select;
|
||||
const { Title, Paragraph } = Typography;
|
||||
// const { Option } = Select; // Removed
|
||||
|
||||
interface ConfigStructure {
|
||||
[key: string]: {
|
||||
@@ -24,17 +24,13 @@ interface ConfigTabsProps {
|
||||
isMobile: boolean;
|
||||
activeKey: string;
|
||||
onTabChange: (key: string) => void;
|
||||
storageType: string;
|
||||
onStorageTypeChange: (type: string) => void;
|
||||
formsMap: Record<string, any>;
|
||||
allDescriptions: Record<string, Record<string, string>>;
|
||||
onSaveSingleConfig: (formInstance: any, groupName: string, key: string) => Promise<void>;
|
||||
onSaveAllForGroup: (formInstance: any, groupName: string, itemKeys: string[]) => Promise<void>;
|
||||
onBaseSaveConfig: (group: string, key: string, value: string) => Promise<boolean>;
|
||||
setConfigs: React.Dispatch<React.SetStateAction<ConfigStructure>>;
|
||||
storageOptions: Array<{ value: string; label: string; icon: React.ReactNode; }>;
|
||||
imageFormatOptions: Array<{ value: string; label: string; description: string; }>;
|
||||
imageQualityOptions: Array<{ value: string; label: string; description: string; }>;
|
||||
// imageQualityOptions: Array<{ value: string; label: string; description: string; }>; // Removed
|
||||
}
|
||||
|
||||
const ConfigTabs: React.FC<ConfigTabsProps> = ({
|
||||
@@ -43,17 +39,13 @@ const ConfigTabs: React.FC<ConfigTabsProps> = ({
|
||||
isMobile,
|
||||
activeKey,
|
||||
onTabChange,
|
||||
storageType,
|
||||
onStorageTypeChange,
|
||||
formsMap,
|
||||
allDescriptions,
|
||||
onSaveSingleConfig,
|
||||
onSaveAllForGroup,
|
||||
onBaseSaveConfig,
|
||||
setConfigs,
|
||||
storageOptions,
|
||||
imageFormatOptions,
|
||||
imageQualityOptions,
|
||||
// imageQualityOptions, // Removed
|
||||
}) => {
|
||||
|
||||
const renderConfigFormItems = (formInstance: any, groupName: string, itemKeys: string[]) => {
|
||||
@@ -306,245 +298,93 @@ const ConfigTabs: React.FC<ConfigTabsProps> = ({
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'Storage',
|
||||
label: '存储设置',
|
||||
icon: <CloudServerOutlined />,
|
||||
key: 'Upload',
|
||||
label: '上传设置',
|
||||
icon: <UploadOutlined />,
|
||||
children: (
|
||||
<>
|
||||
<ConfigSection
|
||||
title="存储类型配置"
|
||||
icon={<DatabaseOutlined />}
|
||||
description="配置系统默认使用的文件存储方式"
|
||||
isMobile={isMobile}
|
||||
>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: isMobile ? '1fr' : 'repeat(auto-fit, minmax(300px, 1fr))',
|
||||
gap: isMobile ? 12 : 16,
|
||||
marginBottom: 0
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ marginBottom: 8, fontSize: 14, fontWeight: 500, color: '#666' }}>
|
||||
登录用户默认存储
|
||||
</div>
|
||||
<Select
|
||||
value={configs.Storage?.DefaultStorage || 'Local'}
|
||||
onChange={(value) => onBaseSaveConfig('Storage', 'DefaultStorage', value)}
|
||||
style={{ width: '100%' }}
|
||||
size="large"
|
||||
placeholder="选择登录用户的默认存储方式"
|
||||
optionLabelProp="label"
|
||||
>
|
||||
{storageOptions.map(option => (
|
||||
<Option key={option.value} value={option.value} label={option.label}>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
{option.icon}
|
||||
<span style={{ marginLeft: 8 }}>{option.label}</span>
|
||||
</div>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
<div style={{ fontSize: 12, color: '#999', marginTop: 4 }}>
|
||||
{allDescriptions.Storage?.DefaultStorage}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ marginBottom: 8, fontSize: 14, fontWeight: 500, color: '#666' }}>
|
||||
匿名用户默认存储
|
||||
</div>
|
||||
<Select
|
||||
value={configs.Storage?.AnonymousDefaultStorage || 'Local'}
|
||||
onChange={(value) => onBaseSaveConfig('Storage', 'AnonymousDefaultStorage', value)}
|
||||
style={{ width: '100%' }}
|
||||
size="large"
|
||||
placeholder="选择匿名用户的默认存储方式"
|
||||
optionLabelProp="label"
|
||||
>
|
||||
{storageOptions.map(option => (
|
||||
<Option key={option.value} value={option.value} label={option.label}>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
{option.icon}
|
||||
<span style={{ marginLeft: 8 }}>{option.label}</span>
|
||||
</div>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
<div style={{ fontSize: 12, color: '#999', marginTop: 4 }}>
|
||||
{allDescriptions.Storage?.AnonymousDefaultStorage}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ConfigSection>
|
||||
|
||||
<ConfigSection
|
||||
title="上传设置配置"
|
||||
title="上传参数配置"
|
||||
icon={<UploadOutlined />}
|
||||
description="配置文件上传处理方式和图片转换参数"
|
||||
isMobile={isMobile}
|
||||
>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: isMobile ? '1fr' : 'repeat(auto-fit, minmax(300px, 1fr))',
|
||||
gap: isMobile ? 12 : 16,
|
||||
gridTemplateColumns: '1fr',
|
||||
gap: isMobile ? 20 : 24,
|
||||
marginBottom: 0
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ marginBottom: 8, fontSize: 14, fontWeight: 500, color: '#666' }}>
|
||||
默认图片格式
|
||||
缩略图最大宽度 (px)
|
||||
</div>
|
||||
<Select
|
||||
value={configs.Upload?.DefaultImageFormat || 'Original'}
|
||||
onChange={(value) => onBaseSaveConfig('Upload', 'DefaultImageFormat', value)}
|
||||
<InputNumber
|
||||
min={100}
|
||||
max={1000}
|
||||
step={50}
|
||||
value={parseInt(configs.Upload?.ThumbnailMaxWidth || '400', 10)}
|
||||
onChange={(value) => {
|
||||
if (value !== null) {
|
||||
onBaseSaveConfig('Upload', 'ThumbnailMaxWidth', value.toString())
|
||||
}
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
size="large"
|
||||
placeholder="选择上传图片的默认处理格式"
|
||||
optionLabelProp="label"
|
||||
>
|
||||
{imageFormatOptions.map(option => (
|
||||
<Option key={option.value} value={option.value} label={option.label}>
|
||||
<div>
|
||||
<div>{option.label}</div>
|
||||
<div style={{ fontSize: '12px', color: '#999' }}>{option.description}</div>
|
||||
</div>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
/>
|
||||
<div style={{ fontSize: 12, color: '#999', marginTop: 4 }}>
|
||||
{allDescriptions.Upload?.DefaultImageFormat}
|
||||
{allDescriptions.Upload?.ThumbnailMaxWidth}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ marginBottom: 8, fontSize: 14, fontWeight: 500, color: '#666' }}>
|
||||
默认压缩质量
|
||||
缩略图压缩质量
|
||||
</div>
|
||||
<Select
|
||||
value={configs.Upload?.DefaultImageQuality || '95'}
|
||||
onChange={(value) => onBaseSaveConfig('Upload', 'DefaultImageQuality', value)}
|
||||
style={{ width: '100%' }}
|
||||
size="large"
|
||||
placeholder="选择图片压缩质量"
|
||||
optionLabelProp="label"
|
||||
>
|
||||
{imageQualityOptions.map(option => (
|
||||
<Option key={option.value} value={option.value} label={option.label}>
|
||||
<div>
|
||||
<div>{option.label}</div>
|
||||
<div style={{ fontSize: '12px', color: '#999' }}>{option.description}</div>
|
||||
</div>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
<div style={{ fontSize: 12, color: '#999', marginTop: 4 }}>
|
||||
{allDescriptions.Upload?.DefaultImageQuality}
|
||||
<Slider
|
||||
min={30}
|
||||
max={90}
|
||||
step={5}
|
||||
value={parseInt(configs.Upload?.ThumbnailCompressionQuality || '75', 10)}
|
||||
onChange={(value) => onBaseSaveConfig('Upload', 'ThumbnailCompressionQuality', value.toString())}
|
||||
style={{ margin: isMobile ? '0 5px' : '0 10px' }}
|
||||
tooltip={{
|
||||
formatter: value => `${value}%`
|
||||
}}
|
||||
marks={{
|
||||
30: '30%',
|
||||
60: '60%',
|
||||
90: '90%'
|
||||
}}
|
||||
/>
|
||||
<div style={{ fontSize: 12, color: '#999', marginTop: 16, textAlign: 'center' }}>
|
||||
{allDescriptions.Upload?.ThumbnailCompressionQuality}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ConfigSection>
|
||||
|
||||
<ConfigSection
|
||||
title="存储服务配置"
|
||||
icon={<CloudServerOutlined />}
|
||||
description="配置各种外部存储服务的连接参数"
|
||||
isMobile={isMobile}
|
||||
>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ marginBottom: 8, fontSize: 14, fontWeight: 500, color: '#666' }}>
|
||||
选择要配置的存储服务
|
||||
</div>
|
||||
<Select
|
||||
value={storageType}
|
||||
onChange={onStorageTypeChange}
|
||||
style={{ width: isMobile ? '100%' : '300px' }}
|
||||
size="large"
|
||||
placeholder="选择需要配置的存储服务类型"
|
||||
optionLabelProp="label"
|
||||
>
|
||||
{storageOptions.map(option => (
|
||||
<Option key={option.value} value={option.value} label={option.label}>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
{option.icon}
|
||||
<span style={{ marginLeft: 8 }}>{option.label}</span>
|
||||
</div>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
<div style={{ fontSize: 12, color: '#999', marginTop: 4, marginBottom: 16 }}>
|
||||
选择后将显示对应存储服务的详细配置选项
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ border: '1px solid #f0f0f0', borderRadius: 6, padding: isMobile ? 12 : 16, backgroundColor: '#fafafa' }}>
|
||||
{storageType === 'Local' && (
|
||||
<div style={{ textAlign: 'center', color: '#999', padding: '30px 0' }}>
|
||||
<DatabaseOutlined style={{ fontSize: 32, color: '#52c41a', marginBottom: 16 }} />
|
||||
<Title level={5}>本地存储无需额外配置</Title>
|
||||
<Paragraph type="secondary">文件将直接存储在服务器的本地文件系统中</Paragraph>
|
||||
<div>
|
||||
<div style={{ marginBottom: 8, fontSize: 14, fontWeight: 500, color: '#666' }}>
|
||||
高清图片压缩质量
|
||||
</div>
|
||||
)}
|
||||
{storageType === 'Telegram' && (
|
||||
<Form form={formsMap.TelegramStorage} layout="vertical" size={isMobile ? "middle" : "large"}>
|
||||
{renderConfigFormItems(formsMap.TelegramStorage, "Storage", ["TelegramStorageBotToken", "TelegramStorageChatId", "TelegramProxyAddress", "TelegramProxyPort", "TelegramProxyUsername", "TelegramProxyPassword"])}
|
||||
<Divider style={{ margin: '12px 0 20px' }} />
|
||||
<Form.Item style={{ marginBottom: 0, textAlign: 'center' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
onClick={() => onSaveAllForGroup(formsMap.TelegramStorage, "Storage", ["TelegramStorageBotToken", "TelegramStorageChatId", "TelegramProxyAddress", "TelegramProxyPort", "TelegramProxyUsername", "TelegramProxyPassword"])}
|
||||
style={{ width: isMobile ? '100%' : '240px' }}
|
||||
>
|
||||
保存所有 Telegram 配置
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
)}
|
||||
{storageType === 'S3' && (
|
||||
<Form form={formsMap.S3Storage} layout="vertical" size={isMobile ? "middle" : "large"}>
|
||||
{renderConfigFormItems(formsMap.S3Storage, "Storage", ["S3StorageAccessKey", "S3StorageSecretKey", "S3StorageBucketName", "S3StorageRegion", "S3StorageEndpoint", "S3StorageCdnUrl", "S3StorageUsePathStyleUrls"])}
|
||||
<Divider style={{ margin: '12px 0 20px' }} />
|
||||
<Form.Item style={{ marginBottom: 0, textAlign: 'center' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
onClick={() => onSaveAllForGroup(formsMap.S3Storage, "Storage", ["S3StorageAccessKey", "S3StorageSecretKey", "S3StorageBucketName", "S3StorageRegion", "S3StorageEndpoint", "S3StorageCdnUrl", "S3StorageUsePathStyleUrls"])}
|
||||
style={{ width: isMobile ? '100%' : '240px' }}
|
||||
>
|
||||
保存所有 S3 配置
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
)}
|
||||
{storageType === 'Cos' && (
|
||||
<Form form={formsMap.CosStorage} layout="vertical" size={isMobile ? "middle" : "large"}>
|
||||
{renderConfigFormItems(formsMap.CosStorage, "Storage", ["CosStorageSecretId", "CosStorageSecretKey", "CosStorageToken", "CosStorageBucketName", "CosStorageRegion", "CosStorageCdnUrl"])}
|
||||
<Divider style={{ margin: '12px 0 20px' }} />
|
||||
<Form.Item style={{ marginBottom: 0, textAlign: 'center' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
onClick={() => onSaveAllForGroup(formsMap.CosStorage, "Storage", ["CosStorageSecretId", "CosStorageSecretKey", "CosStorageToken", "CosStorageBucketName", "CosStorageRegion", "CosStorageCdnUrl"])}
|
||||
style={{ width: isMobile ? '100%' : '240px' }}
|
||||
>
|
||||
保存所有 COS 配置
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
)}
|
||||
{storageType === 'WebDAV' && (
|
||||
<Form form={formsMap.WebDAVStorage} layout="vertical" size={isMobile ? "middle" : "large"}>
|
||||
{renderConfigFormItems(formsMap.WebDAVStorage, "Storage", ["WebDAVServerUrl", "WebDAVUserName", "WebDAVPassword", "WebDAVBasePath", "WebDAVPublicUrl"])}
|
||||
<Divider style={{ margin: '12px 0 20px' }} />
|
||||
<Form.Item style={{ marginBottom: 0, textAlign: 'center' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
onClick={() => onSaveAllForGroup(formsMap.WebDAVStorage, "Storage", ["WebDAVServerUrl", "WebDAVUserName", "WebDAVPassword", "WebDAVBasePath", "WebDAVPublicUrl"])}
|
||||
style={{ width: isMobile ? '100%' : '240px' }}
|
||||
>
|
||||
保存所有 WebDAV 配置
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
)}
|
||||
<Slider
|
||||
min={50}
|
||||
max={100}
|
||||
step={5}
|
||||
value={parseInt(configs.Upload?.HighQualityImageCompressionQuality || '95', 10)}
|
||||
onChange={(value) => onBaseSaveConfig('Upload', 'HighQualityImageCompressionQuality', value.toString())}
|
||||
style={{ margin: isMobile ? '0 5px' : '0 10px' }}
|
||||
tooltip={{
|
||||
formatter: value => `${value}%`
|
||||
}}
|
||||
marks={{
|
||||
50: '50%',
|
||||
75: '75%',
|
||||
100: '100%'
|
||||
}}
|
||||
/>
|
||||
<div style={{ fontSize: 12, color: '#999', marginTop: 16, textAlign: 'center' }}>
|
||||
{allDescriptions.Upload?.HighQualityImageCompressionQuality}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ConfigSection>
|
||||
</>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { Card, message, Spin, Button, Upload, Modal, Space, Tooltip, Form, Typography, notification } from 'antd';
|
||||
import {
|
||||
CloudOutlined, DatabaseOutlined, CloudServerOutlined, GlobalOutlined,
|
||||
DownloadOutlined, UploadOutlined, QuestionCircleOutlined,
|
||||
SettingOutlined,
|
||||
CheckCircleOutlined
|
||||
@@ -46,37 +45,10 @@ const allDescriptions: Record<string, Record<string, string>> = {
|
||||
ServerUrl: '服务器URL',
|
||||
MaxConcurrentTasks: '后台任务最大并发处理数量 (例如: 图像分析、标签生成等)'
|
||||
},
|
||||
Storage: {
|
||||
DefaultStorage: '已登录用户上传文件时的默认存储位置',
|
||||
AnonymousDefaultStorage: '未登录用户上传文件时的默认存储位置',
|
||||
TelegramStorageBotToken: 'Telegram 机器人令牌',
|
||||
TelegramStorageChatId: 'Telegram 聊天ID',
|
||||
TelegramProxyAddress: '代理服务器地址 (例如: 127.0.0.1)',
|
||||
TelegramProxyPort: '代理服务器端口 (例如: 1080)',
|
||||
TelegramProxyUsername: '代理用户名 (可选)',
|
||||
TelegramProxyPassword: '代理密码 (可选)',
|
||||
S3StorageAccessKey: 'S3访问密钥',
|
||||
S3StorageSecretKey: 'S3私有密钥',
|
||||
S3StorageBucketName: 'S3存储桶名称',
|
||||
S3StorageRegion: 'S3区域 (例如:us-east-1)',
|
||||
S3StorageEndpoint: 'S3端点URL (可选,默认为AWS S3)',
|
||||
S3StorageCdnUrl: 'CDN URL (可选,用于加速文件访问)',
|
||||
S3StorageUsePathStyleUrls: '使用路径形式URLs (true/false,兼容非AWS服务)',
|
||||
CosStorageSecretId: '腾讯云COS密钥ID',
|
||||
CosStorageSecretKey: '腾讯云COS私有密钥',
|
||||
CosStorageToken: '腾讯云COS临时令牌(可选)',
|
||||
CosStorageBucketName: 'COS存储桶名称',
|
||||
CosStorageRegion: 'COS区域 (例如:ap-shanghai)',
|
||||
CosStorageCdnUrl: 'CDN URL (可选,用于加速文件访问)',
|
||||
WebDAVServerUrl: 'WebDAV 服务器 URL (例如: https://dav.example.com)',
|
||||
WebDAVUserName: 'WebDAV 用户名',
|
||||
WebDAVPassword: 'WebDAV 密码',
|
||||
WebDAVBasePath: 'WebDAV 基础路径 (例如: files/upload)',
|
||||
WebDAVPublicUrl: 'WebDAV 公共访问 URL (可选,用于文件访问)',
|
||||
},
|
||||
Upload: {
|
||||
DefaultImageFormat: '上传图片时的默认处理格式,选择合适的格式可以优化存储和显示',
|
||||
DefaultImageQuality: '适用于JPEG和WebP格式的图片质量设置,越高图片质量越好但文件越大'
|
||||
HighQualityImageCompressionQuality: '高清图片的压缩质量,越高图片质量越好但文件越大。范围 50-100。',
|
||||
ThumbnailMaxWidth: '缩略图的最大宽度(像素),例如设置为 400。',
|
||||
ThumbnailCompressionQuality: '缩略图的压缩质量,用于平衡文件大小和清晰度。范围 30-90。'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -86,7 +58,6 @@ const System: React.FC = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [configs, setConfigs] = useState<ConfigStructure>({});
|
||||
const [activeKey, setActiveKey] = useState('AI');
|
||||
const [storageType, setStorageType] = useState('Telegram');
|
||||
const [backupLoading, setBackupLoading] = useState(false);
|
||||
const [restoreLoading, setRestoreLoading] = useState(false);
|
||||
const [restoreModalVisible, setRestoreModalVisible] = useState(false);
|
||||
@@ -100,10 +71,6 @@ const System: React.FC = () => {
|
||||
const [jwtForm] = Form.useForm();
|
||||
const [authForm] = Form.useForm();
|
||||
const [appSettingsForm] = Form.useForm();
|
||||
const [telegramForm] = Form.useForm();
|
||||
const [s3Form] = Form.useForm();
|
||||
const [cosForm] = Form.useForm();
|
||||
const [webDAVForm] = Form.useForm();
|
||||
const [uploadForm] = Form.useForm();
|
||||
|
||||
const formsMap: Record<string, any> = {
|
||||
@@ -111,10 +78,6 @@ const System: React.FC = () => {
|
||||
Jwt: jwtForm,
|
||||
Authentication: authForm,
|
||||
AppSettings: appSettingsForm,
|
||||
TelegramStorage: telegramForm,
|
||||
S3Storage: s3Form,
|
||||
CosStorage: cosForm,
|
||||
WebDAVStorage: webDAVForm,
|
||||
Upload: uploadForm,
|
||||
};
|
||||
|
||||
@@ -145,20 +108,11 @@ const System: React.FC = () => {
|
||||
setConfigs(configGroups);
|
||||
setSecretFields(secretFieldsMap);
|
||||
|
||||
if (configGroups.Storage?.DefaultStorage) {
|
||||
setStorageType(configGroups.Storage.DefaultStorage);
|
||||
}
|
||||
|
||||
// 更高效地更新表单值
|
||||
Object.keys(configGroups).forEach(group => {
|
||||
let formInstanceKey = group;
|
||||
if (group === "Storage") {
|
||||
} else if (group.endsWith("Storage") && formsMap[group]) {
|
||||
formInstanceKey = group;
|
||||
}
|
||||
|
||||
|
||||
const formInstance = formsMap[formInstanceKey] || (group === "Storage" ? formsMap[`${configGroups.Storage.DefaultStorage}Storage`] : null);
|
||||
const formInstance = formsMap[formInstanceKey];
|
||||
|
||||
if (formInstance) {
|
||||
const initialGroupValues: Record<string, string> = {};
|
||||
@@ -456,34 +410,6 @@ const System: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 存储类型选项
|
||||
const storageOptions = [
|
||||
{ value: 'Local', label: '本地存储', icon: <DatabaseOutlined style={{ color: '#52c41a' }} /> },
|
||||
{ value: 'Telegram', label: 'Telegram 频道', icon: <CloudOutlined style={{ color: '#0088cc' }} /> },
|
||||
{ value: 'S3', label: '亚马逊 S3', icon: <CloudServerOutlined style={{ color: '#ff9900' }} /> },
|
||||
{ value: 'Cos', label: '腾讯云 COS', icon: <CloudServerOutlined style={{ color: '#00a4ff' }} /> },
|
||||
{ value: 'WebDAV', label: 'WebDAV 存储', icon: <GlobalOutlined style={{ color: '#1890ff' }} /> },
|
||||
];
|
||||
|
||||
// 上传格式选项
|
||||
const imageFormatOptions = [
|
||||
{ value: 'Original', label: '保持原始格式', description: '不改变原始图片格式' },
|
||||
{ value: 'Jpeg', label: '转换为JPEG', description: '适合照片,文件较小但有损压缩' },
|
||||
{ value: 'Png', label: '转换为PNG', description: '适合图形,无损但文件较大' },
|
||||
{ value: 'Webp', label: '转换为WebP', description: '现代格式,体积小且质量好' },
|
||||
];
|
||||
|
||||
// 图片质量选项
|
||||
const imageQualityOptions = [
|
||||
{ value: '100', label: '100% - 最高质量', description: '无损压缩,文件较大' },
|
||||
{ value: '95', label: '95% - 高质量', description: '几乎无损,推荐用于高质量需求' },
|
||||
{ value: '90', label: '90% - 优质', description: '良好平衡,推荐一般用途' },
|
||||
{ value: '85', label: '85% - 良好', description: '适合网页展示,节省空间' },
|
||||
{ value: '80', label: '80% - 节省空间', description: '明显压缩但质量可接受' },
|
||||
{ value: '75', label: '75% - 平衡', description: '显著减小文件大小' },
|
||||
{ value: '70', label: '70% - 压缩', description: '最大压缩,质量较低' },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfigs();
|
||||
}, []);
|
||||
@@ -546,17 +472,12 @@ const System: React.FC = () => {
|
||||
isMobile={isMobile}
|
||||
activeKey={activeKey}
|
||||
onTabChange={setActiveKey}
|
||||
storageType={storageType}
|
||||
onStorageTypeChange={setStorageType}
|
||||
formsMap={formsMap}
|
||||
allDescriptions={allDescriptions}
|
||||
onSaveSingleConfig={handleSaveSingleConfig}
|
||||
onSaveAllForGroup={handleSaveAllForGroup}
|
||||
onBaseSaveConfig={baseSaveConfig}
|
||||
setConfigs={setConfigs}
|
||||
storageOptions={storageOptions}
|
||||
imageFormatOptions={imageFormatOptions}
|
||||
imageQualityOptions={imageQualityOptions}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import UserManagement from '../pages/admin/users/Index';
|
||||
import PictureManagement from '../pages/admin/pictures/Index';
|
||||
import UserDetail from '../pages/admin/users/UserDetail';
|
||||
import AdminLogManagement from '../pages/admin/log/Index';
|
||||
import StorageManagementPage from '../pages/admin/storage/StorageManagement';
|
||||
|
||||
export interface RouteConfig {
|
||||
path: string;
|
||||
@@ -182,6 +183,17 @@ const routes: RouteConfig[] = [
|
||||
breadcrumb: {
|
||||
title: '日志中心'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'storage',
|
||||
key: 'admin-storage',
|
||||
icon: <FileTextOutlined />,
|
||||
label: '存储配置',
|
||||
element: <StorageManagementPage />,
|
||||
area: 'admin',
|
||||
breadcrumb: {
|
||||
title: '存储配置'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'system',
|
||||
|
||||
Reference in New Issue
Block a user