feat: Support multiple vector database selection, add InMemory and Qdrant adapters, introduce admin dashboard

This commit is contained in:
shiyu
2025-05-31 21:00:48 +08:00
parent b2bacc54a9
commit 44d2616fd4
51 changed files with 5498 additions and 1214 deletions

View File

@@ -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>
);
}

View File

@@ -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';

View 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>;
};

View File

@@ -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;
}

View 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)
}
);
};

View 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
};
}
};

View File

@@ -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);

View File

@@ -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;

View 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;

View File

@@ -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>

View File

@@ -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>
);
};

View File

@@ -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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View File

@@ -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;

View File

@@ -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()}

View File

@@ -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;

View File

@@ -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';

View File

@@ -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;