diff --git a/backend/routes/projects.js b/backend/routes/projects.js index ac95bbd..6247851 100644 --- a/backend/routes/projects.js +++ b/backend/routes/projects.js @@ -697,15 +697,16 @@ router.post('/:id/phases/init', async (req, res) => { router.put('/:id/phases/:phaseId/complete', async (req, res) => { try { const { id, phaseId } = req.params; - const { remark, photos, completed_by } = req.body; + const { remark, photos, completed_by, attachments } = req.body; const phaseResult = await db.query('SELECT * FROM project_phases WHERE id = $1 AND project_id = $2', [phaseId, id]); if (phaseResult.rows.length === 0) { return res.status(404).json({ success: false, message: '阶段不存在' }); } const phase = phaseResult.rows[0]; + const allAttachments = attachments || photos || phase.photos || []; await db.query( `UPDATE project_phases SET status = 'completed', completed_at = NOW(), completed_by = $1, remark = $2, photos = $3 WHERE id = $4`, - [completed_by || null, remark || phase.remark, photos || phase.photos, phaseId] + [completed_by || null, remark || phase.remark, JSON.stringify(allAttachments), phaseId] ); const allPhases = await db.query('SELECT * FROM project_phases WHERE project_id = $1 ORDER BY phase_order', [id]); const completedCount = allPhases.rows.filter(p => p.status === 'completed').length; diff --git a/frontend/src/pages/construction/ConstructionProgress.tsx b/frontend/src/pages/construction/ConstructionProgress.tsx index 9c5e463..d4d2846 100644 --- a/frontend/src/pages/construction/ConstructionProgress.tsx +++ b/frontend/src/pages/construction/ConstructionProgress.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react'; import { Card, Button, Progress, Tag, Checkbox, Input, Upload, Space, Spin, message, List, Typography, Modal, Image, Divider } from 'antd'; -import { ArrowLeftOutlined, CameraOutlined, CheckCircleOutlined, ClockCircleOutlined, MinusCircleOutlined, UndoOutlined } from '@ant-design/icons'; +import { ArrowLeftOutlined, CameraOutlined, CheckCircleOutlined, ClockCircleOutlined, MinusCircleOutlined, UndoOutlined, UploadOutlined, PaperClipOutlined, EyeOutlined } from '@ant-design/icons'; import { useNavigate, useParams } from 'react-router-dom'; import apiClient from '../../utils/request'; @@ -15,12 +15,28 @@ interface Phase { depends_on: number[]; status: string; started_at: string | null; - completed_at: string | null; + completed_at: string | string | null; remark: string | null; photos: string[]; + attachments: any[]; sub_items: { name: string; completed: boolean }[]; } +const isImageUrl = (url: string) => { + if (!url) return false; + const lower = url.toLowerCase(); + return lower.match(/\.(jpg|jpeg|png|gif|bmp|webp|svg)(\?|$)/) !== null; +}; + +const getFileName = (url: string) => { + if (!url) return '附件'; + const parts = url.split('/'); + const last = parts[parts.length - 1]; + const decoded = decodeURIComponent(last); + const noTimestamp = decoded.replace(/^\d+_/, ''); + return noTimestamp; +}; + const ConstructionProgress: React.FC = () => { const { id } = useParams<{ id: string }>(); const [project, setProject] = useState(null); @@ -28,6 +44,9 @@ const ConstructionProgress: React.FC = () => { const [loading, setLoading] = useState(false); const [completingPhaseId, setCompletingPhaseId] = useState(null); const [remark, setRemark] = useState(''); + const [confirmAttachments, setConfirmAttachments] = useState([]); + const [previewVisible, setPreviewVisible] = useState(false); + const [previewUrl, setPreviewUrl] = useState(''); const navigate = useNavigate(); useEffect(() => { if (id) { fetchProject(); fetchPhases(); } }, [id]); @@ -51,14 +70,42 @@ const ConstructionProgress: React.FC = () => { const handleCompletePhase = (phaseId: number) => { setCompletingPhaseId(phaseId); setRemark(''); + setConfirmAttachments([]); + }; + + const handleUpload = async (file: File) => { + try { + const formData = new FormData(); + formData.append('file', file); + const res = await apiClient.post('/upload/single', formData); + if (res.data.success) { + const uploaded = res.data.data; + setConfirmAttachments(prev => [...prev, { + uid: String(Date.now()), + name: uploaded.name || file.name, + url: uploaded.url, + isImage: uploaded.isImage, + status: 'done', + }]); + } + } catch (e) { + message.error('上传失败'); + } + return false; + }; + + const handleRemoveAttachment = (uid: string) => { + setConfirmAttachments(prev => prev.filter(a => a.uid !== uid)); }; const confirmComplete = async () => { if (!completingPhaseId || !id) return; try { + const attachmentUrls = confirmAttachments.map(a => a.url); const res = await apiClient.put(`/projects/${id}/phases/${completingPhaseId}/complete`, { remark, completed_by: 1, + attachments: attachmentUrls, }); if (res.data.success) { message.success(`阶段完成!进度: ${res.data.data.progress}%`); @@ -117,6 +164,50 @@ const ConstructionProgress: React.FC = () => { } }; + const renderAttachments = (attachments: any[]) => { + if (!attachments || attachments.length === 0) return null; + const images = attachments.filter(a => isImageUrl(typeof a === 'string' ? a : a.url)); + const files = attachments.filter(a => !isImageUrl(typeof a === 'string' ? a : a.url)); + + return ( +
+ {images.length > 0 && ( +
+ + {images.map((a, i) => { + const url = typeof a === 'string' ? a : a.url; + return ( + }} + fallback="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mN88P/BfwAJhAPk2iMa1AAAAABJRU5ErkJggg==" + /> + ); + })} + +
+ )} + {files.length > 0 && ( +
+ {files.map((a, i) => { + const url = typeof a === 'string' ? a : a.url; + const name = typeof a === 'string' ? getFileName(a) : a.name || getFileName(a.url); + return ( +
+ {name} +
+ ); + })} +
+ )} +
+ ); + }; + const currentPhase = phases.find(p => p.status === 'in_progress'); const completedPhases = phases.filter(p => p.status === 'completed').reverse(); const pendingPhases = phases.filter(p => p.status === 'pending'); @@ -213,6 +304,7 @@ const ConstructionProgress: React.FC = () => { {phase.remark && {phase.remark}} + {renderAttachments(phase.attachments || phase.photos)} ))} @@ -254,6 +346,39 @@ const ConstructionProgress: React.FC = () => { > 确认此阶段已完成?系统将自动推进到下一阶段。 {remark && 备注:{remark}} + + + +
+ 上传证明材料(照片/文件): +
+ { handleUpload(file); return false; }} + onRemove={(file) => handleRemoveAttachment(file.uid)} + fileList={confirmAttachments.map(a => ({ + uid: a.uid, + name: a.name, + status: 'done' as const, + url: a.url, + thumbUrl: a.isImage ? a.url : undefined, + }))} + listType="picture" + accept="image/*,.pdf,.doc,.docx,.xls,.xlsx" + multiple + > + + +
+ 支持照片、PDF、Word、Excel等文件 +
+ + + setPreviewVisible(false)} + > + ); diff --git a/frontend/src/pages/projects/ProjectsPage.tsx b/frontend/src/pages/projects/ProjectsPage.tsx index be81254..bc23a30 100644 --- a/frontend/src/pages/projects/ProjectsPage.tsx +++ b/frontend/src/pages/projects/ProjectsPage.tsx @@ -61,7 +61,7 @@ const ProjectsPage: React.FC = () => { setProjects(response.data.data.map((p: Project) => ({ ...p, key: p.id.toString(), - progress: Math.floor(Math.random() * 100), + progress: p.status === 'completed' ? 100 : (p.phase_progress || 0), manager_name: p.manager_name || '未分配' }))); } else {