refactor: restructure directories to improve module organization Foxel.Models.Request.Picture - Foxel.Models.Request.Tag - Foxel.Models.Request.Auth - Foxel.Models.Request.Picture

This commit is contained in:
ShiYu
2025-05-23 15:07:37 +08:00
parent a03e245d67
commit 0691f1c87d
91 changed files with 30 additions and 30 deletions

View File

@@ -0,0 +1,375 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useParams, useNavigate, useOutletContext } from 'react-router';
import {
Typography, Button, Spin, Empty, message,
Popconfirm, Modal, Form, Input} from 'antd';
import {
EditOutlined, DeleteOutlined, PlusOutlined} from '@ant-design/icons';
import { getAlbumById, deleteAlbum, favoritePicture, unfavoritePicture, addPicturesToAlbum, updateAlbum } from '../../api';
import type { AlbumResponse, PictureResponse } from '../../api';
import ImageGrid from '../../components/image/ImageGrid';
const { Title, Text } = Typography;
const { TextArea } = Input;
type OutletContextType = {
updateBreadcrumbTitle: (title: string) => void;
};
function AlbumDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { updateBreadcrumbTitle } = useOutletContext<OutletContextType>();
const [album, setAlbum] = useState<AlbumResponse | null>(null);
const [loading, setLoading] = useState(true);
const [isAddModalVisible, setIsAddModalVisible] = useState(false);
const [isEditModalVisible, setIsEditModalVisible] = useState(false);
const [selectedPictures, setSelectedPictures] = useState<number[]>([]);
const [editForm] = Form.useForm();
const [submitting, setSubmitting] = useState(false);
const loadAlbum = async () => {
if (!id) return;
setLoading(true);
try {
const result = await getAlbumById(parseInt(id));
if (result.success && result.data) {
setAlbum(result.data);
// 更新面包屑标题
updateBreadcrumbTitle(result.data.name);
} else {
message.error(result.message || '获取相册失败');
}
} catch (error) {
console.error('加载相册出错:', error);
message.error('加载相册详情出错');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadAlbum();
}, [id]);
const handleDeleteAlbum = async () => {
if (!album) return;
try {
const result = await deleteAlbum(album.id);
if (result.success) {
message.success('相册已删除');
navigate('/albums');
} else {
message.error(result.message || '删除相册失败');
}
} catch (error) {
console.error('删除相册出错:', error);
message.error('删除相册失败,请重试');
}
};
const handleToggleFavorite = async (image: PictureResponse) => {
try {
if (image.isFavorited) {
const result = await unfavoritePicture(image.id);
if (result.success) {
message.success('已取消收藏');
} else {
message.error(result.message || '取消收藏失败');
}
} else {
const result = await favoritePicture(image.id);
if (result.success) {
message.success('已添加到收藏');
} else {
message.error(result.message || '收藏失败');
}
}
} catch (error) {
console.error('处理收藏操作失败:', error);
message.error('操作失败,请重试');
}
};
const openAddModal = async () => {
setIsAddModalVisible(true);
setSelectedPictures([]);
};
const handleAddPictures = async () => {
if (!album || selectedPictures.length === 0) {
message.info('请先选择图片');
return;
}
try {
setIsAddModalVisible(false);
const result = await addPicturesToAlbum(album.id, selectedPictures);
if (result.success) {
message.success(`已添加 ${selectedPictures.length} 张图片到相册`);
setSelectedPictures([]);
loadAlbum();
} else {
message.error(result.message || '添加图片到相册失败');
}
} catch (error) {
console.error('添加图片到相册出错:', error);
message.error('添加图片到相册失败,请重试');
}
};
const getFormattedDate = (dateString?: Date) => {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' });
};
const imagesLoadedRef = useRef(false);
const handleAlbumImagesLoaded = useCallback(() => {
if (!imagesLoadedRef.current) {
imagesLoadedRef.current = true;
}
}, []);
useEffect(() => {
return () => {
imagesLoadedRef.current = false;
};
}, [id]);
// 打开编辑对话框
const openEditModal = () => {
if (album) {
editForm.setFieldsValue({
name: album.name,
description: album.description || ''
});
setIsEditModalVisible(true);
}
};
// 提交编辑表单
const handleEditAlbum = async () => {
if (!album) return;
try {
setSubmitting(true);
const values = await editForm.validateFields();
const result = await updateAlbum({
id: album.id,
name: values.name,
description: values.description
});
if (result.success) {
message.success('相册已更新');
setIsEditModalVisible(false);
// 重新加载相册信息
loadAlbum();
} else {
message.error(result.message || '更新相册失败');
}
} catch (error) {
console.error('编辑相册出错:', error);
message.error('表单验证失败或提交时出错');
} finally {
setSubmitting(false);
}
};
if (loading && !album) {
return (
<div style={{ textAlign: 'center', padding: '100px 0' }}>
<Spin size="large" />
</div>
);
}
if (!album) {
return (
<div style={{ textAlign: 'center', padding: '100px 0' }}>
<Empty description="相册不存在或已被删除" />
<Button type="primary" style={{ marginTop: 20 }} onClick={() => navigate('/albums')}>
</Button>
</div>
);
}
return (
<div>
{/* 移除原来的面包屑代码 */}
<div style={{
marginBottom: 40,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start'
}}>
<div>
<Title level={2} style={{
margin: 0,
marginBottom: 10,
fontWeight: 600,
letterSpacing: '0.5px',
fontSize: 32,
background: 'linear-gradient(120deg, #000000, #444444)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}}>{album.name}</Title>
<Text type="secondary" style={{
fontSize: 16,
display: 'block',
marginBottom: 8
}}>{album.description || "无描述"}</Text>
<Text type="secondary" style={{ fontSize: 14 }}>
{getFormattedDate(album.createdAt)} · {album?.pictureCount || 0}
</Text>
</div>
<div style={{ display: 'flex', gap: 12 }}>
<Button
icon={<PlusOutlined />}
onClick={openAddModal}
style={{
borderRadius: 10,
height: 40,
padding: '0 20px',
display: 'flex',
alignItems: 'center',
gap: 8
}}
>
</Button>
<Button
icon={<EditOutlined />}
style={{
borderRadius: 10,
height: 40,
padding: '0 20px',
display: 'flex',
alignItems: 'center',
gap: 8
}}
onClick={openEditModal}
>
</Button>
<Popconfirm
title="确定要删除这个相册吗?"
description="删除后不可恢复,但相册中的照片不会被删除。"
onConfirm={handleDeleteAlbum}
okText="确定"
cancelText="取消"
>
<Button
danger
icon={<DeleteOutlined />}
style={{
borderRadius: 10,
height: 40,
padding: '0 20px',
display: 'flex',
alignItems: 'center',
gap: 8
}}
>
</Button>
</Popconfirm>
</div>
</div>
<ImageGrid
queryParams={{ albumId: parseInt(id || '0') }} // 直接传入albumId参数获取相册图片
onToggleFavorite={handleToggleFavorite}
showFavoriteCount={true}
showPagination={true}
emptyText="相册中还没有照片"
onImagesLoaded={handleAlbumImagesLoaded}
/>
{/* 添加图片到相册的对话框 */}
<Modal
title="添加图片到相册"
open={isAddModalVisible}
onCancel={() => setIsAddModalVisible(false)}
onOk={handleAddPictures}
width={1000}
>
<div style={{ maxHeight: '60vh', overflowY: 'auto', padding: '20px 0' }}>
<ImageGrid
queryParams={{ excludeAlbumId: parseInt(id || '0') }}
showFavoriteCount={false}
emptyText="没有可添加的图片"
pageSize={12}
selectedIds={selectedPictures}
selectable={true} // 新增:启用选择模式
onSelectionChange={setSelectedPictures} // 新增:设置选择变化回调
/>
<div style={{ marginTop: 20, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>{selectedPictures.length} </span>
</div>
</div>
</Modal>
{/* 编辑相册的对话框 */}
<Modal
title="编辑相册"
open={isEditModalVisible}
onCancel={() => setIsEditModalVisible(false)}
footer={[
<Button key="back" onClick={() => setIsEditModalVisible(false)}>
</Button>,
<Button
key="submit"
type="primary"
loading={submitting}
onClick={handleEditAlbum}
>
</Button>,
]}
>
<Form
form={editForm}
layout="vertical"
initialValues={{
name: album.name,
description: album.description || ''
}}
>
<Form.Item
name="name"
label="相册名称"
rules={[{ required: true, message: '请输入相册名称' }]}
>
<Input placeholder="请输入相册名称" maxLength={50} />
</Form.Item>
<Form.Item
name="description"
label="相册描述"
>
<TextArea
placeholder="请输入相册描述"
autoSize={{ minRows: 3, maxRows: 6 }}
maxLength={500}
showCount
/>
</Form.Item>
</Form>
</Modal>
</div>
);
}
export default AlbumDetail;

View File

@@ -0,0 +1,335 @@
import { useState, useEffect } from 'react';
import { Typography, Row, Col, Card, Button, Modal, Form, Input, Spin, Empty, message, Popconfirm } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, PictureOutlined } from '@ant-design/icons';
import { getAlbums, createAlbum, updateAlbum, deleteAlbum } from '../../api';
import type { AlbumResponse, CreateAlbumRequest, UpdateAlbumRequest } from '../../api';
import { Link } from 'react-router';
const { Title, Text } = Typography;
const { Meta } = Card;
const { TextArea } = Input;
function Albums() {
const [albums, setAlbums] = useState<AlbumResponse[]>([]);
const [loading, setLoading] = useState(true);
const [totalAlbums, setTotalAlbums] = useState(0);
const [isCreateModalVisible, setIsCreateModalVisible] = useState(false);
const [isEditModalVisible, setIsEditModalVisible] = useState(false);
const [currentAlbum, setCurrentAlbum] = useState<AlbumResponse | null>(null);
const [form] = Form.useForm();
const [editForm] = Form.useForm();
const loadAlbums = async () => {
setLoading(true);
try {
const result = await getAlbums();
if (result.success && result.data) {
setAlbums(result.data);
setTotalAlbums(result.totalCount);
} else {
message.error(result.message || '获取相册失败');
}
} catch (error) {
console.error('加载相册出错:', error);
message.error('加载相册列表出错');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadAlbums();
}, []);
const handleCreateAlbum = async (values: CreateAlbumRequest) => {
try {
const result = await createAlbum(values);
if (result.success && result.data) {
message.success('相册创建成功');
setIsCreateModalVisible(false);
form.resetFields();
loadAlbums();
} else {
message.error(result.message || '创建相册失败');
}
} catch (error) {
console.error('创建相册出错:', error);
message.error('创建相册失败,请重试');
}
};
const handleEditAlbum = async (values: UpdateAlbumRequest) => {
if (!currentAlbum) return;
try {
const result = await updateAlbum({
...values,
id: currentAlbum.id
});
if (result.success && result.data) {
message.success('相册更新成功');
setIsEditModalVisible(false);
editForm.resetFields();
setCurrentAlbum(null);
loadAlbums();
} else {
message.error(result.message || '更新相册失败');
}
} catch (error) {
console.error('更新相册出错:', error);
message.error('更新相册失败,请重试');
}
};
const handleDeleteAlbum = async (id: number) => {
try {
const result = await deleteAlbum(id);
if (result.success) {
message.success('相册已删除');
loadAlbums();
} else {
message.error(result.message || '删除相册失败');
}
} catch (error) {
console.error('删除相册出错:', error);
message.error('删除相册失败,请重试');
}
};
const openEditModal = (album: AlbumResponse) => {
setCurrentAlbum(album);
editForm.setFieldsValue({
name: album.name,
description: album.description
});
setIsEditModalVisible(true);
};
const getRandomColor = () => {
const colors = ['#f56a00', '#7265e6', '#ffbf00', '#00a2ae', '#87d068', '#108ee9', '#f50', '#13c2c2'];
return colors[Math.floor(Math.random() * colors.length)];
};
const getFormattedDate = (dateString: Date) => {
const date = new Date(dateString);
return date.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' });
};
return (
<div>
<div style={{
marginBottom: 50,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}>
<div>
<Title level={2} style={{
margin: 0,
marginBottom: 10,
fontWeight: 600,
letterSpacing: '0.5px',
fontSize: 32,
background: 'linear-gradient(120deg, #000000, #444444)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}}></Title>
<Text type="secondary" style={{
fontSize: 16,
color: '#888',
letterSpacing: '0.3px'
}}> {totalAlbums} </Text>
</div>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setIsCreateModalVisible(true)}
style={{
borderRadius: 10,
height: 46,
padding: '0 24px',
display: 'flex',
alignItems: 'center',
gap: 8,
fontSize: 15
}}
>
</Button>
</div>
{loading ? (
<div style={{ textAlign: 'center', padding: '50px 0' }}>
<Spin size="large" />
</div>
) : albums.length === 0 ? (
<Empty
description="暂无相册"
style={{ margin: '80px 0' }}
image={Empty.PRESENTED_IMAGE_SIMPLE}
>
<Button type="primary" onClick={() => setIsCreateModalVisible(true)}></Button>
</Empty>
) : (
<Row gutter={[40, 40]}>
{albums.map(album => (
<Col xs={24} sm={12} md={8} lg={6} key={album.id}>
<Card
hoverable
style={{
borderRadius: 16,
overflow: 'hidden',
border: 'none',
background: '#ffffff',
boxShadow: '0 8px 30px rgba(0,0,0,0.05)',
transition: 'all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275)',
transform: 'translateY(0)'
}}
bodyStyle={{ padding: '20px' }}
cover={
<Link to={`/albums/${album.id}`}>
{album.coverImageUrl ? (
<img alt={album.name} src={album.coverImageUrl} style={{
height: 180,
width: '100%',
objectFit: 'cover'
}} />
) : (
<div style={{
height: 180,
width: '100%',
background: getRandomColor(),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
<PictureOutlined style={{ fontSize: 60, color: 'white' }} />
</div>
)}
</Link>
}
actions={[
<Link to={`/albums/${album.id}`} key="view"></Link>,
<EditOutlined key="edit" onClick={() => openEditModal(album)} />,
<Popconfirm
title="确定要删除这个相册吗?"
description="删除后不可恢复,但相册中的照片不会被删除。"
onConfirm={() => handleDeleteAlbum(album.id)}
okText="确定"
cancelText="取消"
key="delete"
>
<DeleteOutlined />
</Popconfirm>
]}
>
<Meta
title={<Link to={`/albums/${album.id}`} style={{ color: 'inherit' }}>{album.name}</Link>}
description={
<div style={{ marginTop: 8 }}>
<Text ellipsis style={{ display: 'block', marginBottom: 8 }}>
{album.description || "无描述"}
</Text>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Text type="secondary">{album.pictureCount || 0} </Text>
<Text type="secondary">{getFormattedDate(album.createdAt)}</Text>
</div>
</div>
}
/>
</Card>
</Col>
))}
</Row>
)}
{/* 创建相册对话框 */}
<Modal
title="创建新相册"
open={isCreateModalVisible}
onCancel={() => {
setIsCreateModalVisible(false);
form.resetFields();
}}
footer={null}
>
<Form
form={form}
layout="vertical"
onFinish={handleCreateAlbum}
>
<Form.Item
name="name"
label="相册名称"
rules={[{ required: true, message: '请输入相册名称' }]}
>
<Input placeholder="给你的相册起个名字" />
</Form.Item>
<Form.Item
name="description"
label="相册描述"
>
<TextArea placeholder="描述一下这个相册" rows={4} />
</Form.Item>
<Form.Item style={{ marginBottom: 0, textAlign: 'right' }}>
<Button style={{ marginRight: 8 }} onClick={() => {
setIsCreateModalVisible(false);
form.resetFields();
}}>
</Button>
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
</Form>
</Modal>
{/* 编辑相册对话框 */}
<Modal
title="编辑相册"
open={isEditModalVisible}
onCancel={() => {
setIsEditModalVisible(false);
editForm.resetFields();
setCurrentAlbum(null);
}}
footer={null}
>
<Form
form={editForm}
layout="vertical"
onFinish={handleEditAlbum}
>
<Form.Item
name="name"
label="相册名称"
rules={[{ required: true, message: '请输入相册名称' }]}
>
<Input placeholder="相册名称" />
</Form.Item>
<Form.Item
name="description"
label="相册描述"
>
<TextArea placeholder="相册描述" rows={4} />
</Form.Item>
<Form.Item style={{ marginBottom: 0, textAlign: 'right' }}>
<Button style={{ marginRight: 8 }} onClick={() => {
setIsEditModalVisible(false);
editForm.resetFields();
setCurrentAlbum(null);
}}>
</Button>
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
</Form>
</Modal>
</div>
);
}
export default Albums;

View File

@@ -0,0 +1,162 @@
import { useState, useRef, useMemo, useCallback } from 'react';
import { Typography, Button, Dropdown, message, Row, Col } from 'antd';
import { SortAscendingOutlined, UploadOutlined } from '@ant-design/icons';
import type { PictureResponse } from '../../api';
import ImageUploadDialog from '../../components/upload/ImageUploadDialog';
import ImageGrid from '../../components/image/ImageGrid';
import useIsMobile from '../../hooks/useIsMobile';
const { Title } = Typography;
function AllImages() {
const isMobile = useIsMobile();
const [images, setImages] = useState<PictureResponse[]>([]);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [sortBy, setSortBy] = useState<string>('uploadDate_desc');
const [isUploadDialogVisible, setIsUploadDialogVisible] = useState(false);
// 使用useRef记忆sortBy值避免重复渲染
const sortByRef = useRef(sortBy);
// 优化handleSortChange减少不必要的状态更新
const handleSortChange = (newSortBy: string) => {
if (sortBy !== newSortBy) {
setSortBy(newSortBy);
sortByRef.current = newSortBy;
}
};
// 使用useMemo创建稳定的queryParams对象
const queryParamsObject = useMemo(() => {
return { sortBy };
}, [sortBy]);
const handleToggleFavorite = (image: PictureResponse) => {
// 只需处理 viewer 中的图片
setImages(prevImages =>
prevImages.map(img =>
img.id === image.id ? {
...img,
isFavorited: !img.isFavorited,
favoriteCount: img.isFavorited
? Math.max(0, img.favoriteCount - 1)
: img.favoriteCount + 1
} : img
)
);
};
const handleUploadComplete = () => {
message.success('图片上传完成,刷新列表');
};
// 当分页变化时,保存当前浏览的页码
const handlePageChange = (page: number, pageSize: number) => {
setCurrentPage(page);
setPageSize(pageSize);
};
const handleImagesLoaded = useCallback((loadedImages: PictureResponse[]) => {
if (images.length === 0) {
setImages(loadedImages);
}
}, [images.length]);
const sortMenu = {
items: [
{ key: 'takenAt_desc', label: '最新拍摄' },
{ key: 'takenAt_asc', label: '最早拍摄' },
{ key: 'uploadDate_desc', label: '最新上传' },
{ key: 'uploadDate_asc', label: '最早上传' },
{ key: 'name_asc', label: '名称 A-Z' },
{ key: 'name_desc', label: '名称 Z-A' },
],
onClick: ({ key }: { key: string }) => handleSortChange(key),
};
return (
<>
<div style={{
marginBottom: isMobile ? 30 : 50,
position: 'relative',
zIndex: 1
}}>
<Row gutter={[16, 16]} align="middle">
<Col xs={24} sm={24} md={12} lg={12}>
<Title level={2} style={{
margin: 0,
marginBottom: isMobile ? 5 : 10,
fontWeight: 600,
letterSpacing: '0.5px',
fontSize: isMobile ? 24 : 32,
background: 'linear-gradient(120deg, #000000, #444444)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
textAlign: isMobile ? 'center' : 'left',
}}></Title>
</Col>
<Col xs={24} sm={24} md={12} lg={12}>
<div style={{
display: 'flex',
gap: isMobile ? 8 : 12,
justifyContent: isMobile ? 'center' : 'flex-end'
}}>
<Button
type="primary"
icon={<UploadOutlined />}
style={{
borderRadius: 10,
height: isMobile ? 40 : 46,
padding: isMobile ? '0 15px' : '0 24px',
display: 'flex',
alignItems: 'center',
gap: 8,
fontSize: isMobile ? 14 : 15
}}
onClick={() => setIsUploadDialogVisible(true)}
>
</Button>
<Dropdown menu={sortMenu} placement="bottomRight">
<Button style={{
borderRadius: 10,
height: isMobile ? 40 : 46,
border: '1px solid #f0f0f0',
padding: isMobile ? '0 15px' : '0 24px',
display: 'flex',
alignItems: 'center',
gap: 8,
fontSize: isMobile ? 14 : 15,
boxShadow: '0 2px 8px rgba(0,0,0,0.02)',
background: '#ffffff'
}}>
<SortAscendingOutlined />
{!isMobile && "排序方式"}
</Button>
</Dropdown>
</div>
</Col>
</Row>
</div>
<ImageGrid
queryParams={queryParamsObject}
pageSize={pageSize}
defaultPage={currentPage}
onPageChange={handlePageChange}
onToggleFavorite={handleToggleFavorite}
showFavoriteCount={true}
onImagesLoaded={handleImagesLoaded}
/>
<ImageUploadDialog
visible={isUploadDialogVisible}
onClose={() => setIsUploadDialogVisible(false)}
onUploadComplete={handleUploadComplete}
/>
</>
);
}
export default AllImages;

View File

@@ -0,0 +1,424 @@
import React, { useState } from 'react';
import {
Upload,
Button,
Card,
message,
Typography,
Row,
Col,
Empty,
Layout,
Divider,
Space
} from 'antd';
import {
UploadOutlined,
FileImageOutlined,
LinkOutlined,
CloudUploadOutlined
} from '@ant-design/icons';
import type { RcFile, UploadFile as AntUploadFile } from 'antd/es/upload/interface';
import { v4 as uuidv4 } from 'uuid';
import { Link } from 'react-router';
import ShareImageDialog from '../../components/image/ShareImageDialog';
import { uploadPicture } from '../../api/pictureApi';
import type { PictureResponse } from '../../api/types';
const { Title, Text } = Typography;
const { Dragger } = Upload;
const { Header, Content } = Layout;
const AnonymousPage: React.FC = () => {
const [fileList, setFileList] = useState<AntUploadFile[]>([]);
const [uploading, setUploading] = useState<boolean>(false);
const [uploadedImages, setUploadedImages] = useState<PictureResponse[]>([]);
const [shareImage, setShareImage] = useState<PictureResponse | null>(null);
const [shareDialogVisible, setShareDialogVisible] = useState<boolean>(false);
// 处理文件选择
const handleBeforeUpload = (file: RcFile) => {
// 检查文件类型
if (!file.type.startsWith('image/')) {
message.error(`${file.name} 不是图片文件`);
return false;
}
// 限制文件大小
const isLt10M = file.size / 1024 / 1024 < 10;
if (!isLt10M) {
message.error('图片大小不能超过 10MB!');
return false;
}
// 添加到上传列表
const newFile: AntUploadFile = {
uid: uuidv4(),
name: file.name,
status: 'done',
size: file.size,
type: file.type,
originFileObj: file,
};
setFileList((prevList) => [...prevList, newFile]);
// 阻止默认上传行为
return false;
};
// 执行上传
const handleUpload = async () => {
if (fileList.length === 0) {
message.warning('请先选择需要上传的图片');
return;
}
setUploading(true);
const uploadedList: PictureResponse[] = [];
for (let i = 0; i < fileList.length; i++) {
const file = fileList[i];
const originFile = file.originFileObj;
if (!originFile) continue;
// 更新状态为上传中
setFileList((prevList) =>
prevList.map(item => {
if (item.uid === file.uid) {
return { ...item, status: 'uploading' };
}
return item;
})
);
try {
const result = await uploadPicture(originFile, {
permission: 0, // 匿名上传默认为公开
onProgress: (percent) => {
setFileList((prevList) =>
prevList.map(item => {
if (item.uid === file.uid) {
return { ...item, percent };
}
return item;
})
);
}
});
// 处理上传结果
if (result.success && result.data) {
// 更新上传完成状态
setFileList((prevList) =>
prevList.map(item => {
if (item.uid === file.uid) {
return { ...item, status: 'done', percent: 100 };
}
return item;
})
);
// 添加到已上传图片列表
uploadedList.push(result.data);
} else {
// 更新上传失败状态
setFileList((prevList) =>
prevList.map(item => {
if (item.uid === file.uid) {
return { ...item, status: 'error' };
}
return item;
})
);
message.error(`${file.name} 上传失败: ${result.message || '未知错误'}`);
}
} catch (error) {
console.error('上传出错:', error);
// 更新上传失败状态
setFileList((prevList) =>
prevList.map(item => {
if (item.uid === file.uid) {
return { ...item, status: 'error' };
}
return item;
})
);
message.error(`${file.name} 上传出错`);
}
}
// 更新上传完成的图片列表
setUploadedImages((prev) => [...prev, ...uploadedList]);
setUploading(false);
// 如果有成功上传的图片,清空上传队列
if (uploadedList.length > 0) {
message.success(`成功上传 ${uploadedList.length} 张图片`);
setFileList([]);
}
};
// 移除待上传文件
const handleRemove = (file: AntUploadFile) => {
setFileList((prevList) => prevList.filter(item => item.uid !== file.uid));
};
// 打开分享对话框
const handleShareImage = (image: PictureResponse) => {
setShareImage(image);
setShareDialogVisible(true);
};
// 关闭分享对话框
const handleCloseShareDialog = () => {
setShareDialogVisible(false);
};
return (
<Layout style={{ minHeight: '100vh', background: 'linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%)' }}>
<Header style={{
background: 'rgba(255, 255, 255, 0.9)',
padding: '0 24px',
boxShadow: '0 2px 10px rgba(0,0,0,0.08)',
backdropFilter: 'blur(10px)',
position: 'sticky',
top: 0,
zIndex: 100,
}}>
<div style={{ display: 'flex', alignItems: 'center', height: '100%', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<Title level={3} style={{ margin: 0 }}>Foxel </Title>
</div>
<div>
<Space>
<Link to="/login">
<Button type="primary" ghost></Button>
</Link>
<Link to="https://github.com/DrizzleTime/Foxel">
<Button type="primary" ghost>Github</Button>
</Link>
</Space>
</div>
</div>
</Header>
<Content style={{ padding: '32px', maxWidth: '1200px', margin: '0 auto', width: '100%' }}>
<Card
style={{
marginBottom: '32px',
borderRadius: '12px',
boxShadow: '0 8px 24px rgba(0,0,0,0.05)',
overflow: 'hidden'
}}
bodyStyle={{ padding: '32px' }}
>
<div style={{ textAlign: 'center', marginBottom: '24px' }}>
<CloudUploadOutlined style={{ fontSize: '48px', color: '#1890ff', marginBottom: '12px' }} />
<Title level={3} style={{ margin: 0 }}></Title>
<Text type="secondary" style={{ marginTop: '8px', display: 'block' }}>
</Text>
<Divider style={{ margin: '24px 0' }} />
</div>
{/* 上传区域 */}
<Dragger
multiple
fileList={fileList}
beforeUpload={handleBeforeUpload}
onRemove={handleRemove}
style={{
backgroundColor: 'rgba(240, 244, 248, 0.6)',
borderRadius: '8px',
border: '2px dashed #d9d9d9',
padding: '20px 0'
}}
itemRender={(originNode, file) => (
<div style={{
display: 'flex',
alignItems: 'center',
padding: '8px 16px',
backgroundColor: file.status === 'error' ? 'rgba(255,0,0,0.05)' :
file.status === 'done' ? 'rgba(82,196,26,0.05)' : 'transparent',
borderRadius: '6px',
marginBottom: '8px'
}}>
{file.status === 'uploading' ? (
<div style={{ marginRight: '16px', color: '#1890ff', minWidth: '80px' }}>
{file.percent?.toFixed(0)}%
</div>
) : file.status === 'error' ? (
<div style={{ marginRight: '16px', color: '#ff4d4f', minWidth: '80px' }}></div>
) : file.status === 'done' ? (
<div style={{ marginRight: '16px', color: '#52c41a', minWidth: '80px' }}></div>
) : (
<div style={{ marginRight: '16px', color: '#8c8c8c', minWidth: '80px' }}></div>
)}
{originNode}
</div>
)}
>
<p className="ant-upload-drag-icon">
<FileImageOutlined style={{ fontSize: '56px', color: '#1890ff' }} />
</p>
<p className="ant-upload-text" style={{ fontSize: '18px', margin: '16px 0' }}>
</p>
<p className="ant-upload-hint" style={{ fontSize: '14px' }}>
10MB
</p>
</Dragger>
<div style={{ marginTop: '24px', textAlign: 'center' }}>
<Button
type="primary"
onClick={handleUpload}
loading={uploading}
disabled={fileList.length === 0}
icon={<UploadOutlined />}
size="large"
style={{
height: '46px',
paddingLeft: '30px',
paddingRight: '30px',
fontSize: '16px',
boxShadow: '0 2px 10px rgba(24,144,255,0.3)'
}}
>
{uploading ? '正在上传...' : '开始上传'}
</Button>
</div>
</Card>
{/* 已上传图片展示 */}
<Card
style={{
borderRadius: '12px',
boxShadow: '0 8px 24px rgba(0,0,0,0.05)',
}}
bodyStyle={{ padding: '32px' }}
>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '24px' }}>
<Title level={4} style={{ margin: 0, flexGrow: 1 }}></Title>
{uploadedImages.length > 0 && (
<Text type="secondary"> {uploadedImages.length} </Text>
)}
</div>
{uploadedImages.length > 0 ? (
<Row gutter={[24, 24]}>
{uploadedImages.map((image) => (
<Col xs={24} sm={12} md={8} lg={6} key={image.id}>
<Card
hoverable
cover={
<div style={{
height: '180px',
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#f0f0f0',
borderTopLeftRadius: '8px',
borderTopRightRadius: '8px',
position: 'relative',
}}>
<img
alt={image.name || 'uploaded image'}
src={image.path}
style={{
maxWidth: '100%',
maxHeight: '180px',
objectFit: 'contain',
transition: 'all 0.3s ease'
}}
/>
<div style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
background: 'rgba(0,0,0,0.02)',
transition: 'all 0.3s ease',
}}></div>
</div>
}
style={{
borderRadius: '8px',
overflow: 'hidden',
transition: 'all 0.3s ease',
}}
bodyStyle={{ padding: '16px' }}
actions={[
<Button
type="primary"
key="share"
onClick={() => handleShareImage(image)}
icon={<LinkOutlined />}
style={{ width: '80%' }}
>
</Button>
]}
>
<Card.Meta
title={
<div style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}>
{image.name || '未命名图片'}
</div>
}
description={
<div style={{ fontSize: '12px', color: '#8c8c8c' }}>
· {new Date().toLocaleString()}
</div>
}
/>
</Card>
</Col>
))}
</Row>
) : (
<Empty
description={
<Space direction="vertical" align="center" size="small">
<Text style={{ fontSize: '16px' }}></Text>
<Text type="secondary"></Text>
</Space>
}
style={{
margin: '60px 0',
padding: '30px',
background: 'rgba(0,0,0,0.01)',
borderRadius: '8px'
}}
/>
)}
</Card>
<div style={{ textAlign: 'center', margin: '32px 0 16px', opacity: 0.6 }}>
<Text type="secondary">Foxel · · 便</Text>
</div>
</Content>
{/* 分享对话框 */}
<ShareImageDialog
visible={shareDialogVisible}
onClose={handleCloseShareDialog}
image={shareImage}
/>
</Layout>
);
};
export default AnonymousPage;

View File

@@ -0,0 +1,252 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Typography, Table, Card, Tag, Space, Button, Empty, message, Modal } from 'antd';
import { SyncOutlined, EyeOutlined } from '@ant-design/icons';
import { getUserTasks } from '../../api';
import { type PictureProcessingTask, ProcessingStatus } from '../../api/types';
import TaskProgressBar from '../../components/TaskProgressBar';
import dayjs from 'dayjs';
import { Link } from 'react-router';
import type { ColumnType } from 'antd/es/table';
const { Title, Text } = Typography;
const BackgroundTasks: React.FC = () => {
const [tasks, setTasks] = useState<PictureProcessingTask[]>([]);
const [loading, setLoading] = useState(true);
const [pollingActive, setPollingActive] = useState(true);
const [pollingInterval, setPollingIntervalState] = useState<number | null>(null);
// 加载任务数据
const fetchTasks = useCallback(async () => {
try {
setLoading(true);
const result = await getUserTasks();
if (result.success && result.data) {
setTasks(result.data);
} else {
message.error(result.message || '获取任务列表失败');
}
} catch (error) {
console.error('获取任务失败:', error);
message.error('加载任务列表时出错');
} finally {
setLoading(false);
}
}, []);
// 自动刷新逻辑
useEffect(() => {
fetchTasks();
// 设置轮询
if (pollingActive) {
const interval = setInterval(fetchTasks, 3000);
setPollingIntervalState(interval);
}
return () => {
if (pollingInterval) {
clearInterval(pollingInterval);
}
};
}, [fetchTasks, pollingActive]);
// 检查是否有活跃的任务,如果没有则停止轮询
useEffect(() => {
const hasActiveTasks = tasks.some(
task => task.status === ProcessingStatus.Pending || task.status === ProcessingStatus.Processing
);
if (!hasActiveTasks && pollingActive) {
setPollingActive(false);
if (pollingInterval) {
clearInterval(pollingInterval);
setPollingIntervalState(null);
}
} else if (hasActiveTasks && !pollingActive) {
setPollingActive(true);
const interval = setInterval(fetchTasks, 3000);
setPollingIntervalState(interval);
}
}, [tasks, pollingActive, pollingInterval, fetchTasks]);
// 渲染状态标签
const renderStatus = (status: ProcessingStatus) => {
let color = '';
let text = '';
let icon = null;
switch (status) {
case ProcessingStatus.Pending:
color = 'orange';
text = '等待中';
icon = <SyncOutlined spin />;
break;
case ProcessingStatus.Processing:
color = 'processing';
text = '处理中';
icon = <SyncOutlined spin />;
break;
case ProcessingStatus.Completed:
color = 'success';
text = '已完成';
break;
case ProcessingStatus.Failed:
color = 'error';
text = '失败';
break;
}
return <Tag color={color} icon={icon}>{text}</Tag>;
};
// 格式化日期
const formatDate = (date: Date | undefined) => {
if (!date) return '-';
return dayjs(date).format('YYYY-MM-DD HH:mm:ss');
};
// 渲染错误信息
const showErrorMessage = (error: string) => {
Modal.error({
title: '处理失败',
content: error,
});
};
// 表格列定义
const columns: ColumnType<PictureProcessingTask>[] = [
{
title: '图片名称',
dataIndex: 'pictureName',
key: 'pictureName',
render: (text: string, record: PictureProcessingTask) => (
<Link to={`/pictures/${record.pictureId}`}>{text}</Link>
),
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
render: (status: ProcessingStatus) => renderStatus(status),
filters: [
{ text: '等待中', value: ProcessingStatus.Pending },
{ text: '处理中', value: ProcessingStatus.Processing },
{ text: '已完成', value: ProcessingStatus.Completed },
{ text: '失败', value: ProcessingStatus.Failed },
],
onFilter: (value, record: PictureProcessingTask) =>
record.status === value.toString(),
},
{
title: '进度',
dataIndex: 'progress',
key: 'progress',
render: (progress: number, record: PictureProcessingTask) => (
<TaskProgressBar
status={record.status}
progress={progress}
error={record.error}
showLabel={false}
size="small"
style={{ width: '150px' }}
/>
),
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
render: (date: Date) => formatDate(date),
sorter: (a: PictureProcessingTask, b: PictureProcessingTask) =>
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
},
{
title: '完成时间',
dataIndex: 'completedAt',
key: 'completedAt',
render: (date: Date) => formatDate(date),
},
{
title: '操作',
key: 'action',
render: (_: any, record: PictureProcessingTask) => (
<Space size="middle">
<Link to={`/pictures/${record.pictureId}`}>
<Button type="link" icon={<EyeOutlined />} size="small">
</Button>
</Link>
{record.status === ProcessingStatus.Failed && record.error && (
<Button
type="link"
danger
size="small"
onClick={() => showErrorMessage(record.error!)}
>
</Button>
)}
</Space>
),
},
];
return (
<div className="background-tasks-container">
<div style={{
marginBottom: 30,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}>
<div>
<Title level={2} style={{
margin: 0,
marginBottom: 10,
fontWeight: 600,
letterSpacing: '0.5px',
fontSize: 32,
background: 'linear-gradient(120deg, #000000, #444444)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}}></Title>
<Text type="secondary" style={{
fontSize: 16,
color: '#888',
letterSpacing: '0.3px'
}}></Text>
</div>
<Button
type="primary"
icon={<SyncOutlined />}
onClick={fetchTasks}
loading={loading}
>
</Button>
</div>
<Card>
{tasks.length > 0 ? (
<Table
dataSource={tasks}
columns={columns}
rowKey="taskId"
loading={loading}
pagination={{ pageSize: 10 }}
/>
) : (
<Empty
description={
loading ? "正在加载..." : "暂无处理任务"
}
style={{ margin: '40px 0' }}
/>
)}
</Card>
</div>
);
};
export default BackgroundTasks;

View File

@@ -0,0 +1,129 @@
import { useState, useRef, useMemo, useCallback } from 'react';
import { Typography, Button, Dropdown } from 'antd';
import { SortAscendingOutlined } from '@ant-design/icons';
import type { PictureResponse } from '../../api';
import ImageGrid from '../../components/image/ImageGrid';
const { Title } = Typography;
function Favorites() {
const [images, setImages] = useState<PictureResponse[]>([]);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(8);
const [sortBy, setSortBy] = useState<string>('uploadDate_desc');
// 使用useRef记忆sortBy值避免重复渲染
const sortByRef = useRef(sortBy);
// 优化handleSortChange减少不必要的状态更新
const handleSortChange = (newSortBy: string) => {
if (sortBy !== newSortBy) {
setSortBy(newSortBy);
sortByRef.current = newSortBy;
}
};
// 使用useMemo创建稳定的queryParams对象
const queryParamsObject = useMemo(() => {
return {
sortBy,
onlyFavorites: true
};
}, [sortBy]);
const handleToggleFavorite = (image: PictureResponse) => {
// 处理收藏/取消收藏
setImages(prevImages =>
prevImages.map(img =>
img.id === image.id ? {
...img,
isFavorited: !img.isFavorited,
favoriteCount: img.isFavorited
? Math.max(0, img.favoriteCount - 1)
: img.favoriteCount + 1
} : img
)
);
};
// 当分页变化时,保存当前浏览的页码
const handlePageChange = (page: number, pageSize: number) => {
setCurrentPage(page);
setPageSize(pageSize);
};
const handleImagesLoaded = useCallback((loadedImages: PictureResponse[]) => {
if (images.length === 0) {
setImages(loadedImages);
}
}, [images.length]);
const sortMenu = {
items: [
{ key: 'takenAt_desc', label: '最新拍摄' },
{ key: 'takenAt_asc', label: '最早拍摄' },
{ key: 'uploadDate_desc', label: '最新收藏' },
{ key: 'uploadDate_asc', label: '最早收藏' },
{ key: 'name_asc', label: '名称 A-Z' },
{ key: 'name_desc', label: '名称 Z-A' },
],
onClick: ({ key }: { key: string }) => handleSortChange(key),
};
return (
<>
<div style={{
marginBottom: 50,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
position: 'relative',
zIndex: 1
}}>
<div>
<Title level={2} style={{
margin: 0,
marginBottom: 10,
fontWeight: 600,
letterSpacing: '0.5px',
fontSize: 32,
background: 'linear-gradient(120deg, #000000, #444444)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}}></Title>
</div>
<div style={{ display: 'flex', gap: 12 }}>
<Dropdown menu={sortMenu} placement="bottomRight">
<Button style={{
borderRadius: 10,
height: 46,
border: '1px solid #f0f0f0',
padding: '0 24px',
display: 'flex',
alignItems: 'center',
gap: 8,
fontSize: 15,
boxShadow: '0 2px 8px rgba(0,0,0,0.02)',
background: '#ffffff'
}}>
<SortAscendingOutlined />
</Button>
</Dropdown>
</div>
</div>
<ImageGrid
queryParams={queryParamsObject}
pageSize={pageSize}
defaultPage={currentPage}
onPageChange={handlePageChange}
onToggleFavorite={handleToggleFavorite}
showFavoriteCount={true}
onImagesLoaded={handleImagesLoaded}
/>
</>
);
}
export default Favorites;

View File

@@ -0,0 +1,331 @@
import React, {useState, useEffect} from 'react';
import {Form, Input, Button, Checkbox, Typography, Row, Col, Divider, message} from 'antd';
import {UserOutlined, LockOutlined, GithubOutlined, GoogleOutlined} from '@ant-design/icons';
import {useNavigate, Link} from 'react-router';
import {login, saveAuthData, isAuthenticated, handleOAuthCallback, getGitHubLoginUrl} from '../../api';
import useIsMobile from '../../hooks/useIsMobile';
const {Title, Text} = Typography;
const Login: React.FC = () => {
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const isMobile = useIsMobile();
useEffect(() => {
const checkOAuthCallback = async () => {
try {
if (await handleOAuthCallback()) {
message.success('第三方登录成功!');
navigate('/');
return;
}
if (isAuthenticated()) {
navigate('/');
}
} catch (error) {
console.error('处理登录回调失败:', error);
message.error('登录过程中出现错误');
}
};
checkOAuthCallback();
}, [navigate]);
const onFinish = async (values: any) => {
setLoading(true);
try {
const response = await login({
email: values.email,
password: values.password
});
if (response.success && response.data) {
// 保存认证信息
saveAuthData(response.data);
// 显示成功消息
message.success(response.message || '登录成功!');
// 跳转到首页
navigate('/');
} else {
// 显示错误消息
message.error(response.message || '登录失败,请检查账号和密码');
}
} catch (error) {
console.error('登录出错:', error);
message.error('登录过程中出现错误,请稍后重试');
} finally {
setLoading(false);
}
};
const handleGitHubLogin = () => {
window.location.href = getGitHubLoginUrl();
};
return (
<Row style={{height: '100vh', overflow: 'hidden'}}>
{/* 左侧登录表单 */}
<Col
xs={24}
md={isMobile ? 24 : 12}
style={{
padding: isMobile ? '20px' : '40px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
maxWidth: isMobile ? '100%' : '650px',
margin: '0 auto'
}}
>
<div style={{
maxWidth: isMobile ? '100%' : '400px',
width: '100%',
margin: '0 auto',
paddingBottom: isMobile ? '40px' : 0
}}>
<div style={{marginBottom: '40px', textAlign: 'center'}}>
<Title level={2} style={{
marginBottom: '8px',
fontWeight: 700,
background: 'linear-gradient(120deg, #18181b, #444444)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent'
}}>
Foxel
</Title>
<Text style={{fontSize: '16px', color: '#666'}}>
使
</Text>
</div>
<Form
name="login_form"
initialValues={{remember: true}}
onFinish={onFinish}
size="large"
layout="vertical"
>
<Form.Item
name="email"
rules={[{required: true, message: '请输入您的邮箱'}]}
>
<Input
prefix={<UserOutlined style={{color: '#bfbfbf'}}/>}
placeholder="邮箱"
style={{
height: '50px',
borderRadius: '10px',
backgroundColor: '#f8f9fa',
border: '1px solid #eaeaea'
}}
/>
</Form.Item>
<Form.Item
name="password"
rules={[{required: true, message: '请输入您的密码'}]}
>
<Input.Password
prefix={<LockOutlined style={{color: '#bfbfbf'}}/>}
placeholder="密码"
style={{
height: '50px',
borderRadius: '10px',
backgroundColor: '#f8f9fa',
border: '1px solid #eaeaea'
}}
/>
</Form.Item>
<Form.Item>
<div style={{display: 'flex', justifyContent: 'space-between'}}>
<Checkbox name="remember"></Checkbox>
<a href="#forgot" style={{color: '#18181b'}}></a>
</div>
</Form.Item>
<Form.Item>
<Button
type="primary"
htmlType="submit"
loading={loading}
style={{
width: '100%',
height: '50px',
borderRadius: '10px',
fontWeight: 500,
fontSize: '16px',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)'
}}
>
</Button>
</Form.Item>
<Divider plain style={{color: '#999', fontSize: '14px'}}>
使
</Divider>
<div style={{display: 'flex', justifyContent: 'center', gap: '20px', margin: '20px 0'}}>
<Button
icon={<GithubOutlined/>}
size="large"
shape="circle"
onClick={handleGitHubLogin}
style={{
backgroundColor: '#f6f6f6',
border: 'none',
boxShadow: '0 2px 8px rgba(0,0,0,0.06)'
}}
/>
<Button
icon={<GoogleOutlined/>}
size="large"
shape="circle"
style={{
backgroundColor: '#f6f6f6',
border: 'none',
boxShadow: '0 2px 8px rgba(0,0,0,0.06)'
}}
/>
</div>
<div style={{textAlign: 'center', marginTop: '30px'}}>
<Text style={{color: '#666'}}>
<Link to="/register"
style={{color: '#18181b', fontWeight: 500}}></Link>
</Text>
<div style={{marginTop: '15px'}}>
<Link to="/anonymous" style={{color: '#666'}}>
<Button type="link" style={{padding: '0', fontWeight: 500}}></Button>
</Link>
</div>
</div>
</Form>
</div>
</Col>
{/* 右侧视觉区域 - 仅在非移动设备上显示 */}
{!isMobile && (
<Col md={12} style={{
background: 'linear-gradient(135deg, #18181b 0%, #444444 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
overflow: 'hidden',
height: '100vh'
}}>
<div style={{
position: 'absolute',
width: '600px',
height: '600px',
background: 'radial-gradient(circle, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0) 70%)',
top: '20%',
left: '30%'
}}></div>
<div style={{
position: 'absolute',
width: '400px',
height: '400px',
background: 'radial-gradient(circle, rgba(255,255,255,0.08) 0%, rgba(255,255,255,0) 70%)',
bottom: '10%',
right: '20%'
}}></div>
<div style={{
padding: '40px',
textAlign: 'center',
color: 'white',
position: 'relative',
zIndex: 1,
maxWidth: '500px'
}}>
<Title level={2} style={{
color: 'white',
marginBottom: '25px',
fontWeight: 700,
letterSpacing: '1px'
}}>
</Title>
<Text style={{
fontSize: '18px',
color: 'rgba(255,255,255,0.8)',
lineHeight: '1.8',
display: 'block',
marginBottom: '30px'
}}>
Foxel
</Text>
{/* 图片管理界面预览 */}
<div style={{
width: '100%',
height: '300px',
background: 'rgba(255,255,255,0.1)',
borderRadius: '20px',
boxShadow: '0 20px 40px rgba(0,0,0,0.3)',
backdropFilter: 'blur(20px)',
border: '1px solid rgba(255,255,255,0.2)',
position: 'relative',
overflow: 'hidden'
}}>
<div style={{
position: 'absolute',
top: '12px',
left: '12px',
right: '12px',
height: '30px',
borderRadius: '8px 8px 0 0',
display: 'flex',
alignItems: 'center',
gap: '8px',
paddingLeft: '12px'
}}>
<div style={{
width: '10px',
height: '10px',
borderRadius: '50%',
background: 'rgba(255,255,255,0.6)'
}}/>
<div style={{
width: '10px',
height: '10px',
borderRadius: '50%',
background: 'rgba(255,255,255,0.6)'
}}/>
<div style={{
width: '10px',
height: '10px',
borderRadius: '50%',
background: 'rgba(255,255,255,0.6)'
}}/>
</div>
<div style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
fontSize: '24px',
fontWeight: 'bold',
color: 'rgba(255,255,255,0.7)'
}}>
Foxel
</div>
</div>
</div>
</Col>
)}
</Row>
);
};
export default Login;

View File

@@ -0,0 +1,215 @@
import React, { useState, useEffect } from 'react';
import {
Row,
Col,
Input,
Select,
Button,
Divider,
Typography,
Space,
} from 'antd';
import {
FireOutlined,
ThunderboltOutlined,
SearchOutlined,
FilterOutlined,
} from '@ant-design/icons';
import ImageGrid from '../../components/image/ImageGrid';
import type { PictureResponse } from '../../api/types';
import { getFilteredTags } from '../../api/tagApi';
import useIsMobile from '../../hooks/useIsMobile';
const { Title, Text, Paragraph } = Typography;
const { Search } = Input;
const { Option } = Select;
const PixHub: React.FC = () => {
const isMobile = useIsMobile();
const [activeCategory, setActiveCategory] = useState('全部');
const [sortBy, setSortBy] = useState('newest');
const [searchQuery, setSearchQuery] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [totalCount, setTotalCount] = useState(0);
const [categories, setCategories] = useState<string[]>(['全部']);
const [loading, setLoading] = useState(true);
// 获取热门标签
useEffect(() => {
const fetchTopTags = async () => {
try {
setLoading(true);
const response = await getFilteredTags({
page: 1,
pageSize: 8, // 只获取8个最热门的标签
sortBy: 'pictureCount',
sortDirection: 'desc'
});
if (response.success && response.data.length > 0) {
// 始终保持"全部"作为第一个选项然后添加从API获取的标签
const tagNames = response.data.map(tag => tag.name);
setCategories(['全部', ...tagNames]);
}
} catch (error) {
console.error('获取热门标签失败:', error);
} finally {
setLoading(false);
}
};
fetchTopTags();
}, []);
// 处理分页变化
const handlePageChange = (page: number, size: number) => {
setCurrentPage(page);
setPageSize(size);
};
// 处理图片加载完成回调
const handleImagesLoaded = (_: PictureResponse[], total: number) => {
setTotalCount(total);
};
// 构建查询参数
const queryParams = {
search: searchQuery || undefined,
tags: activeCategory !== '全部' ? [activeCategory] : undefined,
sortBy: sortBy === 'popular' ? 'favoriteCount_desc' :
sortBy === 'newest' ? 'newest' :
'oldest',
includeAllPublic: true,
};
return (
<div className="image-square">
<div className="page-header" style={{ marginBottom: isMobile ? 20 : 32 }}>
<Row gutter={[24, isMobile ? 12 : 24]} align="middle">
<Col lg={10} md={12} sm={24} xs={24}>
<Title level={2} style={{
marginBottom: 8,
fontWeight: 600,
fontSize: isMobile ? 24 : 30,
textAlign: isMobile ? 'center' : 'left'
}}>
广
{!isMobile && (
<Text style={{ fontSize: 16, fontWeight: 400, marginLeft: 12, color: '#8c8c8c' }}>
</Text>
)}
</Title>
{isMobile && (
<Text style={{
fontSize: 14,
fontWeight: 400,
color: '#8c8c8c',
display: 'block',
textAlign: 'center'
}}>
</Text>
)}
<Paragraph style={{
color: '#666666',
textAlign: isMobile ? 'center' : 'left',
marginBottom: isMobile ? 5 : 'inherit'
}}>
</Paragraph>
</Col>
<Col lg={14} md={12} sm={24} xs={24}>
<Row gutter={[16, 16]} justify={isMobile ? "center" : "end"}>
<Col lg={16} md={16} sm={16} xs={24}>
<Search
placeholder="搜索图片、标签或创作者"
allowClear
enterButton={<SearchOutlined />}
size={isMobile ? "middle" : "large"}
onSearch={(value) => setSearchQuery(value)}
style={{ width: '100%' }}
/>
</Col>
<Col lg={8} md={8} sm={8} xs={24}>
<Select
style={{ width: '100%' }}
size={isMobile ? "middle" : "large"}
value={sortBy}
onChange={(value) => setSortBy(value)}
suffixIcon={<FilterOutlined />}
>
<Option value="popular"></Option>
<Option value="newest"></Option>
<Option value="oldest"></Option>
</Select>
</Col>
</Row>
</Col>
</Row>
</div>
<div className="category-nav" style={{
marginBottom: isMobile ? 20 : 28,
overflowX: 'auto',
paddingBottom: 8
}}>
<Space size={[isMobile ? 8 : 12, isMobile ? 10 : 20]} wrap style={{ justifyContent: 'center' }}>
{categories.map(category => (
<Button
key={category}
type={activeCategory === category ? "primary" : "default"}
shape="round"
size={isMobile ? "middle" : "large"}
onClick={() => setActiveCategory(category)}
style={{
fontWeight: 500,
minWidth: isMobile ? 70 : 80,
boxShadow: activeCategory === category ? '0 4px 12px rgba(0,0,0,0.15)' : 'none',
}}
icon={category === '全部' ? <ThunderboltOutlined /> : null}
loading={category === '全部' ? loading : false}
>
{category}
</Button>
))}
</Space>
</div>
<Divider style={{ margin: isMobile ? '16px 0' : '24px 0' }} />
<div className="results-info" style={{ marginBottom: isMobile ? 16 : 24 }}>
<Row justify="space-between" align="middle">
<Col>
<Text style={{ fontSize: isMobile ? 14 : 15 }}>
<strong>{totalCount}</strong>
{activeCategory !== '全部' && <span> · {activeCategory}</span>}
{searchQuery && <span> · "{searchQuery}"</span>}
</Text>
</Col>
<Col>
<Space>
<FireOutlined style={{ color: '#ff4d4f' }} />
<Text type="secondary"></Text>
</Space>
</Col>
</Row>
</div>
<ImageGrid
queryParams={queryParams}
pageSize={pageSize}
defaultPage={currentPage}
onPageChange={handlePageChange}
showFavoriteCount={true}
onImagesLoaded={handleImagesLoaded}
emptyText="未找到匹配的图片,请尝试更改搜索条件或选择其他分类"
/>
</div>
);
};
export default PixHub;

View File

@@ -0,0 +1,391 @@
import React, { useState, useEffect } from 'react';
import { Form, Input, Button, Checkbox, Typography, Row, Col, Divider, message } from 'antd';
import { UserOutlined, LockOutlined, MailOutlined, GithubOutlined, GoogleOutlined } from '@ant-design/icons';
import { useNavigate, Link } from 'react-router';
import { register, saveAuthData, isAuthenticated, handleOAuthCallback, getGitHubLoginUrl } from '../../api';
import useIsMobile from '../../hooks/useIsMobile';
const { Title, Text } = Typography;
const Register: React.FC = () => {
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const isMobile = useIsMobile();
useEffect(() => {
const checkOAuthCallback = async () => {
try {
if (await handleOAuthCallback()) {
message.success('使用GitHub账号注册成功');
navigate('/');
return;
}
if (isAuthenticated()) {
navigate('/');
}
} catch (error) {
console.error('处理登录回调失败:', error);
message.error('登录过程中出现错误');
}
};
checkOAuthCallback();
}, [navigate]);
const onFinish = async (values: any) => {
setLoading(true);
try {
const response = await register({
username: values.username,
email: values.email,
password: values.password
});
if (response.success && response.data) {
// 保存认证信息
saveAuthData(response.data);
// 显示成功消息
message.success(response.message || '注册成功!');
// 跳转到首页
navigate('/');
} else {
// 显示错误消息
message.error(response.message || '注册失败,请检查填写信息');
}
} catch (error) {
console.error('注册出错:', error);
message.error('注册过程中出现错误,请稍后重试');
} finally {
setLoading(false);
}
};
const handleGitHubLogin = () => {
window.location.href = getGitHubLoginUrl();
};
return (
<Row style={{ height: '100vh', overflow: 'hidden' }}>
{/* 左侧注册表单 */}
<Col
xs={24}
md={isMobile ? 24 : 12}
style={{
padding: isMobile ? '20px' : '20px 40px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
maxWidth: isMobile ? '100%' : '650px',
margin: '0 auto'
}}
>
<div style={{
maxWidth: isMobile ? '100%' : '400px',
width: '100%',
margin: '0 auto',
paddingBottom: isMobile ? '40px' : 0
}}>
<div style={{ marginBottom: '30px', textAlign: 'center' }}>
<Title level={2} style={{
marginBottom: '8px',
fontWeight: 700,
background: 'linear-gradient(120deg, #18181b, #444444)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent'
}}>
Foxel
</Title>
<Text style={{ fontSize: '16px', color: '#666' }}>
</Text>
</div>
<Form
name="register_form"
initialValues={{ agreement: true }}
onFinish={onFinish}
size="large"
layout="vertical"
>
<Form.Item
name="username"
rules={[
{ required: true, message: '请输入您的用户名' },
{ min: 3, message: '用户名至少3个字符' }
]}
>
<Input
prefix={<UserOutlined style={{ color: '#bfbfbf' }} />}
placeholder="用户名"
style={{
height: '50px',
borderRadius: '10px',
backgroundColor: '#f8f9fa',
border: '1px solid #eaeaea'
}}
/>
</Form.Item>
<Form.Item
name="email"
rules={[
{ required: true, message: '请输入您的电子邮箱' },
{ type: 'email', message: '请输入有效的电子邮箱地址' }
]}
>
<Input
prefix={<MailOutlined style={{ color: '#bfbfbf' }} />}
placeholder="电子邮箱"
style={{
height: '50px',
borderRadius: '10px',
backgroundColor: '#f8f9fa',
border: '1px solid #eaeaea'
}}
/>
</Form.Item>
<Form.Item
name="password"
rules={[
{ required: true, message: '请输入您的密码' },
{ min: 8, message: '密码至少8个字符' }
]}
>
<Input.Password
prefix={<LockOutlined style={{ color: '#bfbfbf' }} />}
placeholder="密码"
style={{
height: '50px',
borderRadius: '10px',
backgroundColor: '#f8f9fa',
border: '1px solid #eaeaea'
}}
/>
</Form.Item>
<Form.Item
name="confirm"
dependencies={['password']}
rules={[
{ required: true, message: '请确认您的密码' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('password') === value) {
return Promise.resolve();
}
return Promise.reject(new Error('两次输入的密码不匹配'));
},
}),
]}
>
<Input.Password
prefix={<LockOutlined style={{ color: '#bfbfbf' }} />}
placeholder="确认密码"
style={{
height: '50px',
borderRadius: '10px',
backgroundColor: '#f8f9fa',
border: '1px solid #eaeaea'
}}
/>
</Form.Item>
<Form.Item
name="agreement"
valuePropName="checked"
rules={[
{
validator: (_, value) =>
value ? Promise.resolve() : Promise.reject(new Error('请阅读并同意服务条款和隐私政策'))
},
]}
>
<Checkbox>
<a href="#terms"></a> <a href="#privacy"></a>
</Checkbox>
</Form.Item>
<Form.Item>
<Button
type="primary"
htmlType="submit"
loading={loading}
style={{
width: '100%',
height: '50px',
borderRadius: '10px',
fontWeight: 500,
fontSize: '16px',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)'
}}
>
</Button>
</Form.Item>
<Divider plain style={{ color: '#999', fontSize: '14px' }}>
使
</Divider>
<div style={{ display: 'flex', justifyContent: 'center', gap: '20px', margin: '20px 0' }}>
<Button
icon={<GithubOutlined />}
size="large"
shape="circle"
onClick={handleGitHubLogin}
style={{
backgroundColor: '#f6f6f6',
border: 'none',
boxShadow: '0 2px 8px rgba(0,0,0,0.06)'
}}
/>
<Button
icon={<GoogleOutlined />}
size="large"
shape="circle"
style={{
backgroundColor: '#f6f6f6',
border: 'none',
boxShadow: '0 2px 8px rgba(0,0,0,0.06)'
}}
/>
</div>
<div style={{ textAlign: 'center', marginTop: '20px' }}>
<Text style={{ color: '#666' }}>
<Link to="/login" style={{ color: '#18181b', fontWeight: 500 }}></Link>
</Text>
</div>
</Form>
</div>
</Col>
{/* 右侧视觉区域 - 仅在非移动设备上显示 */}
{!isMobile && (
<Col md={12} style={{
background: 'linear-gradient(135deg, #18181b 0%, #444444 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
overflow: 'hidden',
height: '100vh'
}}>
<div style={{
position: 'absolute',
width: '500px',
height: '500px',
background: 'radial-gradient(circle, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0) 70%)',
bottom: '20%',
left: '10%'
}}></div>
<div style={{
position: 'absolute',
width: '300px',
height: '300px',
background: 'radial-gradient(circle, rgba(255,255,255,0.08) 0%, rgba(255,255,255,0) 70%)',
top: '15%',
right: '15%'
}}></div>
<div style={{
padding: '40px',
textAlign: 'center',
color: 'white',
position: 'relative',
zIndex: 1,
maxWidth: '500px'
}}>
<Title level={2} style={{
color: 'white',
marginBottom: '25px',
fontWeight: 700,
letterSpacing: '1px'
}}>
Foxel
</Title>
<Text style={{
fontSize: '18px',
color: 'rgba(255,255,255,0.8)',
lineHeight: '1.8',
display: 'block',
marginBottom: '30px'
}}>
</Text>
{/* 特性列表 */}
<div style={{
display: 'flex',
flexDirection: 'column',
gap: '20px',
marginTop: '30px',
alignItems: 'flex-start',
backgroundColor: 'rgba(255,255,255,0.05)',
padding: '30px',
borderRadius: '20px',
boxShadow: '0 15px 35px rgba(0,0,0,0.2)',
border: '1px solid rgba(255,255,255,0.1)'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '15px' }}>
<div style={{
width: '40px',
height: '40px',
borderRadius: '10px',
background: 'rgba(255,255,255,0.15)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '18px'
}}></div>
<Text style={{ color: 'white', fontSize: '16px', textAlign: 'left' }}>
</Text>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '15px' }}>
<div style={{
width: '40px',
height: '40px',
borderRadius: '10px',
background: 'rgba(255,255,255,0.15)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '18px'
}}></div>
<Text style={{ color: 'white', fontSize: '16px', textAlign: 'left' }}>
AI
</Text>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '15px' }}>
<div style={{
width: '40px',
height: '40px',
borderRadius: '10px',
background: 'rgba(255,255,255,0.15)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '18px'
}}></div>
<Text style={{ color: 'white', fontSize: '16px', textAlign: 'left' }}>
访
</Text>
</div>
</div>
</div>
</Col>
)}
</Row>
);
};
export default Register;

