修复进度条假数据+施工确认添加附件上传+后端支持attachments
This commit is contained in:
@@ -697,15 +697,16 @@ router.post('/:id/phases/init', async (req, res) => {
|
|||||||
router.put('/:id/phases/:phaseId/complete', async (req, res) => {
|
router.put('/:id/phases/:phaseId/complete', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id, phaseId } = req.params;
|
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]);
|
const phaseResult = await db.query('SELECT * FROM project_phases WHERE id = $1 AND project_id = $2', [phaseId, id]);
|
||||||
if (phaseResult.rows.length === 0) {
|
if (phaseResult.rows.length === 0) {
|
||||||
return res.status(404).json({ success: false, message: '阶段不存在' });
|
return res.status(404).json({ success: false, message: '阶段不存在' });
|
||||||
}
|
}
|
||||||
const phase = phaseResult.rows[0];
|
const phase = phaseResult.rows[0];
|
||||||
|
const allAttachments = attachments || photos || phase.photos || [];
|
||||||
await db.query(
|
await db.query(
|
||||||
`UPDATE project_phases SET status = 'completed', completed_at = NOW(), completed_by = $1, remark = $2, photos = $3 WHERE id = $4`,
|
`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 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;
|
const completedCount = allPhases.rows.filter(p => p.status === 'completed').length;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
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 { 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 { useNavigate, useParams } from 'react-router-dom';
|
||||||
import apiClient from '../../utils/request';
|
import apiClient from '../../utils/request';
|
||||||
|
|
||||||
@@ -15,12 +15,28 @@ interface Phase {
|
|||||||
depends_on: number[];
|
depends_on: number[];
|
||||||
status: string;
|
status: string;
|
||||||
started_at: string | null;
|
started_at: string | null;
|
||||||
completed_at: string | null;
|
completed_at: string | string | null;
|
||||||
remark: string | null;
|
remark: string | null;
|
||||||
photos: string[];
|
photos: string[];
|
||||||
|
attachments: any[];
|
||||||
sub_items: { name: string; completed: boolean }[];
|
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 ConstructionProgress: React.FC = () => {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const [project, setProject] = useState<any>(null);
|
const [project, setProject] = useState<any>(null);
|
||||||
@@ -28,6 +44,9 @@ const ConstructionProgress: React.FC = () => {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [completingPhaseId, setCompletingPhaseId] = useState<number | null>(null);
|
const [completingPhaseId, setCompletingPhaseId] = useState<number | null>(null);
|
||||||
const [remark, setRemark] = useState('');
|
const [remark, setRemark] = useState('');
|
||||||
|
const [confirmAttachments, setConfirmAttachments] = useState<any[]>([]);
|
||||||
|
const [previewVisible, setPreviewVisible] = useState(false);
|
||||||
|
const [previewUrl, setPreviewUrl] = useState('');
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => { if (id) { fetchProject(); fetchPhases(); } }, [id]);
|
useEffect(() => { if (id) { fetchProject(); fetchPhases(); } }, [id]);
|
||||||
@@ -51,14 +70,42 @@ const ConstructionProgress: React.FC = () => {
|
|||||||
const handleCompletePhase = (phaseId: number) => {
|
const handleCompletePhase = (phaseId: number) => {
|
||||||
setCompletingPhaseId(phaseId);
|
setCompletingPhaseId(phaseId);
|
||||||
setRemark('');
|
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 () => {
|
const confirmComplete = async () => {
|
||||||
if (!completingPhaseId || !id) return;
|
if (!completingPhaseId || !id) return;
|
||||||
try {
|
try {
|
||||||
|
const attachmentUrls = confirmAttachments.map(a => a.url);
|
||||||
const res = await apiClient.put(`/projects/${id}/phases/${completingPhaseId}/complete`, {
|
const res = await apiClient.put(`/projects/${id}/phases/${completingPhaseId}/complete`, {
|
||||||
remark,
|
remark,
|
||||||
completed_by: 1,
|
completed_by: 1,
|
||||||
|
attachments: attachmentUrls,
|
||||||
});
|
});
|
||||||
if (res.data.success) {
|
if (res.data.success) {
|
||||||
message.success(`阶段完成!进度: ${res.data.data.progress}%`);
|
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 (
|
||||||
|
<div style={{ marginTop: 8, marginLeft: 28 }}>
|
||||||
|
{images.length > 0 && (
|
||||||
|
<div style={{ marginBottom: 4 }}>
|
||||||
|
<Image.PreviewGroup>
|
||||||
|
{images.map((a, i) => {
|
||||||
|
const url = typeof a === 'string' ? a : a.url;
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
key={i}
|
||||||
|
src={url}
|
||||||
|
width={48}
|
||||||
|
height={48}
|
||||||
|
style={{ borderRadius: 4, objectFit: 'cover', marginRight: 4, cursor: 'pointer' }}
|
||||||
|
preview={{ mask: <EyeOutlined /> }}
|
||||||
|
fallback="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mN88P/BfwAJhAPk2iMa1AAAAABJRU5ErkJggg=="
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Image.PreviewGroup>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{files.length > 0 && (
|
||||||
|
<div>
|
||||||
|
{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 (
|
||||||
|
<div key={i} style={{ fontSize: 12, color: '#1890ff', marginBottom: 2 }}>
|
||||||
|
<PaperClipOutlined /> <a href={url} target="_blank" rel="noopener">{name}</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const currentPhase = phases.find(p => p.status === 'in_progress');
|
const currentPhase = phases.find(p => p.status === 'in_progress');
|
||||||
const completedPhases = phases.filter(p => p.status === 'completed').reverse();
|
const completedPhases = phases.filter(p => p.status === 'completed').reverse();
|
||||||
const pendingPhases = phases.filter(p => p.status === 'pending');
|
const pendingPhases = phases.filter(p => p.status === 'pending');
|
||||||
@@ -213,6 +304,7 @@ const ConstructionProgress: React.FC = () => {
|
|||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
{phase.remark && <Paragraph type="secondary" style={{ margin: '4px 0 0 28px', fontSize: 12 }}>{phase.remark}</Paragraph>}
|
{phase.remark && <Paragraph type="secondary" style={{ margin: '4px 0 0 28px', fontSize: 12 }}>{phase.remark}</Paragraph>}
|
||||||
|
{renderAttachments(phase.attachments || phase.photos)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</Card>
|
</Card>
|
||||||
@@ -254,6 +346,39 @@ const ConstructionProgress: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<Paragraph>确认此阶段已完成?系统将自动推进到下一阶段。</Paragraph>
|
<Paragraph>确认此阶段已完成?系统将自动推进到下一阶段。</Paragraph>
|
||||||
{remark && <Paragraph type="secondary">备注:{remark}</Paragraph>}
|
{remark && <Paragraph type="secondary">备注:{remark}</Paragraph>}
|
||||||
|
|
||||||
|
<Divider style={{ margin: '12px 0' }} />
|
||||||
|
|
||||||
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
<Text strong>上传证明材料(照片/文件):</Text>
|
||||||
|
</div>
|
||||||
|
<Upload
|
||||||
|
beforeUpload={(file) => { 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
|
||||||
|
>
|
||||||
|
<Button icon={<UploadOutlined />}>选择文件</Button>
|
||||||
|
</Upload>
|
||||||
|
<div style={{ marginTop: 4 }}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>支持照片、PDF、Word、Excel等文件</Text>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={previewVisible}
|
||||||
|
footer={null}
|
||||||
|
onCancel={() => setPreviewVisible(false)}
|
||||||
|
>
|
||||||
|
<img src={previewUrl} style={{ width: '100%' }} />
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ const ProjectsPage: React.FC = () => {
|
|||||||
setProjects(response.data.data.map((p: Project) => ({
|
setProjects(response.data.data.map((p: Project) => ({
|
||||||
...p,
|
...p,
|
||||||
key: p.id.toString(),
|
key: p.id.toString(),
|
||||||
progress: Math.floor(Math.random() * 100),
|
progress: p.status === 'completed' ? 100 : (p.phase_progress || 0),
|
||||||
manager_name: p.manager_name || '未分配'
|
manager_name: p.manager_name || '未分配'
|
||||||
})));
|
})));
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Reference in New Issue
Block a user