Initial commit: ERP system with advance verification fixes
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, Typography, Button, Space, Tag, Progress, Empty, Spin, message, Row, Col, Divider } from 'antd';
|
||||
import { FileTextOutlined, CameraOutlined, ScheduleOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import dayjs from 'dayjs';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
|
||||
const { Title, Paragraph, Text } = Typography;
|
||||
|
||||
// 天气图标映射
|
||||
const WEATHER_ICONS: Record<string, string> = {
|
||||
sunny: '☀️ 晴',
|
||||
cloudy: '⛅ 多云',
|
||||
rainy: '🌧️ 雨',
|
||||
stormy: '⛈️ 雷暴',
|
||||
windy: '💨 大风',
|
||||
};
|
||||
|
||||
// 项目状态映射
|
||||
const STATUS_CONFIG: Record<string, { color: string; text: string }> = {
|
||||
pending: { color: 'default', text: '待开始' },
|
||||
active: { color: 'processing', text: '施工中' },
|
||||
completed: { color: 'success', text: '完工' },
|
||||
suspended: { color: 'warning', text: '暂停' },
|
||||
cancelled: { color: 'error', text: '已取消' },
|
||||
};
|
||||
|
||||
interface Project {
|
||||
id: number;
|
||||
project_code: string;
|
||||
name: string;
|
||||
customer_name: string;
|
||||
status: string;
|
||||
start_date: string;
|
||||
expected_end_date: string;
|
||||
contract_amount: number;
|
||||
currency: string;
|
||||
manager_name: string;
|
||||
progress_percentage: number;
|
||||
latest_log?: {
|
||||
id: number;
|
||||
log_date: string;
|
||||
weather: string;
|
||||
work_content: string;
|
||||
};
|
||||
}
|
||||
|
||||
const ConstructionList: React.FC = () => {
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => setIsMobile(window.innerWidth <= 768);
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProjects();
|
||||
}, []);
|
||||
|
||||
const fetchProjects = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await axios.get('/api/construction/my-projects');
|
||||
if (res.data.success) {
|
||||
setProjects(res.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取项目列表失败:', error);
|
||||
message.error('获取项目列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number, currency: string = 'CNY') => {
|
||||
const symbols: Record<string, string> = {
|
||||
CNY: '¥',
|
||||
USD: '$',
|
||||
LAK: '₭',
|
||||
THB: '฿',
|
||||
};
|
||||
const symbol = symbols[currency] || '¥';
|
||||
return `${symbol}${(amount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 0 })}`;
|
||||
};
|
||||
|
||||
const isToday = (dateStr: string) => {
|
||||
return dayjs(dateStr).isSame(dayjs(), 'day');
|
||||
};
|
||||
|
||||
const renderProjectCard = (project: Project) => {
|
||||
const statusConfig = STATUS_CONFIG[project.status] || STATUS_CONFIG.pending;
|
||||
const hasTodayLog = project.latest_log && isToday(project.latest_log.log_date);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={project.id}
|
||||
style={{
|
||||
marginBottom: isMobile ? 12 : 16,
|
||||
borderRadius: 12,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
|
||||
}}
|
||||
styles={{ body: { padding: isMobile ? 16 : 20 } }}
|
||||
>
|
||||
{/* 项目头部 */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 12 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||
<span style={{ fontSize: 20 }}>🎯</span>
|
||||
<Text strong style={{ fontSize: isMobile ? 15 : 16 }}>{project.name}</Text>
|
||||
</div>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||
客户: {project.customer_name || '未指定'}
|
||||
</Text>
|
||||
</div>
|
||||
<Tag color={statusConfig.color} style={{ marginLeft: 8 }}>
|
||||
{statusConfig.text}
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>施工进度</Text>
|
||||
<Text strong style={{ fontSize: 12 }}>{Math.round((project.progress_percentage || 0))}%</Text>
|
||||
</div>
|
||||
<Progress
|
||||
percent={Math.round((project.progress_percentage || 0))}
|
||||
showInfo={false}
|
||||
strokeColor={{
|
||||
'0%': '#108ee9',
|
||||
'100%': '#87d068',
|
||||
}}
|
||||
trailColor="#f0f0f0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 最新日志状态 */}
|
||||
{project.status === 'active' && (
|
||||
<div style={{
|
||||
padding: '8px 12px',
|
||||
background: hasTodayLog ? '#f6ffed' : '#fff7e6',
|
||||
borderRadius: 8,
|
||||
marginBottom: 12,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8
|
||||
}}>
|
||||
{hasTodayLog ? (
|
||||
<>
|
||||
<span>✅</span>
|
||||
<Text style={{ fontSize: 13 }}>
|
||||
今日日志: {project.latest_log?.work_content?.substring(0, 30)}...
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>⚠️</span>
|
||||
<Text type="warning" style={{ fontSize: 13 }}>今日日志: 未填写</Text>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<Row gutter={[8, 8]}>
|
||||
<Col xs={24} sm={8}>
|
||||
<Button
|
||||
type={project.status === 'active' && !hasTodayLog ? 'primary' : 'default'}
|
||||
icon={<FileTextOutlined />}
|
||||
onClick={() => navigate(`/construction/${project.id}/logs`)}
|
||||
block
|
||||
size={isMobile ? 'large' : 'middle'}
|
||||
style={{ borderRadius: 8 }}
|
||||
>
|
||||
{project.status === 'active' && !hasTodayLog ? '📝 写今日日志' : '📝 施工日志'}
|
||||
</Button>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Button
|
||||
icon={<CameraOutlined />}
|
||||
onClick={() => navigate(`/construction/${project.id}/logs`)}
|
||||
block
|
||||
size={isMobile ? 'large' : 'middle'}
|
||||
style={{ borderRadius: 8 }}
|
||||
>
|
||||
📷 上传照片
|
||||
</Button>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Button
|
||||
icon={<ScheduleOutlined />}
|
||||
onClick={() => navigate(`/construction/${project.id}/milestones`)}
|
||||
block
|
||||
size={isMobile ? 'large' : 'middle'}
|
||||
style={{ borderRadius: 8 }}
|
||||
>
|
||||
📋 节点进度
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: isMobile ? 12 : 24,
|
||||
maxWidth: 1200,
|
||||
margin: '0 auto'
|
||||
}}>
|
||||
{/* 页面标题 */}
|
||||
<div style={{ marginBottom: isMobile ? 16 : 24 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Title level={isMobile ? 4 : 3} style={{ marginBottom: 0 }}>施工管理</Title>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={fetchProjects}
|
||||
loading={loading}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
<Paragraph type="secondary" style={{ marginTop: 8, marginBottom: 0 }}>
|
||||
查看和管理您的施工项目
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
{/* 项目列表 */}
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: 60 }}>
|
||||
<Spin size="large" />
|
||||
<Paragraph type="secondary" style={{ marginTop: 16 }}>加载中...</Paragraph>
|
||||
</div>
|
||||
) : projects.length === 0 ? (
|
||||
<Card style={{ borderRadius: 12 }}>
|
||||
<Empty
|
||||
description="暂无施工项目"
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
>
|
||||
<Text type="secondary">请联系管理员为您分配施工项目</Text>
|
||||
</Empty>
|
||||
</Card>
|
||||
) : (
|
||||
<div>
|
||||
<Text type="secondary" style={{ marginBottom: 12, display: 'block' }}>
|
||||
我的施工项目 ({projects.length})
|
||||
</Text>
|
||||
{projects.map(project => renderProjectCard(project))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConstructionList;
|
||||
@@ -0,0 +1,441 @@
|
||||
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;
|
||||
@@ -0,0 +1,240 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Card, Typography, Button, Space, Tag, Spin, Empty, Timeline, Progress, Divider
|
||||
} from 'antd';
|
||||
import {
|
||||
ArrowLeftOutlined, CheckCircleOutlined, ClockCircleOutlined,
|
||||
SyncOutlined, CloseCircleOutlined
|
||||
} from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const { Title, Paragraph, Text } = Typography;
|
||||
|
||||
// 节点状态配置
|
||||
const STATUS_CONFIG: Record<string, {
|
||||
color: string;
|
||||
text: string;
|
||||
icon: React.ReactNode;
|
||||
timelineColor: string;
|
||||
}> = {
|
||||
pending: {
|
||||
color: 'default',
|
||||
text: '待开始',
|
||||
icon: <ClockCircleOutlined />,
|
||||
timelineColor: 'gray'
|
||||
},
|
||||
in_progress: {
|
||||
color: 'processing',
|
||||
text: '进行中',
|
||||
icon: <SyncOutlined spin />,
|
||||
timelineColor: 'blue'
|
||||
},
|
||||
completed: {
|
||||
color: 'success',
|
||||
text: '已完成',
|
||||
icon: <CheckCircleOutlined />,
|
||||
timelineColor: 'green'
|
||||
},
|
||||
cancelled: {
|
||||
color: 'error',
|
||||
text: '已取消',
|
||||
icon: <CloseCircleOutlined />,
|
||||
timelineColor: 'red'
|
||||
},
|
||||
};
|
||||
|
||||
interface Milestone {
|
||||
id: number;
|
||||
node_name: string;
|
||||
node_type: string;
|
||||
status: string;
|
||||
due_date: string;
|
||||
trigger_condition: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const ConstructionMilestones: React.FC = () => {
|
||||
const { id: projectId } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [milestones, setMilestones] = useState<Milestone[]>([]);
|
||||
const [projectInfo, setProjectInfo] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => setIsMobile(window.innerWidth <= 768);
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
fetchMilestones();
|
||||
fetchProjectInfo();
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const fetchMilestones = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await axios.get(`/api/construction/projects/${projectId}/milestones`);
|
||||
if (res.data.success) {
|
||||
setMilestones(res.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取节点列表失败:', 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 completedCount = milestones.filter(m => m.status === 'completed').length;
|
||||
const totalCount = milestones.length;
|
||||
const progressPercent = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0;
|
||||
|
||||
const renderTimelineItem = (milestone: Milestone, index: number) => {
|
||||
const statusConfig = STATUS_CONFIG[milestone.status] || STATUS_CONFIG.pending;
|
||||
|
||||
return (
|
||||
<Timeline.Item
|
||||
key={milestone.id}
|
||||
color={statusConfig.timelineColor}
|
||||
dot={
|
||||
<span style={{ fontSize: 16 }}>
|
||||
{statusConfig.icon}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Card
|
||||
size="small"
|
||||
style={{
|
||||
marginBottom: 8,
|
||||
borderRadius: 8,
|
||||
background: milestone.status === 'completed' ? '#f6ffed' : '#fff',
|
||||
}}
|
||||
styles={{ body: { padding: 12 } }}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<Text strong style={{ fontSize: 14 }}>{milestone.node_name}</Text>
|
||||
{milestone.trigger_condition && (
|
||||
<Paragraph
|
||||
type="secondary"
|
||||
style={{ margin: '4px 0 0', fontSize: 12 }}
|
||||
>
|
||||
{milestone.trigger_condition}
|
||||
</Paragraph>
|
||||
)}
|
||||
</div>
|
||||
<Tag color={statusConfig.color} icon={statusConfig.icon}>
|
||||
{statusConfig.text}
|
||||
</Tag>
|
||||
</div>
|
||||
{milestone.due_date && (
|
||||
<Text type="secondary" style={{ fontSize: 12, marginTop: 4, display: 'block' }}>
|
||||
计划完成: {dayjs(milestone.due_date).format('YYYY-MM-DD')}
|
||||
</Text>
|
||||
)}
|
||||
</Card>
|
||||
</Timeline.Item>
|
||||
);
|
||||
};
|
||||
|
||||
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 && milestones.length > 0 && (
|
||||
<Card style={{ marginBottom: 16, borderRadius: 12 }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 16 }}>
|
||||
<Text type="secondary">整体进度</Text>
|
||||
<Title level={2} style={{ margin: '8px 0 0' }}>{progressPercent}%</Title>
|
||||
</div>
|
||||
<Progress
|
||||
percent={progressPercent}
|
||||
strokeColor={{
|
||||
'0%': '#108ee9',
|
||||
'100%': '#87d068',
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 24, marginTop: 16 }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Text strong style={{ fontSize: 20 }}>{completedCount}</Text>
|
||||
<br />
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>已完成</Text>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Text strong style={{ fontSize: 20 }}>{totalCount - completedCount}</Text>
|
||||
<br />
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>进行中</Text>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Text strong style={{ fontSize: 20 }}>{totalCount}</Text>
|
||||
<br />
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>总节点</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 节点时间线 */}
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: 60 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : milestones.length === 0 ? (
|
||||
<Card style={{ borderRadius: 12 }}>
|
||||
<Empty description="暂无施工节点">
|
||||
<Text type="secondary">节点由项目经理在项目设置中配置</Text>
|
||||
</Empty>
|
||||
</Card>
|
||||
) : (
|
||||
<Card style={{ borderRadius: 12 }}>
|
||||
<Timeline style={{ marginTop: 16 }}>
|
||||
{milestones.map((milestone, index) => renderTimelineItem(milestone, index))}
|
||||
</Timeline>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConstructionMilestones;
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as ConstructionList } from "./ConstructionList";
|
||||
export { default as ConstructionLog } from "./ConstructionLog";
|
||||
export { default as ConstructionMilestones } from "./ConstructionMilestones";
|
||||
export { default } from "./ConstructionList";
|
||||
Reference in New Issue
Block a user