diff --git a/models/Base/File.ts b/models/Base/File.ts new file mode 100644 index 0000000..8368205 --- /dev/null +++ b/models/Base/File.ts @@ -0,0 +1,207 @@ +interface FileUploadOptions { + allowedTypes?: string[]; + maxSize?: number; + onProgress?: (progress: number) => void; +} + +interface FileUploadResponse { + success: boolean; + data?: { + id: string; + filename: string; + url: string; + size: number; + type: string; + }; + error?: string; +} + +interface FileParseResponse { + success: boolean; + data?: any; + error?: string; +} + +export class FileAPI { + private baseUrl: string; + + constructor(baseUrl: string = '/api/files') { + this.baseUrl = baseUrl; + } + + async upload(file: File, options: FileUploadOptions = {}): Promise { + const { allowedTypes, maxSize, onProgress } = options; + + // Validate file type + if (allowedTypes && !allowedTypes.includes(file.type)) { + return { + success: false, + error: `File type ${file.type} is not allowed` + }; + } + + // Validate file size + if (maxSize && file.size > maxSize) { + return { + success: false, + error: `File size exceeds maximum limit of ${maxSize} bytes` + }; + } + + const formData = new FormData(); + formData.append('file', file); + + try { + const xhr = new XMLHttpRequest(); + + return new Promise((resolve, reject) => { + xhr.upload.addEventListener('progress', (event) => { + if (event.lengthComputable && onProgress) { + const progress = (event.loaded / event.total) * 100; + onProgress(progress); + } + }); + + xhr.onload = () => { + if (xhr.status === 200) { + try { + const response = JSON.parse(xhr.responseText); + resolve(response); + } catch (error) { + resolve({ + success: false, + error: 'Invalid response format' + }); + } + } else { + resolve({ + success: false, + error: `Upload failed with status ${xhr.status}` + }); + } + }; + + xhr.onerror = () => { + resolve({ + success: false, + error: 'Network error during upload' + }); + }; + + xhr.open('POST', `${this.baseUrl}/upload`); + xhr.send(formData); + }); + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }; + } + } + + async parse(fileId: string, format?: string): Promise { + try { + const url = new URL(`${this.baseUrl}/parse/${fileId}`, window.location.origin); + if (format) { + url.searchParams.append('format', format); + } + + const response = await fetch(url.toString(), { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + return { + success: false, + error: `Parse failed with status ${response.status}` + }; + } + + const result = await response.json(); + return result; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }; + } + } + + async delete(fileId: string): Promise<{ success: boolean; error?: string }> { + try { + const response = await fetch(`${this.baseUrl}/${fileId}`, { + method: 'DELETE' + }); + + if (!response.ok) { + return { + success: false, + error: `Delete failed with status ${response.status}` + }; + } + + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }; + } + } + + async getInfo(fileId: string): Promise { + try { + const response = await fetch(`${this.baseUrl}/${fileId}`); + + if (!response.ok) { + return { + success: false, + error: `Failed to get file info with status ${response.status}` + }; + } + + const result = await response.json(); + return result; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }; + } + } + + validateFile(file: File, options: FileUploadOptions = {}): { valid: boolean; error?: string } { + const { allowedTypes, maxSize } = options; + + if (allowedTypes && !allowedTypes.includes(file.type)) { + return { + valid: false, + error: `File type ${file.type} is not allowed` + }; + } + + if (maxSize && file.size > maxSize) { + return { + valid: false, + error: `File size exceeds maximum limit of ${maxSize} bytes` + }; + } + + return { valid: true }; + } + + formatFileSize(bytes: number): string { + if (bytes === 0) return '0 Bytes'; + + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + } +} + +export const fileAPI = new FileAPI(); \ No newline at end of file diff --git a/pages/dashboard/project/[id].tsx b/pages/dashboard/project/[id].tsx index 42b0e11..df20fbc 100644 --- a/pages/dashboard/project/[id].tsx +++ b/pages/dashboard/project/[id].tsx @@ -1,202 +1,268 @@ -import { ConsultMessage, User, UserRole } from '@idea2app/data-server'; -import { Avatar, Button, Container, Paper, TextField, Typography } from '@mui/material'; -import { marked } from 'marked'; -import { observer } from 'mobx-react'; -import { ObservedComponent, reaction } from 'mobx-react-helper'; -import { compose, JWTProps, jwtVerifier, RouteProps, router } from 'next-ssr-middleware'; -import { FormEvent, KeyboardEventHandler } from 'react'; -import { formToJSON, scrollTo, sleep } from 'web-utility'; +import { useState, useRef, useCallback } from 'react'; +import { useRouter } from 'next/router'; +import { Upload, X, FileText, Image } from 'lucide-react'; -import { PageHead } from '../../../components/PageHead'; -import { EvaluationDisplay } from '../../../components/Project/EvaluationDisplay'; -import { ScrollList } from '../../../components/ScrollList'; -import { SessionBox } from '../../../components/User/SessionBox'; -import { ConsultMessageModel, ProjectModel } from '../../../models/ProjectEvaluation'; -import { i18n, I18nContext } from '../../../models/Translation'; - -type ProjectEvaluationPageProps = JWTProps & RouteProps<{ id: string }>; +interface FileItem { + id: string; + name: string; + size: number; + type: string; + content: string; +} -export const getServerSideProps = compose<{}, ProjectEvaluationPageProps>(jwtVerifier(), router); +export default function ProjectRequirementPage() { + const router = useRouter(); + const { id } = router.query; + const [requirement, setRequirement] = useState(''); + const [uploadedFiles, setUploadedFiles] = useState([]); + const [isDragOver, setIsDragOver] = useState(false); + const fileInputRef = useRef(null); + const textareaRef = useRef(null); -@observer -export default class ProjectEvaluationPage extends ObservedComponent< - ProjectEvaluationPageProps, - typeof i18n -> { - static contextType = I18nContext; + const processFile = useCallback(async (file: File): Promise => { + const id = Date.now().toString() + Math.random().toString(36).substr(2, 9); + + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = (e) => { + const content = e.target?.result as string; + resolve({ + id, + name: file.name, + size: file.size, + type: file.type, + content + }); + }; + + reader.onerror = () => reject(new Error('文件读取失败')); + + if (file.type.startsWith('text/') || file.name.endsWith('.md') || file.name.endsWith('.txt')) { + reader.readAsText(file); + } else if (file.type.startsWith('image/')) { + reader.readAsDataURL(file); + } else { + reader.readAsText(file); + } + }); + }, []); - projectId = +this.props.route!.params!.id; + const handleFileSelect = useCallback(async (files: FileList) => { + const filePromises = Array.from(files).map(processFile); + try { + const processedFiles = await Promise.all(filePromises); + setUploadedFiles(prev => [...prev, ...processedFiles]); + + // 如果是文本文件,将内容添加到需求输入框 + const textFiles = processedFiles.filter(file => + file.type.startsWith('text/') || file.name.endsWith('.md') || file.name.endsWith('.txt') + ); + + if (textFiles.length > 0) { + const textContent = textFiles.map(file => `\n\n--- ${file.name} ---\n${file.content}`).join(''); + setRequirement(prev => prev + textContent); + } + } catch (error) { + console.error('文件处理失败:', error); + } + }, [processFile]); - projectStore = new ProjectModel(); + const handleFileUpload = useCallback(() => { + fileInputRef.current?.click(); + }, []); - messageStore = new ConsultMessageModel(this.projectId); + const handleFileInputChange = useCallback((e: React.ChangeEvent) => { + const files = e.target.files; + if (files && files.length > 0) { + handleFileSelect(files); + } + // 重置input值以便重复选择同一文件 + e.target.value = ''; + }, [handleFileSelect]); - get menu() { - const { t } = this.observedContext; + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setIsDragOver(true); + }, []); - return [ - { href: '/dashboard', title: t('overview') }, - { href: `/dashboard/project/${this.projectId}`, title: t('project_evaluation') }, - ]; - } + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setIsDragOver(false); + }, []); - componentDidMount() { - super.componentDidMount(); + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setIsDragOver(false); + + const files = e.dataTransfer.files; + if (files && files.length > 0) { + handleFileSelect(files); + } + }, [handleFileSelect]); - this.projectStore.getOne(this.projectId); - } + const handlePaste = useCallback((e: React.ClipboardEvent) => { + const items = e.clipboardData?.items; + if (!items) return; - @reaction(({ messageStore }) => messageStore.allItems) - async handleMessageChange() { - await sleep(); + for (const item of Array.from(items)) { + if (item.kind === 'file') { + const file = item.getAsFile(); + if (file) { + handleFileSelect([file] as any); + } + } + } + }, [handleFileSelect]); - scrollTo('#last-message'); - } + const removeFile = useCallback((fileId: string) => { + setUploadedFiles(prev => prev.filter(file => file.id !== fileId)); + }, []); - handleMessageSubmit = async (event: FormEvent) => { - event.preventDefault(); + const formatFileSize = useCallback((bytes: number): string => { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + }, []); - const form = event.currentTarget; + const getFileIcon = useCallback((type: string, name: string) => { + if (type.startsWith('image/')) { + return ; + } + return ; + }, []); - let { content } = formToJSON<{ content: string }>(form); + return ( +
+
+
+
+

项目需求评估

+

请详细描述您的项目需求,或上传相关文档

+
- content = content.trim(); +
+
+ {/* 文件上传区域 */} +
+
+ + +
- if (!content) return; + {/* 拖拽上传区域 */} +
+ +

+ 拖拽文件到此处上传,或{' '} + +

+

+ 支持 TXT, MD, 图片等格式 +

+
- await this.messageStore.updateOne({ content }); + {/* 已上传文件列表 */} + {uploadedFiles.length > 0 && ( +
+

已上传文件

+
+ {uploadedFiles.map((file) => ( +
+
+ {getFileIcon(file.type, file.name)} +
+

{file.name}

+

{formatFileSize(file.size)}

+
+
+ +
+ ))} +
+
+ )} +
- form.reset(); - }; + {/* 需求输入区域 */} +
+ +