mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-05-19 23:30:35 +08:00
feat(album): add cover picture functionality to albums and enhance album management API
This commit is contained in:
@@ -5,18 +5,21 @@ export interface AlbumResponse {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
coverImageUrl?: string;
|
||||
pictureCount: number;
|
||||
userId: number;
|
||||
username?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
coverPictureId?: number | null;
|
||||
coverPicturePath?: string;
|
||||
coverPictureThumbnailPath?: string;
|
||||
}
|
||||
|
||||
// 创建相册请求
|
||||
export interface CreateAlbumRequest {
|
||||
name: string;
|
||||
description: string;
|
||||
coverPictureId?: number | null; // 新增:封面图片ID
|
||||
}
|
||||
|
||||
// 更新相册请求
|
||||
@@ -24,6 +27,7 @@ export interface UpdateAlbumRequest {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
coverPictureId?: number | null; // 新增:封面图片ID
|
||||
}
|
||||
|
||||
// 相册图片操作请求
|
||||
|
||||
96
Web/src/api/albumManagementApi.ts
Normal file
96
Web/src/api/albumManagementApi.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { AlbumResponse } from './albumApi';
|
||||
import { fetchApi, type BaseResult, type BatchDeleteResult, type PaginatedResult } from './fetchClient';
|
||||
import type { PictureResponse } from './pictureApi'; // For pictures within an album
|
||||
|
||||
|
||||
export interface AlbumCreateRequest {
|
||||
name: string;
|
||||
description?: string;
|
||||
coverPictureId?: number | null;
|
||||
}
|
||||
|
||||
export interface AlbumUpdateRequest {
|
||||
name?: string;
|
||||
description?: string;
|
||||
coverPictureId?: number | null;
|
||||
}
|
||||
|
||||
export const getManagementAlbums = async (
|
||||
page: number = 1,
|
||||
pageSize: number = 10,
|
||||
searchQuery?: string,
|
||||
userId?: number
|
||||
): Promise<PaginatedResult<AlbumResponse>> => {
|
||||
const params = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
pageSize: pageSize.toString()
|
||||
});
|
||||
if (searchQuery) params.append('searchQuery', searchQuery);
|
||||
if (userId) params.append('userId', userId.toString());
|
||||
|
||||
const response = await fetchApi(`/management/album/get_albums?${params.toString()}`);
|
||||
return response as PaginatedResult<AlbumResponse>;
|
||||
};
|
||||
|
||||
// Get album by ID
|
||||
export const getManagementAlbumById = async (id: number): Promise<BaseResult<AlbumResponse>> => {
|
||||
return fetchApi<AlbumResponse>(`/management/album/get_album/${id}`);
|
||||
};
|
||||
|
||||
// Create album
|
||||
export const createManagementAlbum = async (request: AlbumCreateRequest): Promise<BaseResult<AlbumResponse>> => {
|
||||
return fetchApi<AlbumResponse>(
|
||||
'/management/album/create_album',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// Update album
|
||||
export const updateManagementAlbum = async (id: number, request: AlbumUpdateRequest): Promise<BaseResult<AlbumResponse>> => {
|
||||
return fetchApi<AlbumResponse>(
|
||||
`/management/album/update_album/${id}`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// Delete album
|
||||
export const deleteManagementAlbum = async (id: number): Promise<BaseResult<boolean>> => {
|
||||
return fetchApi<boolean>(
|
||||
'/management/album/delete_album',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(id) // Backend expects int id in body
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// Batch delete albums
|
||||
export const batchDeleteManagementAlbums = async (ids: number[]): Promise<BaseResult<BatchDeleteResult>> => {
|
||||
return fetchApi<BatchDeleteResult>(
|
||||
'/management/album/batch_delete_albums',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(ids)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// Get pictures in album
|
||||
export const getPicturesInAlbum = async (
|
||||
albumId: number,
|
||||
page: number = 1,
|
||||
pageSize: number = 10
|
||||
): Promise<PaginatedResult<PictureResponse>> => {
|
||||
const params = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
pageSize: pageSize.toString()
|
||||
});
|
||||
const response = await fetchApi(`/management/album/${albumId}/pictures?${params.toString()}`);
|
||||
return response as PaginatedResult<PictureResponse>;
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './authApi';
|
||||
export * from './albumApi';
|
||||
export * from './albumManagementApi';
|
||||
export * from './backgroundTaskApi';
|
||||
export * from './configApi';
|
||||
export * from './fetchClient';
|
||||
|
||||
364
Web/src/pages/admin/album/Index.tsx
Normal file
364
Web/src/pages/admin/album/Index.tsx
Normal file
@@ -0,0 +1,364 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Table, Button, Card, Input, Space, Modal,
|
||||
message, Typography, Popconfirm, Row, Col, Image,
|
||||
AutoComplete, Form, Divider, Select, Tag
|
||||
} from 'antd';
|
||||
import {
|
||||
BookOutlined, DeleteOutlined, SearchOutlined, ExclamationCircleOutlined,
|
||||
ReloadOutlined, FilterOutlined, ClearOutlined, PlusOutlined, EditOutlined, PictureOutlined
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
getManagementAlbums, deleteManagementAlbum, batchDeleteManagementAlbums,
|
||||
createManagementAlbum, updateManagementAlbum,
|
||||
type AlbumCreateRequest, type AlbumUpdateRequest
|
||||
} from '../../../api/albumManagementApi';
|
||||
import { getUsers, getManagementPictures, type AlbumResponse } from '../../../api'; // Renamed to avoid conflict
|
||||
import { useOutletContext } from 'react-router';
|
||||
import type { Breakpoint } from 'antd';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { confirm } = Modal;
|
||||
|
||||
interface PictureOption {
|
||||
value: number;
|
||||
label: string;
|
||||
thumbnailPath?: string;
|
||||
}
|
||||
|
||||
const AlbumManagement: React.FC = () => {
|
||||
const { isMobile } = useOutletContext<{ isMobile: boolean }>();
|
||||
|
||||
const [albums, setAlbums] = useState<AlbumResponse[]>([]);
|
||||
const [userOptions, setUserOptions] = useState<{ value: number; label: string }[]>([]);
|
||||
const [pictureOptions, setPictureOptions] = useState<PictureOption[]>([]);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedUserId, setSelectedUserId] = useState<number | undefined>();
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [filterForm] = Form.useForm();
|
||||
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
const [editingAlbum, setEditingAlbum] = useState<AlbumResponse | null>(null);
|
||||
const [albumForm] = Form.useForm<AlbumCreateRequest | AlbumUpdateRequest>();
|
||||
|
||||
const searchUsers = useCallback(async (searchValue: string) => {
|
||||
if (!searchValue.trim()) {
|
||||
setUserOptions([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await getUsers({ page: 1, pageSize: 20, searchQuery: searchValue });
|
||||
if (response.success && response.data) {
|
||||
setUserOptions(response.data.map(user => ({ value: user.id, label: `${user.userName} (${user.email})` })));
|
||||
}
|
||||
} catch (error) { console.error('Error searching users:', error); }
|
||||
}, []);
|
||||
|
||||
const searchPicturesForCover = useCallback(async (searchValue: string) => {
|
||||
if (!searchValue.trim() && pictureOptions.length > 5) return; // Avoid frequent calls if not searching
|
||||
try {
|
||||
const response = await getManagementPictures(1, 20, searchValue);
|
||||
if (response.success && response.data) {
|
||||
setPictureOptions(response.data.map(pic => ({
|
||||
value: pic.id,
|
||||
label: `${pic.name || '未命名图片'} (ID: ${pic.id})`,
|
||||
thumbnailPath: pic.thumbnailPath || pic.path,
|
||||
})));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error searching pictures for cover:', error);
|
||||
message.error('搜索封面图片失败');
|
||||
}
|
||||
}, [pictureOptions.length]);
|
||||
|
||||
|
||||
const fetchAlbums = useCallback(async (
|
||||
page = currentPage, size = pageSize, query = searchQuery, userId = selectedUserId
|
||||
) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getManagementAlbums(page, size, query, userId);
|
||||
if (response.success && response.data) {
|
||||
setAlbums(response.data || []);
|
||||
setTotal(response.totalCount || 0);
|
||||
} else {
|
||||
message.error(response.message || '获取相册列表失败');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('获取相册列表失败,请检查网络连接');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [currentPage, pageSize, searchQuery, selectedUserId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAlbums();
|
||||
searchPicturesForCover(''); // Initial load for picture options
|
||||
}, [fetchAlbums, searchPicturesForCover]);
|
||||
|
||||
const handlePageChange = (page: number, size?: number) => {
|
||||
setCurrentPage(page);
|
||||
if (size) setPageSize(size);
|
||||
fetchAlbums(page, size || pageSize);
|
||||
};
|
||||
|
||||
const handleQuickSearch = (value: string) => {
|
||||
setSearchQuery(value);
|
||||
setCurrentPage(1);
|
||||
fetchAlbums(1, pageSize, value, selectedUserId);
|
||||
};
|
||||
|
||||
const handleFilter = async () => {
|
||||
const values = await filterForm.validateFields();
|
||||
setSearchQuery(values.searchQuery || '');
|
||||
setSelectedUserId(values.userId);
|
||||
setCurrentPage(1);
|
||||
fetchAlbums(1, pageSize, values.searchQuery, values.userId);
|
||||
};
|
||||
|
||||
const handleClearFilters = () => {
|
||||
filterForm.resetFields();
|
||||
setSearchQuery('');
|
||||
setSelectedUserId(undefined);
|
||||
setUserOptions([]);
|
||||
setCurrentPage(1);
|
||||
fetchAlbums(1, pageSize, '', undefined);
|
||||
};
|
||||
|
||||
const showCreateModal = () => {
|
||||
setEditingAlbum(null);
|
||||
albumForm.resetFields();
|
||||
albumForm.setFieldsValue({ coverPictureId: null }); // Ensure coverPictureId is null or undefined
|
||||
setIsModalVisible(true);
|
||||
};
|
||||
|
||||
const showEditModal = (album: AlbumResponse) => {
|
||||
setEditingAlbum(album);
|
||||
const formValues = {
|
||||
name: album.name,
|
||||
description: album.description,
|
||||
coverPictureId: album.coverPictureId, // 使用正确的 coverPictureId
|
||||
};
|
||||
|
||||
// 如果存在封面图片ID,并且该图片不在当前选项中,则尝试添加它以便Select可以正确显示
|
||||
if (album.coverPictureId && (album.coverPictureThumbnailPath || album.coverPicturePath)) {
|
||||
const existingOption = pictureOptions.find(opt => opt.value === album.coverPictureId);
|
||||
if (!existingOption) {
|
||||
// 为了在Select中显示当前封面,需要一个标签。
|
||||
// 理想情况下,AlbumResponse会包含封面图片的名称。
|
||||
// 此处使用文件名或ID作为后备标签。
|
||||
const pictureLabel = album.name ? `${album.name} (封面)` : `图片ID: ${album.coverPictureId}`;
|
||||
|
||||
const newPictureOption: PictureOption = {
|
||||
value: album.coverPictureId,
|
||||
label: pictureLabel, // 使用相册名或ID作为临时标签
|
||||
thumbnailPath: album.coverPictureThumbnailPath || album.coverPicturePath,
|
||||
};
|
||||
// 将当前封面图片添加到选项列表的开头,以便在Select中显示
|
||||
setPictureOptions(prevOptions => [newPictureOption, ...prevOptions.filter(opt => opt.value !== album.coverPictureId)]);
|
||||
}
|
||||
}
|
||||
|
||||
albumForm.setFieldsValue(formValues);
|
||||
setIsModalVisible(true);
|
||||
};
|
||||
|
||||
const handleModalOk = async () => {
|
||||
try {
|
||||
const values = await albumForm.validateFields() as AlbumCreateRequest | AlbumUpdateRequest;
|
||||
setLoading(true);
|
||||
let response;
|
||||
if (editingAlbum) {
|
||||
response = await updateManagementAlbum(editingAlbum.id, values);
|
||||
} else {
|
||||
response = await createManagementAlbum(values as AlbumCreateRequest);
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
message.success(editingAlbum ? '相册更新成功' : '相册创建成功');
|
||||
setIsModalVisible(false);
|
||||
fetchAlbums();
|
||||
} else {
|
||||
message.error(response.message || (editingAlbum ? '更新失败' : '创建失败'));
|
||||
}
|
||||
} catch (errorInfo) {
|
||||
console.log('Validate Failed:', errorInfo);
|
||||
message.error('请检查表单输入');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
const response = await deleteManagementAlbum(id);
|
||||
if (response.success) {
|
||||
message.success('相册删除成功');
|
||||
fetchAlbums();
|
||||
} else {
|
||||
message.error(response.message || '删除相册失败');
|
||||
}
|
||||
} catch (error) { message.error('删除相册失败'); }
|
||||
};
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
if (selectedRowKeys.length === 0) {
|
||||
message.warning('请选择要删除的相册');
|
||||
return;
|
||||
}
|
||||
confirm({
|
||||
title: `确定要删除 ${selectedRowKeys.length} 个相册吗?`,
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
content: '此操作不可逆,相册将被永久删除 (相册内图片不会被删除)',
|
||||
async onOk() {
|
||||
try {
|
||||
const response = await batchDeleteManagementAlbums(selectedRowKeys as number[]);
|
||||
if (response.success && response.data) {
|
||||
message.success(`成功删除 ${response.data.successCount} 个相册`);
|
||||
if (response.data.failedCount > 0) {
|
||||
message.warning(`${response.data.failedCount} 个相册删除失败`);
|
||||
}
|
||||
setSelectedRowKeys([]);
|
||||
fetchAlbums();
|
||||
} else {
|
||||
message.error(response.message || '批量删除失败');
|
||||
}
|
||||
} catch (error) { message.error('批量删除失败'); }
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID', dataIndex: 'id', key: 'id', responsive: ['md'] as Breakpoint[] },
|
||||
{
|
||||
title: '封面', dataIndex: 'coverPictureThumbnailPath', key: 'cover',
|
||||
render: (path?: string, record?: AlbumResponse) => (
|
||||
path ? <Image width={50} height={50} src={path} style={{ objectFit: 'cover', borderRadius: 4 }} />
|
||||
: record?.coverPicturePath ? <Image width={50} height={50} src={record.coverPicturePath} style={{ objectFit: 'cover', borderRadius: 4 }} />
|
||||
: <PictureOutlined style={{ fontSize: 30, color: '#ccc' }}/>
|
||||
),
|
||||
},
|
||||
{ title: '名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '描述', dataIndex: 'description', key: 'description', responsive: ['md'] as Breakpoint[], render: (desc: string) => desc || '-'},
|
||||
{ title: '图片数', dataIndex: 'pictureCount', key: 'pictureCount', render: (count: number) => <Tag>{count}</Tag> },
|
||||
{ title: '用户', dataIndex: 'username', key: 'username', responsive: ['lg'] as Breakpoint[] },
|
||||
{ title: '创建时间', dataIndex: 'createdAt', key: 'createdAt', responsive: ['lg'] as Breakpoint[], render: (date: Date) => new Date(date).toLocaleString() },
|
||||
{
|
||||
title: '操作', key: 'action',
|
||||
render: (_: any, record: AlbumResponse) => (
|
||||
<Space size="small" wrap>
|
||||
<Button type="link" icon={<EditOutlined />} onClick={() => showEditModal(record)}>编辑</Button>
|
||||
<Popconfirm title="确定删除此相册?" onConfirm={() => handleDelete(record.id)}>
|
||||
<Button type="link" danger icon={<DeleteOutlined />}>删除</Button>
|
||||
</Popconfirm>
|
||||
{/* Add 'Set Cover' and 'Manage Pictures' buttons here later */}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="album-management">
|
||||
<Row gutter={[16, 16]} align="middle" justify="space-between">
|
||||
<Col>
|
||||
<Space align="center">
|
||||
<BookOutlined style={{ fontSize: 24 }} />
|
||||
<Title level={2} style={{ margin: 0 }}>相册管理</Title>
|
||||
</Space>
|
||||
<Text type="secondary" style={{ marginTop: 8, display: 'block' }}>
|
||||
管理系统中的所有相册,包括创建、编辑、删除和批量操作
|
||||
</Text>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card style={{ marginTop: 16 }}>
|
||||
<Row gutter={[16, 16]} justify="space-between" style={{ marginBottom: 16 }}>
|
||||
<Col xs={24} sm={12} md={16}>
|
||||
<Space wrap>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={showCreateModal}>创建相册</Button>
|
||||
<Button danger icon={<DeleteOutlined />} onClick={handleBatchDelete} disabled={selectedRowKeys.length === 0}>批量删除</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => fetchAlbums()}>刷新</Button>
|
||||
<Button icon={<FilterOutlined />} onClick={() => setShowFilters(!showFilters)} type={showFilters ? 'primary' : 'default'}>高级筛选</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Input.Search placeholder="搜索相册名称或描述" allowClear enterButton={<SearchOutlined />} onSearch={handleQuickSearch} value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} />
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{showFilters && (
|
||||
<>
|
||||
<Card size="small" style={{ marginBottom: 16, backgroundColor: '#fafafa' }}>
|
||||
<Form form={filterForm} layout="inline" onFinish={handleFilter} initialValues={{ searchQuery, userId: selectedUserId }}>
|
||||
<Form.Item name="searchQuery" label="关键词"><Input placeholder="名称或描述" style={{ width: 200 }} /></Form.Item>
|
||||
<Form.Item name="userId" label="所属用户">
|
||||
<AutoComplete style={{ width: 250 }} options={userOptions} onSearch={searchUsers} placeholder="输入用户名或邮箱" allowClear filterOption={false} />
|
||||
</Form.Item>
|
||||
<Form.Item><Space><Button type="primary" htmlType="submit" icon={<SearchOutlined />}>筛选</Button><Button icon={<ClearOutlined />} onClick={handleClearFilters}>清除</Button></Space></Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
<Divider style={{ margin: '16px 0' }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={albums}
|
||||
loading={loading}
|
||||
pagination={{ current: currentPage, pageSize, total, showSizeChanger: true, showQuickJumper: true, onChange: handlePageChange, showTotal: (total) => `共 ${total} 条` }}
|
||||
rowSelection={{ selectedRowKeys, onChange: (keys) => setSelectedRowKeys(keys) }}
|
||||
size={isMobile ? "small" : "middle"}
|
||||
scroll={{ x: 'max-content' }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editingAlbum ? '编辑相册' : '创建新相册'}
|
||||
open={isModalVisible}
|
||||
onOk={handleModalOk}
|
||||
onCancel={() => setIsModalVisible(false)}
|
||||
confirmLoading={loading}
|
||||
destroyOnClose
|
||||
width={600}
|
||||
>
|
||||
<Form form={albumForm} layout="vertical" name="albumForm">
|
||||
<Form.Item name="name" label="相册名称" rules={[{ required: true, message: '请输入相册名称' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="相册描述">
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
<Form.Item name="coverPictureId" label="封面图片 (可选)">
|
||||
<Select
|
||||
showSearch
|
||||
allowClear
|
||||
placeholder="搜索并选择封面图片"
|
||||
onSearch={searchPicturesForCover}
|
||||
filterOption={false} // Server-side search
|
||||
notFoundContent={loading ? "搜索中..." : "无匹配图片"}
|
||||
options={pictureOptions}
|
||||
optionRender={(option) => (
|
||||
<Space>
|
||||
{option.data?.thumbnailPath && <Image src={option.data.thumbnailPath} width={30} height={30} preview={false} style={{objectFit: 'cover'}}/>}
|
||||
<span>{option.label}</span>
|
||||
</Space>
|
||||
)}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlbumManagement;
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { useOutletContext } from 'react-router';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { getUsers, getManagementPictures } from '../../../api';
|
||||
import { getUsers, getManagementPictures, getManagementAlbums } from '../../../api';
|
||||
import type { UserResponse, PictureResponse } from '../../../api';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
@@ -21,8 +21,6 @@ interface DashboardStats {
|
||||
totalAlbums: number;
|
||||
totalPhotos: number;
|
||||
storageUsagePercentage: number;
|
||||
newUsersToday: number;
|
||||
newPhotosToday: number;
|
||||
softwareVersion: string;
|
||||
systemVersion: string;
|
||||
cpuArchitecture: string;
|
||||
@@ -37,8 +35,6 @@ const AdminDashboard: React.FC = () => {
|
||||
totalAlbums: 0,
|
||||
totalPhotos: 0,
|
||||
storageUsagePercentage: 0,
|
||||
newUsersToday: 0,
|
||||
newPhotosToday: 0,
|
||||
softwareVersion: 'N/A',
|
||||
systemVersion: 'N/A',
|
||||
cpuArchitecture: 'N/A'
|
||||
@@ -85,40 +81,32 @@ const AdminDashboard: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 计算今日新增数据
|
||||
const calculateTodayStats = (users: UserResponse[], photos: PictureResponse[]) => {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const newUsersToday = users.filter(user => {
|
||||
const userDate = new Date(user.createdAt);
|
||||
userDate.setHours(0, 0, 0, 0);
|
||||
return userDate.getTime() === today.getTime();
|
||||
}).length;
|
||||
|
||||
const newPhotosToday = photos.filter(photo => {
|
||||
const photoDate = new Date(photo.createdAt);
|
||||
photoDate.setHours(0, 0, 0, 0);
|
||||
return photoDate.getTime() === today.getTime();
|
||||
}).length;
|
||||
|
||||
setStats(prev => ({
|
||||
...prev,
|
||||
newUsersToday,
|
||||
newPhotosToday
|
||||
}));
|
||||
// 获取相册总数
|
||||
const fetchTotalAlbums = async () => {
|
||||
try {
|
||||
// 我们只需要总数,所以 pageSize 可以设为 1
|
||||
const response = await getManagementAlbums(1, 1);
|
||||
if (response.success) {
|
||||
setStats(prev => ({
|
||||
...prev,
|
||||
totalAlbums: response.totalCount || 0
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching total albums:', error);
|
||||
message.error('获取相册总数失败');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await Promise.all([fetchRecentUsers(), fetchRecentPhotos()]);
|
||||
await Promise.all([fetchRecentUsers(), fetchRecentPhotos(), fetchTotalAlbums()]);
|
||||
|
||||
// 设置其他静态统计数据
|
||||
setStats(prev => ({
|
||||
...prev,
|
||||
totalAlbums: 348, // 相册功能暂未实现,使用模拟数据
|
||||
storageUsagePercentage: 68,
|
||||
softwareVersion: 'Foxel Dev 尝鲜版',
|
||||
systemVersion: 'Fedora 42',
|
||||
@@ -134,13 +122,6 @@ const AdminDashboard: React.FC = () => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
// 当用户和图片数据都加载完成后计算今日统计
|
||||
useEffect(() => {
|
||||
if (recentUsers.length > 0 && recentPhotos.length > 0) {
|
||||
calculateTodayStats(recentUsers, recentPhotos);
|
||||
}
|
||||
}, [recentUsers, recentPhotos]);
|
||||
|
||||
const userColumns = useMemo<ColumnsType<UserResponse>>(() => [
|
||||
{
|
||||
title: '用户名',
|
||||
@@ -237,11 +218,6 @@ const AdminDashboard: React.FC = () => {
|
||||
title="用户总数"
|
||||
value={stats.totalUsers}
|
||||
prefix={<UserOutlined />}
|
||||
suffix={
|
||||
<Tag color="green" style={{ marginLeft: 8 }}>
|
||||
<ArrowUpOutlined /> {stats.newUsersToday} 今日
|
||||
</Tag>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
@@ -260,11 +236,6 @@ const AdminDashboard: React.FC = () => {
|
||||
title="照片总数"
|
||||
value={stats.totalPhotos}
|
||||
prefix={<PictureOutlined />}
|
||||
suffix={
|
||||
<Tag color="green" style={{ marginLeft: 8 }}>
|
||||
<ArrowUpOutlined /> {stats.newPhotosToday} 今日
|
||||
</Tag>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
@@ -2,7 +2,8 @@ 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';
|
||||
Popconfirm, Modal, Form, Input, InputNumber, Select // Added InputNumber, Select
|
||||
} from 'antd';
|
||||
import {
|
||||
EditOutlined, DeleteOutlined, PlusOutlined} from '@ant-design/icons';
|
||||
import { getAlbumById, deleteAlbum, favoritePicture, unfavoritePicture, addPicturesToAlbum, updateAlbum } from '../../api';
|
||||
@@ -28,7 +29,8 @@ function AlbumDetail() {
|
||||
const [selectedPictures, setSelectedPictures] = useState<number[]>([]);
|
||||
const [editForm] = Form.useForm();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0); // 添加刷新触发器
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||
const [albumPicturesForSelect, setAlbumPicturesForSelect] = useState<PictureResponse[]>([]); // 用于编辑时选择封面
|
||||
|
||||
const loadAlbum = async () => {
|
||||
if (!id) return;
|
||||
@@ -37,10 +39,11 @@ function AlbumDetail() {
|
||||
const result = await getAlbumById(parseInt(id));
|
||||
if (result.success && result.data) {
|
||||
setAlbum(result.data);
|
||||
// 添加检查确保 updateBreadcrumbTitle 是一个函数
|
||||
if (typeof updateBreadcrumbTitle === 'function') {
|
||||
updateBreadcrumbTitle(result.data.name);
|
||||
}
|
||||
// 假设相册详情API返回了图片列表,或者需要另外获取
|
||||
// setAlbumPicturesForSelect(result.data.pictures || []);
|
||||
} else {
|
||||
message.error(result.message || '获取相册失败');
|
||||
}
|
||||
@@ -54,7 +57,22 @@ function AlbumDetail() {
|
||||
|
||||
useEffect(() => {
|
||||
loadAlbum();
|
||||
}, [id]);
|
||||
// 如果需要单独获取相册图片用于选择器:
|
||||
// if (id) fetchPicturesForAlbumSelect(parseInt(id));
|
||||
}, [id, refreshTrigger]); // refreshTrigger 确保编辑后重新加载图片列表(如果需要)
|
||||
|
||||
// 示例:获取相册内图片用于选择封面的函数
|
||||
// const fetchPicturesForAlbumSelect = async (albumId: number) => {
|
||||
// try {
|
||||
// // 替换为实际获取相册内图片的API调用
|
||||
// // const picturesResult = await getPicturesInAlbum(albumId, 1, 200); // 获取相册内所有图片
|
||||
// // if (picturesResult.success && picturesResult.data) {
|
||||
// // setAlbumPicturesForSelect(picturesResult.data);
|
||||
// // }
|
||||
// } catch (error) {
|
||||
// message.error('获取相册内图片列表失败');
|
||||
// }
|
||||
// };
|
||||
|
||||
const handleDeleteAlbum = async () => {
|
||||
if (!album) return;
|
||||
@@ -152,8 +170,12 @@ function AlbumDetail() {
|
||||
if (album) {
|
||||
editForm.setFieldsValue({
|
||||
name: album.name,
|
||||
description: album.description || ''
|
||||
description: album.description || '',
|
||||
coverPictureId: album.coverPictureId // 设置当前封面ID
|
||||
});
|
||||
// 如果 album.pictures 存在,可以用它来填充选择器
|
||||
// 或者在打开模态框时调用 fetchPicturesForAlbumSelect(album.id)
|
||||
// setAlbumPicturesForSelect(album.pictures || []); // 假设 album 对象包含 pictures 数组
|
||||
setIsEditModalVisible(true);
|
||||
}
|
||||
};
|
||||
@@ -164,12 +186,13 @@ function AlbumDetail() {
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
const values = await editForm.validateFields();
|
||||
const values = await editForm.validateFields(); // values 包含 name, description, coverPictureId
|
||||
|
||||
const result = await updateAlbum({
|
||||
id: album.id,
|
||||
name: values.name,
|
||||
description: values.description
|
||||
description: values.description,
|
||||
coverPictureId: values.coverPictureId // 传递封面ID
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
@@ -350,7 +373,8 @@ function AlbumDetail() {
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
name: album.name,
|
||||
description: album.description || ''
|
||||
description: album.description || '',
|
||||
coverPictureId: album.coverPictureId // 初始化表单的封面ID
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
@@ -371,6 +395,36 @@ function AlbumDetail() {
|
||||
showCount
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="coverPictureId"
|
||||
label="封面图片 (可选)"
|
||||
tooltip="从当前相册中选择一张图片作为封面。实际应用中应为图片选择器。"
|
||||
>
|
||||
{/*
|
||||
实际应用中替换为图片选择器,例如:
|
||||
<Select
|
||||
showSearch
|
||||
allowClear
|
||||
placeholder="选择封面图片"
|
||||
// loading={loadingAlbumPicturesForSelect}
|
||||
filterOption={(input, option) =>
|
||||
option?.label.toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
options={albumPicturesForSelect.map(p => ({
|
||||
value: p.id,
|
||||
label: p.name || `图片 ${p.id}`,
|
||||
thumbnail: p.thumbnailPath || p.path
|
||||
}))}
|
||||
// optionRender={(option) => (
|
||||
// <Space>
|
||||
// {option.data.thumbnail && <img src={option.data.thumbnail} alt={option.label} style={{width: 24, height: 24, objectFit: 'cover'}}/>}
|
||||
// <span>{option.label}</span>
|
||||
// </Space>
|
||||
// )}
|
||||
/>
|
||||
*/}
|
||||
<InputNumber placeholder="输入封面图片ID" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Typography, Row, Col, Card, Button, Modal, Form, Input, Spin, Empty, message, Popconfirm } from 'antd';
|
||||
import { Typography, Row, Col, Card, Button, Modal, Form, Input, Spin, Empty, message, Popconfirm, InputNumber, Select } 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';
|
||||
@@ -43,7 +43,7 @@ function Albums() {
|
||||
|
||||
const handleCreateAlbum = async (values: CreateAlbumRequest) => {
|
||||
try {
|
||||
const result = await createAlbum(values);
|
||||
const result = await createAlbum(values); // values 现在可以包含 coverPictureId
|
||||
if (result.success && result.data) {
|
||||
message.success('相册创建成功');
|
||||
setIsCreateModalVisible(false);
|
||||
@@ -58,14 +58,16 @@ function Albums() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditAlbum = async (values: UpdateAlbumRequest) => {
|
||||
const handleEditAlbum = async (values: Omit<UpdateAlbumRequest, 'id'>) => { // Form values don't include id
|
||||
if (!currentAlbum) return;
|
||||
|
||||
try {
|
||||
const result = await updateAlbum({
|
||||
const requestData: UpdateAlbumRequest = {
|
||||
...values,
|
||||
id: currentAlbum.id
|
||||
});
|
||||
id: currentAlbum.id,
|
||||
// coverPictureId 来自表单 values.coverPictureId
|
||||
};
|
||||
const result = await updateAlbum(requestData);
|
||||
|
||||
if (result.success && result.data) {
|
||||
message.success('相册更新成功');
|
||||
@@ -101,7 +103,8 @@ function Albums() {
|
||||
setCurrentAlbum(album);
|
||||
editForm.setFieldsValue({
|
||||
name: album.name,
|
||||
description: album.description
|
||||
description: album.description,
|
||||
coverPictureId: album.coverPictureId // 设置当前封面ID
|
||||
});
|
||||
setIsEditModalVisible(true);
|
||||
};
|
||||
@@ -188,12 +191,16 @@ function Albums() {
|
||||
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'
|
||||
}} />
|
||||
{album.coverPictureThumbnailPath || album.coverPicturePath ? (
|
||||
<img
|
||||
alt={album.name}
|
||||
src={album.coverPictureThumbnailPath || album.coverPicturePath}
|
||||
style={{
|
||||
height: 180,
|
||||
width: '100%',
|
||||
objectFit: 'cover'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div style={{
|
||||
height: 180,
|
||||
@@ -271,6 +278,32 @@ function Albums() {
|
||||
>
|
||||
<TextArea placeholder="描述一下这个相册" rows={4} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="coverPictureId"
|
||||
label="封面图片 (可选)"
|
||||
tooltip="输入图片ID。实际应用中应为图片选择器。"
|
||||
>
|
||||
{/*
|
||||
实际应用中替换为图片选择器,例如:
|
||||
<Select
|
||||
showSearch
|
||||
allowClear
|
||||
placeholder="选择封面图片"
|
||||
loading={loadingUserPictures}
|
||||
filterOption={(input, option) =>
|
||||
option?.label.toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
options={userPictures.map(p => ({ value: p.id, label: p.name || `图片 ${p.id}`, thumbnail: p.thumbnailPath }))}
|
||||
// optionRender={(option) => (
|
||||
// <Space>
|
||||
// {option.data.thumbnail && <img src={option.data.thumbnail} alt={option.label} style={{width: 24, height: 24, objectFit: 'cover'}}/>}
|
||||
// <span>{option.label}</span>
|
||||
// </Space>
|
||||
// )}
|
||||
/>
|
||||
*/}
|
||||
<InputNumber placeholder="输入封面图片ID" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item style={{ marginBottom: 0, textAlign: 'right' }}>
|
||||
<Button style={{ marginRight: 8 }} onClick={() => {
|
||||
setIsCreateModalVisible(false);
|
||||
@@ -314,6 +347,18 @@ function Albums() {
|
||||
>
|
||||
<TextArea placeholder="相册描述" rows={4} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="coverPictureId"
|
||||
label="封面图片 (可选)"
|
||||
tooltip="输入图片ID。实际应用中应为图片选择器,图片源为当前相册内图片或用户所有图片。"
|
||||
>
|
||||
{/*
|
||||
实际应用中替换为图片选择器。
|
||||
如果从当前相册选择,需要获取相册内图片列表。
|
||||
如果像管理员一样从所有图片选择,则使用类似创建时的逻辑。
|
||||
*/}
|
||||
<InputNumber placeholder="输入封面图片ID" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item style={{ marginBottom: 0, textAlign: 'right' }}>
|
||||
<Button style={{ marginRight: 8 }} onClick={() => {
|
||||
setIsEditModalVisible(false);
|
||||
|
||||
@@ -26,6 +26,7 @@ import PictureManagement from '../pages/admin/pictures/Index';
|
||||
import UserDetail from '../pages/admin/users/UserDetail';
|
||||
import AdminLogManagement from '../pages/admin/log/Index';
|
||||
import StorageManagementPage from '../pages/admin/storage/StorageManagement';
|
||||
import AlbumManagement from '../pages/admin/album/Index';
|
||||
|
||||
export interface RouteConfig {
|
||||
path: string;
|
||||
@@ -175,6 +176,18 @@ const routes: RouteConfig[] = [
|
||||
title: '图片管理'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'albums-admin',
|
||||
key: 'admin-album',
|
||||
icon: <FolderOutlined />,
|
||||
label: '相册管理',
|
||||
element: <AlbumManagement />,
|
||||
area: 'admin',
|
||||
groupLabel: '内容管理',
|
||||
breadcrumb: {
|
||||
title: '相册管理'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'log',
|
||||
key: 'admin-log',
|
||||
|
||||
Reference in New Issue
Block a user