mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-06-04 23:20:05 +08:00
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:
375
Web/src/pages/albumDetail/Index.tsx
Normal file
375
Web/src/pages/albumDetail/Index.tsx
Normal 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;
|
||||
335
Web/src/pages/albums/Index.tsx
Normal file
335
Web/src/pages/albums/Index.tsx
Normal 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;
|
||||
162
Web/src/pages/allImages/Index.tsx
Normal file
162
Web/src/pages/allImages/Index.tsx
Normal 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;
|
||||
424
Web/src/pages/anonymous/Index.tsx
Normal file
424
Web/src/pages/anonymous/Index.tsx
Normal 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;
|
||||
252
Web/src/pages/backgroundTasks/Index.tsx
Normal file
252
Web/src/pages/backgroundTasks/Index.tsx
Normal 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;
|
||||
129
Web/src/pages/favorites/Index.tsx
Normal file
129
Web/src/pages/favorites/Index.tsx
Normal 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;
|
||||
331
Web/src/pages/login/Index.tsx
Normal file
331
Web/src/pages/login/Index.tsx
Normal 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;
|
||||
215
Web/src/pages/pixHub/Index.tsx
Normal file
215
Web/src/pages/pixHub/Index.tsx
Normal 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;
|
||||
391
Web/src/pages/register/Index.tsx
Normal file
391
Web/src/pages/register/Index.tsx
Normal 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;
|
||||
114
Web/src/pages/settings/ConfigGroup.tsx
Normal file
114
Web/src/pages/settings/ConfigGroup.tsx
Normal 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;
|
||||
172
Web/src/pages/settings/Index.tsx
Normal file
172
Web/src/pages/settings/Index.tsx
Normal 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;
|
||||
355
Web/src/pages/settings/SystemConfig.tsx
Normal file
355
Web/src/pages/settings/SystemConfig.tsx
Normal 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;
|
||||
181
Web/src/pages/settings/UserProfile.tsx
Normal file
181
Web/src/pages/settings/UserProfile.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user