442 lines
13 KiB
TypeScript
442 lines
13 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { useParams, useNavigate } from 'react-router-dom';
|
|
import {
|
|
Card, Typography, Button, Space, Modal, Form, Input, DatePicker, Select,
|
|
Upload, message, Spin, Empty, Image, Tag, Divider, Popconfirm, Row, Col
|
|
} from 'antd';
|
|
import {
|
|
PlusOutlined, ArrowLeftOutlined, DeleteOutlined,
|
|
CameraOutlined, CalendarOutlined, CloudOutlined
|
|
} from '@ant-design/icons';
|
|
import axios from 'axios';
|
|
import dayjs from 'dayjs';
|
|
import { useAuthStore } from '../../store/authStore';
|
|
|
|
const { Title, Paragraph, Text } = Typography;
|
|
const { TextArea } = Input;
|
|
const { Option } = Select;
|
|
|
|
// 天气选项
|
|
const WEATHER_OPTIONS = [
|
|
{ value: 'sunny', label: '☀️ 晴', icon: '☀️' },
|
|
{ value: 'cloudy', label: '⛅ 多云', icon: '⛅' },
|
|
{ value: 'rainy', label: '🌧️ 雨', icon: '🌧️' },
|
|
{ value: 'stormy', label: '⛈️ 雷暴', icon: '⛈️' },
|
|
{ value: 'windy', label: '💨 大风', icon: '💨' },
|
|
];
|
|
|
|
interface Log {
|
|
id: number;
|
|
log_date: string;
|
|
weather: string;
|
|
work_content: string;
|
|
next_plan: string;
|
|
issues: string;
|
|
recorder_name: string;
|
|
photos: Photo[];
|
|
created_at: string;
|
|
}
|
|
|
|
interface Photo {
|
|
id: number;
|
|
photo_url: string;
|
|
photo_name: string;
|
|
photo_type: string;
|
|
file_size: number;
|
|
created_at: string;
|
|
}
|
|
|
|
const ConstructionLog: React.FC = () => {
|
|
const { id: projectId } = useParams<{ id: string }>();
|
|
const navigate = useNavigate();
|
|
const [isMobile, setIsMobile] = useState(false);
|
|
const [logs, setLogs] = useState<Log[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [modalVisible, setModalVisible] = useState(false);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [projectInfo, setProjectInfo] = useState<any>(null);
|
|
const [form] = Form.useForm();
|
|
const { user } = useAuthStore();
|
|
|
|
useEffect(() => {
|
|
const checkMobile = () => setIsMobile(window.innerWidth <= 768);
|
|
checkMobile();
|
|
window.addEventListener('resize', checkMobile);
|
|
return () => window.removeEventListener('resize', checkMobile);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (projectId) {
|
|
fetchLogs();
|
|
fetchProjectInfo();
|
|
}
|
|
}, [projectId]);
|
|
|
|
const fetchLogs = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const res = await axios.get(`/api/projects/${projectId}/construction-logs`);
|
|
if (res.data.success) {
|
|
setLogs(res.data.data);
|
|
}
|
|
} catch (error) {
|
|
console.error('获取日志列表失败:', error);
|
|
message.error('获取日志列表失败');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const fetchProjectInfo = async () => {
|
|
try {
|
|
const res = await axios.get(`/api/projects/${projectId}`);
|
|
if (res.data.success) {
|
|
setProjectInfo(res.data.data);
|
|
}
|
|
} catch (error) {
|
|
console.error('获取项目信息失败:', error);
|
|
}
|
|
};
|
|
|
|
const handleSubmit = async () => {
|
|
try {
|
|
const values = await form.validateFields();
|
|
setSubmitting(true);
|
|
|
|
const res = await axios.post(`/api/projects/${projectId}/construction-logs`, {
|
|
log_date: values.log_date.format('YYYY-MM-DD'),
|
|
weather: values.weather,
|
|
work_content: values.work_content,
|
|
photos: '', // 暂时为空,后续添加照片上传功能
|
|
});
|
|
|
|
if (res.data.success) {
|
|
message.success('日志添加成功');
|
|
setModalVisible(false);
|
|
form.resetFields();
|
|
fetchLogs();
|
|
}
|
|
} catch (error) {
|
|
console.error('添加日志失败:', error);
|
|
message.error('添加日志失败');
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleDeleteLog = async (logId: number) => {
|
|
try {
|
|
const res = await axios.delete(`/api/construction-logs/${logId}`);
|
|
if (res.data.success) {
|
|
message.success('日志删除成功');
|
|
fetchLogs();
|
|
}
|
|
} catch (error) {
|
|
console.error('删除日志失败:', error);
|
|
message.error('删除日志失败');
|
|
}
|
|
};
|
|
|
|
// 按日期分组
|
|
const groupedLogs = logs.reduce((acc, log) => {
|
|
const month = dayjs(log.log_date).format('YYYY年MM月');
|
|
if (!acc[month]) {
|
|
acc[month] = [];
|
|
}
|
|
acc[month].push(log);
|
|
return acc;
|
|
}, {} as Record<string, Log[]>);
|
|
|
|
const getWeatherLabel = (value: string) => {
|
|
const option = WEATHER_OPTIONS.find(o => o.value === value);
|
|
return option ? option.label : value;
|
|
};
|
|
|
|
const formatFileSize = (bytes: number) => {
|
|
if (bytes < 1024) return bytes + ' B';
|
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
|
};
|
|
|
|
const renderLogCard = (log: Log) => (
|
|
<Card
|
|
key={log.id}
|
|
style={{
|
|
marginBottom: 16,
|
|
borderRadius: 12,
|
|
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
|
|
}}
|
|
styles={{ body: { padding: isMobile ? 16 : 20 } }}
|
|
>
|
|
{/* 日志头部 */}
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
|
<Space>
|
|
<CalendarOutlined style={{ color: '#1890ff' }} />
|
|
<Text strong style={{ fontSize: 15 }}>{dayjs(log.log_date).format('MM月DD日')}</Text>
|
|
<Tag color="blue">{getWeatherLabel(log.weather)}</Tag>
|
|
</Space>
|
|
<Space>
|
|
<Text type="secondary" style={{ fontSize: 12 }}>记录人: {log.recorder_name || '未知'}</Text>
|
|
<Popconfirm
|
|
title="确定删除此日志?"
|
|
description="删除后无法恢复"
|
|
onConfirm={() => handleDeleteLog(log.id)}
|
|
okText="确定"
|
|
cancelText="取消"
|
|
>
|
|
<Button type="text" danger size="small" icon={<DeleteOutlined />} />
|
|
</Popconfirm>
|
|
</Space>
|
|
</div>
|
|
|
|
{/* 工作内容 */}
|
|
{log.work_content && (
|
|
<div style={{ marginBottom: 12 }}>
|
|
<Text type="secondary" style={{ fontSize: 12 }}>今日工作:</Text>
|
|
<Paragraph style={{ margin: '4px 0 0', whiteSpace: 'pre-wrap' }}>
|
|
{log.work_content}
|
|
</Paragraph>
|
|
</div>
|
|
)}
|
|
|
|
{/* 明日计划 */}
|
|
{log.next_plan && (
|
|
<div style={{ marginBottom: 12 }}>
|
|
<Text type="secondary" style={{ fontSize: 12 }}>明日计划:</Text>
|
|
<Paragraph style={{ margin: '4px 0 0', whiteSpace: 'pre-wrap' }}>
|
|
{log.next_plan}
|
|
</Paragraph>
|
|
</div>
|
|
)}
|
|
|
|
{/* 问题记录 */}
|
|
{log.issues && (
|
|
<div style={{ marginBottom: 12 }}>
|
|
<Text type="secondary" style={{ fontSize: 12 }}>问题记录:</Text>
|
|
<Paragraph style={{ margin: '4px 0 0', color: '#fa8c16', whiteSpace: 'pre-wrap' }}>
|
|
{log.issues}
|
|
</Paragraph>
|
|
</div>
|
|
)}
|
|
|
|
{/* 照片展示 */}
|
|
{log.photos && log.photos.length > 0 && (
|
|
<div style={{ marginTop: 12 }}>
|
|
<Text type="secondary" style={{ fontSize: 12, marginBottom: 8, display: 'block' }}>
|
|
施工照片 ({log.photos.length}张):
|
|
</Text>
|
|
<Image.PreviewGroup>
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
|
{log.photos.map(photo => (
|
|
<Image
|
|
key={photo.id}
|
|
src={photo.photo_url}
|
|
width={isMobile ? 80 : 100}
|
|
height={isMobile ? 80 : 100}
|
|
style={{
|
|
borderRadius: 8,
|
|
objectFit: 'cover',
|
|
cursor: 'pointer'
|
|
}}
|
|
placeholder={
|
|
<div style={{
|
|
width: isMobile ? 80 : 100,
|
|
height: isMobile ? 80 : 100,
|
|
background: '#f0f0f0',
|
|
borderRadius: 8,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center'
|
|
}}>
|
|
<CameraOutlined style={{ fontSize: 24, color: '#bfbfbf' }} />
|
|
</div>
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
</Image.PreviewGroup>
|
|
</div>
|
|
)}
|
|
</Card>
|
|
);
|
|
|
|
return (
|
|
<div style={{
|
|
padding: isMobile ? 12 : 24,
|
|
maxWidth: 800,
|
|
margin: '0 auto'
|
|
}}>
|
|
{/* 页面头部 */}
|
|
<div style={{ marginBottom: isMobile ? 16 : 24 }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
|
|
<Button
|
|
icon={<ArrowLeftOutlined />}
|
|
onClick={() => navigate('/construction')}
|
|
/>
|
|
<div>
|
|
<Title level={isMobile ? 4 : 3} style={{ margin: 0 }}>
|
|
施工日志
|
|
</Title>
|
|
{projectInfo && (
|
|
<Text type="secondary" style={{ fontSize: 13 }}>
|
|
{projectInfo.name}
|
|
</Text>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 日志列表 */}
|
|
{loading ? (
|
|
<div style={{ textAlign: 'center', padding: 60 }}>
|
|
<Spin size="large" />
|
|
</div>
|
|
) : logs.length === 0 ? (
|
|
<Card style={{ borderRadius: 12 }}>
|
|
<Empty description="暂无施工日志">
|
|
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalVisible(true)}>
|
|
添加第一条日志
|
|
</Button>
|
|
</Empty>
|
|
</Card>
|
|
) : (
|
|
<div>
|
|
{Object.entries(groupedLogs).map(([month, monthLogs]) => (
|
|
<div key={month}>
|
|
<Divider orientation="left" style={{ margin: '16px 0' }}>
|
|
<Text strong style={{ fontSize: 14 }}>{month}</Text>
|
|
</Divider>
|
|
{monthLogs.map(log => renderLogCard(log))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* 底部添加按钮 */}
|
|
<div style={{
|
|
position: 'fixed',
|
|
bottom: 24,
|
|
right: 24,
|
|
zIndex: 100
|
|
}}>
|
|
<Button
|
|
type="primary"
|
|
icon={<PlusOutlined />}
|
|
size="large"
|
|
onClick={() => setModalVisible(true)}
|
|
style={{
|
|
borderRadius: 24,
|
|
height: 48,
|
|
paddingLeft: 24,
|
|
paddingRight: 24,
|
|
boxShadow: '0 4px 12px rgba(24, 144, 255, 0.4)'
|
|
}}
|
|
>
|
|
新增日志
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 新增日志弹窗 */}
|
|
<Modal
|
|
title="新增施工日志"
|
|
open={modalVisible}
|
|
onOk={handleSubmit}
|
|
onCancel={() => setModalVisible(false)}
|
|
confirmLoading={submitting}
|
|
okText="提交"
|
|
cancelText="取消"
|
|
width={isMobile ? '95%' : 500}
|
|
style={{ top: 20 }}
|
|
>
|
|
<Form
|
|
form={form}
|
|
layout="vertical"
|
|
initialValues={{
|
|
log_date: dayjs(),
|
|
weather: 'sunny'
|
|
}}
|
|
>
|
|
<Row gutter={16}>
|
|
<Col span={12}>
|
|
<Form.Item
|
|
name="log_date"
|
|
label="日期"
|
|
rules={[{ required: true, message: '请选择日期' }]}
|
|
>
|
|
<DatePicker
|
|
style={{ width: '100%' }}
|
|
size="large"
|
|
disabledDate={(current) => current && current > dayjs().endOf('day')}
|
|
/>
|
|
</Form.Item>
|
|
</Col>
|
|
<Col span={12}>
|
|
<Form.Item
|
|
name="weather"
|
|
label="天气"
|
|
rules={[{ required: true, message: '请选择天气' }]}
|
|
>
|
|
<Select size="large">
|
|
{WEATHER_OPTIONS.map(opt => (
|
|
<Option key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</Option>
|
|
))}
|
|
</Select>
|
|
</Form.Item>
|
|
</Col>
|
|
</Row>
|
|
|
|
<Form.Item
|
|
name="work_content"
|
|
label="今日工作"
|
|
rules={[{ required: true, message: '请填写今日工作内容' }]}
|
|
>
|
|
<TextArea
|
|
rows={3}
|
|
placeholder="描述今日完成的施工工作..."
|
|
size="large"
|
|
/>
|
|
</Form.Item>
|
|
|
|
<Form.Item name="next_plan" label="明日计划">
|
|
<TextArea
|
|
rows={2}
|
|
placeholder="明日工作计划..."
|
|
size="large"
|
|
/>
|
|
</Form.Item>
|
|
|
|
<Form.Item name="issues" label="问题记录">
|
|
<TextArea
|
|
rows={2}
|
|
placeholder="遇到的问题或需要协调的事项..."
|
|
size="large"
|
|
/>
|
|
</Form.Item>
|
|
|
|
<Form.Item label="上传照片">
|
|
<Upload
|
|
listType="picture-card"
|
|
multiple
|
|
maxCount={9}
|
|
accept="image/*"
|
|
beforeUpload={() => false}
|
|
>
|
|
<div>
|
|
<CameraOutlined style={{ fontSize: 20 }} />
|
|
<div style={{ marginTop: 4, fontSize: 12 }}>添加照片</div>
|
|
</div>
|
|
</Upload>
|
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
|
支持上传多张照片,最多9张
|
|
</Text>
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ConstructionLog;
|