hejianhao
2026-01-26 2dbc28ba7026e1837791bd12d63f479f786e2b65
feat(项目进度): 新增项目进度管理功能

- 在菜单配置中添加项目进度设置入口
- 新增项目进度 API 接口文件,包含增删改查功能
- 配置路由映射,添加项目进度页面懒加载
- 实现项目进度管理页面,支持按项目名称、委托方和时间范围查询
- 创建项目进度编辑表单,支持图片/视频上传和预览
3个文件已添加
2个文件已修改
601 ■■■■■ 已修改文件
estate-frontend-v2024/src/api/projectProgress.js 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
estate-frontend-v2024/src/config/menuConfig.js 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
estate-frontend-v2024/src/config/routeMap.js 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
estate-frontend-v2024/src/views/screenSetting/forms/edit-projectProgress-form.jsx 319 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
estate-frontend-v2024/src/views/screenSetting/projectProgress.jsx 235 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
estate-frontend-v2024/src/api/projectProgress.js
New file
@@ -0,0 +1,40 @@
import request from '@/utils/request'
export function getProjectProgressList(data, params) {
  return request({
    url: `/admin/project-progress/list`,
    method: 'post',
    data,
    params
  })
}
export function getProjectProgressDetail(id) {
  return request({
    url: `/admin/project-progress/detail/${id}`,
    method: 'get'
  })
}
export function addProjectProgress(data) {
  return request({
    url: `/admin/project-progress/add`,
    method: 'post',
    data
  })
}
export function editProjectProgress(data) {
  return request({
    url: `/admin/project-progress/edit`,
    method: 'post',
    data
  })
}
export function deleteProjectProgress(ids) {
  return request({
    url: `/admin/project-progress/delete/${ids}`,
    method: 'delete'
  })
}
estate-frontend-v2024/src/config/menuConfig.js
@@ -42,6 +42,11 @@
        path: "/screenSetting/accidentMessageSetting",
        permissions: ["*"],
      },
      {
        title: "项目进度设置",
        path: "/screenSetting/projectProgress",
        permissions: ["*"],
      },
    ],
  },
  {
estate-frontend-v2024/src/config/routeMap.js
@@ -59,6 +59,7 @@
const Personal = Loadable({ loader: () => import(/*webpackChunkName:'Personal'*/'@/views/personal'), loading: Loading });
const AccountSetting = Loadable({ loader: () => import(/*webpackChunkName:'AccountSetting'*/'@/views/screenSetting/accountSetting'), loading: Loading });
const AccidentTypeSetting = Loadable({ loader: () => import(/*webpackChunkName:'AccidentTypeSetting'*/'@/views/screenSetting/accidentTypeSetting'), loading: Loading });
const ProjectProgress = Loadable({ loader: () => import(/*webpackChunkName:'ProjectProgress'*/'@/views/screenSetting/projectProgress'), loading: Loading });
const AccidentMessageSetting = Loadable({ loader: () => import(/*webpackChunkName:'AccidentMessageSetting'*/'@/views/screenSetting/accidentMessageSetting'), loading: Loading });
const PositionSetting = Loadable({ loader: () => import(/*webpackChunkName:'PositionSetting'*/'@/views/standard/position'), loading: Loading });
const OnDuty = Loadable({ loader: () => import(/*webpackChunkName:'OnDuty'*/'@/views/business/onDuty'), loading: Loading });
@@ -125,6 +126,7 @@
  { path: "/screenSetting/accountSetting", component: AccountSetting },
  { path: "/screenSetting/accidentTypeSetting", component: AccidentTypeSetting },
  { path: "/screenSetting/projectProgress", component: ProjectProgress },
  { path: "/screenSetting/accidentMessageSetting", component: AccidentMessageSetting },
  { path: "/standard/position", component: PositionSetting },
  { path: "/businessData/on-duty", component: OnDuty },
estate-frontend-v2024/src/views/screenSetting/forms/edit-projectProgress-form.jsx
New file
@@ -0,0 +1,319 @@
import React from "react";
import { Form, Input, Modal, Select, DatePicker, Upload, Button, Icon, message } from "antd";
import EditFormBase from "../../common/edit-form-base";
import { getClientOptions, uploadFile } from "../../../api/business";
import { getProjectOptions } from "../../../api/projects";
import { getProjectProgressDetail } from "../../../api/projectProgress";
import { toOptionsData } from "../../common/common_func";
import { BASE_URL } from "../../common/constants";
import moment from "moment";
const { Option } = Select;
const { TextArea } = Input;
class EditProjectProgressForm extends EditFormBase {
  state = {
    ...this.state,
    clientOptions: [],
    projectOptions: [],
    fileList: [],
    previewVisible: false,
    previewUrl: "",
    previewType: "image",
  };
  initView() {
    this.getClientOptionsFunc();
  }
  getPreviewUrl(url) {
    if (!url) return '';
    if (url.startsWith('http') || url.startsWith('data:')) return url;
    return `http://47.93.28.249:8181/public/file/download/${url}`;
  }
  viewWillAppear() {
    // Reset form to ensure no stale data from previous edits
    this.props.form.resetFields();
    const { editableData } = this.state;
    if (editableData && editableData.id) {
      getProjectProgressDetail(editableData.id).then(res => {
        if (res.data.status === 'OK') {
          const data = res.data.data;
          this.setState({ editableData: data });
          if (data.clientId) {
            this.getProjectOptionsFunc(data.clientId);
          }
          if (data.url) {
            const urls = data.url.split(',');
            const fileList = urls.map((url, index) => ({
              uid: -index - 1,
              name: url,
              status: 'done',
              url: this.getPreviewUrl(url),
            }));
            this.setState({ fileList });
          } else {
            this.setState({ fileList: [] });
          }
          this.props.form.setFieldsValue({
            clientId: String(data.clientId),
            projectId: String(data.projectId),
            detail: data.detail,
            time: data.time ? moment(data.time) : null,
            url: data.url
          });
        }
      });
    } else {
      // Add mode: explicit cleanup
      this.setState({
        fileList: [],
        projectOptions: [],
        previewVisible: false,
        previewUrl: ""
      });
      if (editableData && editableData.clientId) {
        this.getProjectOptionsFunc(editableData.clientId);
      }
      if (editableData && editableData.url) {
        const urls = editableData.url.split(',');
        const fileList = urls.map((url, index) => ({
          uid: -index - 1,
          name: url,
          status: 'done',
          url: this.getPreviewUrl(url),
        }));
        this.setState({ fileList });
      }
    }
  }
  getClientOptionsFunc() {
    getClientOptions().then((res) => {
      if (res.data.status === "OK") {
        this.setState({
          clientOptions: toOptionsData(res.data.data),
        });
      }
    });
  }
  getProjectOptionsFunc(clientId) {
    getProjectOptions(clientId).then((res) => {
      if (res.data.status === "OK") {
        this.setState({
          projectOptions: toOptionsData(res.data.data),
        });
      }
    });
  }
  handleClientChangeLocal = (value) => {
    this.setFieldValue("clientId", value);
    this.setFieldValue("projectId", undefined); // Reset project
    this.props.form.setFieldsValue({ projectId: undefined });
    this.setState({ projectOptions: [] });
    if (value) {
      this.getProjectOptionsFunc(value);
    }
  };
  handleUploadChange = ({ fileList }) => {
    const newFileList = fileList.map(file => {
      if (file.status === 'done' && !file.url && file.response && file.response.finalName) {
        file.url = this.getPreviewUrl(file.response.finalName);
      }
      return file;
    });
    this.setState({ fileList: newFileList });
    const validFiles = newFileList.filter(f => f.status === 'done');
    const urls = validFiles.map(f => {
      if (f.response && f.response.finalName) {
        return f.response.finalName;
      }
      return f.name;
    });
    const urlString = urls.join(',');
    this.setFieldValue("url", urlString);
    this.props.form.setFieldsValue({ url: urlString });
  };
  customRequest = (data) => {
    this.uploading = true;
    uploadFile(data.file).then(res => {
      this.uploading = false;
      if (res.data.status === 'OK') {
        let finalName = "";
        const responseData = res.data.data;
        if (responseData && typeof responseData === 'object') {
          const name = responseData.name || '';
          finalName = name;
        } else {
          finalName = responseData;
        }
        const responsePayload = { finalName, ...res.data };
        data.onSuccess(responsePayload, data.file);
        // Note: handleUploadChange will trigger after this via onChange
      } else {
        message.error("上传失败");
        data.onError();
      }
    }).catch(err => {
      this.uploading = false;
      message.error("上传出错");
      data.onError();
    });
  };
  handlePreview = async (file) => {
    console.log(file);
    let url = file.url || file.preview;
    if (!url && file.response && file.response.finalName) {
      url = this.getPreviewUrl(file.response.finalName);
    }
    // Fallback for old logic or direct string
    if (!url && file.response && file.response.data && typeof file.response.data.data === 'string') {
      url = this.getPreviewUrl(file.response.data.data);
    }
    // If we still don't have a url (e.g. file.originFileObj), we can read it.
    if (!url && file.originFileObj) {
      url = await new Promise((resolve) => {
        const reader = new FileReader();
        reader.readAsDataURL(file.originFileObj);
        reader.onload = () => resolve(reader.result);
      });
    }
    // Determine type
    const isVideo = (url && url.match(/\.(mp4|webm|ogg)$/i)) || (file.type && file.type.startsWith('video/'));
    this.setState({
      previewUrl: url,
      previewVisible: true,
      previewType: isVideo ? "video" : "image",
    });
  };
  render() {
    const { getFieldDecorator } = this.props.form;
    const { editableData, clientOptions, projectOptions, fileList, previewVisible, previewUrl, previewType } = this.state;
    return (
      <Modal
        title={this.state.isEdit ? "编辑项目进度" : "新增项目进度"}
        visible={this.props.visible}
        onCancel={this.props.onCancel}
        onOk={this.props.onOk}
        width={700}
      >
        <Form>
          <Form.Item label="委托方" {...this.formItemLayout}>
            {getFieldDecorator("clientId", {
              initialValue: editableData.clientId,
              rules: [{ required: true, message: "请选择委托方" }],
            })(
              <Select
                placeholder="请选择委托方"
                onChange={this.handleClientChangeLocal}
                allowClear
              >
                {clientOptions.map((item) => (
                  <Option key={item.value} value={item.value}>
                    {item.label}
                  </Option>
                ))}
              </Select>
            )}
          </Form.Item>
          <Form.Item label="项目" {...this.formItemLayout}>
            {getFieldDecorator("projectId", {
              initialValue: editableData.projectId,
              rules: [{ required: true, message: "请选择项目" }],
            })(
              <Select placeholder="请选择项目" allowClear>
                {projectOptions.map((item) => (
                  <Option key={item.value} value={item.value}>
                    {item.label}
                  </Option>
                ))}
              </Select>
            )}
          </Form.Item>
          <Form.Item label="进度说明" {...this.formItemLayout}>
            {getFieldDecorator("detail", {
              initialValue: editableData.detail,
              rules: [{ required: true, message: "请输入进度说明" }],
            })(<TextArea rows={4} placeholder="请输入进度说明" />)}
          </Form.Item>
          <Form.Item label="进度更新时间" {...this.formItemLayout}>
            {getFieldDecorator("time", {
              initialValue: editableData.time ? moment(editableData.time) : null,
              rules: [{ required: true, message: "请选择进度更新时间" }],
            })(
              <DatePicker
                style={{ width: "100%" }}
                format="YYYY-MM-DD"
                onChange={this.handleDateFieldChange("time")}
              />
            )}
          </Form.Item>
          <Form.Item label="进度图片/视频" {...this.formItemLayout}>
            {getFieldDecorator("url", {
              initialValue: editableData.url,
              rules: [{ required: true, message: "请上传进度图片/视频" }],
            })(
              <Input style={{ display: 'none' }} />
            )}
            <Upload
              listType="picture-card"
              fileList={fileList}
              customRequest={this.customRequest}
              onChange={this.handleUploadChange}
              onPreview={this.handlePreview}
              accept="image/*,video/*"
              multiple={true}
            >
              <div>
                <Icon type="plus" />
                <div className="ant-upload-text">上传</div>
              </div>
            </Upload>
          </Form.Item>
        </Form>
        <Modal
          visible={previewVisible}
          footer={null}
          onCancel={() => this.setState({ previewVisible: false })}
          width={800}
        >
          {previewType === 'video' ? (
            <video style={{ width: '100%' }} controls src={previewUrl} />
          ) : (
            <img alt="preview" style={{ width: '100%' }} src={previewUrl} />
          )}
        </Modal>
      </Modal>
    );
  }
}
export default Form.create()(EditProjectProgressForm);
estate-frontend-v2024/src/views/screenSetting/projectProgress.jsx
New file
@@ -0,0 +1,235 @@
import React from "react";
import { Button, Card, Icon, Input, Select, Table, DatePicker, message, Modal, Divider } from "antd";
import CrudViewBase from "../common/crud-view-base";
import { getProjectProgressList, deleteProjectProgress, addProjectProgress, editProjectProgress } from "../../api/projectProgress";
import { getClientOptions } from "../../api/business";
import { toOptionsData } from "../common/common_func";
import EditProjectProgressForm from "./forms/edit-projectProgress-form";
import moment from "moment";
import "../business/style/clientInfo.less";
const { Column } = Table;
const { Option } = Select;
const { RangePicker } = DatePicker;
const inputLongStyle = {
  padding: "1px 10px",
  width: "250px",
};
class ProjectProgress extends CrudViewBase {
  initListBody() {
    return {
      name: "",
      clientId: null,
      time: "",
    };
  }
  initView() {
    this.getClientOptionsFunc();
  }
  getClientOptionsFunc() {
    getClientOptions().then((res) => {
      if (res.data.status === "OK") {
        this.setState({
          clientOptions: toOptionsData(res.data.data),
        });
      }
    });
  }
  getEntityList(pageInfo, listBody) {
    return getProjectProgressList(pageInfo, listBody);
  }
  editEntity(data, isEdit) {
    return editProjectProgress(data);
  }
  addEntity(data) {
    return addProjectProgress(data);
  }
  deleteEntities(ids) {
    return deleteProjectProgress(ids.ids[0]);
  }
  handleTimeRangeChange = (dates, dateStrings) => {
    let timeStr = "";
    if (dates && dates.length === 2) {
      timeStr = `${dateStrings[0]} - ${dateStrings[1]}`;
    }
    this.handleListBodyChange("time", timeStr);
  };
  handleOk = () => {
    const form = this.formRef.props.form;
    form.validateFields((err, values) => {
      if (err) {
        return;
      }
      let params = { ...values };
      if (params.time && moment.isMoment(params.time)) {
        params.time = params.time.format('YYYY-MM-DD');
      }
      if (this.state.currentRowData && this.state.currentRowData.id) {
        params.id = this.state.currentRowData.id;
        this.editEntity(params, true).then((res) => {
            if (res.data.status === "OK") {
              message.success("编辑成功");
              this.setState({
                editModalVisible: false,
                currentRowData: null,
              });
              this.reloadEntities();
            } else {
              message.error(res.data.errorMsg);
            }
          });
      } else {
        this.addEntity(params).then((res) => {
            if (res.data.status === "OK") {
              message.success("添加成功");
              this.setState({
                editModalVisible: false,
                currentRowData: null,
              });
              this.reloadEntities();
            } else {
              message.error(res.data.errorMsg);
            }
          });
      }
    });
  };
  render() {
    const { entities } = this.state;
    const title = (
      <div className="flex title">
        <div>
          <Input
            style={inputLongStyle}
            placeholder="项目名称"
            onChange={(e) => {
              this.handleListBodyChange("name", e.target.value);
            }}
            prefix={<Icon style={{ marginLeft: "4px" }} type="search" />}
            allowClear
          />
          &nbsp;
          <Select
            style={{
              width: 250,
            }}
            placeholder="委托方"
            allowClear
            onChange={(value) => {
              this.handleListBodyChange("clientId", value);
            }}
          >
            {this.state.clientOptions &&
              this.state.clientOptions.map((item) => (
                <Option value={item.value} key={item.value}>
                  {item.label}
                </Option>
              ))}
          </Select>
          &nbsp;
          <RangePicker
            style={{ width: 300 }}
            onChange={this.handleTimeRangeChange}
            format="YYYY-MM-DD"
          />
        </div>
        <div className="right">
          {this.renderAddButton("device:add")}
        </div>
      </div>
    );
    return (
      <div className="app-container">
        <Card title={title}>
          <Table
            bordered
            rowKey="id"
            dataSource={entities}
            scroll={{ x: "100%" }}
            pagination={false}
          >
            <Column
              title="序号"
              dataIndex="id"
              key="id"
              align="center"
              width={80}
            />
            <Column
              title="项目名称"
              dataIndex="projectName"
              key="projectName"
              align="center"
              ellipsis={true}
            />
            <Column
              title="所属委托方"
              dataIndex="clientName"
              key="clientName"
              align="center"
              ellipsis={true}
            />
            <Column
              title="进度说明"
              dataIndex="detail"
              key="detail"
              align="center"
              ellipsis={true}
            />
            <Column
              title="进度更新日期"
              dataIndex="time"
              key="time"
              align="center"
              width={150}
            />
            <Column
              title="操作"
              key="action"
              align="center"
              width={200}
              render={(text, row) => (
                <span>
                  {this.renderEditButton(row, "device:edit")}
                  <Divider type="vertical" />
                  {this.renderDeleteButton(row, "device:delete")}
                </span>
              )}
            />
          </Table>
          <br />
          {this.renderPagination()}
        </Card>
        <EditProjectProgressForm
          wrappedComponentRef={(formRef) => (this.formRef = formRef)}
          visible={this.state.editModalVisible}
          onCancel={() =>
            this.setState({
              editModalVisible: false,
              currentRowData: null,
            })
          }
          onOk={this.handleOk}
          currentRowData={this.state.currentRowData}
        />
      </div>
    );
  }
}
export default ProjectProgress;