View File

@@ -0,0 +1,114 @@
import React from 'react';
import { Form, Input, Button, Space, Row, Col, Tooltip } from 'antd';
import { SaveOutlined, QuestionCircleOutlined } 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;
};
isMobile?: boolean;
}
const ConfigGroup: React.FC<ConfigGroupProps> = ({
groupName,
configs,
onSave,
descriptions,
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);
}
};
return (
<Form
form={form}
layout="vertical"
initialValues={configs}
size={isMobile ? "middle" : "large"}
>
{Object.keys(configs).map(key => (
<Row key={key} gutter={isMobile ? [8, 8] : [16, 16]} align="middle">
<Col xs={24} lg={16}>
<Form.Item
name={key}
label={
<Space>
{key}
{descriptions[key] && (
<Tooltip title={descriptions[key]}>
<QuestionCircleOutlined />
</Tooltip>
)}
</Space>
}
>
{key.toLowerCase().includes('secret') || key.toLowerCase().includes('key') || key.toLowerCase().includes('password') ? (
<Input.Password placeholder={`请输入${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

@@ -0,0 +1,172 @@
import { Tabs, Layout, Menu, Space } from 'antd';
import { useAuth } from '../../api/AuthContext';
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 {
UserOutlined,
SettingOutlined,
BgColorsOutlined,
BellOutlined,
} from '@ant-design/icons';
const { TabPane } = Tabs;
const { Sider, Content } = Layout;
function Settings() {
const { hasRole } = useAuth();
const isMobile = useIsMobile();
const [activeMenu, setActiveMenu] = useState('profile');
const [activeTab, setActiveTab] = useState('basic');
const renderContent = () => {
switch (activeMenu) {
case 'profile':
return (
<div className="settings-content">
<UserProfile />
</div>
);
case 'system':
return (
<div className="settings-content">
<SystemConfig />
</div>
);
case 'appearance':
return (
<div className="settings-content">
<Tabs activeKey={activeTab} onChange={setActiveTab} className="horizontal-tabs">
<TabPane tab="主题" key="theme">
<div></div>
</TabPane>
<TabPane tab="布局" key="layout">
<div></div>
</TabPane>
</Tabs>
</div>
);
case 'notifications':
return (
<div className="settings-content">
<Tabs activeKey={activeTab} onChange={setActiveTab} className="horizontal-tabs">
<TabPane tab="通知类型" key="types">
<div></div>
</TabPane>
<TabPane tab="提醒方式" key="methods">
<div></div>
</TabPane>
</Tabs>
</div>
);
default:
return null;
}
};
// 菜单项配置
const menuItems = [
{
key: 'profile',
icon: <UserOutlined />,
label: '个人资料',
},
hasRole(UserRole.Administrator) ? {
key: 'system',
icon: <SettingOutlined />,
label: '系统配置',
} : null,
{
key: 'appearance',
icon: <BgColorsOutlined />,
label: '外观设置',
},
{
key: 'notifications',
icon: <BellOutlined />,
label: '通知设置',
},
].filter(Boolean);
const handleMenuChange = (key: SetStateAction<string>) => {
setActiveMenu(key);
switch (key) {
case 'profile':
setActiveTab('basic');
break;
case 'system':
setActiveTab('basic');
break;
case 'appearance':
setActiveTab('theme');
break;
case 'notifications':
setActiveTab('types');
break;
}
};
// 手机版使用Tabs作为顶部导航
if (isMobile) {
return (
<div style={{ padding: 0 }}>
<Tabs
activeKey={activeMenu}
onChange={(key) => handleMenuChange(key)}
centered
size="large"
tabBarStyle={{
marginBottom: 16,
fontWeight: 500,
backgroundColor: '#f5f5f5',
padding: '8px 0',
borderRadius: '8px'
}}
>
{menuItems.map((item) => (
<TabPane
tab={
<Space size={4}>
{item?.icon}
<span>{item?.label}</span>
</Space>
}
key={item?.key || ''}
>
<div style={{ padding: '0 4px' }}>
{renderContent()}
</div>
</TabPane>
))}
</Tabs>
</div>
);
}
// 桌面版使用侧边栏
return (
<Layout style={{
background: '#fff',
borderRadius: '8px',
overflow: 'hidden',
}}>
<Sider width={200} style={{ background: '#f5f5f5' }}>
<Menu
mode="vertical"
selectedKeys={[activeMenu]}
style={{ height: '100%', borderRight: 0 }}
items={menuItems}
onClick={({ key }) => handleMenuChange(key)}
/>
</Sider>
<Content style={{ minHeight: 500 }}>
{renderContent()}
</Content>
</Layout>
);
}
export default Settings;

View File

@@ -0,0 +1,355 @@
import React, { useEffect, useState } from 'react';
import { Tabs, Card, message, Spin, Select } from 'antd';
import { CloudOutlined, DatabaseOutlined, CloudServerOutlined, GlobalOutlined } from '@ant-design/icons';
import { getAllConfigs, setConfig } 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 fetchConfigs = async () => {
setLoading(true);
try {
const response = await getAllConfigs();
if (response.success && response.data) {
const configGroups: ConfigStructure = {};
response.data.forEach(config => {
const [group, key] = config.key.split(':');
if (!configGroups[group]) {
configGroups[group] = {};
}
configGroups[group][key] = config.value;
});
setConfigs(configGroups);
// 设置初始存储类型
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 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="系统配置"
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">
<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: '嵌入向量模型名称'
}}
isMobile={isMobile}
/>
</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 接收者',
}}
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 认证回调地址'
}}
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">
<div style={{
marginBottom: isMobile ? 16 : 20,
display: 'flex',
flexDirection: isMobile ? 'column' : 'row',
alignItems: isMobile ? 'flex-start' : 'center',
gap: isMobile ? 8 : 0
}}>
<span style={{
marginRight: isMobile ? 0 : 8,
display: 'inline-block',
width: isMobile ? 'auto' : 100,
marginBottom: isMobile ? 4 : 0
}}>
:
</span>
<Select
value={configs.Storage?.DefaultStorage || 'Local'}
onChange={(value) => {
handleSaveConfig('Storage', 'DefaultStorage', value);
}}
style={{ width: isMobile ? '100%' : 200 }}
size={isMobile ? "middle" : "large"}
>
{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>
<div style={{
marginBottom: isMobile ? 16 : 20,
display: 'flex',
flexDirection: isMobile ? 'column' : 'row',
alignItems: isMobile ? 'flex-start' : 'center',
gap: isMobile ? 8 : 0
}}>
<span style={{
marginRight: isMobile ? 0 : 8,
display: 'inline-block',
width: isMobile ? 'auto' : 100,
marginBottom: isMobile ? 4 : 0
}}>
:
</span>
<Select
value={storageType}
onChange={(value) => {
setStorageType(value);
}}
style={{ width: isMobile ? '100%' : 200 }}
size={isMobile ? "middle" : "large"}
>
{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>
{storageType === 'Telegram' && (
<ConfigGroup
groupName="Storage"
configs={{
"TelegramStorageBotToken": configs.Storage?.TelegramStorageBotToken || '',
"TelegramStorageChatId": configs.Storage?.TelegramStorageChatId || ''
}}
onSave={handleSaveConfig}
descriptions={{
"TelegramStorageBotToken": 'Telegram 机器人令牌',
"TelegramStorageChatId": 'Telegram 聊天ID'
}}
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服务)'
}}
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 (可选,用于加速文件访问)',
}}
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 (可选,用于文件访问)',
}}
isMobile={isMobile}
/>
)}
</TabPane>
</Tabs>
)}
</Card>
);
};
export default SystemConfig;

View File

@@ -0,0 +1,181 @@
import React, { useState } from 'react';
import { Card, Form, Input, Button, message } from 'antd';
import { useAuth } from '../../api/AuthContext';
import UserAvatar from '../../components/UserAvatar';
import useIsMobile from '../../hooks/useIsMobile';
import { updateUserInfo } from '../../api';
const UserProfile: React.FC = () => {
const { user, refreshUser } = useAuth();
const isMobile = useIsMobile();
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const handleSubmit = async (values: any) => {
// 验证两次密码输入是否一致
if (values.newPassword && values.newPassword !== values.confirmPassword) {
message.error('两次输入的密码不一致');
return;
}
setLoading(true);
try {
const updateData = {
userName: values.username !== user?.userName ? values.username : undefined,
email: values.email !== user?.email ? values.email : undefined,
currentPassword: values.currentPassword,
newPassword: values.newPassword
};
// 过滤掉空值
Object.keys(updateData).forEach(key => {
if (updateData[key as keyof typeof updateData] === undefined ||
updateData[key as keyof typeof updateData] === '') {
delete updateData[key as keyof typeof updateData];
}
});
// 如果没有需要更新的内容,直接返回
if (Object.keys(updateData).length === 0) {
message.info('没有内容需要更新');
setLoading(false);
return;
}
const response = await updateUserInfo(updateData);
if (response.success && response.data) {
message.success('个人信息更新成功');
// 更新Context中的用户信息
refreshUser();
// 清除密码字段
form.setFieldsValue({
currentPassword: '',
newPassword: '',
confirmPassword: ''
});
} else {
message.error(response.message || '更新失败');
}
} catch (error) {
console.error('更新个人信息时出错:', error);
message.error('系统错误,请稍后再试');
} finally {
setLoading(false);
}
};
return (
<Card
title="个人资料"
style={{
maxWidth: '100%',
margin: isMobile ? '0 auto' : '0 auto',
boxShadow: isMobile ? 'none' : '0 1px 3px rgba(0,0,0,0.1)'
}}
bodyStyle={{
padding: isMobile ? '16px 12px' : '24px'
}}
>
<div style={{
display: 'flex',
justifyContent: 'center',
marginBottom: isMobile ? 16 : 24
}}>
<UserAvatar
size={isMobile ? 80 : 100}
email={user?.email}
text={user?.userName}
/>
</div>
<Form
form={form}
layout="vertical"
initialValues={{
username: user?.userName || '',
email: user?.email || '',
}}
size={isMobile ? "middle" : "large"}
onFinish={handleSubmit}
>
<Form.Item
name="username"
label="用户名"
rules={[
{ required: true, message: '请输入用户名' },
{ max: 50, message: '用户名最长50个字符' }
]}
>
<Input placeholder="用户名" />
</Form.Item>
<Form.Item name="email" label="邮箱">
<Input placeholder="邮箱" disabled />
</Form.Item>
<Form.Item
name="currentPassword"
label="当前密码"
rules={[
{
required: false,
message: '更改密码时需要输入当前密码'
}
]}
tooltip="修改密码时必填,其他情况可不填"
>
<Input.Password placeholder="只有修改密码时才需要填写" />
</Form.Item>
<Form.Item
name="newPassword"
label="新密码"
rules={[
{ min: 6, message: '密码至少6位' },
{ max: 20, message: '密码最长20位' }
]}
dependencies={['currentPassword']}
>
<Input.Password placeholder="留空则不更改密码" />
</Form.Item>
<Form.Item
name="confirmPassword"
label="确认新密码"
dependencies={['newPassword']}
rules={[
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('newPassword') === value) {
return Promise.resolve();
}
return Promise.reject(new Error('两次输入的密码不一致'));
},
}),
]}
>
<Input.Password placeholder="确认新密码" />
</Form.Item>
<Form.Item>
<Button
type="primary"
htmlType="submit"
block={isMobile}
size={isMobile ? "middle" : "large"}
loading={loading}
style={{
height: isMobile ? 40 : 'auto'
}}
>
</Button>
</Form.Item>
</Form>
</Card>
);
};
export default UserProfile;