mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-05-20 07:40:57 +08:00
feat(task-processor): implement PictureTaskProcessor for background image processing tasks
This commit is contained in:
@@ -1,29 +1,36 @@
|
||||
import { fetchApi, type BaseResult } from './fetchClient';
|
||||
import type { ProcessingStatus } from './pictureApi';
|
||||
|
||||
// 图片处理任务
|
||||
export interface PictureProcessingTask {
|
||||
pictureId: number;
|
||||
// 通用任务视图模型
|
||||
export interface TaskDetailsViewModel {
|
||||
taskId: string;
|
||||
pictureName: string;
|
||||
status: ProcessingStatus;
|
||||
taskName: string; // 任务的描述性名称
|
||||
taskType: number; // 任务类型 (例如 0 for "PictureProcessing")
|
||||
status: TaskExecutionStatus; // 修改: 类型将是数字枚举
|
||||
progress: number; // 0-100
|
||||
error?: string;
|
||||
createdAt: Date;
|
||||
completedAt?: Date;
|
||||
relatedEntityId?: number;
|
||||
}
|
||||
// 修改: TaskExecutionStatus 定义为数字枚举
|
||||
export enum TaskExecutionStatus {
|
||||
Pending = 0, // 等待处理
|
||||
Processing = 1, // 处理中
|
||||
Completed = 2, // 处理完成
|
||||
Failed = 3 // 处理失败
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户的所有处理任务
|
||||
*/
|
||||
export const getUserTasks = async (): Promise<BaseResult<PictureProcessingTask[]>> => {
|
||||
return fetchApi<PictureProcessingTask[]>('/background-tasks/user-tasks');
|
||||
export const getUserTasks = async (): Promise<BaseResult<TaskDetailsViewModel[]>> => {
|
||||
return fetchApi<TaskDetailsViewModel[]>('/background-tasks/user-tasks');
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取特定图片的处理状态
|
||||
* 获取特定图片的处理状态 (实际获取的是与该图片关联的任务状态)
|
||||
* @param pictureId 图片ID
|
||||
*/
|
||||
export const getPictureProcessingStatus = async (pictureId: number): Promise<BaseResult<PictureProcessingTask>> => {
|
||||
return fetchApi<PictureProcessingTask>(`/background-tasks/picture-status/${pictureId}`);
|
||||
export const getPictureTaskExecutionStatus = async (pictureId: number): Promise<BaseResult<TaskDetailsViewModel>> => {
|
||||
return fetchApi<TaskDetailsViewModel>(`/background-tasks/picture-status/${pictureId}`);
|
||||
};
|
||||
|
||||
@@ -20,16 +20,7 @@ export interface FilteredPicturesRequest {
|
||||
includeAllPublic?: boolean;
|
||||
}
|
||||
|
||||
// 将类型定义改为枚举,这样既可以作为类型也可以作为值使用
|
||||
export type ProcessingStatus = 'Pending' | 'Processing' | 'Completed' | 'Failed';
|
||||
|
||||
// 添加常量对象提供运行时值
|
||||
export const ProcessingStatus = {
|
||||
Pending: 'Pending' as ProcessingStatus,
|
||||
Processing: 'Processing' as ProcessingStatus,
|
||||
Completed: 'Completed' as ProcessingStatus,
|
||||
Failed: 'Failed' as ProcessingStatus
|
||||
};
|
||||
|
||||
// 图片响应数据
|
||||
export interface PictureResponse {
|
||||
@@ -49,9 +40,6 @@ export interface PictureResponse {
|
||||
permission: number;
|
||||
albumId?: number;
|
||||
albumName?: string;
|
||||
processingStatus: ProcessingStatus;
|
||||
processingError?: string;
|
||||
processingProgress: number;
|
||||
}
|
||||
|
||||
// 收藏请求
|
||||
|
||||
@@ -6,10 +6,10 @@ import {
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { ProcessingStatus } from '../api';
|
||||
import { TaskExecutionStatus } from '../api';
|
||||
|
||||
interface TaskProgressBarProps {
|
||||
status: ProcessingStatus;
|
||||
status: TaskExecutionStatus; // status 现在是数字
|
||||
progress: number;
|
||||
error?: string;
|
||||
showLabel?: boolean;
|
||||
@@ -32,26 +32,26 @@ const TaskProgressBar: React.FC<TaskProgressBarProps> = ({
|
||||
let statusText = '';
|
||||
let progressStatus: "success" | "exception" | "active" | "normal" | undefined;
|
||||
|
||||
switch (status) {
|
||||
case ProcessingStatus.Pending:
|
||||
switch (status) { // status 现在是数字
|
||||
case TaskExecutionStatus.Pending: // 使用数字枚举成员
|
||||
statusColor = 'orange';
|
||||
progressStatus = 'normal';
|
||||
icon = <ClockCircleOutlined />;
|
||||
statusText = '等待中';
|
||||
break;
|
||||
case ProcessingStatus.Processing:
|
||||
case TaskExecutionStatus.Processing: // 使用数字枚举成员
|
||||
statusColor = 'processing';
|
||||
progressStatus = 'active';
|
||||
icon = <SyncOutlined spin />;
|
||||
statusText = '处理中';
|
||||
break;
|
||||
case ProcessingStatus.Completed:
|
||||
case TaskExecutionStatus.Completed: // 使用数字枚举成员
|
||||
statusColor = 'success';
|
||||
progressStatus = 'success';
|
||||
icon = <CheckCircleOutlined />;
|
||||
statusText = '已完成';
|
||||
break;
|
||||
case ProcessingStatus.Failed:
|
||||
case TaskExecutionStatus.Failed: // 使用数字枚举成员
|
||||
statusColor = 'error';
|
||||
progressStatus = 'exception';
|
||||
icon = <CloseCircleOutlined />;
|
||||
@@ -66,7 +66,7 @@ const TaskProgressBar: React.FC<TaskProgressBarProps> = ({
|
||||
<Tag color={statusColor} icon={icon} style={{ marginRight: 8 }}>
|
||||
{statusText}
|
||||
</Tag>
|
||||
{status === ProcessingStatus.Failed && error && (
|
||||
{status === TaskExecutionStatus.Failed && error && ( // 使用数字枚举成员
|
||||
<Tooltip title={error}>
|
||||
<span style={{ color: '#ff4d4f', cursor: 'pointer', fontSize: 13 }}>
|
||||
查看错误
|
||||
@@ -81,7 +81,7 @@ const TaskProgressBar: React.FC<TaskProgressBarProps> = ({
|
||||
size={size}
|
||||
status={progressStatus}
|
||||
showInfo={size !== 'small'}
|
||||
strokeColor={status === ProcessingStatus.Failed ? '#ff4d4f' : undefined}
|
||||
strokeColor={status === TaskExecutionStatus.Failed ? '#ff4d4f' : undefined} // 使用数字枚举成员
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
205
Web/src/components/image/ImageInfo.css
Normal file
205
Web/src/components/image/ImageInfo.css
Normal file
@@ -0,0 +1,205 @@
|
||||
/* 抽屉基础样式 */
|
||||
.imageinfo-drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 350px;
|
||||
height: 100vh;
|
||||
z-index: 1050;
|
||||
background-color: rgba(28, 30, 34, 0.5);
|
||||
backdrop-filter: blur(24px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(180%);
|
||||
border: none;
|
||||
box-shadow: -10px 0 30px rgba(0, 0, 0, 0.2);
|
||||
transition: transform 0.3s ease;
|
||||
transform: translateX(100%);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.imageinfo-drawer-visible {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.imageinfo-header {
|
||||
background-color: rgba(28, 30, 34, 0.6);
|
||||
backdrop-filter: blur(24px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(180%);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.imageinfo-header-title {
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.imageinfo-close-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.imageinfo-body {
|
||||
padding: 24px 20px;
|
||||
height: calc(100% - 57px);
|
||||
scrollbar-width: none;
|
||||
overflow-y: auto;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 标题样式 */
|
||||
.imageinfo-title-container {
|
||||
padding: 0 0 16px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.imageinfo-title {
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.imageinfo-date {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 描述区域 */
|
||||
.imageinfo-desc-section {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
backdrop-filter: blur(10px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(10px) saturate(180%);
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.imageinfo-desc-text {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
line-height: 1.6;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
-webkit-line-clamp: 8;
|
||||
}
|
||||
.imageinfo-desc-text-expand {
|
||||
-webkit-line-clamp: unset;
|
||||
}
|
||||
.imageinfo-expand-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
cursor: pointer;
|
||||
padding: 8px 0 0 0;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 标签区域 */
|
||||
.imageinfo-tags-section {
|
||||
margin-bottom: 20px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.imageinfo-tag-title {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.imageinfo-tag-item {
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 16px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
border: none;
|
||||
padding: 4px 12px;
|
||||
margin: 0 8px 8px 0;
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 规格信息区 */
|
||||
.imageinfo-specs-section {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
backdrop-filter: blur(10px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(10px) saturate(180%);
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
margin: 16px 0 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.imageinfo-specs-container {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
text-align: center;
|
||||
}
|
||||
.imageinfo-spec-item {
|
||||
padding: 0 8px;
|
||||
flex: 1;
|
||||
}
|
||||
.imageinfo-spec-value {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
margin-bottom: 4px;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.imageinfo-spec-label {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
/* EXIF信息区 */
|
||||
.imageinfo-exif-container {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.imageinfo-exif-category {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.imageinfo-exif-divider {
|
||||
border-color: rgba(255, 255, 255, 0.08) !important;
|
||||
margin: 10px 0 16px !important;
|
||||
font-size: 14px !important;
|
||||
color: rgba(255, 255, 255, 0.8) !important;
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
.imageinfo-exif-table {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.imageinfo-exif-row {
|
||||
display: flex;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.imageinfo-exif-label {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
padding: 8px 12px;
|
||||
width: 100px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.imageinfo-exif-value {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.imageinfo-exif-empty {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Divider } from 'antd';
|
||||
import { CloseOutlined, DownOutlined, UpOutlined } from '@ant-design/icons';
|
||||
import type { PictureResponse } from '../../api';
|
||||
import './ImageViewer.css';
|
||||
import './ImageInfo.css';
|
||||
|
||||
interface ImageInfoProps {
|
||||
image: PictureResponse;
|
||||
@@ -25,7 +26,7 @@ const ImageInfo: React.FC<ImageInfoProps> = ({
|
||||
// 格式化EXIF数据
|
||||
const formatExifInfo = (exifInfo: any) => {
|
||||
if (!exifInfo) return [];
|
||||
|
||||
|
||||
// 定义EXIF信息分类
|
||||
const categories = {
|
||||
basic: { title: "基本信息", items: [] as any[] },
|
||||
@@ -34,18 +35,18 @@ const ImageInfo: React.FC<ImageInfoProps> = ({
|
||||
time: { title: "时间信息", items: [] as any[] },
|
||||
location: { title: "位置信息", items: [] as any[] }
|
||||
};
|
||||
|
||||
|
||||
// 将EXIF信息映射到对应字段
|
||||
const exifMapping: Record<string, { key: string; category: keyof typeof categories; formatter?: (value: any) => string }> = {
|
||||
// 基本信息
|
||||
width: { key: "width", category: "basic", formatter: (v) => `${v}px` },
|
||||
height: { key: "height", category: "basic", formatter: (v) => `${v}px` },
|
||||
|
||||
|
||||
// 相机信息
|
||||
cameraMaker: { key: "make", category: "camera" },
|
||||
cameraModel: { key: "model", category: "camera" },
|
||||
software: { key: "software", category: "camera" },
|
||||
|
||||
|
||||
// 拍摄参数
|
||||
exposureTime: { key: "exposureTime", category: "settings" },
|
||||
aperture: { key: "fNumber", category: "settings", formatter: (v) => `f/${v}` },
|
||||
@@ -54,9 +55,9 @@ const ImageInfo: React.FC<ImageInfoProps> = ({
|
||||
flash: { key: "flash", category: "settings" },
|
||||
meteringMode: { key: "meteringMode", category: "settings" },
|
||||
whiteBalance: { key: "whiteBalance", category: "settings" },
|
||||
dateTimeOriginal: {
|
||||
key: "dateTime",
|
||||
category: "time",
|
||||
dateTimeOriginal: {
|
||||
key: "dateTime",
|
||||
category: "time",
|
||||
formatter: (v) => {
|
||||
if (typeof v === 'string' && v.match(/^\d{4}:\d{2}:\d{2} \d{2}:\d{2}:\d{2}$/)) {
|
||||
const normalized = v.replace(/^(\d{4}):(\d{2}):(\d{2})/, '$1-$2-$3');
|
||||
@@ -65,24 +66,24 @@ const ImageInfo: React.FC<ImageInfoProps> = ({
|
||||
return date.toLocaleString();
|
||||
}
|
||||
}
|
||||
return v.toString();
|
||||
return v.toString();
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
// 位置信息
|
||||
gpsLatitude: { key: "latitude", category: "location" },
|
||||
gpsLongitude: { key: "longitude", category: "location" }
|
||||
};
|
||||
|
||||
|
||||
// 处理每个EXIF字段
|
||||
Object.entries(exifInfo).forEach(([key, value]) => {
|
||||
if (value === null || value === undefined || value === '') return;
|
||||
|
||||
|
||||
const mapping = exifMapping[key];
|
||||
if (mapping) {
|
||||
const formattedValue = mapping.formatter ? mapping.formatter(value) : value.toString();
|
||||
const label = formatExifLabel(mapping.key);
|
||||
|
||||
|
||||
categories[mapping.category].items.push({
|
||||
key: mapping.key,
|
||||
label,
|
||||
@@ -90,7 +91,7 @@ const ImageInfo: React.FC<ImageInfoProps> = ({
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// 返回包含数据的分类
|
||||
return Object.values(categories).filter(category => category.items.length > 0);
|
||||
};
|
||||
@@ -101,12 +102,12 @@ const ImageInfo: React.FC<ImageInfoProps> = ({
|
||||
// 基本信息
|
||||
width: "宽度",
|
||||
height: "高度",
|
||||
|
||||
|
||||
// 相机信息
|
||||
make: "相机品牌",
|
||||
model: "相机型号",
|
||||
software: "软件",
|
||||
|
||||
|
||||
// 拍摄参数
|
||||
exposureTime: "曝光时间",
|
||||
fNumber: "光圈值",
|
||||
@@ -115,43 +116,43 @@ const ImageInfo: React.FC<ImageInfoProps> = ({
|
||||
flash: "闪光灯",
|
||||
meteringMode: "测光模式",
|
||||
whiteBalance: "白平衡",
|
||||
|
||||
|
||||
// 时间信息
|
||||
dateTime: "拍摄时间",
|
||||
|
||||
|
||||
// 位置信息
|
||||
latitude: "纬度",
|
||||
longitude: "经度"
|
||||
};
|
||||
|
||||
|
||||
return labels[key] || key.charAt(0).toUpperCase() + key.slice(1).replace(/([A-Z])/g, ' $1');
|
||||
};
|
||||
|
||||
// 渲染EXIF信息
|
||||
const renderExifInfo = (styles: any) => {
|
||||
if (!image?.exifInfo) return <div style={{ color: 'rgba(255, 255, 255, 0.6)' }}>无EXIF信息</div>;
|
||||
|
||||
const renderExifInfo = () => {
|
||||
if (!image?.exifInfo) return <div className="imageinfo-exif-empty">无EXIF信息</div>;
|
||||
|
||||
const formattedCategories = formatExifInfo(image.exifInfo);
|
||||
|
||||
|
||||
if (formattedCategories.length === 0) {
|
||||
return <div style={{ color: 'rgba(255, 255, 255, 0.6)' }}>无EXIF信息</div>;
|
||||
return <div className="imageinfo-exif-empty">无EXIF信息</div>;
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div style={styles.exifContainer}>
|
||||
<div className="imageinfo-exif-container">
|
||||
{formattedCategories.map(category => (
|
||||
<div key={category.title} style={styles.exifCategory}>
|
||||
<div key={category.title} className="imageinfo-exif-category">
|
||||
<Divider
|
||||
orientation="left"
|
||||
style={styles.divider}
|
||||
className="imageinfo-exif-divider"
|
||||
>
|
||||
{category.title}
|
||||
</Divider>
|
||||
<div style={styles.exifTable}>
|
||||
<div className="imageinfo-exif-table">
|
||||
{category.items.map(item => (
|
||||
<div key={item.key} style={{ display: 'flex', borderBottom: '1px solid rgba(255, 255, 255, 0.05)' }}>
|
||||
<div style={styles.exifLabel}>{item.label}</div>
|
||||
<div style={styles.exifValue}>{item.value}</div>
|
||||
<div key={item.key} className="imageinfo-exif-row">
|
||||
<div className="imageinfo-exif-label">{item.label}</div>
|
||||
<div className="imageinfo-exif-value">{item.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -161,211 +162,31 @@ const ImageInfo: React.FC<ImageInfoProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
// 定义内联样式对象
|
||||
const styles = {
|
||||
// 抽屉基础样式
|
||||
drawer: {
|
||||
position: 'fixed' as const,
|
||||
top: 0,
|
||||
right: 0,
|
||||
width: '350px',
|
||||
height: '100%',
|
||||
zIndex: 1050,
|
||||
backgroundColor: 'rgba(28, 30, 34, 0.5)',
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: 'none',
|
||||
boxShadow: '-10px 0 30px rgba(0, 0, 0, 0.2)',
|
||||
transition: 'transform 0.3s ease',
|
||||
transform: visible ? 'translateX(0)' : 'translateX(100%)',
|
||||
overflowY: 'auto' as const
|
||||
},
|
||||
header: {
|
||||
backgroundColor: 'rgba(28, 30, 34, 0.6)',
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
borderBottom: '1px solid rgba(255, 255, 255, 0.08)',
|
||||
color: 'rgba(255, 255, 255, 0.95)',
|
||||
padding: '16px 20px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
},
|
||||
headerTitle: {
|
||||
color: 'rgba(255, 255, 255, 0.95)',
|
||||
margin: 0,
|
||||
fontSize: '16px',
|
||||
fontWeight: 500
|
||||
},
|
||||
closeButton: {
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'rgba(255, 255, 255, 0.8)',
|
||||
cursor: 'pointer',
|
||||
padding: '4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
body: {
|
||||
padding: '24px 20px',
|
||||
color: 'white'
|
||||
},
|
||||
// 标题样式
|
||||
titleContainer: {
|
||||
padding: '0 0 16px 0',
|
||||
borderBottom: '1px solid rgba(255, 255, 255, 0.08)',
|
||||
marginBottom: '20px'
|
||||
},
|
||||
title: {
|
||||
color: 'rgba(255, 255, 255, 0.95)',
|
||||
margin: '0 0 4px 0',
|
||||
fontSize: '18px',
|
||||
fontWeight: 500,
|
||||
textShadow: '0 1px 2px rgba(0, 0, 0, 0.1)'
|
||||
},
|
||||
date: {
|
||||
color: 'rgba(255, 255, 255, 0.6)',
|
||||
fontSize: '13px'
|
||||
},
|
||||
// 描述区域
|
||||
descSection: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
||||
backdropFilter: 'blur(10px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(10px) saturate(180%)',
|
||||
padding: '16px',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '20px',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)'
|
||||
},
|
||||
descText: {
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
lineHeight: '1.6',
|
||||
display: '-webkit-box',
|
||||
WebkitBoxOrient: 'vertical' as const,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
WebkitLineClamp: expandDescription ? 'unset' : 8
|
||||
},
|
||||
expandButton: {
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'rgba(255, 255, 255, 0.7)',
|
||||
cursor: 'pointer',
|
||||
padding: '8px 0 0 0',
|
||||
fontSize: '13px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
// 标签区域
|
||||
tagsSection: {
|
||||
marginBottom: '20px',
|
||||
padding: '0 4px'
|
||||
},
|
||||
tagTitle: {
|
||||
color: 'rgba(255, 255, 255, 0.8)',
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
marginBottom: '12px'
|
||||
},
|
||||
tagItem: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.15)',
|
||||
borderRadius: '16px',
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
border: 'none',
|
||||
padding: '4px 12px',
|
||||
margin: '0 8px 8px 0',
|
||||
display: 'inline-block',
|
||||
fontSize: '12px'
|
||||
},
|
||||
// 规格信息区
|
||||
specsSection: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
||||
backdropFilter: 'blur(10px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(10px) saturate(180%)',
|
||||
padding: '16px',
|
||||
borderRadius: '8px',
|
||||
margin: '16px 0 20px',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)'
|
||||
},
|
||||
specsContainer: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-around',
|
||||
textAlign: 'center' as const
|
||||
},
|
||||
specItem: {
|
||||
padding: '0 8px',
|
||||
flex: 1
|
||||
},
|
||||
specValue: {
|
||||
fontSize: '15px',
|
||||
fontWeight: 500,
|
||||
color: 'rgba(255, 255, 255, 0.95)',
|
||||
marginBottom: '4px',
|
||||
textShadow: '0 1px 2px rgba(0, 0, 0, 0.1)'
|
||||
},
|
||||
specLabel: {
|
||||
fontSize: '12px',
|
||||
color: 'rgba(255, 255, 255, 0.6)'
|
||||
},
|
||||
// EXIF信息区
|
||||
exifContainer: {
|
||||
marginTop: '10px'
|
||||
},
|
||||
exifCategory: {
|
||||
marginBottom: '20px'
|
||||
},
|
||||
divider: {
|
||||
borderColor: 'rgba(255, 255, 255, 0.08)',
|
||||
margin: '10px 0 16px',
|
||||
fontSize: '14px',
|
||||
color: 'rgba(255, 255, 255, 0.8)',
|
||||
fontWeight: 500
|
||||
},
|
||||
exifTable: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)'
|
||||
},
|
||||
exifLabel: {
|
||||
color: 'rgba(255, 255, 255, 0.7)',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.2)',
|
||||
padding: '8px 12px',
|
||||
width: '100px',
|
||||
fontSize: '13px'
|
||||
},
|
||||
exifValue: {
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
||||
padding: '8px 12px',
|
||||
fontSize: '13px'
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={styles.drawer}>
|
||||
<div style={styles.header}>
|
||||
<h3 style={styles.headerTitle}>图片信息</h3>
|
||||
<button style={styles.closeButton} onClick={onClose}>
|
||||
<div
|
||||
className={`imageinfo-drawer${visible ? ' imageinfo-drawer-visible' : ''}`}
|
||||
>
|
||||
<div className="imageinfo-header">
|
||||
<h3 className="imageinfo-header-title">图片信息</h3>
|
||||
<button className="imageinfo-close-btn" onClick={onClose}>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
</div>
|
||||
<div style={styles.body}>
|
||||
<div style={styles.titleContainer}>
|
||||
<h4 style={styles.title}>{image?.name}</h4>
|
||||
<div style={styles.date}>上传于{new Date(image?.createdAt).toLocaleString()}</div>
|
||||
<div className="imageinfo-body">
|
||||
<div className="imageinfo-title-container">
|
||||
<h4 className="imageinfo-title">{image?.name}</h4>
|
||||
<div className="imageinfo-date">上传于{new Date(image?.createdAt).toLocaleString()}</div>
|
||||
</div>
|
||||
|
||||
|
||||
{image?.description && (
|
||||
<div style={styles.descSection}>
|
||||
<div style={styles.descText}>{image.description}</div>
|
||||
<div className="imageinfo-desc-section">
|
||||
<div
|
||||
className={`imageinfo-desc-text${expandDescription ? ' imageinfo-desc-text-expand' : ''}`}
|
||||
>
|
||||
{image.description}
|
||||
</div>
|
||||
{image.description.split('\n').length > 8 || image.description.length > 200 ? (
|
||||
<button style={styles.expandButton} onClick={toggleDescription}>
|
||||
<button className="imageinfo-expand-btn" onClick={toggleDescription}>
|
||||
{expandDescription ? (
|
||||
<>收起 <UpOutlined style={{ fontSize: '12px', marginLeft: '4px' }} /></>
|
||||
) : (
|
||||
@@ -375,37 +196,37 @@ const ImageInfo: React.FC<ImageInfoProps> = ({
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{image?.tags && image.tags.length > 0 && (
|
||||
<div style={styles.tagsSection}>
|
||||
<div style={styles.tagTitle}>标签</div>
|
||||
<div className="imageinfo-tags-section">
|
||||
<div className="imageinfo-tag-title">标签</div>
|
||||
<div>
|
||||
{image.tags.map(tag => (
|
||||
<span key={tag} style={styles.tagItem}>#{tag}</span>
|
||||
<span key={tag} className="imageinfo-tag-item">#{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{image?.exifInfo && (
|
||||
<div style={styles.specsSection}>
|
||||
<div style={styles.specsContainer}>
|
||||
<div style={styles.specItem}>
|
||||
<div style={styles.specValue}>{image.exifInfo.width}×{image.exifInfo.height}</div>
|
||||
<div style={styles.specLabel}>分辨率</div>
|
||||
<div className="imageinfo-specs-section">
|
||||
<div className="imageinfo-specs-container">
|
||||
<div className="imageinfo-spec-item">
|
||||
<div className="imageinfo-spec-value">{image.exifInfo.width}×{image.exifInfo.height}</div>
|
||||
<div className="imageinfo-spec-label">分辨率</div>
|
||||
</div>
|
||||
{image.exifInfo.focalLength && (
|
||||
<div style={styles.specItem}>
|
||||
<div style={styles.specValue}>{image.exifInfo.focalLength}</div>
|
||||
<div style={styles.specLabel}>焦距</div>
|
||||
<div className="imageinfo-spec-item">
|
||||
<div className="imageinfo-spec-value">{image.exifInfo.focalLength}</div>
|
||||
<div className="imageinfo-spec-label">焦距</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* 渲染EXIF信息 */}
|
||||
{renderExifInfo(styles)}
|
||||
{renderExifInfo()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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';
|
||||
import { getUserTasks, TaskExecutionStatus } from '../../api';
|
||||
import { type TaskDetailsViewModel } from '../../api';
|
||||
import TaskProgressBar from '../../components/TaskProgressBar';
|
||||
import dayjs from 'dayjs';
|
||||
import { Link } from 'react-router';
|
||||
@@ -10,8 +10,16 @@ import type { ColumnType } from 'antd/es/table';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
// 定义任务类型映射
|
||||
const taskTypeDisplayMapping: { [key: number]: string } = {
|
||||
0: '图片处理', // 对应后端的 PictureProcessing = 0
|
||||
// 如果有其他任务类型,在此处添加
|
||||
// 1: '视频处理',
|
||||
// 2: '数据导出',
|
||||
};
|
||||
|
||||
const BackgroundTasks: React.FC = () => {
|
||||
const [tasks, setTasks] = useState<PictureProcessingTask[]>([]);
|
||||
const [tasks, setTasks] = useState<TaskDetailsViewModel[]>([]); // Updated type
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [pollingActive, setPollingActive] = useState(true);
|
||||
const [pollingInterval, setPollingIntervalState] = useState<number | null>(null);
|
||||
@@ -40,61 +48,63 @@ const BackgroundTasks: React.FC = () => {
|
||||
|
||||
// 设置轮询
|
||||
if (pollingActive) {
|
||||
const interval = setInterval(fetchTasks, 3000);
|
||||
setPollingIntervalState(interval as unknown as number);
|
||||
const interval = setInterval(fetchTasks, 3000); // 轮询间隔调整为3秒
|
||||
setPollingIntervalState(interval as unknown as number); // 保存 interval ID
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (pollingInterval) {
|
||||
clearInterval(pollingInterval);
|
||||
clearInterval(pollingInterval); // 清除 interval
|
||||
}
|
||||
};
|
||||
}, [fetchTasks, pollingActive]);
|
||||
}, [fetchTasks, pollingActive]); // 依赖项中移除 pollingInterval
|
||||
|
||||
// 检查是否有活跃的任务,如果没有则停止轮询
|
||||
useEffect(() => {
|
||||
const hasActiveTasks = tasks.some(
|
||||
task => task.status === ProcessingStatus.Pending || task.status === ProcessingStatus.Processing
|
||||
task => task.status === TaskExecutionStatus.Pending || task.status === TaskExecutionStatus.Processing // 使用数字枚举成员
|
||||
);
|
||||
|
||||
|
||||
if (!hasActiveTasks && pollingActive) {
|
||||
setPollingActive(false);
|
||||
if (pollingInterval) {
|
||||
clearInterval(pollingInterval);
|
||||
setPollingIntervalState(null);
|
||||
}
|
||||
} else if (hasActiveTasks && !pollingActive) {
|
||||
} else if (hasActiveTasks && !pollingActive && tasks.length > 0) { // 确保有任务才重新激活轮询
|
||||
setPollingActive(true);
|
||||
const interval = setInterval(fetchTasks, 3000);
|
||||
setPollingIntervalState(interval as unknown as number);
|
||||
// 不需要在这里重新创建 interval,上面的 useEffect 会处理
|
||||
}
|
||||
}, [tasks, pollingActive, pollingInterval, fetchTasks]);
|
||||
}, [tasks, pollingActive, pollingInterval, fetchTasks]); // 保持依赖项
|
||||
|
||||
// 渲染状态标签
|
||||
const renderStatus = (status: ProcessingStatus) => {
|
||||
const renderStatus = (status: TaskExecutionStatus) => { // status 现在是数字
|
||||
let color = '';
|
||||
let text = '';
|
||||
let icon = null;
|
||||
|
||||
switch (status) {
|
||||
case ProcessingStatus.Pending:
|
||||
case TaskExecutionStatus.Pending: // 使用数字枚举成员
|
||||
color = 'orange';
|
||||
text = '等待中';
|
||||
icon = <SyncOutlined spin />;
|
||||
break;
|
||||
case ProcessingStatus.Processing:
|
||||
case TaskExecutionStatus.Processing: // 使用数字枚举成员
|
||||
color = 'processing';
|
||||
text = '处理中';
|
||||
icon = <SyncOutlined spin />;
|
||||
break;
|
||||
case ProcessingStatus.Completed:
|
||||
case TaskExecutionStatus.Completed: // 使用数字枚举成员
|
||||
color = 'success';
|
||||
text = '已完成';
|
||||
break;
|
||||
case ProcessingStatus.Failed:
|
||||
case TaskExecutionStatus.Failed: // 使用数字枚举成员
|
||||
color = 'error';
|
||||
text = '失败';
|
||||
break;
|
||||
default:
|
||||
text = `未知状态 (${status})`;
|
||||
break;
|
||||
}
|
||||
|
||||
return <Tag color={color} icon={icon}>{text}</Tag>;
|
||||
@@ -115,40 +125,49 @@ const BackgroundTasks: React.FC = () => {
|
||||
};
|
||||
|
||||
// 表格列定义
|
||||
const columns: ColumnType<PictureProcessingTask>[] = [
|
||||
const columns: ColumnType<TaskDetailsViewModel>[] = [ // Updated type
|
||||
{
|
||||
title: '图片名称',
|
||||
dataIndex: 'pictureName',
|
||||
key: 'pictureName',
|
||||
render: (text: string, record: PictureProcessingTask) => (
|
||||
<Link to={`/pictures/${record.pictureId}`}>{text}</Link>
|
||||
title: '任务名称', // Changed title
|
||||
dataIndex: 'taskName', // Changed dataIndex
|
||||
key: 'taskName',
|
||||
render: (text: string, record: TaskDetailsViewModel) => ( // Updated type and logic
|
||||
record.taskType === 0 && record.relatedEntityId // 修正: 使用数字 0 比较
|
||||
? <Link to={`/pictures/${record.relatedEntityId}`}>{text}</Link>
|
||||
: text
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status: ProcessingStatus) => renderStatus(status),
|
||||
render: (status: TaskExecutionStatus) => renderStatus(status), // status 现在是数字
|
||||
filters: [
|
||||
{ text: '等待中', value: ProcessingStatus.Pending },
|
||||
{ text: '处理中', value: ProcessingStatus.Processing },
|
||||
{ text: '已完成', value: ProcessingStatus.Completed },
|
||||
{ text: '失败', value: ProcessingStatus.Failed },
|
||||
{ text: '等待中', value: TaskExecutionStatus.Pending }, // 使用数字枚举成员
|
||||
{ text: '处理中', value: TaskExecutionStatus.Processing }, // 使用数字枚举成员
|
||||
{ text: '已完成', value: TaskExecutionStatus.Completed }, // 使用数字枚举成员
|
||||
{ text: '失败', value: TaskExecutionStatus.Failed }, // 使用数字枚举成员
|
||||
],
|
||||
onFilter: (value, record: PictureProcessingTask) =>
|
||||
record.status === value.toString(),
|
||||
onFilter: (value, record: TaskDetailsViewModel) =>
|
||||
record.status === (value as TaskExecutionStatus), // value 已经是数字
|
||||
},
|
||||
{
|
||||
title: '任务类型',
|
||||
dataIndex: 'taskType',
|
||||
key: 'taskType',
|
||||
render: (taskType: number | undefined) => // 接收数字类型的 taskType
|
||||
taskType !== undefined ? taskTypeDisplayMapping[taskType] || `未知类型 (${taskType})` : '-',
|
||||
},
|
||||
{
|
||||
title: '进度',
|
||||
dataIndex: 'progress',
|
||||
key: 'progress',
|
||||
render: (progress: number, record: PictureProcessingTask) => (
|
||||
<TaskProgressBar
|
||||
status={record.status}
|
||||
progress={progress}
|
||||
error={record.error}
|
||||
render: (progress: number, record: TaskDetailsViewModel) => ( // Updated type
|
||||
<TaskProgressBar
|
||||
status={record.status}
|
||||
progress={progress}
|
||||
error={record.error}
|
||||
showLabel={false}
|
||||
size="small"
|
||||
size="small"
|
||||
style={{ width: '150px' }}
|
||||
/>
|
||||
),
|
||||
@@ -158,29 +177,31 @@ const BackgroundTasks: React.FC = () => {
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
render: (date: Date) => formatDate(date),
|
||||
sorter: (a: PictureProcessingTask, b: PictureProcessingTask) =>
|
||||
sorter: (a: TaskDetailsViewModel, b: TaskDetailsViewModel) => // Updated type
|
||||
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
||||
},
|
||||
{
|
||||
title: '完成时间',
|
||||
dataIndex: 'completedAt',
|
||||
key: 'completedAt',
|
||||
render: (date: Date) => formatDate(date),
|
||||
render: (date: Date | undefined) => formatDate(date), // Ensure date can be undefined
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_: any, record: PictureProcessingTask) => (
|
||||
render: (_: any, record: TaskDetailsViewModel) => ( // Updated type
|
||||
<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
|
||||
{record.taskType === 0 && record.relatedEntityId && (
|
||||
<Link to={`/pictures/${record.relatedEntityId}`}>
|
||||
<Button type="link" icon={<EyeOutlined />} size="small">
|
||||
查看关联图片
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
{record.status === TaskExecutionStatus.Failed && record.error && ( // 使用数字枚举成员
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
size="small"
|
||||
onClick={() => showErrorMessage(record.error!)}
|
||||
>
|
||||
@@ -194,50 +215,50 @@ const BackgroundTasks: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="background-tasks-container">
|
||||
<div style={{
|
||||
marginBottom: 30,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
<div style={{
|
||||
marginBottom: 30,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}>
|
||||
<div>
|
||||
<Title level={2} style={{
|
||||
margin: 0,
|
||||
marginBottom: 10,
|
||||
fontWeight: 600,
|
||||
<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={{
|
||||
<Text type="secondary" style={{
|
||||
fontSize: 16,
|
||||
color: '#888',
|
||||
letterSpacing: '0.3px'
|
||||
}}>查看和管理图片后台处理任务</Text>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SyncOutlined />}
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SyncOutlined />}
|
||||
onClick={fetchTasks}
|
||||
loading={loading}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
<Card>
|
||||
{tasks.length > 0 ? (
|
||||
<Table
|
||||
dataSource={tasks}
|
||||
columns={columns}
|
||||
<Table
|
||||
dataSource={tasks}
|
||||
columns={columns}
|
||||
rowKey="taskId"
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 10 }}
|
||||
/>
|
||||
) : (
|
||||
<Empty
|
||||
<Empty
|
||||
description={
|
||||
loading ? "正在加载..." : "暂无处理任务"
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"erasableSyntaxOnly": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user