mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-05-19 23:30:35 +08:00
feat: Support multiple vector database selection, add InMemory and Qdrant adapters, introduce admin dashboard
This commit is contained in:
@@ -6,9 +6,10 @@ import Register from './pages/register/Index';
|
||||
import { isAuthenticated } from './api';
|
||||
import type { JSX } from 'react';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import routes from './config/routeConfig';
|
||||
import { AuthProvider } from './api/AuthContext'; // 导入 AuthProvider
|
||||
import { getMainRoutes, getAdminRoutes } from './routes';
|
||||
import { AuthProvider } from './auth/AuthContext';
|
||||
import AnonymousPage from './pages/anonymous/Index';
|
||||
import AdminLayout from './layouts/AdminLayout';
|
||||
|
||||
const PrivateRoute = ({ children }: { children: JSX.Element }) => {
|
||||
return isAuthenticated() ? children : <Navigate to="/login" />;
|
||||
@@ -45,9 +46,12 @@ const customTheme = {
|
||||
};
|
||||
|
||||
function App() {
|
||||
const mainRoutes = getMainRoutes();
|
||||
const adminRoutes = getAdminRoutes();
|
||||
|
||||
return (
|
||||
<AuthProvider>
|
||||
<ConfigProvider theme={customTheme}>
|
||||
<ConfigProvider theme={customTheme}>
|
||||
<AuthProvider>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
@@ -59,14 +63,32 @@ function App() {
|
||||
<MainLayout />
|
||||
</PrivateRoute>
|
||||
}>
|
||||
{routes.map((route) => (
|
||||
<Route key={route.key} path={route.path} element={route.element} />
|
||||
{mainRoutes.map((route) => (
|
||||
<Route
|
||||
key={route.key}
|
||||
path={route.path === '/' ? '' : route.path}
|
||||
element={route.element}
|
||||
/>
|
||||
))}
|
||||
</Route>
|
||||
|
||||
<Route path="/admin" element={
|
||||
<PrivateRoute>
|
||||
<AdminLayout />
|
||||
</PrivateRoute>
|
||||
}>
|
||||
{adminRoutes.map((route) => (
|
||||
<Route
|
||||
key={route.key}
|
||||
path={route.path}
|
||||
element={route.element}
|
||||
/>
|
||||
))}
|
||||
</Route>
|
||||
</Routes>
|
||||
</Router>
|
||||
</ConfigProvider>
|
||||
</AuthProvider>
|
||||
</AuthProvider>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -57,3 +57,30 @@ export {
|
||||
restoreConfigs
|
||||
} from './configApi';
|
||||
|
||||
// 导出UserManagement API
|
||||
export {
|
||||
getUsers,
|
||||
getUserById,
|
||||
createUser,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
batchDeleteUsers
|
||||
} from './userManagementApi';
|
||||
|
||||
// 导出PictureManagement API
|
||||
export {
|
||||
getManagementPictures,
|
||||
getManagementPictureById,
|
||||
deleteManagementPicture,
|
||||
batchDeleteManagementPictures,
|
||||
getManagementPicturesByUserId
|
||||
} from './pictureManagementApi';
|
||||
|
||||
// 导出向量数据库 API
|
||||
export {
|
||||
getCurrentVectorDb,
|
||||
switchVectorDb,
|
||||
clearVectors,
|
||||
rebuildVectors
|
||||
} from './vectorDbApi';
|
||||
|
||||
|
||||
58
Web/src/api/pictureManagementApi.ts
Normal file
58
Web/src/api/pictureManagementApi.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { fetchApi } from './fetchClient';
|
||||
import {
|
||||
type BaseResult,
|
||||
type PaginatedResult,
|
||||
type PictureResponse,
|
||||
type BatchDeleteResult
|
||||
} from './types';
|
||||
|
||||
// 获取图片列表
|
||||
export const getManagementPictures = async (
|
||||
page: number = 1,
|
||||
pageSize: number = 10
|
||||
): Promise<PaginatedResult<PictureResponse>> => {
|
||||
const response = await fetchApi(`/management/picture/get_pictures?page=${page}&pageSize=${pageSize}`);
|
||||
return response as PaginatedResult<PictureResponse>;
|
||||
};
|
||||
|
||||
// 根据ID获取单张图片
|
||||
export const getManagementPictureById = async (id: number): Promise<BaseResult<PictureResponse>> => {
|
||||
return fetchApi<PictureResponse>(
|
||||
`/management/picture/get_picture/${id}`,
|
||||
{ method: 'GET' }
|
||||
);
|
||||
};
|
||||
|
||||
// 删除图片
|
||||
export const deleteManagementPicture = async (id: number): Promise<BaseResult<boolean>> => {
|
||||
return fetchApi<boolean>(
|
||||
'/management/picture/delete_picture',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(id)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// 批量删除图片
|
||||
export const batchDeleteManagementPictures = async (
|
||||
ids: number[]
|
||||
): Promise<BaseResult<BatchDeleteResult>> => {
|
||||
return fetchApi<BatchDeleteResult>(
|
||||
'/management/picture/batch_delete_pictures',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(ids)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// 根据用户ID获取图片
|
||||
export const getManagementPicturesByUserId = async (
|
||||
userId: number,
|
||||
page: number = 1,
|
||||
pageSize: number = 10
|
||||
): Promise<PaginatedResult<PictureResponse>> => {
|
||||
const response = await fetchApi(`/management/picture/get_pictures_by_user/${userId}?page=${page}&pageSize=${pageSize}`);
|
||||
return response as PaginatedResult<PictureResponse>;
|
||||
};
|
||||
@@ -227,3 +227,47 @@ export interface UpdatePictureRequest {
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
// 用户管理相关类型
|
||||
export interface UserResponse {
|
||||
id: number;
|
||||
userName: string;
|
||||
email: string;
|
||||
role: string;
|
||||
createdAt: Date;
|
||||
lastLoginAt?: Date;
|
||||
}
|
||||
|
||||
// 管理员创建用户请求
|
||||
export interface CreateUserRequest {
|
||||
userName: string;
|
||||
email: string;
|
||||
password: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
// 管理员更新用户请求
|
||||
export interface AdminUpdateUserRequest {
|
||||
id: number;
|
||||
userName?: string;
|
||||
email?: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
// 批量删除结果
|
||||
export interface BatchDeleteResult {
|
||||
successCount: number;
|
||||
failedCount: number;
|
||||
failedIds?: number[];
|
||||
}
|
||||
|
||||
export type VectorDbType = "InMemory" | "Qdrant";
|
||||
|
||||
export const VectorDbType = {
|
||||
InMemory: "InMemory" as VectorDbType,
|
||||
Qdrant: "Qdrant" as VectorDbType,
|
||||
};
|
||||
|
||||
export interface VectorDbInfo {
|
||||
type: string;
|
||||
}
|
||||
|
||||
76
Web/src/api/userManagementApi.ts
Normal file
76
Web/src/api/userManagementApi.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { fetchApi } from './fetchClient';
|
||||
import {
|
||||
type BaseResult,
|
||||
type PaginatedResult,
|
||||
type UserResponse,
|
||||
type CreateUserRequest,
|
||||
type AdminUpdateUserRequest,
|
||||
type BatchDeleteResult
|
||||
} from './types';
|
||||
|
||||
// 获取用户列表
|
||||
export const getUsers = async (
|
||||
page: number = 1,
|
||||
pageSize: number = 10
|
||||
): Promise<PaginatedResult<UserResponse>> => {
|
||||
const response = await fetchApi(`/management/user/get_users?page=${page}&pageSize=${pageSize}`);
|
||||
return response as PaginatedResult<UserResponse>;
|
||||
};
|
||||
|
||||
// 根据ID获取单个用户
|
||||
export const getUserById = async (id: number): Promise<BaseResult<UserResponse>> => {
|
||||
return fetchApi<UserResponse>(
|
||||
`/management/user/get_user/${id}`,
|
||||
{ method: 'GET' }
|
||||
);
|
||||
};
|
||||
|
||||
// 创建用户
|
||||
export const createUser = async (
|
||||
userData: CreateUserRequest
|
||||
): Promise<BaseResult<UserResponse>> => {
|
||||
return fetchApi<UserResponse>(
|
||||
'/management/user/create_user',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(userData)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// 更新用户
|
||||
export const updateUser = async (
|
||||
userData: AdminUpdateUserRequest
|
||||
): Promise<BaseResult<UserResponse>> => {
|
||||
return fetchApi<UserResponse>(
|
||||
'/management/user/update_user',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(userData)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// 删除用户
|
||||
export const deleteUser = async (id: number): Promise<BaseResult<boolean>> => {
|
||||
return fetchApi<boolean>(
|
||||
'/management/user/delete_user',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(id)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// 批量删除用户
|
||||
export const batchDeleteUsers = async (
|
||||
ids: number[]
|
||||
): Promise<BaseResult<BatchDeleteResult>> => {
|
||||
return fetchApi<BatchDeleteResult>(
|
||||
'/management/user/batch_delete_users',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(ids)
|
||||
}
|
||||
);
|
||||
};
|
||||
61
Web/src/api/vectorDbApi.ts
Normal file
61
Web/src/api/vectorDbApi.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { type BaseResult, type VectorDbInfo, VectorDbType } from './types';
|
||||
import { fetchApi } from './fetchClient';
|
||||
|
||||
// 获取当前向量数据库类型
|
||||
export const getCurrentVectorDb = async (): Promise<BaseResult<VectorDbInfo>> => {
|
||||
try {
|
||||
return await fetchApi<VectorDbInfo>('/management/system/vector-db/current');
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: `获取当前向量数据库失败: ${error.message}`,
|
||||
code: 500
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// 切换向量数据库类型
|
||||
export const switchVectorDb = async (type: VectorDbType): Promise<BaseResult<boolean>> => {
|
||||
try {
|
||||
return await fetchApi<boolean>('/management/system/vector-db/switch', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ type }),
|
||||
});
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: `切换向量数据库失败: ${error.message}`,
|
||||
code: 500
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// 清空向量数据库
|
||||
export const clearVectors = async (): Promise<BaseResult<boolean>> => {
|
||||
try {
|
||||
return await fetchApi<boolean>('/management/system/vector-db/clear', {
|
||||
method: 'DELETE'
|
||||
});
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: `清空向量数据库失败: ${error.message}`,
|
||||
code: 500
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// 重建向量数据库
|
||||
export const rebuildVectors = async (): Promise<BaseResult<boolean>> => {
|
||||
try {
|
||||
return await fetchApi<boolean>('/management/system/vector-db/rebuild', {
|
||||
method: 'POST'
|
||||
});
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: `重建向量数据库失败: ${error.message}`,
|
||||
code: 500
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { createContext, useState, useEffect, useContext, useCallback } from 'react';
|
||||
import { getCurrentUser, isAuthenticated, clearAuthData, getStoredUser } from './index';
|
||||
import type { UserProfile } from './types';
|
||||
import { UserRole } from './types';
|
||||
import { getCurrentUser, isAuthenticated, clearAuthData, getStoredUser } from '../api/index';
|
||||
import type { UserProfile } from '../api/types';
|
||||
import { UserRole } from '../api/types';
|
||||
|
||||
interface AuthContextType {
|
||||
user: UserProfile | null;
|
||||
@@ -27,7 +27,6 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
const [user, setUser] = useState<UserProfile | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [authError, setAuthError] = useState<string | null>(null);
|
||||
|
||||
const refreshUser = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setAuthError(null);
|
||||
@@ -10,7 +10,7 @@ import ImageViewer from './ImageViewer';
|
||||
import ShareImageDialog from './ShareImageDialog';
|
||||
import EditImageDialog from './EditImageDialog';
|
||||
import './ImageGrid.css';
|
||||
import { useAuth } from '../../api/AuthContext';
|
||||
import { useAuth } from '../../auth/AuthContext';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
|
||||
208
Web/src/layouts/AdminLayout.tsx
Normal file
208
Web/src/layouts/AdminLayout.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Outlet, useNavigate, useLocation, matchPath, Navigate } from 'react-router';
|
||||
import { Layout, theme, message } from 'antd';
|
||||
import { clearAuthData, isAuthenticated } from '../api';
|
||||
import useIsMobile from '../hooks/useIsMobile';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import Header from './components/Header';
|
||||
import Footer from './components/Footer';
|
||||
import { UserRole } from '../api/types';
|
||||
import { getAdminRoutes, type RouteConfig } from '../routes';
|
||||
|
||||
const { Content } = Layout;
|
||||
|
||||
function AdminLayout() {
|
||||
const { refreshUser, hasRole, user, loading } = useAuth();
|
||||
const isMobile = useIsMobile();
|
||||
const [collapsed, setCollapsed] = useState(isMobile);
|
||||
const [currentRouteData, setCurrentRouteData] = useState<{
|
||||
routeInfo: RouteConfig | undefined;
|
||||
params: Record<string, string>;
|
||||
title?: string;
|
||||
}>({
|
||||
routeInfo: undefined,
|
||||
params: {}
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const routes = useMemo(() => getAdminRoutes(), []);
|
||||
|
||||
const headerRouteData = useMemo(() => ({
|
||||
routeInfo: currentRouteData.routeInfo,
|
||||
params: currentRouteData.params,
|
||||
title: (currentRouteData.routeInfo?.label || '')
|
||||
}), [currentRouteData]);
|
||||
|
||||
const {
|
||||
token: { colorBgContainer },
|
||||
} = theme.useToken();
|
||||
|
||||
const findCurrentRoute = useCallback(() => {
|
||||
const pathname = location.pathname;
|
||||
const adminPath = pathname.replace(/^\/admin\/?/, '');
|
||||
|
||||
if (adminPath === '') {
|
||||
const defaultRoute = routes.find(route => route.path === '');
|
||||
if (defaultRoute) {
|
||||
return {
|
||||
routeInfo: defaultRoute,
|
||||
params: {}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 查找精确匹配的路由
|
||||
for (const route of routes) {
|
||||
const match = matchPath(
|
||||
{ path: route.path, end: true },
|
||||
adminPath
|
||||
);
|
||||
|
||||
if (match) {
|
||||
return {
|
||||
routeInfo: route,
|
||||
params: Object.fromEntries(
|
||||
Object.entries(match.params || {}).filter(
|
||||
([, value]) => value !== undefined
|
||||
)
|
||||
) as Record<string, string>
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 查找包含参数的路由
|
||||
for (const route of routes) {
|
||||
if (route.path.includes(':')) {
|
||||
const basePath = route.path.split('/:')[0];
|
||||
if (adminPath.startsWith(basePath)) {
|
||||
const match = matchPath(
|
||||
{ path: route.path, end: false },
|
||||
adminPath
|
||||
);
|
||||
|
||||
if (match) {
|
||||
return {
|
||||
routeInfo: route,
|
||||
params: Object.fromEntries(
|
||||
Object.entries(match.params || {}).filter(
|
||||
([, value]) => value !== undefined
|
||||
)
|
||||
) as Record<string, string>
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
routeInfo: undefined,
|
||||
params: {}
|
||||
};
|
||||
}, [location.pathname, routes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated()) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
refreshUser();
|
||||
}
|
||||
}, [navigate, refreshUser, user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && user && !hasRole(UserRole.Administrator)) {
|
||||
message.error('您没有权限访问管理后台');
|
||||
navigate('/');
|
||||
}
|
||||
}, [user, hasRole, navigate, loading]);
|
||||
useEffect(() => {
|
||||
const routeData = findCurrentRoute();
|
||||
setCurrentRouteData(routeData);
|
||||
}, [location.pathname, findCurrentRoute]);
|
||||
useEffect(() => {
|
||||
setCollapsed(isMobile);
|
||||
}, [isMobile]);
|
||||
|
||||
// 退出登录处理
|
||||
const handleLogout = () => {
|
||||
clearAuthData();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const toggleCollapsed = () => {
|
||||
setCollapsed(!collapsed);
|
||||
};
|
||||
|
||||
// 加载状态
|
||||
if (loading) {
|
||||
return <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||
加载中...
|
||||
</div>;
|
||||
}
|
||||
|
||||
// 权限检查
|
||||
if (user && !hasRole(UserRole.Administrator)) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout style={{
|
||||
height: '100vh',
|
||||
background: '#f0f2f5',
|
||||
fontWeight: 400
|
||||
}}>
|
||||
{/* 侧边栏组件 */}
|
||||
<Sidebar
|
||||
collapsed={collapsed}
|
||||
isMobile={isMobile}
|
||||
onClose={toggleCollapsed}
|
||||
area="admin"
|
||||
/>
|
||||
|
||||
<Layout>
|
||||
{/* 顶部导航栏组件 */}
|
||||
<Header
|
||||
collapsed={collapsed}
|
||||
toggleCollapsed={toggleCollapsed}
|
||||
onLogout={handleLogout}
|
||||
currentRouteData={headerRouteData}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
|
||||
{/* 主要内容区 */}
|
||||
<Content style={{
|
||||
margin: isMobile ? '10px' : '20px',
|
||||
background: '#f0f2f5',
|
||||
position: 'relative',
|
||||
borderRadius: isMobile ? 10 : 20,
|
||||
overflowY: 'auto'
|
||||
}}>
|
||||
<div style={{
|
||||
padding: isMobile ? '15px' : '25px',
|
||||
minHeight: '100%',
|
||||
background: colorBgContainer,
|
||||
boxShadow: '0 6px 30px rgba(0,0,0,0.03)',
|
||||
border: '1px solid #f0f0f0',
|
||||
position: 'relative',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{/* 渲染子路由组件 */}
|
||||
<Outlet context={{
|
||||
isMobile,
|
||||
isAdminPanel: true
|
||||
}} />
|
||||
</div>
|
||||
</Content>
|
||||
{/* 页脚组件 */}
|
||||
<Footer isMobile={isMobile} />
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminLayout;
|
||||
@@ -3,11 +3,11 @@ import {Outlet, useNavigate, useLocation, matchPath} from 'react-router';
|
||||
import {Layout, theme} from 'antd';
|
||||
import {clearAuthData, isAuthenticated} from '../api';
|
||||
import useIsMobile from '../hooks/useIsMobile';
|
||||
import {useAuth} from '../api/AuthContext';
|
||||
import {useAuth} from '../auth/AuthContext';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import Header from './components/Header';
|
||||
import Footer from './components/Footer';
|
||||
import routes, {type RouteConfig} from '../config/routeConfig';
|
||||
import {getMainRoutes, type RouteConfig} from '../routes';
|
||||
|
||||
const {Content} = Layout;
|
||||
|
||||
@@ -26,6 +26,7 @@ function MainLayout() {
|
||||
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const routes = getMainRoutes();
|
||||
|
||||
const {
|
||||
token: {colorBgContainer},
|
||||
@@ -45,7 +46,6 @@ function MainLayout() {
|
||||
if (match) {
|
||||
return {
|
||||
routeInfo: route,
|
||||
// 确保params是Record<string, string>类型
|
||||
params: Object.fromEntries(
|
||||
Object.entries(match.params || {}).filter(
|
||||
([, value]) => value !== undefined
|
||||
@@ -70,7 +70,6 @@ function MainLayout() {
|
||||
if (match) {
|
||||
return {
|
||||
routeInfo: route,
|
||||
// 确保params是Record<string, string>类型
|
||||
params: Object.fromEntries(
|
||||
Object.entries(match.params || {}).filter(
|
||||
([, value]) => value !== undefined
|
||||
@@ -122,11 +121,12 @@ function MainLayout() {
|
||||
background: '#fcfcfc',
|
||||
fontWeight: 400
|
||||
}}>
|
||||
{/* 侧边栏组件 - 添加onClose回调 */}
|
||||
{/* 侧边栏组件 */}
|
||||
<Sidebar
|
||||
collapsed={collapsed}
|
||||
isMobile={isMobile}
|
||||
onClose={toggleCollapsed}
|
||||
area="main"
|
||||
/>
|
||||
|
||||
<Layout>
|
||||
|
||||
@@ -1,192 +1,286 @@
|
||||
import { Layout, Button, Dropdown, Breadcrumb, Input } from 'antd';
|
||||
import {
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
UserOutlined,
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Layout, Button, Dropdown, Space, theme, Breadcrumb, Input } from 'antd';
|
||||
import {
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
UserOutlined,
|
||||
LogoutOutlined,
|
||||
SettingOutlined
|
||||
DashboardOutlined,
|
||||
HomeOutlined,
|
||||
RightOutlined,
|
||||
SearchOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate, Link } from 'react-router';
|
||||
import routes, { type RouteConfig } from '../../config/routeConfig';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
import { useAuth } from '../../auth/AuthContext';
|
||||
import { type RouteConfig } from '../../routes';
|
||||
import UserAvatar from '../../components/UserAvatar';
|
||||
import { useAuth } from '../../api/AuthContext';
|
||||
import { useState } from 'react';
|
||||
import { UserRole } from '../../api/types';
|
||||
import SearchDialog from '../../components/search/SearchDialog';
|
||||
|
||||
const { Header: AntHeader } = Layout;
|
||||
const { Search } = Input;
|
||||
|
||||
interface HeaderProps {
|
||||
collapsed: boolean;
|
||||
toggleCollapsed: () => void;
|
||||
onLogout: () => void;
|
||||
currentRouteData?: {
|
||||
routeInfo: RouteConfig | undefined;
|
||||
params: Record<string, string>;
|
||||
title?: string; // 动态标题,用于显示如"相册名称"等动态数据
|
||||
currentRouteData: {
|
||||
routeInfo?: RouteConfig;
|
||||
params?: Record<string, string>;
|
||||
title?: string;
|
||||
};
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
const Header = ({
|
||||
collapsed,
|
||||
toggleCollapsed,
|
||||
onLogout,
|
||||
// 面包屑项目类型定义
|
||||
interface BreadcrumbItem {
|
||||
title: string;
|
||||
href?: string;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
const Header: React.FC<HeaderProps> = ({
|
||||
collapsed,
|
||||
toggleCollapsed,
|
||||
onLogout,
|
||||
currentRouteData,
|
||||
isMobile = false
|
||||
}: HeaderProps) => {
|
||||
const navigate = useNavigate();
|
||||
isMobile = false
|
||||
}) => {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
const { hasRole } = useAuth();
|
||||
|
||||
// 添加搜索对话框状态
|
||||
const [searchDialogVisible, setSearchDialogVisible] = useState(false);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
|
||||
const {
|
||||
token: { colorBgContainer },
|
||||
} = theme.useToken();
|
||||
|
||||
// 用户菜单项
|
||||
const userMenuItems = [
|
||||
{
|
||||
key: 'profile',
|
||||
icon: <UserOutlined/>,
|
||||
label: '个人资料',
|
||||
onClick: () => navigate('/settings')
|
||||
},
|
||||
{
|
||||
key: 'settings',
|
||||
icon: <SettingOutlined/>,
|
||||
label: '设置',
|
||||
icon: <UserOutlined />,
|
||||
label: '个人中心',
|
||||
onClick: () => navigate('/settings')
|
||||
},
|
||||
...(hasRole(UserRole.Administrator) ? [
|
||||
{
|
||||
key: 'admin',
|
||||
icon: <DashboardOutlined />,
|
||||
label: '后台管理',
|
||||
onClick: () => navigate('/admin')
|
||||
}
|
||||
] : []),
|
||||
{
|
||||
key: 'logout',
|
||||
icon: <LogoutOutlined/>,
|
||||
icon: <LogoutOutlined />,
|
||||
label: '退出登录',
|
||||
onClick: onLogout
|
||||
}
|
||||
];
|
||||
|
||||
// 生成面包屑项
|
||||
const generateBreadcrumbItems = () => {
|
||||
const breadcrumbItems = [];
|
||||
|
||||
// 添加首页
|
||||
breadcrumbItems.push({
|
||||
key: 'home',
|
||||
title: <Link to="/">首页</Link>,
|
||||
});
|
||||
// 根据路由信息生成面包屑导航
|
||||
const renderBreadcrumb = () => {
|
||||
// 如果有传入的标题,直接使用标题作为面包屑
|
||||
if (currentRouteData.title) {
|
||||
return (
|
||||
<Breadcrumb
|
||||
separator={<RightOutlined style={{ fontSize: 12 }} />}
|
||||
style={{ margin: 0 }}
|
||||
items={[
|
||||
{
|
||||
title: '首页',
|
||||
href: '/',
|
||||
},
|
||||
{
|
||||
title: currentRouteData.title
|
||||
}
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 确保routeInfo和breadcrumb都存在
|
||||
if (currentRouteData?.routeInfo && currentRouteData.routeInfo.breadcrumb) {
|
||||
const { routeInfo, title } = currentRouteData;
|
||||
const breadcrumb = routeInfo.breadcrumb;
|
||||
|
||||
// 如果有父级路由,先添加父级路由的面包屑
|
||||
if (breadcrumb && breadcrumb.parent) {
|
||||
const parentRoute = routes.find(r => r.key === breadcrumb.parent);
|
||||
if (parentRoute && parentRoute.breadcrumb) {
|
||||
breadcrumbItems.push({
|
||||
key: parentRoute.key,
|
||||
title: <Link to={`/${parentRoute.path}`}>{parentRoute.breadcrumb.title}</Link>,
|
||||
});
|
||||
}
|
||||
// 如果没有路由信息,返回首页面包屑
|
||||
if (!currentRouteData.routeInfo) {
|
||||
return (
|
||||
<Breadcrumb
|
||||
separator={<RightOutlined style={{ fontSize: 12 }} />}
|
||||
style={{ margin: 0 }}
|
||||
items={[
|
||||
{
|
||||
title: '首页',
|
||||
href: '/',
|
||||
}
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 获取当前路由信息
|
||||
const { routeInfo, params } = currentRouteData;
|
||||
const breadcrumb = routeInfo.breadcrumb;
|
||||
|
||||
if (!breadcrumb) {
|
||||
return (
|
||||
<Breadcrumb
|
||||
separator={<RightOutlined style={{ fontSize: 12 }} />}
|
||||
style={{ margin: 0 }}
|
||||
items={[
|
||||
{
|
||||
title: '首页',
|
||||
href: '/',
|
||||
},
|
||||
{
|
||||
title: routeInfo.label
|
||||
}
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 准备面包屑项目
|
||||
const breadcrumbItems: BreadcrumbItem[] = [
|
||||
{
|
||||
title: routeInfo.area === 'admin' ? '管理后台' : '首页',
|
||||
href: routeInfo.area === 'admin' ? '/admin' : '/',
|
||||
icon: routeInfo.area === 'admin' ? <DashboardOutlined /> : <HomeOutlined />
|
||||
}
|
||||
|
||||
// 添加当前路由的面包屑
|
||||
];
|
||||
|
||||
// 如果有父级,添加父级面包屑
|
||||
if (breadcrumb.parent) {
|
||||
const parentPath = routeInfo.area === 'admin'
|
||||
? `/admin/${breadcrumb.parent}`
|
||||
: `/${breadcrumb.parent}`;
|
||||
|
||||
breadcrumbItems.push({
|
||||
key: routeInfo.key,
|
||||
title: title || breadcrumb?.title,
|
||||
title: breadcrumb.parent.charAt(0).toUpperCase() + breadcrumb.parent.slice(1),
|
||||
href: parentPath
|
||||
});
|
||||
}
|
||||
|
||||
return breadcrumbItems;
|
||||
};
|
||||
|
||||
// 处理搜索框输入
|
||||
const handleSearchInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchText(e.target.value);
|
||||
};
|
||||
|
||||
// 处理搜索操作,仅当点击搜索按钮或按回车时执行
|
||||
const handleSearch = (value: string) => {
|
||||
if (value.trim() || !value) { // 允许空搜索打开高级搜索
|
||||
setSearchDialogVisible(true);
|
||||
// 获取动态标题
|
||||
let title = breadcrumb.title;
|
||||
if (params && Object.keys(params).length > 0) {
|
||||
// 用参数替换标题中的占位符,如 ":id"
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
title = title.replace(`:${key}`, value);
|
||||
});
|
||||
}
|
||||
|
||||
// 添加当前页面面包屑
|
||||
breadcrumbItems.push({
|
||||
title: title
|
||||
});
|
||||
|
||||
return (
|
||||
<Breadcrumb
|
||||
separator={<RightOutlined style={{ fontSize: 12 }} />}
|
||||
style={{ margin: 0 }}
|
||||
items={breadcrumbItems.map(item => ({
|
||||
title: item.href ? (
|
||||
<Link to={item.href} style={{ color: '#666', fontSize: isMobile ? 13 : 14 }}>
|
||||
{item.icon && <span style={{ marginRight: 4 }}>{item.icon}</span>}
|
||||
{isMobile && !item.icon ? '' : item.title}
|
||||
</Link>
|
||||
) : (
|
||||
<span style={{ fontSize: isMobile ? 14 : 16, fontWeight: 500 }}>
|
||||
{item.icon && <span style={{ marginRight: 4 }}>{item.icon}</span>}
|
||||
{item.title}
|
||||
</span>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 处理搜索
|
||||
const handleSearch = (value: string) => {
|
||||
setSearchText(value);
|
||||
setSearchDialogVisible(true);
|
||||
};
|
||||
|
||||
// 关闭搜索对话框
|
||||
const handleSearchDialogClose = () => {
|
||||
setSearchDialogVisible(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<AntHeader style={{
|
||||
padding: isMobile ? '0 10px' : '0 40px',
|
||||
background: '#ffffff',
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
<AntHeader
|
||||
ref={headerRef}
|
||||
style={{
|
||||
padding: isMobile ? '0 12px' : '0 24px',
|
||||
background: colorBgContainer,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
height: isMobile ? 56 : 64,
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
zIndex: 100,
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 10,
|
||||
width: '100%',
|
||||
backdropFilter: 'blur(10px)'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={collapsed ? <MenuUnfoldOutlined/> : <MenuFoldOutlined/>}
|
||||
onClick={toggleCollapsed}
|
||||
top: 0
|
||||
}}
|
||||
>
|
||||
{/* 左侧区域:折叠按钮和面包屑 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
onClick={toggleCollapsed}
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
width: 36,
|
||||
height: 36,
|
||||
marginRight: 12
|
||||
}}
|
||||
/>
|
||||
{renderBreadcrumb()}
|
||||
</div>
|
||||
|
||||
{/* 右侧区域:搜索框和用户菜单 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
{/* 搜索框 */}
|
||||
<div style={{
|
||||
marginRight: 16,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
height: '100%'
|
||||
}}>
|
||||
<Input.Search
|
||||
placeholder="搜索图片..."
|
||||
onSearch={handleSearch}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
style={{
|
||||
fontSize: 18,
|
||||
width: 46,
|
||||
height: 46,
|
||||
borderRadius: 12,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
width: isMobile ? 150 : 220,
|
||||
borderRadius: 4
|
||||
}}
|
||||
size={isMobile ? "middle" : "large"}
|
||||
allowClear
|
||||
prefix={<SearchOutlined style={{ color: '#bfbfbf' }} />}
|
||||
/>
|
||||
|
||||
{/* 面包屑导航 */}
|
||||
{!isMobile && (
|
||||
<Breadcrumb
|
||||
items={generateBreadcrumbItems()}
|
||||
style={{ marginLeft: 16 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 25 }}>
|
||||
{/* 搜索框 - 修复交互问题 */}
|
||||
{!isMobile && (
|
||||
<Search
|
||||
placeholder="搜索图片..."
|
||||
allowClear
|
||||
value={searchText}
|
||||
onChange={handleSearchInputChange}
|
||||
onSearch={handleSearch}
|
||||
style={{
|
||||
width: 300,
|
||||
borderRadius: 100
|
||||
}}
|
||||
size="middle"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
|
||||
<UserAvatar
|
||||
size={46}
|
||||
|
||||
{/* 用户菜单 */}
|
||||
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
|
||||
<Space style={{ cursor: 'pointer' }}>
|
||||
<UserAvatar
|
||||
size={isMobile ? 36 : 46}
|
||||
email={user?.email}
|
||||
text={user?.userName}
|
||||
/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</AntHeader>
|
||||
|
||||
{/* 搜索对话框 - 传递搜索文本 */}
|
||||
<SearchDialog
|
||||
</Space>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
{/* 搜索对话框 */}
|
||||
<SearchDialog
|
||||
visible={searchDialogVisible}
|
||||
onClose={handleSearchDialogClose}
|
||||
initialSearchText={searchText}
|
||||
onClose={() => {
|
||||
setSearchDialogVisible(false);
|
||||
// 可选:关闭对话框后清空搜索框
|
||||
// setSearchText('');
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
</AntHeader>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Layout, Menu, type MenuProps } from 'antd';
|
||||
import { useLocation, useNavigate } from 'react-router';
|
||||
import routes from '../../config/routeConfig';
|
||||
import { getMainRoutes, getAdminRoutes } from '../../routes';
|
||||
import logo from '/logo.png';
|
||||
|
||||
const { Sider } = Layout;
|
||||
@@ -10,20 +10,22 @@ interface SidebarProps {
|
||||
collapsed: boolean;
|
||||
isMobile?: boolean;
|
||||
onClose?: () => void;
|
||||
area: 'main' | 'admin';
|
||||
}
|
||||
|
||||
// 定义菜单项类型
|
||||
type MenuItem = Required<MenuProps>['items'][number];
|
||||
|
||||
const Sidebar: React.FC<SidebarProps> = ({ collapsed, isMobile = false, onClose }) => {
|
||||
const Sidebar: React.FC<SidebarProps> = ({ collapsed, isMobile = false, onClose, area }) => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 菜单项样式
|
||||
// 获取对应区域的路由
|
||||
const routes = area === 'main' ? getMainRoutes() : getAdminRoutes();
|
||||
|
||||
// 样式配置
|
||||
const menuItemStyle = { fontSize: 15 };
|
||||
const iconStyle = { fontSize: 18 };
|
||||
|
||||
// 分组标题样式
|
||||
const groupTitleStyle = {
|
||||
fontSize: 12,
|
||||
color: '#8c8c8c',
|
||||
@@ -73,6 +75,30 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed, isMobile = false, onClose
|
||||
// 获取当前选中的菜单项
|
||||
const getSelectedKey = () => {
|
||||
const pathname = location.pathname;
|
||||
|
||||
// 管理后台路径处理
|
||||
if (area === 'admin') {
|
||||
// 提取 /admin/ 后面的部分
|
||||
const adminPath = pathname.replace(/^\/admin\/?/, '');
|
||||
|
||||
// 如果是管理后台首页
|
||||
if (adminPath === '') {
|
||||
const defaultRoute = routes.find(route => route.path === '');
|
||||
return defaultRoute ? defaultRoute.path : '';
|
||||
}
|
||||
|
||||
const matchedRoute = routes.find(route => {
|
||||
if (route.path.includes(':')) {
|
||||
const basePath = route.path.split(':')[0].replace(/\/$/, '');
|
||||
return adminPath.startsWith(basePath);
|
||||
}
|
||||
return adminPath === route.path;
|
||||
});
|
||||
|
||||
return matchedRoute ? matchedRoute.path : '';
|
||||
}
|
||||
|
||||
// 主应用路径处理
|
||||
const matchedRoute = routes.find(route => {
|
||||
if (route.path.includes(':')) {
|
||||
const basePath = route.path.split(':')[0].replace(/\/$/, '');
|
||||
@@ -83,91 +109,132 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed, isMobile = false, onClose
|
||||
}
|
||||
return pathname === '/' + route.path;
|
||||
});
|
||||
|
||||
return matchedRoute ? (matchedRoute.path === '/' ? '/' : matchedRoute.path) : '/';
|
||||
};
|
||||
|
||||
const handleMenuClick = ({ key }: { key: string }) => {
|
||||
navigate(key);
|
||||
if (area === 'admin') {
|
||||
// 处理空路径的特殊情况(首页)
|
||||
if (key === '') {
|
||||
navigate('/admin');
|
||||
} else {
|
||||
navigate(`/admin/${key}`);
|
||||
}
|
||||
} else {
|
||||
navigate(key);
|
||||
}
|
||||
|
||||
// 在移动设备上点击后关闭侧边栏
|
||||
if (isMobile && onClose) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 遮罩层 - 仅在手机模式且侧边栏展开时显示 */}
|
||||
{isMobile && !collapsed && (
|
||||
<div
|
||||
onClick={onClose}
|
||||
// 根据区域获取不同的Logo和标题
|
||||
const getLogoAndTitle = () => {
|
||||
if (area === 'admin') {
|
||||
return {
|
||||
logo: logo,
|
||||
title: 'Foxel 管理后台'
|
||||
};
|
||||
}
|
||||
return {
|
||||
logo: logo,
|
||||
title: 'Foxel'
|
||||
};
|
||||
};
|
||||
|
||||
const { logo: logoSrc, title } = getLogoAndTitle();
|
||||
|
||||
// 侧边栏内容
|
||||
const sidebarContent = (
|
||||
<Sider
|
||||
trigger={null}
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
width={isMobile ? 180 : (area === 'admin' ? 220 : 250)}
|
||||
collapsedWidth={isMobile ? 0 : 80}
|
||||
style={{
|
||||
overflow: 'auto',
|
||||
height: '100vh',
|
||||
position: isMobile ? 'absolute' : 'relative',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
zIndex: isMobile ? 1000 : 1,
|
||||
boxShadow: isMobile && !collapsed ? '0 0 10px rgba(0,0,0,0.2)' : 'none',
|
||||
backgroundColor: 'white',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Logo区域 */}
|
||||
<div style={{
|
||||
height: isMobile ? '56px' : '64px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: collapsed ? 'center' : 'flex-start',
|
||||
padding: collapsed ? '0' : '0 20px',
|
||||
color: '#001529',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '18px',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: 'white',
|
||||
borderBottom: '1px solid #f0f0f0'
|
||||
}}>
|
||||
<img
|
||||
src={logoSrc}
|
||||
alt="Foxel Logo"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.45)',
|
||||
zIndex: 999, // 确保在Sider(1000)之下
|
||||
height: collapsed ? '30px' : '32px',
|
||||
marginRight: collapsed ? '0' : '12px',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Sider
|
||||
trigger={null}
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
width={isMobile ? 180 : 250}
|
||||
collapsedWidth={isMobile ? 0 : 80}
|
||||
{!collapsed && <span>{title}</span>}
|
||||
</div>
|
||||
|
||||
{/* 侧边栏菜单 */}
|
||||
<Menu
|
||||
theme="light"
|
||||
mode="inline"
|
||||
selectedKeys={[getSelectedKey()]}
|
||||
items={generateMenuItems()}
|
||||
onClick={handleMenuClick}
|
||||
style={{
|
||||
overflow: 'auto',
|
||||
height: '100vh',
|
||||
position: isMobile ? 'absolute' : 'relative',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
zIndex: isMobile ? 1000 : 1,
|
||||
boxShadow: isMobile && !collapsed ? '0 0 10px rgba(0,0,0,0.2)' : 'none',
|
||||
backgroundColor: 'white',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderRight: 'none',
|
||||
flex: 1
|
||||
}}
|
||||
>
|
||||
{/* Logo区域 */}
|
||||
<div style={{
|
||||
height: isMobile ? '56px' : '64px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: collapsed ? 'center' : 'flex-start',
|
||||
padding: collapsed ? '0' : '0 20px',
|
||||
color: '#001529',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '18px',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: 'white',
|
||||
borderBottom: '1px solid #f0f0f0'
|
||||
}}>
|
||||
<img
|
||||
src={logo}
|
||||
alt="Foxel Logo"
|
||||
/>
|
||||
</Sider>
|
||||
);
|
||||
|
||||
// 移动设备上使用Drawer组件
|
||||
if (isMobile) {
|
||||
return (
|
||||
<>
|
||||
{/* 遮罩层 - 仅在手机模式且侧边栏展开时显示 */}
|
||||
{!collapsed && (
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
height: collapsed ? '30px' : '32px',
|
||||
marginRight: collapsed ? '0' : '12px',
|
||||
transition: 'all 0.2s'
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.45)',
|
||||
zIndex: 999,
|
||||
}}
|
||||
/>
|
||||
{!collapsed && <span>Foxel</span>}
|
||||
</div>
|
||||
)}
|
||||
{sidebarContent}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
{/* 侧边栏菜单 */}
|
||||
<Menu
|
||||
theme="light"
|
||||
mode="inline"
|
||||
defaultSelectedKeys={[getSelectedKey()]}
|
||||
items={generateMenuItems()}
|
||||
onClick={handleMenuClick}
|
||||
style={{
|
||||
borderRight: 'none',
|
||||
flex: 1
|
||||
}}
|
||||
/>
|
||||
</Sider>
|
||||
</>
|
||||
);
|
||||
return sidebarContent;
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
|
||||
360
Web/src/pages/admin/dashboard/Index.tsx
Normal file
360
Web/src/pages/admin/dashboard/Index.tsx
Normal file
@@ -0,0 +1,360 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Row, Col, Card, Statistic, Table, Button, Spin, Typography, Space, Tag, message } from 'antd';
|
||||
import {
|
||||
UserOutlined,
|
||||
PictureOutlined,
|
||||
EyeOutlined,
|
||||
ClockCircleOutlined,
|
||||
ArrowUpOutlined,
|
||||
InfoCircleOutlined
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { useOutletContext } from 'react-router';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { getUsers, getManagementPictures } from '../../../api';
|
||||
import type { UserResponse, PictureResponse } from '../../../api/types';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
interface DashboardStats {
|
||||
totalUsers: number;
|
||||
totalAlbums: number;
|
||||
totalPhotos: number;
|
||||
storageUsagePercentage: number;
|
||||
newUsersToday: number;
|
||||
newPhotosToday: number;
|
||||
softwareVersion: string;
|
||||
systemVersion: string;
|
||||
cpuArchitecture: string;
|
||||
}
|
||||
|
||||
const AdminDashboard: React.FC = () => {
|
||||
const { isMobile } = useOutletContext<{ isMobile: boolean; isAdminPanel?: boolean }>();
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [stats, setStats] = useState<DashboardStats>({
|
||||
totalUsers: 0,
|
||||
totalAlbums: 0,
|
||||
totalPhotos: 0,
|
||||
storageUsagePercentage: 0,
|
||||
newUsersToday: 0,
|
||||
newPhotosToday: 0,
|
||||
softwareVersion: 'N/A',
|
||||
systemVersion: 'N/A',
|
||||
cpuArchitecture: 'N/A'
|
||||
});
|
||||
const [recentUsers, setRecentUsers] = useState<UserResponse[]>([]);
|
||||
const [recentPhotos, setRecentPhotos] = useState<PictureResponse[]>([]);
|
||||
|
||||
// 获取最近用户数据
|
||||
const fetchRecentUsers = async () => {
|
||||
try {
|
||||
const response = await getUsers(1, 5); // 获取最近5个用户
|
||||
if (response.success && response.data) {
|
||||
setRecentUsers(response.data);
|
||||
// 更新用户总数统计
|
||||
setStats(prev => ({
|
||||
...prev,
|
||||
totalUsers: response.totalCount || 0
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching recent users:', error);
|
||||
message.error('获取最近用户数据失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 获取最近图片数据
|
||||
const fetchRecentPhotos = async () => {
|
||||
try {
|
||||
const response = await getManagementPictures(1, 5); // 获取最近5张图片
|
||||
if (response.success && response.data) {
|
||||
setRecentPhotos(response.data);
|
||||
// 更新图片总数统计
|
||||
setStats(prev => ({
|
||||
...prev,
|
||||
totalPhotos: response.totalCount || 0
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching recent photos:', error);
|
||||
message.error('获取最近图片数据失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 计算今日新增数据
|
||||
const calculateTodayStats = (users: UserResponse[], photos: PictureResponse[]) => {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const newUsersToday = users.filter(user => {
|
||||
const userDate = new Date(user.createdAt);
|
||||
userDate.setHours(0, 0, 0, 0);
|
||||
return userDate.getTime() === today.getTime();
|
||||
}).length;
|
||||
|
||||
const newPhotosToday = photos.filter(photo => {
|
||||
const photoDate = new Date(photo.createdAt);
|
||||
photoDate.setHours(0, 0, 0, 0);
|
||||
return photoDate.getTime() === today.getTime();
|
||||
}).length;
|
||||
|
||||
setStats(prev => ({
|
||||
...prev,
|
||||
newUsersToday,
|
||||
newPhotosToday
|
||||
}));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await Promise.all([fetchRecentUsers(), fetchRecentPhotos()]);
|
||||
|
||||
// 设置其他静态统计数据
|
||||
setStats(prev => ({
|
||||
...prev,
|
||||
totalAlbums: 348, // 相册功能暂未实现,使用模拟数据
|
||||
storageUsagePercentage: 68,
|
||||
softwareVersion: 'Foxel Dev 尝鲜版',
|
||||
systemVersion: 'Fedora 42',
|
||||
cpuArchitecture: 'x86_64'
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error loading dashboard data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
// 当用户和图片数据都加载完成后计算今日统计
|
||||
useEffect(() => {
|
||||
if (recentUsers.length > 0 && recentPhotos.length > 0) {
|
||||
calculateTodayStats(recentUsers, recentPhotos);
|
||||
}
|
||||
}, [recentUsers, recentPhotos]);
|
||||
|
||||
const userColumns = useMemo<ColumnsType<UserResponse>>(() => [
|
||||
{
|
||||
title: '用户名',
|
||||
dataIndex: 'userName',
|
||||
key: 'userName',
|
||||
},
|
||||
{
|
||||
title: '邮箱',
|
||||
dataIndex: 'email',
|
||||
key: 'email',
|
||||
responsive: ['md'],
|
||||
},
|
||||
{
|
||||
title: '注册时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
responsive: ['lg'],
|
||||
render: (date: Date) => new Date(date).toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_) => (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => navigate('/admin/users')}
|
||||
>
|
||||
查看
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
], [navigate]);
|
||||
|
||||
const photoColumns = useMemo<ColumnsType<PictureResponse>>(() => [
|
||||
{
|
||||
title: '图片名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: '上传者',
|
||||
dataIndex: 'username',
|
||||
key: 'username',
|
||||
responsive: ['md'],
|
||||
},
|
||||
{
|
||||
title: '上传时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
responsive: ['lg'],
|
||||
render: (date: Date) => new Date(date).toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_) => (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => navigate('/admin/pictures')}
|
||||
>
|
||||
查看
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
], [navigate]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
|
||||
<Spin size="large" tip="加载中..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-dashboard">
|
||||
<Title level={2}>控制面板</Title>
|
||||
<Text type="secondary" style={{ marginBottom: 24, display: 'block' }}>
|
||||
欢迎使用 Foxel 管理后台,这里可以查看系统概况和管理系统资源。
|
||||
</Text>
|
||||
|
||||
<Row gutter={[24, 24]}>
|
||||
{/* 左侧内容区域 */}
|
||||
<Col xs={24} lg={18}>
|
||||
{/* 主要统计卡片 */}
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={12} md={8}>
|
||||
<Card variant="outlined">
|
||||
<Statistic
|
||||
title="用户总数"
|
||||
value={stats.totalUsers}
|
||||
prefix={<UserOutlined />}
|
||||
suffix={
|
||||
<Tag color="green" style={{ marginLeft: 8 }}>
|
||||
<ArrowUpOutlined /> {stats.newUsersToday} 今日
|
||||
</Tag>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} md={8}>
|
||||
<Card variant="outlined">
|
||||
<Statistic
|
||||
title="相册总数"
|
||||
value={stats.totalAlbums}
|
||||
prefix={<PictureOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} md={8}>
|
||||
<Card variant="outlined">
|
||||
<Statistic
|
||||
title="照片总数"
|
||||
value={stats.totalPhotos}
|
||||
prefix={<PictureOutlined />}
|
||||
suffix={
|
||||
<Tag color="green" style={{ marginLeft: 8 }}>
|
||||
<ArrowUpOutlined /> {stats.newPhotosToday} 今日
|
||||
</Tag>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 最近活动 */}
|
||||
<Row gutter={[16, 16]} style={{ marginTop: 24 }}>
|
||||
<Col xs={24} xl={12}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<UserOutlined />
|
||||
<span>最近注册用户</span>
|
||||
</Space>
|
||||
}
|
||||
extra={<Button type="link" onClick={() => navigate('/admin/users')}>查看全部</Button>}
|
||||
variant="outlined"
|
||||
>
|
||||
<Table
|
||||
columns={userColumns}
|
||||
dataSource={recentUsers}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
size={isMobile ? "small" : "middle"}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} xl={12}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<PictureOutlined />
|
||||
<span>最近上传图片</span>
|
||||
</Space>
|
||||
}
|
||||
extra={<Button type="link" onClick={() => navigate('/admin/pictures')}>查看全部</Button>}
|
||||
variant="outlined"
|
||||
>
|
||||
<Table
|
||||
columns={photoColumns}
|
||||
dataSource={recentPhotos}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
size={isMobile ? "small" : "middle"}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
|
||||
{/* 右侧内容区域 */}
|
||||
<Col xs={24} lg={6}>
|
||||
{/* 系统状态 */}
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<ClockCircleOutlined />
|
||||
<span>系统状态</span>
|
||||
</Space>
|
||||
}
|
||||
variant="outlined"
|
||||
>
|
||||
<Row gutter={[16, 24]}>
|
||||
<Col span={24}>
|
||||
<Statistic
|
||||
title="软件版本"
|
||||
value={stats.softwareVersion}
|
||||
prefix={<InfoCircleOutlined />}
|
||||
valueStyle={{ fontSize: '1em' }}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Statistic
|
||||
title="操作系统"
|
||||
value={stats.systemVersion}
|
||||
prefix={<InfoCircleOutlined />}
|
||||
valueStyle={{ fontSize: '1em' }}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Statistic
|
||||
title="CPU架构"
|
||||
value={stats.cpuArchitecture}
|
||||
prefix={<InfoCircleOutlined />}
|
||||
valueStyle={{ fontSize: '1em' }}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminDashboard;
|
||||
307
Web/src/pages/admin/pictures/Index.tsx
Normal file
307
Web/src/pages/admin/pictures/Index.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Table, Button, Card, Input, Space, Modal,
|
||||
message, Typography, Popconfirm, Row, Col, Image, Select
|
||||
} from 'antd';
|
||||
import {
|
||||
PictureOutlined, DeleteOutlined,
|
||||
SearchOutlined, ExclamationCircleOutlined, ReloadOutlined,
|
||||
FileImageOutlined, UserOutlined
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
getManagementPictures, deleteManagementPicture, batchDeleteManagementPictures,
|
||||
getUsers
|
||||
} from '../../../api';
|
||||
import type { PictureResponse, UserResponse } from '../../../api/types';
|
||||
import { useOutletContext } from 'react-router';
|
||||
import type { Breakpoint } from 'antd';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { Option } = Select;
|
||||
const { confirm } = Modal;
|
||||
|
||||
const PictureManagement: React.FC = () => {
|
||||
const { isMobile } = useOutletContext<{ isMobile: boolean }>();
|
||||
|
||||
// 状态管理
|
||||
const [pictures, setPictures] = useState<PictureResponse[]>([]);
|
||||
const [users, setUsers] = useState<UserResponse[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||
const [selectedUserId, setSelectedUserId] = useState<number | undefined>();
|
||||
|
||||
// 加载用户列表
|
||||
const fetchUsers = useCallback(async () => {
|
||||
try {
|
||||
const response = await getUsers(1, 1000); // 获取所有用户用于筛选
|
||||
if (response.success && response.data) {
|
||||
setUsers(response.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching users:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 加载图片数据
|
||||
const fetchPictures = useCallback(async (page = currentPage, size = pageSize) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getManagementPictures(page, size);
|
||||
if (response.success && response.data) {
|
||||
setPictures(response.data || []);
|
||||
setTotal(response.totalCount || 0);
|
||||
} else {
|
||||
message.error(response.message || '获取图片列表失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching pictures:', error);
|
||||
message.error('获取图片列表失败,请检查网络连接');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [currentPage, pageSize]);
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
fetchPictures();
|
||||
}, [fetchUsers, fetchPictures]);
|
||||
|
||||
// 处理页面变化
|
||||
const handlePageChange = (page: number, size?: number) => {
|
||||
setCurrentPage(page);
|
||||
if (size) setPageSize(size);
|
||||
fetchPictures(page, size || pageSize);
|
||||
};
|
||||
|
||||
// 处理搜索
|
||||
const handleSearch = () => {
|
||||
setCurrentPage(1);
|
||||
fetchPictures(1, pageSize);
|
||||
};
|
||||
|
||||
|
||||
// 处理删除图片
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
const response = await deleteManagementPicture(id);
|
||||
if (response.success) {
|
||||
message.success('图片删除成功');
|
||||
fetchPictures();
|
||||
} else {
|
||||
message.error(response.message || '删除图片失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting picture:', 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 batchDeleteManagementPictures(selectedRowKeys as number[]);
|
||||
if (response.success && response.data) {
|
||||
message.success(`成功删除 ${response.data.successCount} 张图片`);
|
||||
if (response.data.failedCount > 0) {
|
||||
message.warning(`${response.data.failedCount} 张图片删除失败`);
|
||||
}
|
||||
setSelectedRowKeys([]);
|
||||
fetchPictures();
|
||||
} else {
|
||||
message.error(response.message || '批量删除图片失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error batch deleting pictures:', error);
|
||||
message.error('批量删除图片失败,请检查网络连接');
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 处理用户筛选
|
||||
const handleUserFilter = (userId: number | undefined) => {
|
||||
setSelectedUserId(userId);
|
||||
// 这里应该根据用户ID筛选图片,但目前先简单刷新
|
||||
setCurrentPage(1);
|
||||
fetchPictures(1, pageSize);
|
||||
};
|
||||
|
||||
// 表格列定义
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
responsive: ['md' as Breakpoint],
|
||||
},
|
||||
{
|
||||
title: '缩略图',
|
||||
dataIndex: 'thumbnailPath',
|
||||
key: 'thumbnail',
|
||||
render: (thumbnailPath: string, record: PictureResponse) => (
|
||||
<Image
|
||||
width={50}
|
||||
height={50}
|
||||
src={thumbnailPath || record.path}
|
||||
style={{ objectFit: 'cover', borderRadius: 4 }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '图片名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (text: string) => (
|
||||
<Space>
|
||||
<FileImageOutlined />
|
||||
{text}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '用户',
|
||||
dataIndex: 'username',
|
||||
key: 'username',
|
||||
responsive: ['lg' as Breakpoint],
|
||||
render: (username: string) => (
|
||||
<Space>
|
||||
<UserOutlined />
|
||||
{username}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '上传时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
responsive: ['lg' as Breakpoint],
|
||||
render: (date: Date) => new Date(date).toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_: any, record: PictureResponse) => (
|
||||
<Space size="small">
|
||||
<Popconfirm
|
||||
title="确定要删除此图片吗?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
>
|
||||
{isMobile ? '' : '删除'}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="picture-management">
|
||||
<Row gutter={[16, 16]} align="middle" justify="space-between">
|
||||
<Col>
|
||||
<Space align="center">
|
||||
<PictureOutlined style={{ fontSize: 24 }} />
|
||||
<Title level={2} style={{ margin: 0 }}>图片管理</Title>
|
||||
</Space>
|
||||
<Text type="secondary" style={{ marginTop: 8, display: 'block' }}>
|
||||
管理系统中的所有图片,包括查看、删除和批量操作
|
||||
</Text>
|
||||
</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
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={handleBatchDelete}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
>
|
||||
批量删除
|
||||
</Button>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => fetchPictures()}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
<Select
|
||||
style={{ width: 150 }}
|
||||
placeholder="筛选用户"
|
||||
allowClear
|
||||
value={selectedUserId}
|
||||
onChange={handleUserFilter}
|
||||
>
|
||||
{users.map(user => (
|
||||
<Option key={user.id} value={user.id}>
|
||||
{user.userName}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col xs={24} sm={10} md={8}>
|
||||
<Input.Search
|
||||
placeholder="搜索图片名称"
|
||||
allowClear
|
||||
enterButton={<SearchOutlined />}
|
||||
onSearch={handleSearch}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
value={searchQuery}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={pictures}
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: currentPage,
|
||||
pageSize: pageSize,
|
||||
total: total,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
onChange: handlePageChange,
|
||||
showTotal: (total) => `共 ${total} 条记录`,
|
||||
}}
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: (keys) => setSelectedRowKeys(keys),
|
||||
}}
|
||||
size={isMobile ? "small" : "middle"}
|
||||
scroll={{ x: 'max-content' }}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PictureManagement;
|
||||
76
Web/src/pages/admin/system/ConfigFormItem.tsx
Normal file
76
Web/src/pages/admin/system/ConfigFormItem.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React from 'react';
|
||||
import { Row, Col, Form, Input, Button, Space, Tooltip } from 'antd';
|
||||
import { LockOutlined, QuestionCircleOutlined, SaveOutlined } from '@ant-design/icons';
|
||||
|
||||
interface ConfigFormItemProps {
|
||||
groupName: string;
|
||||
itemKey: string;
|
||||
description: string;
|
||||
isSecret: boolean;
|
||||
currentValue: string | undefined;
|
||||
formInstance: any;
|
||||
isMobile: boolean;
|
||||
onSave: (formInstance: any, groupName: string, key: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const ConfigFormItem: React.FC<ConfigFormItemProps> = ({
|
||||
groupName,
|
||||
itemKey,
|
||||
description,
|
||||
isSecret,
|
||||
currentValue,
|
||||
formInstance,
|
||||
isMobile,
|
||||
onSave,
|
||||
}) => {
|
||||
return (
|
||||
<Row key={itemKey} gutter={isMobile ? [8, 8] : [16, 16]} align="top" style={{ marginBottom: isMobile ? 8 : 16 }}>
|
||||
<Col xs={24} sm={isMobile ? 24 : 16} md={isMobile ? 24 : 17} lg={isMobile ? 24 : 18}>
|
||||
<Form.Item
|
||||
name={itemKey}
|
||||
label={
|
||||
<Space align="center">
|
||||
<span style={{ fontWeight: 500 }}>{itemKey}</span>
|
||||
{isSecret && <LockOutlined style={{ color: '#faad14' }} />}
|
||||
{description && (
|
||||
<Tooltip title={description}>
|
||||
<QuestionCircleOutlined style={{ cursor: 'help', color: '#aaa' }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
initialValue={isSecret ? '' : currentValue}
|
||||
rules={isSecret ? [] : []}
|
||||
style={{ marginBottom: isMobile ? 8 : 16 }}
|
||||
help={isSecret && currentValue ?
|
||||
<span style={{ fontSize: '12px', color: '#999' }}>当前已设置值。如需修改,请输入新值。</span> :
|
||||
(isSecret ? <span style={{ fontSize: '12px', color: '#999' }}>此为私密字段。</span> : null)}
|
||||
>
|
||||
{isSecret ? (
|
||||
<Input.Password
|
||||
placeholder={currentValue ? '******(输入新值以更新)' : '请输入新值'}
|
||||
style={{ maxWidth: 400 }}
|
||||
/>
|
||||
) : (
|
||||
<Input placeholder={`请输入 ${itemKey}`} style={{ maxWidth: 400 }} />
|
||||
)}
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={isMobile ? 24 : 8} md={isMobile ? 24 : 7} lg={isMobile ? 24 : 6}
|
||||
style={{ textAlign: isMobile ? 'left' : 'right', paddingTop: isMobile ? 0 : '30px' }}>
|
||||
<Button
|
||||
icon={<SaveOutlined />}
|
||||
type="primary"
|
||||
ghost
|
||||
onClick={() => onSave(formInstance, groupName, itemKey)}
|
||||
size="middle"
|
||||
style={{ width: isMobile ? '100%' : 'auto', marginBottom: isMobile ? 16 : 0 }}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfigFormItem;
|
||||
40
Web/src/pages/admin/system/ConfigSection.tsx
Normal file
40
Web/src/pages/admin/system/ConfigSection.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import { Card, Space, Typography } from 'antd';
|
||||
import { InfoCircleOutlined } from '@ant-design/icons';
|
||||
|
||||
const { Paragraph } = Typography;
|
||||
|
||||
interface ConfigSectionProps {
|
||||
title: string;
|
||||
icon?: React.ReactNode;
|
||||
description?: string;
|
||||
children: React.ReactNode;
|
||||
isMobile: boolean;
|
||||
}
|
||||
|
||||
const ConfigSection: React.FC<ConfigSectionProps> = ({ title, icon, description, children, isMobile }) => {
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
title={
|
||||
<Space>
|
||||
{icon}
|
||||
<span>{title}</span>
|
||||
</Space>
|
||||
}
|
||||
style={{ marginBottom: isMobile ? 16 : 24 }}
|
||||
bodyStyle={{ padding: isMobile ? '16px 12px' : '20px 16px' }}
|
||||
bordered={true}
|
||||
>
|
||||
{description && (
|
||||
<Paragraph type="secondary" style={{ marginBottom: 16 }}>
|
||||
<InfoCircleOutlined style={{ marginRight: 8 }} />
|
||||
{description}
|
||||
</Paragraph>
|
||||
)}
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfigSection;
|
||||
563
Web/src/pages/admin/system/ConfigTabs.tsx
Normal file
563
Web/src/pages/admin/system/ConfigTabs.tsx
Normal file
@@ -0,0 +1,563 @@
|
||||
import React from 'react';
|
||||
import { Tabs, Form, Input, Button, Select, Space, Divider, Typography } from 'antd';
|
||||
import {
|
||||
ApiOutlined, RocketOutlined, PictureOutlined, SaveOutlined,
|
||||
SafetyCertificateOutlined, LockOutlined, GlobalOutlined, SettingOutlined,
|
||||
CloudServerOutlined, DatabaseOutlined, UploadOutlined} from '@ant-design/icons';
|
||||
import ConfigFormItem from './ConfigFormItem';
|
||||
import ConfigSection from './ConfigSection';
|
||||
import VectorDbConfig from './VectorDbConfig';
|
||||
|
||||
const { TabPane } = Tabs;
|
||||
const { Option } = Select;
|
||||
const { Title, Paragraph } = Typography;
|
||||
|
||||
interface ConfigStructure {
|
||||
[key: string]: {
|
||||
[key: string]: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ConfigTabsProps {
|
||||
configs: ConfigStructure;
|
||||
secretFields: Record<string, string[]>;
|
||||
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; }>;
|
||||
}
|
||||
|
||||
const ConfigTabs: React.FC<ConfigTabsProps> = ({
|
||||
configs,
|
||||
secretFields,
|
||||
isMobile,
|
||||
activeKey,
|
||||
onTabChange,
|
||||
storageType,
|
||||
onStorageTypeChange,
|
||||
formsMap,
|
||||
allDescriptions,
|
||||
onSaveSingleConfig,
|
||||
onSaveAllForGroup,
|
||||
onBaseSaveConfig,
|
||||
setConfigs,
|
||||
storageOptions,
|
||||
imageFormatOptions,
|
||||
imageQualityOptions,
|
||||
}) => {
|
||||
|
||||
const renderConfigFormItems = (formInstance: any, groupName: string, itemKeys: string[]) => {
|
||||
return itemKeys.map(key => {
|
||||
const isSecret = secretFields[groupName]?.includes(key);
|
||||
const description = allDescriptions[groupName]?.[key] || '';
|
||||
const currentValue = configs[groupName]?.[key];
|
||||
|
||||
return (
|
||||
<ConfigFormItem
|
||||
key={key}
|
||||
groupName={groupName}
|
||||
itemKey={key}
|
||||
description={description}
|
||||
isSecret={isSecret}
|
||||
currentValue={currentValue}
|
||||
formInstance={formInstance}
|
||||
isMobile={isMobile}
|
||||
onSave={onSaveSingleConfig}
|
||||
/>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const tabItems = [
|
||||
{
|
||||
key: 'AI',
|
||||
label: 'AI 设置',
|
||||
icon: <ApiOutlined />,
|
||||
children: (
|
||||
<Tabs defaultActiveKey="basic" type="card" size={isMobile ? "small" : "middle"}>
|
||||
<TabPane tab="基础配置" key="basic">
|
||||
<ConfigSection
|
||||
title="AI 服务配置"
|
||||
icon={<RocketOutlined />}
|
||||
description="配置AI服务的基本参数,包括API端点、密钥和模型选择"
|
||||
isMobile={isMobile}
|
||||
>
|
||||
<Form form={formsMap.AI} layout="vertical" size={isMobile ? "middle" : "large"}>
|
||||
{renderConfigFormItems(formsMap.AI, "AI", ['ApiEndpoint', 'ApiKey', 'Model', 'EmbeddingModel'])}
|
||||
<Divider style={{ margin: '12px 0 20px' }} />
|
||||
<Form.Item style={{ marginBottom: 0, textAlign: 'center' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
onClick={() => onSaveAllForGroup(formsMap.AI, "AI", ['ApiEndpoint', 'ApiKey', 'Model', 'EmbeddingModel'])}
|
||||
style={{ width: isMobile ? '100%' : '240px' }}
|
||||
>
|
||||
保存所有基础配置
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</ConfigSection>
|
||||
</TabPane>
|
||||
<TabPane tab="提示词设置" key="prompts">
|
||||
<ConfigSection
|
||||
title="图片分析提示词"
|
||||
icon={<PictureOutlined />}
|
||||
description={allDescriptions.AI?.ImageAnalysisPrompt}
|
||||
isMobile={isMobile}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={8}
|
||||
value={configs.AI?.ImageAnalysisPrompt || ""}
|
||||
onChange={(e) => {
|
||||
const newConfigs = { ...configs };
|
||||
if (!newConfigs.AI) newConfigs.AI = {};
|
||||
newConfigs.AI.ImageAnalysisPrompt = e.target.value;
|
||||
setConfigs(newConfigs);
|
||||
}}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
onClick={() => onBaseSaveConfig('AI', 'ImageAnalysisPrompt', configs.AI?.ImageAnalysisPrompt || '')}
|
||||
style={{ width: isMobile ? '100%' : '240px' }}
|
||||
>
|
||||
保存图片分析提示词
|
||||
</Button>
|
||||
</div>
|
||||
</ConfigSection>
|
||||
|
||||
<ConfigSection
|
||||
title="标签生成提示词"
|
||||
icon={<PictureOutlined />}
|
||||
description={allDescriptions.AI?.TagGenerationPrompt}
|
||||
isMobile={isMobile}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={8}
|
||||
value={configs.AI?.TagGenerationPrompt || ""}
|
||||
onChange={(e) => {
|
||||
const newConfigs = { ...configs };
|
||||
if (!newConfigs.AI) newConfigs.AI = {};
|
||||
newConfigs.AI.TagGenerationPrompt = e.target.value;
|
||||
setConfigs(newConfigs);
|
||||
}}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
onClick={() => onBaseSaveConfig('AI', 'TagGenerationPrompt', configs.AI?.TagGenerationPrompt || '')}
|
||||
style={{ width: isMobile ? '100%' : '240px' }}
|
||||
>
|
||||
保存标签生成提示词
|
||||
</Button>
|
||||
</div>
|
||||
</ConfigSection>
|
||||
|
||||
<ConfigSection
|
||||
title="标签匹配提示词"
|
||||
icon={<PictureOutlined />}
|
||||
description={allDescriptions.AI?.TagMatchingPrompt}
|
||||
isMobile={isMobile}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={8}
|
||||
value={configs.AI?.TagMatchingPrompt || ""}
|
||||
onChange={(e) => {
|
||||
const newConfigs = { ...configs };
|
||||
if (!newConfigs.AI) newConfigs.AI = {};
|
||||
newConfigs.AI.TagMatchingPrompt = e.target.value;
|
||||
setConfigs(newConfigs);
|
||||
}}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
onClick={() => onBaseSaveConfig('AI', 'TagMatchingPrompt', configs.AI?.TagMatchingPrompt || '')}
|
||||
style={{ width: isMobile ? '100%' : '240px' }}
|
||||
>
|
||||
保存标签匹配提示词
|
||||
</Button>
|
||||
</div>
|
||||
</ConfigSection>
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'Authorization',
|
||||
label: '授权配置',
|
||||
icon: <SafetyCertificateOutlined />,
|
||||
children: (
|
||||
<Tabs defaultActiveKey="jwt" type="card" size={isMobile ? "small" : "middle"}>
|
||||
<TabPane tab="JWT 设置" key="jwt">
|
||||
<ConfigSection
|
||||
title="JWT 安全配置"
|
||||
icon={<LockOutlined />}
|
||||
description="JSON Web Token (JWT) 的安全设置,用于管理用户身份验证和授权"
|
||||
isMobile={isMobile}
|
||||
>
|
||||
<Form form={formsMap.Jwt} layout="vertical" size={isMobile ? "middle" : "large"}>
|
||||
{renderConfigFormItems(formsMap.Jwt, "Jwt", ['SecretKey', 'Issuer', 'Audience'])}
|
||||
<Divider style={{ margin: '12px 0 20px' }} />
|
||||
<Form.Item style={{ marginBottom: 0, textAlign: 'center' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
onClick={() => onSaveAllForGroup(formsMap.Jwt, "Jwt", ['SecretKey', 'Issuer', 'Audience'])}
|
||||
style={{ width: isMobile ? '100%' : '240px' }}
|
||||
>
|
||||
保存所有 JWT 配置
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</ConfigSection>
|
||||
</TabPane>
|
||||
<TabPane tab="GitHub认证" key="github">
|
||||
<ConfigSection
|
||||
title="GitHub OAuth 配置"
|
||||
icon={<GlobalOutlined />}
|
||||
description="GitHub OAuth 应用配置,用于实现第三方登录功能"
|
||||
isMobile={isMobile}
|
||||
>
|
||||
<Form form={formsMap.Authentication} layout="vertical" size={isMobile ? "middle" : "large"}>
|
||||
{renderConfigFormItems(formsMap.Authentication, "Authentication", ["GitHubClientId", "GitHubClientSecret", "GitHubCallbackUrl"])}
|
||||
<Divider style={{ margin: '12px 0 20px' }} />
|
||||
<Form.Item style={{ marginBottom: 0, textAlign: 'center' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
onClick={() => onSaveAllForGroup(formsMap.Authentication, "Authentication", ["GitHubClientId", "GitHubClientSecret", "GitHubCallbackUrl"])}
|
||||
style={{ width: isMobile ? '100%' : '240px' }}
|
||||
>
|
||||
保存所有 GitHub 认证配置
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</ConfigSection>
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'AppSettings',
|
||||
label: '应用设置',
|
||||
icon: <SettingOutlined />,
|
||||
children: (
|
||||
<ConfigSection
|
||||
title="应用基础设置"
|
||||
icon={<SettingOutlined />}
|
||||
description="应用程序的基本配置参数"
|
||||
isMobile={isMobile}
|
||||
>
|
||||
<Form form={formsMap.AppSettings} layout="vertical" size={isMobile ? "middle" : "large"}>
|
||||
{renderConfigFormItems(formsMap.AppSettings, "AppSettings", ['ServerUrl'])}
|
||||
<Divider style={{ margin: '12px 0 20px' }} />
|
||||
<Form.Item style={{ marginBottom: 0, textAlign: 'center' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
onClick={() => onSaveAllForGroup(formsMap.AppSettings, "AppSettings", ['ServerUrl'])}
|
||||
style={{ width: isMobile ? '100%' : '240px' }}
|
||||
>
|
||||
保存所有应用设置
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</ConfigSection>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'Storage',
|
||||
label: '存储设置',
|
||||
icon: <CloudServerOutlined />,
|
||||
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="上传设置配置"
|
||||
icon={<UploadOutlined />}
|
||||
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.Upload?.DefaultImageFormat || 'Original'}
|
||||
onChange={(value) => onBaseSaveConfig('Upload', 'DefaultImageFormat', value)}
|
||||
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}
|
||||
</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}
|
||||
</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>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
</ConfigSection>
|
||||
</>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'VectorDb',
|
||||
label: '向量数据',
|
||||
icon: <DatabaseOutlined />,
|
||||
children: (
|
||||
<VectorDbConfig isMobile={isMobile} />
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
activeKey={activeKey}
|
||||
onChange={onTabChange}
|
||||
size={isMobile ? "small" : "middle"}
|
||||
tabPosition={isMobile ? "top" : "left"}
|
||||
style={{
|
||||
minHeight: isMobile ? 'auto' : 400
|
||||
}}
|
||||
items={tabItems.map(item => ({
|
||||
key: item.key,
|
||||
label: (
|
||||
<Space>
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
</Space>
|
||||
),
|
||||
children: item.children
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfigTabs;
|
||||
615
Web/src/pages/admin/system/Index.tsx
Normal file
615
Web/src/pages/admin/system/Index.tsx
Normal file
@@ -0,0 +1,615 @@
|
||||
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
|
||||
} from '@ant-design/icons';
|
||||
import { getAllConfigs, setConfig, backupConfigs, restoreConfigs } from '../../../api';
|
||||
import useIsMobile from '../../../hooks/useIsMobile.ts';
|
||||
import ConfigTabs from './ConfigTabs';
|
||||
|
||||
const { Text, Paragraph } = Typography;
|
||||
|
||||
interface ConfigStructure {
|
||||
[key: string]: {
|
||||
[key: string]: string;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
const allDescriptions: Record<string, Record<string, string>> = {
|
||||
AI: {
|
||||
ApiEndpoint: 'AI 服务的API端点地址',
|
||||
ApiKey: 'AI 服务的API密钥',
|
||||
Model: 'AI 模型名称',
|
||||
EmbeddingModel: '嵌入向量模型名称',
|
||||
ImageAnalysisPrompt: '用于分析图片内容并提取描述的提示词。请确保提示词包含返回JSON格式的指示,并且要求返回标题(title)和描述(description)字段。',
|
||||
TagGenerationPrompt: '用于从图片内容生成标签的提示词。请确保提示词包含返回JSON格式的指示,并且要求返回tags数组字段。',
|
||||
TagMatchingPrompt: '用于将描述内容与已有标签进行匹配的提示词。请确保提示词包含{\'{tagsText}\'}和{\'{description}\'}占位符,系统将会用实际的标签列表和描述内容替换这些占位符。'
|
||||
},
|
||||
Jwt: {
|
||||
SecretKey: 'JWT 加密密钥',
|
||||
Issuer: 'JWT 签发者',
|
||||
Audience: 'JWT 接收者',
|
||||
},
|
||||
Authentication: {
|
||||
GitHubClientId: 'GitHub OAuth 应用客户端ID',
|
||||
GitHubClientSecret: 'GitHub OAuth 应用客户端密钥',
|
||||
GitHubCallbackUrl: 'GitHub OAuth 认证回调地址'
|
||||
},
|
||||
AppSettings: {
|
||||
ServerUrl: '服务器URL'
|
||||
},
|
||||
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格式的图片质量设置,越高图片质量越好但文件越大'
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const System: React.FC = () => {
|
||||
const isMobile = useIsMobile();
|
||||
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);
|
||||
const [restoreConfig, setRestoreConfig] = useState<Record<string, string> | null>(null);
|
||||
const [secretFields, setSecretFields] = useState<Record<string, string[]>>({});
|
||||
const [, setSavingFields] = useState<Set<string>>(new Set()); // 保留用于 baseSaveConfig
|
||||
|
||||
const debounceTimerRef = useRef<number | null>(null);
|
||||
|
||||
const [aiForm] = Form.useForm();
|
||||
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> = {
|
||||
AI: aiForm,
|
||||
Jwt: jwtForm,
|
||||
Authentication: authForm,
|
||||
AppSettings: appSettingsForm,
|
||||
TelegramStorage: telegramForm,
|
||||
S3Storage: s3Form,
|
||||
CosStorage: cosForm,
|
||||
WebDAVStorage: webDAVForm,
|
||||
Upload: uploadForm,
|
||||
};
|
||||
|
||||
// 获取所有配置项
|
||||
const fetchConfigs = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getAllConfigs();
|
||||
if (response.success && response.data) {
|
||||
const configGroups: ConfigStructure = {};
|
||||
const secretFieldsMap: Record<string, string[]> = {};
|
||||
|
||||
response.data.forEach(config => {
|
||||
const [group, key] = config.key.split(':');
|
||||
if (!configGroups[group]) {
|
||||
configGroups[group] = {};
|
||||
}
|
||||
configGroups[group][key] = config.value;
|
||||
|
||||
if (config.isSecret) {
|
||||
if (!secretFieldsMap[group]) {
|
||||
secretFieldsMap[group] = [];
|
||||
}
|
||||
secretFieldsMap[group].push(key);
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
if (formInstance) {
|
||||
const initialGroupValues: Record<string, string> = {};
|
||||
Object.keys(configGroups[group]).forEach(key => {
|
||||
if (!secretFieldsMap[group]?.includes(key)) {
|
||||
initialGroupValues[key] = configGroups[group][key];
|
||||
} else {
|
||||
initialGroupValues[key] = '';
|
||||
}
|
||||
});
|
||||
formInstance.setFieldsValue(initialGroupValues);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
message.error('获取配置失败: ' + response.message);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('获取配置出错');
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 自定义防抖函数实现
|
||||
const debounce = useCallback((fn: Function, delay: number) => {
|
||||
return (...args: any[]) => {
|
||||
// 清除之前的定时器
|
||||
if (debounceTimerRef.current) {
|
||||
window.clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
|
||||
// 设置新的定时器
|
||||
debounceTimerRef.current = window.setTimeout(() => {
|
||||
fn(...args);
|
||||
debounceTimerRef.current = null;
|
||||
}, delay);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 保存配置项 (Core API call) - 添加防抖功能和更好的状态管理
|
||||
const baseSaveConfig = async (group: string, key: string, value: string) => {
|
||||
const configKey = `${group}:${key}`;
|
||||
setSavingFields(prev => new Set(prev).add(configKey));
|
||||
|
||||
try {
|
||||
const response = await setConfig({
|
||||
key: configKey,
|
||||
value: value,
|
||||
description: `${group} ${key} setting`
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
notification.success({
|
||||
message: '保存成功',
|
||||
description: `${key} 配置已更新`,
|
||||
icon: <CheckCircleOutlined style={{ color: '#52c41a' }} />,
|
||||
placement: 'bottomRight',
|
||||
duration: 3,
|
||||
});
|
||||
|
||||
setConfigs(prev => {
|
||||
const newConfigs = { ...prev };
|
||||
if (!newConfigs[group]) newConfigs[group] = {};
|
||||
newConfigs[group][key] = value;
|
||||
return newConfigs;
|
||||
});
|
||||
return true;
|
||||
} else {
|
||||
notification.error({
|
||||
message: '保存失败',
|
||||
description: `${key}: ${response.message}`,
|
||||
placement: 'bottomRight',
|
||||
duration: 4,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
notification.error({
|
||||
message: '系统错误',
|
||||
description: `保存 ${key} 配置时发生错误`,
|
||||
placement: 'bottomRight',
|
||||
duration: 4,
|
||||
});
|
||||
console.error(error);
|
||||
return false;
|
||||
} finally {
|
||||
setSavingFields(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(configKey);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveSingleConfig = async (formInstance: any, groupName: string, key: string) => {
|
||||
try {
|
||||
await formInstance.validateFields([key]);
|
||||
const value = formInstance.getFieldValue(key);
|
||||
const isSecret = secretFields[groupName]?.includes(key);
|
||||
|
||||
if (isSecret && (value === '' || value === undefined)) {
|
||||
message.info(`未输入 ${key} 的新值,不作更改。`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用自定义防抖函数包装保存操作
|
||||
debounce((g: string, k: string, v: string) => {
|
||||
baseSaveConfig(g, k, v);
|
||||
}, 300)(groupName, key, value);
|
||||
|
||||
if (isSecret) {
|
||||
formInstance.setFieldsValue({ [key]: '' });
|
||||
}
|
||||
} catch (errorInfo) {
|
||||
console.error(`保存配置 ${groupName}:${key} 失败:`, errorInfo);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveAllForGroup = async (formInstance: any, groupName: string, itemKeys: string[]) => {
|
||||
try {
|
||||
await formInstance.validateFields(itemKeys);
|
||||
const values = formInstance.getFieldsValue(itemKeys);
|
||||
let changesMade = false;
|
||||
let successCount = 0;
|
||||
let totalToSave = 0;
|
||||
|
||||
// 计算需要保存的总数
|
||||
for (const key of itemKeys) {
|
||||
const value = values[key];
|
||||
const isSecret = secretFields[groupName]?.includes(key);
|
||||
if (!(isSecret && (value === '' || value === undefined)) &&
|
||||
(isSecret || configs[groupName]?.[key] !== value)) {
|
||||
totalToSave++;
|
||||
}
|
||||
}
|
||||
|
||||
if (totalToSave === 0) {
|
||||
message.info(`${groupName} 中没有需要更新的配置。`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 显示批量保存开始通知
|
||||
notification.open({
|
||||
key: `saving-${groupName}`,
|
||||
message: `正在保存 ${groupName} 配置`,
|
||||
description: `正在处理 ${totalToSave} 项配置...`,
|
||||
icon: <Spin size="small" />,
|
||||
duration: 0,
|
||||
});
|
||||
|
||||
for (const key of itemKeys) {
|
||||
const value = values[key];
|
||||
const isSecret = secretFields[groupName]?.includes(key);
|
||||
|
||||
if (isSecret && (value === '' || value === undefined)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isSecret && configs[groupName]?.[key] === value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const success = await baseSaveConfig(groupName, key, value);
|
||||
if (success) {
|
||||
changesMade = true;
|
||||
successCount++;
|
||||
if (isSecret) {
|
||||
formInstance.setFieldsValue({ [key]: '' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新或关闭批量保存通知
|
||||
if (changesMade) {
|
||||
notification.success({
|
||||
key: `saving-${groupName}`,
|
||||
message: `${groupName} 配置已更新`,
|
||||
description: `成功保存了 ${successCount} 项配置`,
|
||||
icon: <CheckCircleOutlined style={{ color: '#52c41a' }} />,
|
||||
duration: 3,
|
||||
});
|
||||
} else {
|
||||
notification.destroy(`saving-${groupName}`);
|
||||
message.info(`${groupName} 中没有配置被更改。`);
|
||||
}
|
||||
} catch (errorInfo) {
|
||||
notification.destroy(`saving-${groupName}`);
|
||||
console.error(`保存 ${groupName} 所有配置失败:`, errorInfo);
|
||||
}
|
||||
};
|
||||
|
||||
// 备份配置
|
||||
const handleBackupConfigs = async () => {
|
||||
setBackupLoading(true);
|
||||
try {
|
||||
const response = await backupConfigs();
|
||||
if (response.success && response.data) {
|
||||
const configData = JSON.stringify(response.data, null, 2);
|
||||
const blob = new Blob([configData], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
a.download = `foxel-config-backup-${timestamp}.json`;
|
||||
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
notification.success({
|
||||
message: '备份成功',
|
||||
description: '配置备份文件已下载到您的设备',
|
||||
placement: 'bottomRight',
|
||||
duration: 3,
|
||||
});
|
||||
} else {
|
||||
notification.error({
|
||||
message: '备份失败',
|
||||
description: response.message || '无法生成备份文件',
|
||||
placement: 'bottomRight',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
notification.error({
|
||||
message: '系统错误',
|
||||
description: '备份配置时发生错误',
|
||||
placement: 'bottomRight',
|
||||
});
|
||||
console.error(error);
|
||||
} finally {
|
||||
setBackupLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 上传配置文件
|
||||
const handleFileUpload = (file: File) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const content = e.target?.result as string;
|
||||
const config = JSON.parse(content);
|
||||
setRestoreConfig(config);
|
||||
setRestoreModalVisible(true);
|
||||
} catch (error) {
|
||||
notification.error({
|
||||
message: '文件格式错误',
|
||||
description: '无法解析上传的配置文件,请确认是有效的JSON格式',
|
||||
placement: 'bottomRight',
|
||||
});
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
return false; // 阻止自动上传
|
||||
};
|
||||
|
||||
// 确认恢复配置
|
||||
const handleRestoreConfigs = async () => {
|
||||
if (!restoreConfig) return;
|
||||
|
||||
setRestoreLoading(true);
|
||||
try {
|
||||
const response = await restoreConfigs(restoreConfig);
|
||||
if (response.success) {
|
||||
notification.success({
|
||||
message: '恢复成功',
|
||||
description: '配置已成功恢复,页面将在3秒后刷新',
|
||||
placement: 'bottomRight',
|
||||
duration: 3,
|
||||
});
|
||||
setRestoreModalVisible(false);
|
||||
|
||||
// 重新加载配置
|
||||
setTimeout(() => {
|
||||
fetchConfigs();
|
||||
}, 3000);
|
||||
} else {
|
||||
notification.error({
|
||||
message: '恢复失败',
|
||||
description: response.message || '无法应用配置',
|
||||
placement: 'bottomRight',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
notification.error({
|
||||
message: '系统错误',
|
||||
description: '恢复配置时发生错误',
|
||||
placement: 'bottomRight',
|
||||
});
|
||||
console.error(error);
|
||||
} finally {
|
||||
setRestoreLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 存储类型选项
|
||||
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();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Space>
|
||||
<SettingOutlined />
|
||||
<span>系统配置</span>
|
||||
</Space>
|
||||
<Space>
|
||||
<Tooltip title="下载当前所有配置的备份">
|
||||
<Button
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={handleBackupConfigs}
|
||||
loading={backupLoading}
|
||||
size={isMobile ? "small" : "middle"}
|
||||
type="primary"
|
||||
ghost
|
||||
>
|
||||
{isMobile ? '' : '备份配置'}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Upload
|
||||
beforeUpload={handleFileUpload}
|
||||
showUploadList={false}
|
||||
accept=".json"
|
||||
>
|
||||
<Tooltip title="从备份文件恢复配置">
|
||||
<Button
|
||||
icon={<UploadOutlined />}
|
||||
size={isMobile ? "small" : "middle"}
|
||||
type={isMobile ? "primary" : "default"}
|
||||
ghost={isMobile}
|
||||
>
|
||||
{isMobile ? '' : '恢复配置'}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Upload>
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
className="system-config-card"
|
||||
bodyStyle={{
|
||||
padding: isMobile ? '12px 8px' : '24px',
|
||||
transition: 'all 0.3s'
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px 20px' }}>
|
||||
<Spin size="large" tip="加载系统配置中..." />
|
||||
</div>
|
||||
) : (
|
||||
<ConfigTabs
|
||||
configs={configs}
|
||||
secretFields={secretFields}
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 恢复配置确认对话框 */}
|
||||
<Modal
|
||||
title={
|
||||
<Space>
|
||||
<UploadOutlined />
|
||||
<span>确认恢复配置</span>
|
||||
<Tooltip title="恢复配置将覆盖当前所有配置设置,请确认备份文件正确无误">
|
||||
<QuestionCircleOutlined style={{ cursor: 'help' }} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
}
|
||||
open={restoreModalVisible}
|
||||
onCancel={() => setRestoreModalVisible(false)}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={() => setRestoreModalVisible(false)}>
|
||||
取消
|
||||
</Button>,
|
||||
<Button
|
||||
key="submit"
|
||||
type="primary"
|
||||
danger
|
||||
loading={restoreLoading}
|
||||
onClick={handleRestoreConfigs}
|
||||
>
|
||||
确认恢复
|
||||
</Button>
|
||||
]}
|
||||
width={500}
|
||||
maskClosable={false}
|
||||
>
|
||||
<div style={{ padding: '16px 0' }}>
|
||||
<Paragraph>
|
||||
<Text strong>您确定要从上传的备份文件恢复配置吗?</Text>
|
||||
</Paragraph>
|
||||
<Paragraph type="danger" style={{ fontWeight: 'bold' }}>
|
||||
警告:此操作将覆盖当前系统中的所有配置设置!
|
||||
</Paragraph>
|
||||
{restoreConfig && (
|
||||
<div style={{
|
||||
background: '#f6f6f6',
|
||||
padding: '10px 16px',
|
||||
borderRadius: 4,
|
||||
marginTop: 16
|
||||
}}>
|
||||
<Paragraph>备份文件包含 <Text strong>{Object.keys(restoreConfig).length}</Text> 条配置项</Paragraph>
|
||||
<Paragraph type="secondary" style={{ fontSize: 12, margin: 0 }}>
|
||||
恢复后可能需要重启应用才能使所有配置生效
|
||||
</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default System;
|
||||
396
Web/src/pages/admin/system/VectorDbConfig.tsx
Normal file
396
Web/src/pages/admin/system/VectorDbConfig.tsx
Normal file
@@ -0,0 +1,396 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, Radio, Button, message, Spin, Space, Typography, notification, Form, Input, Modal } from 'antd';
|
||||
import { DatabaseOutlined, SyncOutlined, CheckCircleOutlined, InfoCircleOutlined, SaveOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { getCurrentVectorDb, switchVectorDb, setConfig, clearVectors, rebuildVectors } from '../../../api';
|
||||
import { VectorDbType } from '../../../api/types';
|
||||
|
||||
const { Title, Paragraph } = Typography;
|
||||
|
||||
interface VectorDbConfigProps {
|
||||
isMobile: boolean;
|
||||
}
|
||||
|
||||
const VectorDbConfig: React.FC<VectorDbConfigProps> = ({ isMobile }) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [switching, setSwitching] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [rebuilding, setRebuilding] = useState(false);
|
||||
const [currentType, setCurrentType] = useState<string>('');
|
||||
const [selectedType, setSelectedType] = useState<VectorDbType>(VectorDbType.Qdrant);
|
||||
const [qdrantConfig, setQdrantConfig] = useState({
|
||||
host: '',
|
||||
apiKey: ''
|
||||
});
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const fetchCurrentVectorDb = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getCurrentVectorDb();
|
||||
if (response.success && response.data) {
|
||||
setCurrentType(response.data.type);
|
||||
setSelectedType(response.data.type as VectorDbType);
|
||||
|
||||
// 安全地访问配置值
|
||||
if (response.data && response.data.type) {
|
||||
// 使用可选链和类型断言来安全地访问配置
|
||||
const config = (response.data as any).config;
|
||||
if (config) {
|
||||
setQdrantConfig({
|
||||
host: config.QdrantHost || '',
|
||||
apiKey: config.QdrantApiKey || ''
|
||||
});
|
||||
form.setFieldsValue({
|
||||
QdrantHost: config.QdrantHost || '',
|
||||
QdrantApiKey: '' // 不显示API密钥的值
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
message.error('获取当前向量数据库失败: ' + response.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取当前向量数据库出错:', error);
|
||||
message.error('获取当前向量数据库出错');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSwitchVectorDb = async () => {
|
||||
if (selectedType === currentType) {
|
||||
message.info('当前已经是该向量数据库类型');
|
||||
return;
|
||||
}
|
||||
|
||||
setSwitching(true);
|
||||
try {
|
||||
const response = await switchVectorDb(selectedType);
|
||||
if (response.success) {
|
||||
notification.success({
|
||||
message: '切换成功',
|
||||
description: `已成功切换到 ${selectedType} 向量数据库`,
|
||||
icon: <CheckCircleOutlined style={{ color: '#52c41a' }} />,
|
||||
});
|
||||
setCurrentType(selectedType);
|
||||
} else {
|
||||
notification.error({
|
||||
message: '切换失败',
|
||||
description: response.message,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('切换向量数据库出错:', error);
|
||||
notification.error({
|
||||
message: '切换出错',
|
||||
description: '切换向量数据库时发生错误',
|
||||
});
|
||||
} finally {
|
||||
setSwitching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveQdrantConfig = async () => {
|
||||
try {
|
||||
await form.validateFields();
|
||||
const values = form.getFieldsValue();
|
||||
setSaving(true);
|
||||
|
||||
const saveHost = async () => {
|
||||
if (values.QdrantHost && values.QdrantHost !== qdrantConfig.host) {
|
||||
const response = await setConfig({
|
||||
key: 'VectorDb:QdrantHost',
|
||||
value: values.QdrantHost,
|
||||
description: 'Qdrant服务器地址'
|
||||
});
|
||||
if (!response.success) {
|
||||
throw new Error('保存Qdrant主机地址失败');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const saveApiKey = async () => {
|
||||
if (values.QdrantApiKey) {
|
||||
const response = await setConfig({
|
||||
key: 'VectorDb:QdrantApiKey',
|
||||
value: values.QdrantApiKey,
|
||||
description: 'Qdrant API密钥'
|
||||
});
|
||||
if (!response.success) {
|
||||
throw new Error('保存Qdrant API密钥失败');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const hostSaved = await saveHost();
|
||||
const apiKeySaved = await saveApiKey();
|
||||
|
||||
if (hostSaved || apiKeySaved) {
|
||||
notification.success({
|
||||
message: 'Qdrant配置已保存',
|
||||
description: '向量数据库配置更新成功',
|
||||
icon: <CheckCircleOutlined style={{ color: '#52c41a' }} />,
|
||||
});
|
||||
|
||||
// 更新本地状态
|
||||
if (hostSaved) {
|
||||
setQdrantConfig(prev => ({...prev, host: values.QdrantHost}));
|
||||
}
|
||||
if (apiKeySaved) {
|
||||
setQdrantConfig(prev => ({...prev, apiKey: values.QdrantApiKey}));
|
||||
form.setFieldsValue({QdrantApiKey: ''});
|
||||
}
|
||||
} else {
|
||||
message.info('没有配置被更改');
|
||||
}
|
||||
} catch (error) {
|
||||
notification.error({
|
||||
message: '保存失败',
|
||||
description: error instanceof Error ? error.message : '保存Qdrant配置时发生错误',
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearVectors = () => {
|
||||
Modal.confirm({
|
||||
title: '确认清空向量数据库',
|
||||
content: '此操作将清空所有向量数据,不可恢复。确定要继续吗?',
|
||||
okText: '确认清空',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
setClearing(true);
|
||||
try {
|
||||
const response = await clearVectors();
|
||||
if (response.success) {
|
||||
notification.success({
|
||||
message: '清空成功',
|
||||
description: '已成功清空向量数据库',
|
||||
icon: <CheckCircleOutlined style={{ color: '#52c41a' }} />,
|
||||
});
|
||||
} else {
|
||||
notification.error({
|
||||
message: '清空失败',
|
||||
description: response.message,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('清空向量数据库出错:', error);
|
||||
notification.error({
|
||||
message: '操作出错',
|
||||
description: '清空向量数据库时发生错误',
|
||||
});
|
||||
} finally {
|
||||
setClearing(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleRebuildVectors = () => {
|
||||
Modal.confirm({
|
||||
title: '确认重建向量数据库',
|
||||
content: '此操作将重新构建所有向量数据,可能需要较长时间。确定要继续吗?',
|
||||
okText: '确认重建',
|
||||
okType: 'primary',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
setRebuilding(true);
|
||||
try {
|
||||
const response = await rebuildVectors();
|
||||
if (response.success) {
|
||||
notification.success({
|
||||
message: '重建已开始',
|
||||
description: '向量数据库重建过程已开始,请耐心等待完成',
|
||||
icon: <SyncOutlined spin style={{ color: '#1890ff' }} />,
|
||||
duration: 5,
|
||||
});
|
||||
} else {
|
||||
notification.error({
|
||||
message: '重建失败',
|
||||
description: response.message,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('重建向量数据库出错:', error);
|
||||
notification.error({
|
||||
message: '操作出错',
|
||||
description: '重建向量数据库时发生错误',
|
||||
});
|
||||
} finally {
|
||||
setRebuilding(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchCurrentVectorDb();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: '40px 0' }}>
|
||||
<Spin size="large" tip="加载向量数据库配置..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<DatabaseOutlined />
|
||||
<span>向量数据库配置</span>
|
||||
</Space>
|
||||
}
|
||||
style={{ marginBottom: 16 }}
|
||||
bodyStyle={{ padding: isMobile ? '16px 12px' : '20px 16px' }}
|
||||
>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 16 }}>
|
||||
<InfoCircleOutlined style={{ marginRight: 8 }} />
|
||||
选择用于存储和查询向量数据的数据库类型。切换后,系统会自动迁移现有向量数据。
|
||||
</Paragraph>
|
||||
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Title level={5}>当前向量数据库: {currentType}</Title>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Radio.Group
|
||||
value={selectedType}
|
||||
onChange={(e) => setSelectedType(e.target.value)}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
size="large"
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
<Radio.Button value={VectorDbType.InMemory}>
|
||||
<Space>
|
||||
<DatabaseOutlined />
|
||||
<span>InMemory</span>
|
||||
</Space>
|
||||
</Radio.Button>
|
||||
<Radio.Button value={VectorDbType.Qdrant}>
|
||||
<Space>
|
||||
<DatabaseOutlined />
|
||||
<span>Qdrant</span>
|
||||
</Space>
|
||||
</Radio.Button>
|
||||
</Radio.Group>
|
||||
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SyncOutlined />}
|
||||
loading={switching}
|
||||
onClick={handleSwitchVectorDb}
|
||||
disabled={selectedType === currentType}
|
||||
>
|
||||
切换到 {selectedType}
|
||||
</Button>
|
||||
{selectedType === currentType && (
|
||||
<span style={{ marginLeft: 8, color: '#52c41a' }}>
|
||||
<CheckCircleOutlined /> 当前已启用
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedType === VectorDbType.Qdrant && (
|
||||
<div style={{ marginBottom: 24, border: '1px solid #f0f0f0', borderRadius: 6, padding: 16, backgroundColor: '#fafafa' }}>
|
||||
<Title level={5}>Qdrant 配置</Title>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 16 }}>
|
||||
配置Qdrant服务器的连接信息。这些设置将影响向量搜索功能。
|
||||
</Paragraph>
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
QdrantHost: qdrantConfig.host,
|
||||
QdrantApiKey: ''
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
name="QdrantHost"
|
||||
label="Qdrant 主机地址"
|
||||
rules={[{ required: true, message: '请输入Qdrant服务器地址' }]}
|
||||
tooltip="Qdrant服务器的完整URL,例如:https://example.qdrant.io"
|
||||
>
|
||||
<Input placeholder="例如: your-instance.qdrant.io" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="QdrantApiKey"
|
||||
label="Qdrant API密钥"
|
||||
tooltip="访问Qdrant服务器所需的API密钥"
|
||||
help={qdrantConfig.apiKey ? "当前已设置API密钥。如需修改,请输入新值。" : ""}
|
||||
>
|
||||
<Input.Password placeholder="输入新的API密钥" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
onClick={saveQdrantConfig}
|
||||
loading={saving}
|
||||
>
|
||||
保存Qdrant配置
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Title level={5}>向量数据库维护</Title>
|
||||
<Space size="middle" style={{ marginTop: 12 }}>
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
loading={clearing}
|
||||
onClick={handleClearVectors}
|
||||
>
|
||||
清空向量数据库
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ReloadOutlined />}
|
||||
loading={rebuilding}
|
||||
onClick={handleRebuildVectors}
|
||||
>
|
||||
重建向量数据库
|
||||
</Button>
|
||||
</Space>
|
||||
<Paragraph type="secondary" style={{ marginTop: 8 }}>
|
||||
<InfoCircleOutlined style={{ marginRight: 8 }} />
|
||||
清空操作将删除所有向量数据;重建操作会先清空再重新生成所有向量数据。
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
<div style={{ background: '#f6f6f6', padding: 16, borderRadius: 4 }}>
|
||||
<Title level={5}>向量数据库说明</Title>
|
||||
<ul style={{ paddingLeft: 20 }}>
|
||||
<li>
|
||||
<b>InMemory</b>: 内存中存储的向量数据库,适合快速原型和小规模应用
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<b>Qdrant</b>: 高性能的向量相似度搜索引擎,适合中小规模应用
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default VectorDbConfig;
|
||||
412
Web/src/pages/admin/users/Index.tsx
Normal file
412
Web/src/pages/admin/users/Index.tsx
Normal file
@@ -0,0 +1,412 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Table, Button, Card, Input, Space, Modal, Form,
|
||||
message, Tag, Typography, Popconfirm, Row, Col, Select
|
||||
} from 'antd';
|
||||
import {
|
||||
UserOutlined, DeleteOutlined, EditOutlined,
|
||||
SearchOutlined, ExclamationCircleOutlined, ReloadOutlined,
|
||||
UserAddOutlined, UserDeleteOutlined, TeamOutlined
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
getUsers, deleteUser, createUser, updateUser, batchDeleteUsers, UserRole
|
||||
} from '../../../api';
|
||||
import type { UserResponse, CreateUserRequest, AdminUpdateUserRequest } from '../../../api/types';
|
||||
import { useOutletContext } from 'react-router';
|
||||
import type { Breakpoint } from 'antd';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { Option } = Select;
|
||||
const { confirm } = Modal;
|
||||
|
||||
const UserManagement: React.FC = () => {
|
||||
const { isMobile } = useOutletContext<{ isMobile: boolean }>();
|
||||
|
||||
// 状态管理
|
||||
const [users, setUsers] = useState<UserResponse[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||
|
||||
// 模态框状态
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
const [modalTitle, setModalTitle] = useState('');
|
||||
const [editingUser, setEditingUser] = useState<UserResponse | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
// 加载用户数据
|
||||
const fetchUsers = useCallback(async (page = currentPage, size = pageSize) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getUsers(page, size);
|
||||
if (response.success && response.data) {
|
||||
setUsers(response.data || []);
|
||||
setTotal(response.totalCount || 0);
|
||||
} else {
|
||||
message.error(response.message || '获取用户列表失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching users:', error);
|
||||
message.error('获取用户列表失败,请检查网络连接');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [currentPage, pageSize]);
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, [fetchUsers]);
|
||||
|
||||
// 处理页面变化
|
||||
const handlePageChange = (page: number, size?: number) => {
|
||||
setCurrentPage(page);
|
||||
if (size) setPageSize(size);
|
||||
fetchUsers(page, size || pageSize);
|
||||
};
|
||||
|
||||
// 处理搜索
|
||||
const handleSearch = () => {
|
||||
// 这里应该向后端发送搜索请求,但目前API不支持搜索,所以仅前端过滤
|
||||
// 实际项目中应该添加后端搜索支持
|
||||
setCurrentPage(1);
|
||||
fetchUsers(1, pageSize);
|
||||
};
|
||||
|
||||
// 打开创建用户模态框
|
||||
const showCreateModal = () => {
|
||||
setModalTitle('创建新用户');
|
||||
setEditingUser(null);
|
||||
form.resetFields();
|
||||
setIsModalVisible(true);
|
||||
};
|
||||
|
||||
// 打开编辑用户模态框
|
||||
const showEditModal = (user: UserResponse) => {
|
||||
setModalTitle('编辑用户');
|
||||
setEditingUser(user);
|
||||
form.setFieldsValue({
|
||||
userName: user.userName,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
});
|
||||
setIsModalVisible(true);
|
||||
};
|
||||
|
||||
// 处理模态框确认
|
||||
const handleModalOk = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
|
||||
if (editingUser) {
|
||||
// 更新用户
|
||||
const updateData: AdminUpdateUserRequest = {
|
||||
id: editingUser.id,
|
||||
userName: values.userName,
|
||||
email: values.email,
|
||||
role: values.role,
|
||||
};
|
||||
|
||||
const response = await updateUser(updateData);
|
||||
if (response.success) {
|
||||
message.success('用户更新成功');
|
||||
fetchUsers();
|
||||
} else {
|
||||
message.error(response.message || '更新用户失败');
|
||||
}
|
||||
} else {
|
||||
// 创建用户
|
||||
const createData: CreateUserRequest = {
|
||||
userName: values.userName,
|
||||
email: values.email,
|
||||
password: values.password,
|
||||
role: values.role,
|
||||
};
|
||||
|
||||
const response = await createUser(createData);
|
||||
if (response.success) {
|
||||
message.success('用户创建成功');
|
||||
fetchUsers();
|
||||
} else {
|
||||
message.error(response.message || '创建用户失败');
|
||||
}
|
||||
}
|
||||
|
||||
setIsModalVisible(false);
|
||||
} catch (error) {
|
||||
console.error('Form validation failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理删除用户
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
const response = await deleteUser(id);
|
||||
if (response.success) {
|
||||
message.success('用户删除成功');
|
||||
fetchUsers();
|
||||
} else {
|
||||
message.error(response.message || '删除用户失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting user:', 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 batchDeleteUsers(selectedRowKeys as number[]);
|
||||
if (response.success && response.data) {
|
||||
message.success(`成功删除 ${response.data.successCount} 名用户`);
|
||||
if (response.data.failedCount > 0) {
|
||||
message.warning(`${response.data.failedCount} 名用户删除失败`);
|
||||
}
|
||||
setSelectedRowKeys([]);
|
||||
fetchUsers();
|
||||
} else {
|
||||
message.error(response.message || '批量删除用户失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error batch deleting users:', error);
|
||||
message.error('批量删除用户失败,请检查网络连接');
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 表格列定义
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
responsive: ['md' as Breakpoint],
|
||||
},
|
||||
{
|
||||
title: '用户名',
|
||||
dataIndex: 'userName',
|
||||
key: 'userName',
|
||||
render: (text: string) => (
|
||||
<Space>
|
||||
<UserOutlined />
|
||||
{text}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '邮箱',
|
||||
dataIndex: 'email',
|
||||
key: 'email',
|
||||
responsive: ['lg' as Breakpoint],
|
||||
},
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'role',
|
||||
key: 'role',
|
||||
render: (role: string) => {
|
||||
let color = 'blue';
|
||||
if (role === 'Administrator') {
|
||||
color = 'red';
|
||||
} else {
|
||||
color = 'green';
|
||||
}
|
||||
return <Tag color={color}>{role || '用户'}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '注册时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
responsive: ['lg' as Breakpoint],
|
||||
render: (date: Date) => new Date(date).toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_: any, record: UserResponse) => (
|
||||
<Space size="small">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => showEditModal(record)}
|
||||
>
|
||||
{isMobile ? '' : '编辑'}
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确定要删除此用户吗?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
>
|
||||
{isMobile ? '' : '删除'}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="user-management">
|
||||
<Row gutter={[16, 16]} align="middle" justify="space-between">
|
||||
<Col>
|
||||
<Space align="center">
|
||||
<TeamOutlined style={{ fontSize: 24 }} />
|
||||
<Title level={2} style={{ margin: 0 }}>用户管理</Title>
|
||||
</Space>
|
||||
<Text type="secondary" style={{ marginTop: 8, display: 'block' }}>
|
||||
管理系统中的所有用户账户,包括创建、编辑和删除用户
|
||||
</Text>
|
||||
</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={<UserAddOutlined />}
|
||||
onClick={showCreateModal}
|
||||
>
|
||||
创建用户
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
icon={<UserDeleteOutlined />}
|
||||
onClick={handleBatchDelete}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
>
|
||||
批量删除
|
||||
</Button>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => fetchUsers()}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col xs={24} sm={10} md={8}>
|
||||
<Input.Search
|
||||
placeholder="搜索用户名或邮箱"
|
||||
allowClear
|
||||
enterButton={<SearchOutlined />}
|
||||
onSearch={handleSearch}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
value={searchQuery}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={users}
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: currentPage,
|
||||
pageSize: pageSize,
|
||||
total: total,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
onChange: handlePageChange,
|
||||
showTotal: (total) => `共 ${total} 条记录`,
|
||||
}}
|
||||
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={editingUser ? "更新" : "创建"}
|
||||
cancelText="取消"
|
||||
width={600}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{ role: UserRole.User }}
|
||||
>
|
||||
<Form.Item
|
||||
name="userName"
|
||||
label="用户名"
|
||||
rules={[{ required: true, message: '请输入用户名' }]}
|
||||
>
|
||||
<Input prefix={<UserOutlined />} placeholder="用户名" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="email"
|
||||
label="邮箱"
|
||||
rules={[
|
||||
{ required: true, message: '请输入邮箱' },
|
||||
{ type: 'email', message: '请输入有效的邮箱地址' }
|
||||
]}
|
||||
>
|
||||
<Input placeholder="邮箱地址" />
|
||||
</Form.Item>
|
||||
|
||||
{!editingUser && (
|
||||
<Form.Item
|
||||
name="password"
|
||||
label="密码"
|
||||
rules={[
|
||||
{ required: true, message: '请输入密码' },
|
||||
{ min: 6, message: '密码长度至少为6个字符' }
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder="密码" />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
name="role"
|
||||
label="角色"
|
||||
rules={[{ required: true, message: '请选择角色' }]}
|
||||
>
|
||||
<Select placeholder="选择用户角色">
|
||||
<Option value={UserRole.Administrator}>管理员</Option>
|
||||
<Option value={UserRole.User}>普通用户</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserManagement;
|
||||
215
Web/src/pages/admin/users/UserDetail.tsx
Normal file
215
Web/src/pages/admin/users/UserDetail.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Card, Row, Col, Typography, Button, Space, Descriptions,
|
||||
Avatar, Spin, Tabs, Statistic, message, Tag, Divider,
|
||||
Result
|
||||
} from 'antd';
|
||||
import {
|
||||
UserOutlined, ArrowLeftOutlined, EditOutlined,
|
||||
PictureOutlined, FileImageOutlined, HeartOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
import { getUserById } from '../../../api';
|
||||
import type { UserResponse } from '../../../api/types';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { TabPane } = Tabs;
|
||||
|
||||
const UserDetail: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [user, setUser] = useState<UserResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// 加载用户数据
|
||||
useEffect(() => {
|
||||
const fetchUser = async () => {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await getUserById(parseInt(id));
|
||||
if (response.success && response.data) {
|
||||
setUser(response.data);
|
||||
} else {
|
||||
message.error(response.message || '获取用户信息失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching user:', error);
|
||||
message.error('获取用户信息失败,请检查网络连接');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUser();
|
||||
}, [id]);
|
||||
|
||||
// 返回上一页
|
||||
const handleBack = () => {
|
||||
navigate('/admin/users');
|
||||
};
|
||||
|
||||
// 跳转到编辑页面
|
||||
const handleEdit = () => {
|
||||
navigate(`/admin/users/edit/${id}`);
|
||||
};
|
||||
|
||||
// 模拟数据 - 实际项目中应该从API获取
|
||||
const userStats = {
|
||||
totalPhotos: 125,
|
||||
totalAlbums: 14,
|
||||
totalFavorites: 48,
|
||||
diskUsage: '1.2 GB',
|
||||
lastLogin: '2023-10-25 14:32',
|
||||
accountAge: '268 天',
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: 400 }}>
|
||||
<Spin size="large" tip="加载用户信息..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<Card>
|
||||
<Result
|
||||
status="404"
|
||||
title="用户不存在"
|
||||
subTitle="找不到请求的用户信息"
|
||||
extra={
|
||||
<Button type="primary" onClick={handleBack}>
|
||||
返回用户列表
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="user-detail">
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||
<Col>
|
||||
<Space>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={handleBack}>
|
||||
返回
|
||||
</Button>
|
||||
<Title level={2} style={{ margin: 0 }}>用户详情</Title>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} lg={8}>
|
||||
<Card>
|
||||
<div style={{ textAlign: 'center', marginBottom: 24 }}>
|
||||
<Avatar size={100} icon={<UserOutlined />} />
|
||||
<Title level={3} style={{ marginTop: 16, marginBottom: 0 }}>
|
||||
{user.userName}
|
||||
</Title>
|
||||
<Text type="secondary">{user.email}</Text>
|
||||
<div style={{ margin: '16px 0' }}>
|
||||
<Tag color={user.role === 'Administrator' ? 'red' : 'blue'}>
|
||||
{user.role || '访客'}
|
||||
</Tag>
|
||||
</div>
|
||||
<Button type="primary" icon={<EditOutlined />} onClick={handleEdit}>
|
||||
编辑用户
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Descriptions title="账户信息" column={1}>
|
||||
<Descriptions.Item label="用户ID">{user.id}</Descriptions.Item>
|
||||
<Descriptions.Item label="注册时间">
|
||||
{new Date(user.createdAt).toLocaleString()}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="最近登录">
|
||||
{user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString() : '未登录'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} lg={16}>
|
||||
<Card>
|
||||
<Tabs defaultActiveKey="1">
|
||||
<TabPane
|
||||
tab={<span><PictureOutlined />数据统计</span>}
|
||||
key="1"
|
||||
>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={12} sm={8}>
|
||||
<Statistic
|
||||
title="照片数量"
|
||||
value={userStats.totalPhotos}
|
||||
prefix={<FileImageOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={8}>
|
||||
<Statistic
|
||||
title="相册数量"
|
||||
value={userStats.totalAlbums}
|
||||
prefix={<PictureOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={8}>
|
||||
<Statistic
|
||||
title="收藏数量"
|
||||
value={userStats.totalFavorites}
|
||||
prefix={<HeartOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={8}>
|
||||
<Statistic
|
||||
title="存储使用"
|
||||
value={userStats.diskUsage}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={8}>
|
||||
<Statistic
|
||||
title="最近登录"
|
||||
value={userStats.lastLogin}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={8}>
|
||||
<Statistic
|
||||
title="账户年龄"
|
||||
value={userStats.accountAge}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</TabPane>
|
||||
|
||||
<TabPane
|
||||
tab={<span><FileImageOutlined />最近照片</span>}
|
||||
key="2"
|
||||
>
|
||||
<div style={{ padding: '20px 0', textAlign: 'center' }}>
|
||||
<Text type="secondary">此功能在开发中</Text>
|
||||
</div>
|
||||
</TabPane>
|
||||
|
||||
<TabPane
|
||||
tab={<span><PictureOutlined />最近相册</span>}
|
||||
key="3"
|
||||
>
|
||||
<div style={{ padding: '20px 0', textAlign: 'center' }}>
|
||||
<Text type="secondary">此功能在开发中</Text>
|
||||
</div>
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserDetail;
|
||||
@@ -1,132 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Form, Input, Button, Space, Row, Col, Tooltip } from 'antd';
|
||||
import { SaveOutlined, QuestionCircleOutlined, LockOutlined } from '@ant-design/icons';
|
||||
|
||||
interface ConfigGroupProps {
|
||||
groupName: string;
|
||||
configs: {
|
||||
[key: string]: string;
|
||||
};
|
||||
onSave: (group: string, key: string, value: string) => Promise<void>;
|
||||
descriptions: {
|
||||
[key: string]: string;
|
||||
};
|
||||
secretFields?: string[];
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
const ConfigGroup: React.FC<ConfigGroupProps> = ({
|
||||
groupName,
|
||||
configs,
|
||||
onSave,
|
||||
descriptions,
|
||||
secretFields = [],
|
||||
isMobile = false
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
|
||||
// 保存单个配置项
|
||||
const handleSaveSingle = async (key: string) => {
|
||||
try {
|
||||
const value = form.getFieldValue(key);
|
||||
await onSave(groupName, key, value);
|
||||
} catch (error) {
|
||||
console.error('保存配置失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 保存所有配置项
|
||||
const handleSaveAll = async () => {
|
||||
try {
|
||||
const values = form.getFieldsValue();
|
||||
for (const key in values) {
|
||||
await onSave(groupName, key, values[key]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存所有配置失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const isSecretField = (key: string): boolean => {
|
||||
return secretFields.includes(key);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={configs}
|
||||
size={isMobile ? "middle" : "large"}
|
||||
>
|
||||
{Object.keys(configs).map(key => {
|
||||
const isSecret = isSecretField(key);
|
||||
|
||||
return (
|
||||
<Row key={key} gutter={isMobile ? [8, 8] : [16, 16]} align="middle">
|
||||
<Col xs={24} lg={16}>
|
||||
<Form.Item
|
||||
name={key}
|
||||
label={
|
||||
<Space>
|
||||
{key}
|
||||
{isSecret && <LockOutlined style={{ color: '#faad14' }} />}
|
||||
{descriptions[key] && (
|
||||
<Tooltip title={descriptions[key]}>
|
||||
<QuestionCircleOutlined />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
extra={isSecret &&
|
||||
<div style={{ fontSize: '12px', color: '#faad14', marginTop: '4px' }}>
|
||||
此为私密字段,出于安全考虑不显示实际值。如需修改,请输入新值。
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{isSecret ? (
|
||||
<Input.Password
|
||||
placeholder={configs[key] === '' ? '请输入新值' : '******(已设置,输入新值以更新)'}
|
||||
/>
|
||||
) : (
|
||||
<Input placeholder={`请输入${key}`} />
|
||||
)}
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} lg={8} style={{
|
||||
textAlign: isMobile ? 'left' : 'right',
|
||||
marginTop: isMobile ? -10 : 0,
|
||||
marginBottom: isMobile ? 10 : 0
|
||||
}}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
onClick={() => handleSaveSingle(key)}
|
||||
style={{
|
||||
marginBottom: isMobile ? 16 : 24,
|
||||
width: isMobile ? '100%' : 'auto'
|
||||
}}
|
||||
size={isMobile ? "middle" : "large"}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleSaveAll}
|
||||
style={{ marginTop: isMobile ? 8 : 16 }}
|
||||
block
|
||||
size={isMobile ? "middle" : "large"}
|
||||
>
|
||||
保存所有
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfigGroup;
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Tabs, Layout, Menu, Space } from 'antd';
|
||||
import { useAuth } from '../../api/AuthContext';
|
||||
import { useAuth } from '../../auth/AuthContext.tsx';
|
||||
import { UserRole } from '../../api/types';
|
||||
import { useState, type SetStateAction } from 'react';
|
||||
import SystemConfig from './SystemConfig.tsx';
|
||||
import UserProfile from './UserProfile.tsx';
|
||||
import useIsMobile from '../../hooks/useIsMobile';
|
||||
import {
|
||||
@@ -29,13 +28,6 @@ function Settings() {
|
||||
<UserProfile />
|
||||
</div>
|
||||
);
|
||||
case 'system':
|
||||
return (
|
||||
<div className="settings-content">
|
||||
<SystemConfig />
|
||||
|
||||
</div>
|
||||
);
|
||||
case 'appearance':
|
||||
return (
|
||||
<div className="settings-content">
|
||||
@@ -74,11 +66,6 @@ function Settings() {
|
||||
icon: <UserOutlined />,
|
||||
label: '个人资料',
|
||||
},
|
||||
hasRole(UserRole.Administrator) ? {
|
||||
key: 'system',
|
||||
icon: <SettingOutlined />,
|
||||
label: '系统配置',
|
||||
} : null,
|
||||
{
|
||||
key: 'appearance',
|
||||
icon: <BgColorsOutlined />,
|
||||
@@ -108,8 +95,7 @@ function Settings() {
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// 手机版使用Tabs作为顶部导航
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div style={{ padding: 0 }}>
|
||||
@@ -118,8 +104,8 @@ function Settings() {
|
||||
onChange={(key) => handleMenuChange(key)}
|
||||
centered
|
||||
size="large"
|
||||
tabBarStyle={{
|
||||
marginBottom: 16,
|
||||
tabBarStyle={{
|
||||
marginBottom: 16,
|
||||
fontWeight: 500,
|
||||
backgroundColor: '#f5f5f5',
|
||||
padding: '8px 0',
|
||||
@@ -127,14 +113,14 @@ function Settings() {
|
||||
}}
|
||||
>
|
||||
{menuItems.map((item) => (
|
||||
<TabPane
|
||||
<TabPane
|
||||
tab={
|
||||
<Space size={4}>
|
||||
{item?.icon}
|
||||
<span>{item?.label}</span>
|
||||
</Space>
|
||||
}
|
||||
key={item?.key || ''}
|
||||
}
|
||||
key={item?.key || ''}
|
||||
>
|
||||
<div style={{ padding: '0 4px' }}>
|
||||
{renderContent()}
|
||||
|
||||
@@ -1,802 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Tabs, Card, message, Spin, Select, Button, Upload, Modal, Space, Tooltip, Input } from 'antd';
|
||||
import { CloudOutlined, DatabaseOutlined, CloudServerOutlined, GlobalOutlined, DownloadOutlined, UploadOutlined, QuestionCircleOutlined } from '@ant-design/icons';
|
||||
import { getAllConfigs, setConfig, backupConfigs, restoreConfigs } from '../../api';
|
||||
import ConfigGroup from './ConfigGroup.tsx';
|
||||
import useIsMobile from '../../hooks/useIsMobile';
|
||||
|
||||
const { TabPane } = Tabs;
|
||||
const { Option } = Select;
|
||||
|
||||
interface ConfigStructure {
|
||||
[key: string]: {
|
||||
[key: string]: string;
|
||||
};
|
||||
}
|
||||
|
||||
const SystemConfig: React.FC = () => {
|
||||
const isMobile = useIsMobile();
|
||||
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);
|
||||
const [restoreConfig, setRestoreConfig] = useState<Record<string, string> | null>(null);
|
||||
const [secretFields, setSecretFields] = useState<Record<string, string[]>>({}); // 新增状态管理私密字段
|
||||
|
||||
// 获取所有配置项
|
||||
const fetchConfigs = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getAllConfigs();
|
||||
if (response.success && response.data) {
|
||||
const configGroups: ConfigStructure = {};
|
||||
const secretFieldsMap: Record<string, string[]> = {}; // 记录每个组的私密字段
|
||||
|
||||
response.data.forEach(config => {
|
||||
const [group, key] = config.key.split(':');
|
||||
if (!configGroups[group]) {
|
||||
configGroups[group] = {};
|
||||
secretFieldsMap[group] = [];
|
||||
}
|
||||
configGroups[group][key] = config.value;
|
||||
|
||||
// 记录私密字段
|
||||
if (config.isSecret) {
|
||||
if (!secretFieldsMap[group]) {
|
||||
secretFieldsMap[group] = [];
|
||||
}
|
||||
secretFieldsMap[group].push(key);
|
||||
}
|
||||
});
|
||||
|
||||
setConfigs(configGroups);
|
||||
setSecretFields(secretFieldsMap);
|
||||
|
||||
// 设置初始存储类型
|
||||
if (configGroups.Storage?.DefaultStorage) {
|
||||
setStorageType(configGroups.Storage.DefaultStorage);
|
||||
}
|
||||
} else {
|
||||
message.error('获取配置失败: ' + response.message);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('获取配置出错');
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 保存配置项
|
||||
const handleSaveConfig = async (group: string, key: string, value: string) => {
|
||||
try {
|
||||
const configKey = `${group}:${key}`;
|
||||
const response = await setConfig({
|
||||
key: configKey,
|
||||
value: value,
|
||||
description: `${group} ${key} setting`
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
message.success(`保存 ${key} 配置成功`);
|
||||
// 更新本地状态
|
||||
setConfigs(prev => ({
|
||||
...prev,
|
||||
[group]: {
|
||||
...prev[group],
|
||||
[key]: value
|
||||
}
|
||||
}));
|
||||
} else {
|
||||
message.error(`保存失败: ${response.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('保存配置出错');
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
// 备份配置
|
||||
const handleBackupConfigs = async () => {
|
||||
setBackupLoading(true);
|
||||
try {
|
||||
const response = await backupConfigs();
|
||||
if (response.success && response.data) {
|
||||
const configData = JSON.stringify(response.data, null, 2);
|
||||
const blob = new Blob([configData], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
a.download = `foxel-config-backup-${timestamp}.json`;
|
||||
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
message.success('配置备份已下载');
|
||||
} else {
|
||||
message.error('备份配置失败: ' + response.message);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('备份配置出错');
|
||||
console.error(error);
|
||||
} finally {
|
||||
setBackupLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 上传配置文件
|
||||
const handleFileUpload = (file: File) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const content = e.target?.result as string;
|
||||
const config = JSON.parse(content);
|
||||
setRestoreConfig(config);
|
||||
setRestoreModalVisible(true);
|
||||
} catch (error) {
|
||||
message.error('无效的配置文件格式');
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
return false; // 阻止自动上传
|
||||
};
|
||||
|
||||
// 确认恢复配置
|
||||
const handleRestoreConfigs = async () => {
|
||||
if (!restoreConfig) return;
|
||||
|
||||
setRestoreLoading(true);
|
||||
try {
|
||||
const response = await restoreConfigs(restoreConfig);
|
||||
if (response.success) {
|
||||
message.success('配置恢复成功,将在3秒后刷新页面');
|
||||
setRestoreModalVisible(false);
|
||||
|
||||
// 重新加载配置
|
||||
setTimeout(() => {
|
||||
fetchConfigs();
|
||||
// 可选:刷新页面以确保所有配置生效
|
||||
// window.location.reload();
|
||||
}, 3000);
|
||||
} else {
|
||||
message.error('恢复配置失败: ' + response.message);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('恢复配置出错');
|
||||
console.error(error);
|
||||
} finally {
|
||||
setRestoreLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 存储类型选项
|
||||
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' }} /> },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfigs();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>系统配置</span>
|
||||
<Space>
|
||||
<Tooltip title="下载当前所有配置的备份">
|
||||
<Button
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={handleBackupConfigs}
|
||||
loading={backupLoading}
|
||||
size={isMobile ? "small" : "middle"}
|
||||
>
|
||||
{isMobile ? '' : '备份配置'}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Upload
|
||||
beforeUpload={handleFileUpload}
|
||||
showUploadList={false}
|
||||
accept=".json"
|
||||
>
|
||||
<Tooltip title="从备份文件恢复配置">
|
||||
<Button
|
||||
icon={<UploadOutlined />}
|
||||
size={isMobile ? "small" : "middle"}
|
||||
>
|
||||
{isMobile ? '' : '恢复配置'}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Upload>
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
className="system-config-card"
|
||||
bodyStyle={{
|
||||
padding: isMobile ? '12px 8px' : '24px'
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '20px' }}>
|
||||
<Spin tip="加载配置中..." />
|
||||
</div>
|
||||
) : (
|
||||
<Tabs
|
||||
activeKey={activeKey}
|
||||
onChange={setActiveKey}
|
||||
size={isMobile ? "small" : "middle"}
|
||||
tabPosition={isMobile ? "top" : "left"}
|
||||
style={{
|
||||
minHeight: isMobile ? 'auto' : 400
|
||||
}}
|
||||
>
|
||||
<TabPane tab="AI 设置" key="AI">
|
||||
<Tabs defaultActiveKey="basic" type="card" size={isMobile ? "small" : "middle"}>
|
||||
<TabPane tab="基础配置" key="basic">
|
||||
<ConfigGroup
|
||||
groupName="AI"
|
||||
configs={{
|
||||
ApiEndpoint: configs.AI?.ApiEndpoint || '',
|
||||
ApiKey: configs.AI?.ApiKey || '',
|
||||
Model: configs.AI?.Model || '',
|
||||
EmbeddingModel: configs.AI?.EmbeddingModel || ''
|
||||
}}
|
||||
onSave={handleSaveConfig}
|
||||
descriptions={{
|
||||
ApiEndpoint: 'AI 服务的API端点地址',
|
||||
ApiKey: 'AI 服务的API密钥',
|
||||
Model: 'AI 模型名称',
|
||||
EmbeddingModel: '嵌入向量模型名称'
|
||||
}}
|
||||
secretFields={secretFields.AI || []}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
</TabPane>
|
||||
<TabPane tab="提示词设置" key="prompts">
|
||||
<Card
|
||||
size="small"
|
||||
title="图片分析提示词"
|
||||
style={{ marginBottom: isMobile ? 16 : 24 }}
|
||||
bodyStyle={{ padding: isMobile ? '12px' : '16px' }}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={8}
|
||||
value={configs.AI?.ImageAnalysisPrompt ||
|
||||
"请详细分析这张图片,并提供全面的描述,以便用于向量嵌入和基于文本的图像搜索。描述需要包含:主体对象、场景环境、色彩特点、构图布局、风格特征、情绪氛围、细节特征等关键元素。请提供一个简短有力的标题,然后提供详细描述。\n\n请以JSON格式返回,格式如下:\n{\"title\": \"简短概括图片的核心内容\", \"description\": \"全面详细的描述,包含上述所有元素,使用丰富精确的词汇,避免笼统表达\"}\n\n请确保返回有效的JSON格式。"}
|
||||
onChange={(e) => {
|
||||
const newConfigs = { ...configs };
|
||||
if (!newConfigs.AI) newConfigs.AI = {};
|
||||
newConfigs.AI.ImageAnalysisPrompt = e.target.value;
|
||||
setConfigs(newConfigs);
|
||||
}}
|
||||
/>
|
||||
<div style={{ marginTop: 8, textAlign: 'right' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => handleSaveConfig('AI', 'ImageAnalysisPrompt', configs.AI?.ImageAnalysisPrompt || '')}
|
||||
>
|
||||
保存图片分析提示词
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 12,
|
||||
color: '#999',
|
||||
marginTop: 8
|
||||
}}>
|
||||
用于分析图片内容并提取描述的提示词。请确保提示词包含返回JSON格式的指示,并且要求返回标题(title)和描述(description)字段。
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
size="small"
|
||||
title="标签生成提示词"
|
||||
style={{ marginBottom: isMobile ? 16 : 24 }}
|
||||
bodyStyle={{ padding: isMobile ? '12px' : '16px' }}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={8}
|
||||
value={configs.AI?.TagGenerationPrompt ||
|
||||
"请为图片生成5个最相关的标签,每个标签应该是简短且描述性的词语或短语。\n\n请以JSON格式返回,格式如下:\n{\"tags\": [\"标签1\", \"标签2\", \"标签3\", \"标签4\", \"标签5\"]}\n\n请确保返回有效的JSON格式。"}
|
||||
onChange={(e) => {
|
||||
const newConfigs = { ...configs };
|
||||
if (!newConfigs.AI) newConfigs.AI = {};
|
||||
newConfigs.AI.TagGenerationPrompt = e.target.value;
|
||||
setConfigs(newConfigs);
|
||||
}}
|
||||
/>
|
||||
<div style={{ marginTop: 8, textAlign: 'right' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => handleSaveConfig('AI', 'TagGenerationPrompt', configs.AI?.TagGenerationPrompt || '')}
|
||||
>
|
||||
保存标签生成提示词
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 12,
|
||||
color: '#999',
|
||||
marginTop: 8
|
||||
}}>
|
||||
用于从图片内容生成标签的提示词。请确保提示词包含返回JSON格式的指示,并且要求返回tags数组字段。
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
size="small"
|
||||
title="标签匹配提示词"
|
||||
style={{ marginBottom: isMobile ? 16 : 24 }}
|
||||
bodyStyle={{ padding: isMobile ? '12px' : '16px' }}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={8}
|
||||
value={configs.AI?.TagMatchingPrompt ||
|
||||
"以下是一组标签:[{tagsText}]。\n\n请从这些标签中严格选择与下面描述内容高度相关的标签(最多选择5个)。只选择确实匹配的标签,如果找不到完全匹配或高度相关的标签,宁可返回空数组也不要选择不太相关的标签。\n\n描述内容:{description}\n\n请以JSON格式返回,格式如下:\n{\"tags\": [\"标签1\", \"标签2\", \"标签3\"]}\n\n请确保返回有效的JSON格式前面不要加```,并且只包含确实匹配的标签名称。"}
|
||||
onChange={(e) => {
|
||||
const newConfigs = { ...configs };
|
||||
if (!newConfigs.AI) newConfigs.AI = {};
|
||||
newConfigs.AI.TagMatchingPrompt = e.target.value;
|
||||
setConfigs(newConfigs);
|
||||
}}
|
||||
/>
|
||||
<div style={{ marginTop: 8, textAlign: 'right' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => handleSaveConfig('AI', 'TagMatchingPrompt', configs.AI?.TagMatchingPrompt || '')}
|
||||
>
|
||||
保存标签匹配提示词
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 12,
|
||||
color: '#999',
|
||||
marginTop: 8
|
||||
}}>
|
||||
用于将描述内容与已有标签进行匹配的提示词。请确保提示词包含{'{'+'tagsText'+'}'}和{'{'+'description'+'}'}占位符,系统将会用实际的标签列表和描述内容替换这些占位符。
|
||||
</div>
|
||||
</Card>
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
</TabPane>
|
||||
|
||||
<TabPane tab="授权配置" key="Authorization">
|
||||
<Tabs defaultActiveKey="jwt" type="card" size={isMobile ? "small" : "middle"}>
|
||||
<TabPane tab="JWT 设置" key="jwt">
|
||||
<ConfigGroup
|
||||
groupName="Jwt"
|
||||
configs={{
|
||||
SecretKey: configs.Jwt?.SecretKey || '',
|
||||
Issuer: configs.Jwt?.Issuer || '',
|
||||
Audience: configs.Jwt?.Audience || '',
|
||||
}}
|
||||
onSave={handleSaveConfig}
|
||||
descriptions={{
|
||||
SecretKey: 'JWT 加密密钥',
|
||||
Issuer: 'JWT 签发者',
|
||||
Audience: 'JWT 接收者',
|
||||
}}
|
||||
secretFields={secretFields.Jwt || []}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
</TabPane>
|
||||
<TabPane tab="GitHub认证" key="github">
|
||||
<ConfigGroup
|
||||
groupName="Authentication"
|
||||
configs={{
|
||||
"GitHubClientId": configs.Authentication?.["GitHubClientId"] || '',
|
||||
"GitHubClientSecret": configs.Authentication?.["GitHubClientSecret"] || '',
|
||||
"GitHubCallbackUrl": configs.Authentication?.["GitHubCallbackUrl"] || ''
|
||||
}}
|
||||
onSave={(_group, key, value) => handleSaveConfig('Authentication', key, value)}
|
||||
descriptions={{
|
||||
"GitHubClientId": 'GitHub OAuth 应用客户端ID',
|
||||
"GitHubClientSecret": 'GitHub OAuth 应用客户端密钥',
|
||||
"GitHubCallbackUrl": 'GitHub OAuth 认证回调地址'
|
||||
}}
|
||||
secretFields={secretFields.Authentication || []}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
</TabPane>
|
||||
|
||||
<TabPane tab="应用设置" key="AppSettings">
|
||||
<ConfigGroup
|
||||
groupName="AppSettings"
|
||||
configs={{
|
||||
ServerUrl: configs.AppSettings?.ServerUrl || ''
|
||||
}}
|
||||
onSave={handleSaveConfig}
|
||||
descriptions={{
|
||||
ServerUrl: '服务器URL'
|
||||
}}
|
||||
/>
|
||||
</TabPane>
|
||||
|
||||
<TabPane tab="存储设置" key="Storage">
|
||||
{/* 存储类型配置卡片 */}
|
||||
<Card
|
||||
size="small"
|
||||
title="存储类型配置"
|
||||
style={{ marginBottom: isMobile ? 16 : 24 }}
|
||||
bodyStyle={{ padding: isMobile ? '12px' : '16px' }}
|
||||
>
|
||||
<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) => {
|
||||
handleSaveConfig('Storage', 'DefaultStorage', value);
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
size="large"
|
||||
placeholder="选择登录用户的默认存储方式"
|
||||
>
|
||||
{storageOptions.map(option => (
|
||||
<Option key={option.value} value={option.value}>
|
||||
<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
|
||||
}}>
|
||||
已登录用户上传文件时的默认存储位置
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 匿名用户默认存储 */}
|
||||
<div>
|
||||
<div style={{
|
||||
marginBottom: 8,
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
color: '#666'
|
||||
}}>
|
||||
匿名用户默认存储
|
||||
</div>
|
||||
<Select
|
||||
value={configs.Storage?.AnonymousDefaultStorage || 'Local'}
|
||||
onChange={(value) => {
|
||||
handleSaveConfig('Storage', 'AnonymousDefaultStorage', value);
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
size="large"
|
||||
placeholder="选择匿名用户的默认存储方式"
|
||||
>
|
||||
{storageOptions.map(option => (
|
||||
<Option key={option.value} value={option.value}>
|
||||
<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
|
||||
}}>
|
||||
未登录用户上传文件时的默认存储位置
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 上传设置卡片 - 新增 */}
|
||||
<Card
|
||||
size="small"
|
||||
title="上传设置配置"
|
||||
style={{ marginBottom: isMobile ? 16 : 24 }}
|
||||
bodyStyle={{ padding: isMobile ? '12px' : '16px' }}
|
||||
>
|
||||
<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.Upload?.DefaultImageFormat || 'Original'}
|
||||
onChange={(value) => {
|
||||
handleSaveConfig('Upload', 'DefaultImageFormat', value);
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
size="large"
|
||||
placeholder="选择上传图片的默认处理格式"
|
||||
>
|
||||
<Option value="Original">保持原始格式</Option>
|
||||
<Option value="Jpeg">转换为JPEG</Option>
|
||||
<Option value="Png">转换为PNG</Option>
|
||||
<Option value="Webp">转换为WebP</Option>
|
||||
</Select>
|
||||
<div style={{
|
||||
fontSize: 12,
|
||||
color: '#999',
|
||||
marginTop: 4
|
||||
}}>
|
||||
上传图片时的默认处理格式,选择合适的格式可以优化存储和显示
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 图片压缩质量 */}
|
||||
<div>
|
||||
<div style={{
|
||||
marginBottom: 8,
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
color: '#666'
|
||||
}}>
|
||||
默认压缩质量
|
||||
</div>
|
||||
<Select
|
||||
value={configs.Upload?.DefaultImageQuality || '95'}
|
||||
onChange={(value) => {
|
||||
handleSaveConfig('Upload', 'DefaultImageQuality', value);
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
size="large"
|
||||
placeholder="选择图片压缩质量"
|
||||
>
|
||||
<Option value="100">100% - 最高质量</Option>
|
||||
<Option value="95">95% - 高质量</Option>
|
||||
<Option value="90">90% - 优质</Option>
|
||||
<Option value="85">85% - 良好</Option>
|
||||
<Option value="80">80% - 节省空间</Option>
|
||||
<Option value="75">75% - 平衡</Option>
|
||||
<Option value="70">70% - 压缩</Option>
|
||||
</Select>
|
||||
<div style={{
|
||||
fontSize: 12,
|
||||
color: '#999',
|
||||
marginTop: 4
|
||||
}}>
|
||||
适用于JPEG和WebP格式的图片质量设置,越高图片质量越好但文件越大
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 存储服务配置卡片 */}
|
||||
<Card
|
||||
size="small"
|
||||
title="存储服务配置"
|
||||
style={{ marginBottom: isMobile ? 16 : 24 }}
|
||||
bodyStyle={{ padding: isMobile ? '12px' : '16px' }}
|
||||
>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{
|
||||
marginBottom: 8,
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
color: '#666'
|
||||
}}>
|
||||
选择要配置的存储服务
|
||||
</div>
|
||||
<Select
|
||||
value={storageType}
|
||||
onChange={(value) => {
|
||||
setStorageType(value);
|
||||
}}
|
||||
style={{ width: isMobile ? '100%' : '300px' }}
|
||||
size="large"
|
||||
placeholder="选择需要配置的存储服务类型"
|
||||
>
|
||||
{storageOptions.map(option => (
|
||||
<Option key={option.value} value={option.value}>
|
||||
<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
|
||||
}}>
|
||||
选择后将显示对应存储服务的详细配置选项
|
||||
</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: '20px 0' }}>
|
||||
本地存储无需额外配置
|
||||
</div>
|
||||
)}
|
||||
|
||||
{storageType === 'Telegram' && (
|
||||
<ConfigGroup
|
||||
groupName="Storage"
|
||||
configs={{
|
||||
"TelegramStorageBotToken": configs.Storage?.TelegramStorageBotToken || '',
|
||||
"TelegramStorageChatId": configs.Storage?.TelegramStorageChatId || '',
|
||||
"TelegramProxyAddress": configs.Storage?.TelegramProxyAddress || '',
|
||||
"TelegramProxyPort": configs.Storage?.TelegramProxyPort || '',
|
||||
"TelegramProxyUsername": configs.Storage?.TelegramProxyUsername || '',
|
||||
"TelegramProxyPassword": configs.Storage?.TelegramProxyPassword || ''
|
||||
}}
|
||||
onSave={handleSaveConfig}
|
||||
descriptions={{
|
||||
"TelegramStorageBotToken": 'Telegram 机器人令牌',
|
||||
"TelegramStorageChatId": 'Telegram 聊天ID',
|
||||
"TelegramProxyAddress": '代理服务器地址 (例如: 127.0.0.1)',
|
||||
"TelegramProxyPort": '代理服务器端口 (例如: 1080)',
|
||||
"TelegramProxyUsername": '代理用户名 (可选)',
|
||||
"TelegramProxyPassword": '代理密码 (可选)'
|
||||
}}
|
||||
secretFields={secretFields.Storage || []}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
)}
|
||||
|
||||
{storageType === 'S3' && (
|
||||
<ConfigGroup
|
||||
groupName="Storage"
|
||||
configs={{
|
||||
"S3StorageAccessKey": configs.Storage?.S3StorageAccessKey || '',
|
||||
"S3StorageSecretKey": configs.Storage?.S3StorageSecretKey || '',
|
||||
"S3StorageBucketName": configs.Storage?.S3StorageBucketName || '',
|
||||
"S3StorageRegion": configs.Storage?.S3StorageRegion || '',
|
||||
"S3StorageEndpoint": configs.Storage?.S3StorageEndpoint || '',
|
||||
"S3StorageCdnUrl": configs.Storage?.S3StorageCdnUrl || '',
|
||||
"S3StorageUsePathStyleUrls": configs.Storage?.S3StorageUsePathStyleUrls || 'false'
|
||||
}}
|
||||
onSave={handleSaveConfig}
|
||||
descriptions={{
|
||||
"S3StorageAccessKey": 'S3访问密钥',
|
||||
"S3StorageSecretKey": 'S3私有密钥',
|
||||
"S3StorageBucketName": 'S3存储桶名称',
|
||||
"S3StorageRegion": 'S3区域 (例如:us-east-1)',
|
||||
"S3StorageEndpoint": 'S3端点URL (可选,默认为AWS S3)',
|
||||
"S3StorageCdnUrl": 'CDN URL (可选,用于加速文件访问)',
|
||||
"S3StorageUsePathStyleUrls": '使用路径形式URLs (true/false,兼容非AWS服务)'
|
||||
}}
|
||||
secretFields={secretFields.Storage || []}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
)}
|
||||
|
||||
{storageType === 'Cos' && (
|
||||
<ConfigGroup
|
||||
groupName="Storage"
|
||||
configs={{
|
||||
"CosStorageSecretId": configs.Storage?.CosStorageSecretId || '',
|
||||
"CosStorageSecretKey": configs.Storage?.CosStorageSecretKey || '',
|
||||
"CosStorageToken": configs.Storage?.CosStorageToken || '',
|
||||
"CosStorageBucketName": configs.Storage?.CosStorageBucketName || '',
|
||||
"CosStorageRegion": configs.Storage?.CosStorageRegion || '',
|
||||
"CosStorageCdnUrl": configs.Storage?.CosStorageCdnUrl || '',
|
||||
}}
|
||||
onSave={handleSaveConfig}
|
||||
descriptions={{
|
||||
"CosStorageSecretId": '腾讯云COS密钥ID',
|
||||
"CosStorageSecretKey": '腾讯云COS私有密钥',
|
||||
"CosStorageToken": '腾讯云COS临时令牌(可选)',
|
||||
"CosStorageBucketName": 'COS存储桶名称',
|
||||
"CosStorageRegion": 'COS区域 (例如:ap-shanghai)',
|
||||
"CosStorageCdnUrl": 'CDN URL (可选,用于加速文件访问)',
|
||||
}}
|
||||
secretFields={secretFields.Storage || []}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
)}
|
||||
|
||||
{storageType === 'WebDAV' && (
|
||||
<ConfigGroup
|
||||
groupName="Storage"
|
||||
configs={{
|
||||
"WebDAVServerUrl": configs.Storage?.WebDAVServerUrl || '',
|
||||
"WebDAVUserName": configs.Storage?.WebDAVUserName || '',
|
||||
"WebDAVPassword": configs.Storage?.WebDAVPassword || '',
|
||||
"WebDAVBasePath": configs.Storage?.WebDAVBasePath || '',
|
||||
"WebDAVPublicUrl": configs.Storage?.WebDAVPublicUrl || '',
|
||||
}}
|
||||
onSave={handleSaveConfig}
|
||||
descriptions={{
|
||||
"WebDAVServerUrl": 'WebDAV 服务器 URL (例如: https://dav.example.com)',
|
||||
"WebDAVUserName": 'WebDAV 用户名',
|
||||
"WebDAVPassword": 'WebDAV 密码',
|
||||
"WebDAVBasePath": 'WebDAV 基础路径 (例如: files/upload)',
|
||||
"WebDAVPublicUrl": 'WebDAV 公共访问 URL (可选,用于文件访问)',
|
||||
}}
|
||||
secretFields={secretFields.Storage || []}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
{/* 恢复配置确认对话框 */}
|
||||
<Modal
|
||||
title={
|
||||
<div>
|
||||
<span>确认恢复配置</span>
|
||||
<Tooltip title="恢复配置将覆盖当前所有配置设置,请确认备份文件正确无误">
|
||||
<QuestionCircleOutlined style={{ marginLeft: 8 }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
}
|
||||
open={restoreModalVisible}
|
||||
onCancel={() => setRestoreModalVisible(false)}
|
||||
footer={[
|
||||
<>
|
||||
<Button key="cancel" onClick={() => setRestoreModalVisible(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
key="submit"
|
||||
type="primary"
|
||||
loading={restoreLoading}
|
||||
onClick={handleRestoreConfigs}
|
||||
>
|
||||
确认恢复
|
||||
</Button>
|
||||
</>
|
||||
]}
|
||||
>
|
||||
<p>您确定要从上传的备份文件恢复配置吗?</p>
|
||||
<p style={{ color: '#ff4d4f' }}>警告:此操作将覆盖当前系统中的所有配置设置!</p>
|
||||
{restoreConfig && (
|
||||
<div>
|
||||
<p>备份文件包含 {Object.keys(restoreConfig).length} 条配置项</p>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default SystemConfig;
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card, Form, Input, Button, message } from 'antd';
|
||||
import { useAuth } from '../../api/AuthContext';
|
||||
import { useAuth } from '../../auth/AuthContext';
|
||||
import UserAvatar from '../../components/UserAvatar';
|
||||
import useIsMobile from '../../hooks/useIsMobile';
|
||||
import { updateUserInfo } from '../../api';
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
PictureOutlined,
|
||||
FolderOutlined,
|
||||
HeartOutlined,
|
||||
CloudUploadOutlined,
|
||||
SettingOutlined,
|
||||
CompassOutlined
|
||||
CompassOutlined,
|
||||
DashboardOutlined,
|
||||
UserOutlined
|
||||
} from '@ant-design/icons';
|
||||
import React from 'react';
|
||||
|
||||
import AllImages from '../pages/allImages/Index';
|
||||
import Albums from '../pages/albums/Index';
|
||||
@@ -15,34 +17,37 @@ import Favorites from '../pages/favorites/Index';
|
||||
import Settings from '../pages/settings/Index';
|
||||
import BackgroundTasks from '../pages/backgroundTasks/Index';
|
||||
import PixHub from '../pages/pixHub/Index';
|
||||
import AdminDashboard from '../pages/admin/dashboard/Index';
|
||||
import System from '../pages/admin/system/Index';
|
||||
import UserManagement from '../pages/admin/users/Index';
|
||||
import PictureManagement from '../pages/admin/pictures/Index';
|
||||
|
||||
// 路由配置类型定义
|
||||
export interface RouteConfig {
|
||||
path: string;
|
||||
element: React.ReactNode;
|
||||
// 以下属性用于菜单配置
|
||||
key: string;
|
||||
icon?: React.ReactNode;
|
||||
label: string;
|
||||
area: 'main' | 'admin';
|
||||
hideInMenu?: boolean;
|
||||
children?: RouteConfig[];
|
||||
groupLabel?: string; // 分组标题
|
||||
divider?: boolean; // 是否显示分隔线
|
||||
// 面包屑相关配置
|
||||
groupLabel?: string;
|
||||
divider?: boolean;
|
||||
breadcrumb?: {
|
||||
title: string;
|
||||
parent?: string; // 父级路由的key
|
||||
parent?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// 统一的路由和菜单配置
|
||||
// 统一的路由配置
|
||||
const routes: RouteConfig[] = [
|
||||
// 主应用路由
|
||||
{
|
||||
path: '/',
|
||||
key: 'all-images',
|
||||
icon: <PictureOutlined />,
|
||||
label: '所有图片',
|
||||
element: <AllImages />,
|
||||
area: 'main',
|
||||
breadcrumb: {
|
||||
title: '所有图片'
|
||||
}
|
||||
@@ -53,6 +58,7 @@ const routes: RouteConfig[] = [
|
||||
icon: <FolderOutlined />,
|
||||
label: '相册',
|
||||
element: <Albums />,
|
||||
area: 'main',
|
||||
breadcrumb: {
|
||||
title: '相册'
|
||||
}
|
||||
@@ -62,6 +68,7 @@ const routes: RouteConfig[] = [
|
||||
key: 'album-detail',
|
||||
label: '相册详情',
|
||||
element: <AlbumDetail />,
|
||||
area: 'main',
|
||||
hideInMenu: true,
|
||||
breadcrumb: {
|
||||
title: '相册详情',
|
||||
@@ -74,6 +81,7 @@ const routes: RouteConfig[] = [
|
||||
icon: <HeartOutlined />,
|
||||
label: '收藏',
|
||||
element: <Favorites />,
|
||||
area: 'main',
|
||||
breadcrumb: {
|
||||
title: '收藏'
|
||||
}
|
||||
@@ -84,6 +92,7 @@ const routes: RouteConfig[] = [
|
||||
icon: <CompassOutlined />,
|
||||
label: '图片广场',
|
||||
element: <PixHub />,
|
||||
area: 'main',
|
||||
groupLabel: '社区发现',
|
||||
breadcrumb: {
|
||||
title: '图片广场'
|
||||
@@ -95,6 +104,7 @@ const routes: RouteConfig[] = [
|
||||
icon: <CloudUploadOutlined />,
|
||||
label: '任务中心',
|
||||
element: <BackgroundTasks />,
|
||||
area: 'main',
|
||||
groupLabel: '系统功能',
|
||||
breadcrumb: {
|
||||
title: '任务中心'
|
||||
@@ -106,10 +116,73 @@ const routes: RouteConfig[] = [
|
||||
icon: <SettingOutlined />,
|
||||
label: '设置',
|
||||
element: <Settings />,
|
||||
area: 'main',
|
||||
breadcrumb: {
|
||||
title: '设置'
|
||||
}
|
||||
},
|
||||
|
||||
// 管理后台路由
|
||||
{
|
||||
path: '',
|
||||
key: 'admin-dashboard',
|
||||
icon: <DashboardOutlined />,
|
||||
label: '控制面板',
|
||||
element: <AdminDashboard />,
|
||||
area: 'admin',
|
||||
breadcrumb: {
|
||||
title: '控制面板'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
key: 'admin-user',
|
||||
icon: <UserOutlined />,
|
||||
label: '用户管理',
|
||||
element: <UserManagement />,
|
||||
area: 'admin',
|
||||
breadcrumb: {
|
||||
title: '用户管理'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'pictures',
|
||||
key: 'admin-picture',
|
||||
icon: <PictureOutlined />,
|
||||
label: '图片管理',
|
||||
element: <PictureManagement />,
|
||||
area: 'admin',
|
||||
breadcrumb: {
|
||||
title: '图片管理'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'system',
|
||||
key: 'admin-system',
|
||||
icon: <SettingOutlined />,
|
||||
label: '系统设置',
|
||||
element: <System />,
|
||||
area: 'admin',
|
||||
breadcrumb: {
|
||||
title: '系统设置'
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
let mainRoutesCache: RouteConfig[] | null = null;
|
||||
export const getMainRoutes = () => {
|
||||
if (!mainRoutesCache) {
|
||||
mainRoutesCache = routes.filter(route => route.area === 'main');
|
||||
}
|
||||
return mainRoutesCache;
|
||||
};
|
||||
|
||||
let adminRoutesCache: RouteConfig[] | null = null;
|
||||
export const getAdminRoutes = () => {
|
||||
if (!adminRoutesCache) {
|
||||
adminRoutesCache = routes.filter(route => route.area === 'admin');
|
||||
}
|
||||
return adminRoutesCache;
|
||||
};
|
||||
|
||||
export default routes;
|
||||
Reference in New Issue
Block a user