zhangjie 1 年之前
父节点
当前提交
40e69086df

+ 1 - 0
package.json

@@ -83,6 +83,7 @@
     "crypto-js": "^4.1.1",
     "flyio": "^0.6.14",
     "js-cookie": "^3.0.1",
+    "lodash": "^4.17.21",
     "lodash-es": "^4.17.21",
     "luch-request": "3.0.8",
     "qs": "^6.10.1",

+ 1 - 0
src/api/common.js

@@ -1,2 +1,3 @@
 import { http } from '@/service/request.js'
 export const getServiceUnit = () => http.post('/api/admin/common/query_service_unit')
+export const getAttachmentList = (ids) => http.post('/api/admin/common/file/preview', { ids })

+ 22 - 0
src/api/sop.js

@@ -5,3 +5,25 @@ export const getSopList = (data) => http.post('/api/admin/sop/list', data, { cus
 export const getSopFlowView = (params) => http.post('/api/admin/flow/view', {}, { params, custom: { loading: true } })
 export const deviceCanOut = (params) => http.post('/api/admin/device/in/out/can_out_info', {}, { params })
 export const deviceCanIn = () => http.post('/api/admin/device/in/out/can_in_info')
+
+// 项目计划变更 ------------------->
+// 项目计划变更列表
+export const planChangeList = (params) => http.post('/api/admin/project/exchange/list', {}, { params })
+// 项目变更计划申请(第一步)
+export const createPlanChange = (data) => http.post('/api/admin/project/exchange/save', data)
+export const planChangeDetail = (id) => http.post('/api/admin/project/exchange/view', {}, { params: { id } })
+// 项目变更计划审批
+export const approvePlanChange = (params) => http.post('/api/admin/project/exchange/flow/approve', {}, { params })
+// 延期预警 ------------------->
+// 延期预警列表
+export const getDelayWarnList = (data) => http.post('/api/admin/tb/delay/warn/query', {}, { params: data })
+// 关闭单个延期预警
+export const closeDelayWarn = (id) => http.post('/api/admin/tb/delay/warn/close?id=' + id)
+// 重启延期预警
+export const restartDelayWarn = (id) => http.post('/api/admin/tb/delay/warn/restart?id=' + id)
+// 单个延期预警的详情
+export const delayWarnDetail = (id) => http.get('/api/admin/tb/delay/warn/get?id=' + id)
+// 延期预警明细表
+export const delayWarnDetailList = (id) => http.post('/api/admin/tb/delay/warn/detail/list?id=' + id)
+// 延期预警跟进提交
+export const flowDelayWarn = (data) => http.post('/api/admin/tb/delay/warn/detail/save', data)

+ 1 - 1
src/api/user.js

@@ -4,7 +4,7 @@ import { getBase64 } from '@/utils/crypto'
 export const pcLogin = () =>
   http.post(
     '/api/admin/common/login',
-    { loginName: 'liuyang', password: getBase64('123456'), type: 'ACCOUNT' },
+    { loginName: 'zhangjie', password: getBase64('123456'), type: 'ACCOUNT' },
     {
       custom: { noAuth: true }
     }

+ 107 - 0
src/components/attachment-view.vue

@@ -0,0 +1,107 @@
+<template>
+  <view class="attachment-view">
+    <view v-if="attachments.images.length" class="attachment-image-list">
+      <u-image
+        v-for="(img, index) in attachments.images"
+        :key="index"
+        :src="img"
+        fit="scale-down"
+        :width="imgSize"
+        :height="imgSize"
+        @click="openView(index)"
+      >
+      </u-image>
+    </view>
+    <view v-if="attachments.files.length" class="attachment-link-list">
+      <t-link v-for="file in attachments.files" :key="file" theme="primary">
+        {{ file }}
+      </t-link>
+    </view>
+  </view>
+</template>
+
+<script>
+  import { objTypeOf } from '@/utils/utils'
+  import { getAttachmentList } from '@/api/common'
+
+  export default {
+    name: 'AttachmentView',
+    props: {
+      ids: {
+        type: [String, Array],
+        default: ''
+      },
+      imgSize: {
+        type: Number,
+        default: 100
+      },
+      imageList: {
+        type: Array,
+        default() {
+          return []
+        }
+      }
+    },
+    data() {
+      return {
+        imgVisible: false,
+        imgIndex: 0,
+        attachments: {
+          images: [],
+          files: []
+        }
+      }
+    },
+    computed: {
+      attachmentIds() {
+        if (objTypeOf(this.ids) === 'string') return this.ids.split(',')
+        if (objTypeOf(this.ids) === 'array') return this.ids
+        return []
+      }
+    },
+    watch: {
+      attachmentIds: {
+        immediate: true,
+        handler() {
+          this.getAttachments()
+        }
+      }
+    },
+    methods: {
+      openView(index) {
+        this.imgIndex = index
+        this.imgVisible = true
+      },
+      async getAttachments() {
+        this.attachments = {
+          images: [],
+          files: []
+        }
+
+        // 直接传图片数据时,直接展示
+        if (this.imageList && this.imageList.length) {
+          this.attachments.images = this.imageList
+          return
+        }
+
+        if (!this.attachmentIds.length) return
+
+        const res = await getAttachmentList(this.attachmentIds)
+        const data = res || []
+        data.forEach((item) => {
+          const type = this.checkFileType(item.url)
+          if (type === 'image') {
+            this.attachments.images.push(item.url)
+          } else {
+            this.attachments.files.push(item.url)
+          }
+        })
+      },
+      checkFileType(filepath) {
+        const fileFormat = filepath.split('.').pop().toLocaleLowerCase()
+        const imgTypes = ['jpg', 'jpeg', 'png']
+        return imgTypes.includes(fileFormat) ? 'image' : 'file'
+      }
+    }
+  }
+</script>

+ 122 - 0
src/components/dynamic-table.vue

@@ -0,0 +1,122 @@
+<template>
+  <div class="dynamic-table">
+    <view class="dynamic-header">
+      <h2></h2>
+      <u-button v-if="!readonly" @click="handleAdd">新增</u-button>
+    </view>
+    <view class="dynamic-body">
+      <view v-for="(item, index) in tableData" :key="index" class="key-body">
+        <view class="key-title"></view>
+        <view class="key-info">
+          <view v-for="col in columns" :key="col.colKey" class="key-value"> {{ col.title }}:{{ item[col.colKey] }} </view>
+        </view>
+        <view v-if="!readonly" class="key-foolter">
+          <u-button theme="primary" hover="color" @click="handleEdit(row)"> 修改 </u-button>
+          <u-button theme="primary" hover="color" @click="handleDelete(rowIndex)"> 删除 </u-button>
+        </view>
+      </view>
+    </view>
+
+    <u-popup v-model="showEditColumnDialog" mode="bottom" :mask-close-able="false" :closeable="true" :border-radius="28">
+      <view class="popup-box">
+        <view class="title">{{ title }}</view>
+        <view class="content">
+          <u-form ref="formRef" :data="formData" :rules="rules" :labelWidth="140" colon>
+            <u-form-item v-for="item in columns" :key="item.colKey" :label="item.title" :prop="rules[item.colKey] ? item.colKey : undefined">
+              <u-input v-model="curRow[item.colKey]" />
+            </u-form-item>
+          </u-form>
+        </view>
+      </view>
+    </u-popup>
+  </div>
+</template>
+
+<script>
+  import { randomCode } from '@/utils/utils'
+
+  export default {
+    name: 'DynamicTable',
+    props: {
+      value: {
+        type: Array
+      },
+      columns: {
+        type: Array
+      },
+      readonly: Boolean,
+      modelValue: { type: Array }
+    },
+    data() {
+      return {
+        tableData: [],
+        showEditColumnDialog: false,
+        curRow: {}
+      }
+    },
+    watch: {
+      value: {
+        immediate: true,
+        handler(val) {
+          const vals = val || []
+          this.tableData = vals.map((item) => {
+            return { ...item, key: randomCode() }
+          })
+        }
+      }
+    },
+    computed: {
+      title() {
+        return (this.curRow.key ? '编辑' : '新增') + '数据'
+      },
+      rules() {
+        let rules = {}
+        this.columns
+          .filter((item) => !!item.rules)
+          .forEach((item) => {
+            rules[item.colKey] = item.rules
+          })
+        return rules
+      }
+    },
+    methods: {
+      getRowData() {
+        return this.columns.reduce((row, item) => {
+          row[item.colKey] = ''
+          return row
+        }, {})
+      },
+      handleAdd() {
+        this.curRow = this.getRowData()
+        this.showEditColumnDialog = true
+      },
+      handleEdit(row) {
+        this.curRow = row
+        this.showEditColumnDialog = true
+      },
+      handleDelete(index) {
+        this.tableData.splice(index, 1)
+        this.emitChange()
+      },
+      emitChange() {
+        this.$emit('update:modelValue', this.tableData)
+        this.$emit('change', this.tableData)
+      },
+      columnConfirm(data) {
+        if (data.key) {
+          const pos = this.tableData.findIndex((item) => item.key === data.key)
+          this.tableData.splice(pos, 1, data)
+        } else {
+          data.key = randomCode()
+          this.tableData.push(data)
+        }
+        this.emitChange()
+      },
+      async save() {
+        const valid = await this.$ref.formRef.validate()
+        if (!valid) return
+        this.columnConfirm(this.curRow)
+      }
+    }
+  }
+</script>

+ 41 - 0
src/components/my-select.vue

@@ -0,0 +1,41 @@
+<template>
+  <view>
+    <u-input :value="serviceLabel" :disabled="disabled" type="select" :border="true" placeholder="请选择" @click="show = !show" />
+    <u-select v-model="show" :disabled="disabled" :list="list" mode="single-column" @confirm="confirm"></u-select>
+  </view>
+</template>
+<script>
+  export default {
+    name: 'MySelect',
+    props: {
+      value: {
+        type: String
+      },
+      disabled: {
+        type: Boolean
+      },
+      list: {
+        type: Array,
+        default() {
+          return []
+        }
+      }
+    },
+    computed: {
+      serviceLabel() {
+        return this.list.find((item) => item.value == this.value)?.label || ''
+      }
+    },
+    data() {
+      return {
+        show: false
+      }
+    },
+    methods: {
+      confirm(arr) {
+        this.$emit('update:value', arr[0].value)
+      }
+    },
+    mounted() {}
+  }
+</script>

+ 22 - 7
src/pages.json

@@ -4,22 +4,16 @@
     "^uni-(.*)": "@dcloudio/uni-ui/lib/uni-$1/uni-$1.vue"
   },
   "condition": {
-    //模式配置,仅开发期间生效
     "current": 0,
-    //当前激活的模式(list 的索引项)
     "list": [
       {
         "name": "test",
-        //模式名称
         "path": "",
-        //启动页面,必选
         "query": "uuid=c4bba940-f69e-11ea-a419-6bafda9d095e&__id__=1"
-        //启动参数,在页面的onLoad函数里面得到
       }
     ]
   },
   "pages": [
-
     {
       "path": "pages/index/index",
       "style": {
@@ -41,6 +35,27 @@
         "enablePullDownRefresh": false
       }
     },
+    {
+      "path": "pages/sop/project-change-report/plan-change",
+      "style": {
+        "navigationBarTitleText": "计划变更处理",
+        "enablePullDownRefresh": false
+      }
+    },
+    {
+      "path": "pages/sop/delay-warning/delay-detail",
+      "style": {
+        "navigationBarTitleText": "延期预警处理",
+        "enablePullDownRefresh": false
+      }
+    },
+    {
+      "path": "pages/sop/delay-warning/delay-history",
+      "style": {
+        "navigationBarTitleText": "延期预警历史明细",
+        "enablePullDownRefresh": false
+      }
+    },
     {
       "path": "pages/home/home",
       "style": {
@@ -96,4 +111,4 @@
       }
     ]
   }
-}
+}

+ 0 - 0
src/pages/sop/delay-warning.vue


+ 185 - 0
src/pages/sop/delay-warning/delay-detail.vue

@@ -0,0 +1,185 @@
+<template>
+  <view class="delay-detail sop-step">
+    <scroll-view :scroll-top="scrollTop" scroll-y="true" class="scroll-Y">
+      <view class="main">
+        <RadiusCell title="SOP信息" @click="showPopup1 = true"></RadiusCell>
+        <view class="form-wrap">
+          <u-form :model="formData" ref="formRef" :border-bottom="false" label-position="top">
+            <template v-if="!IS_NEW_MODE">
+              <u-form-item label="报备申请人"> {{ sopInfo.createRealName }}</u-form-item>
+              <u-form-item label="报备申请时间">
+                {{ sopInfo.createTime | timeFormat }}
+              </u-form-item>
+            </template>
+
+            <u-form-item label="跟进说明" prop="remark">
+              <u-input v-model="formData.remark" type="textarea" placeholder="请输入说明" :maxlength="100"></u-input>
+            </u-form-item>
+
+            <u-form-item label="附件说明">
+              <upload-image :config="{ length: 3 }" :onChange="fileChange"></upload-image>
+            </u-form-item>
+          </u-form>
+        </view>
+        <RadiusCell title="历史明细查询" @click="toHistory"></RadiusCell>
+      </view>
+      <view class="footer">
+        <u-button theme="primary" @click="submitHandle">提交</u-button>
+        <u-button theme="default" @click="cancelHandle">取消</u-button>
+      </view>
+    </scroll-view>
+
+    <u-popup v-model="showPopup1" mode="bottom" :mask-close-able="false" :closeable="true" :border-radius="28">
+      <view class="popup-box2">
+        <view class="title">SOP信息</view>
+        <view class="content">
+          <view class="key-value"> 服务单元:{{ sopInfo.service }} </view>
+          <view class="key-value"> SOP流水号:{{ sopInfo.sopNo }} </view>
+          <view class="key-value"> 客户类型:{{ sopInfo.customType }} </view>
+          <view class="key-value"> 客户名称:{{ sopInfo.custom }} </view>
+          <view class="key-value"> 预警时间:{{ sopInfo.warnTime | timeFormat }} </view>
+          <view class="key-value"> 节点负责人:{{ sopInfo.userName }} </view>
+          <!-- <view class="key-value"> 项目单号:{{ sopInfo.crmNo }} </view>
+          <view class="key-value"> 项目名称:{{ sopInfo.crmName }} </view> -->
+          <view class="key-value"> 预警类型:{{ WARN_FLOW_STATUS[sopInfo.type] }} </view>
+          <view class="key-value"> 预警字段:{{ sopInfo.fieldObj }} </view>
+        </view>
+      </view>
+    </u-popup>
+
+    <u-toast ref="uToast" />
+  </view>
+</template>
+
+<script>
+  // import { CUSTOMER_TYPE } from '@/utils/constants'
+  import RadiusCell from '@/components/radius-cell.vue'
+  import { flowDelayWarn } from '@/api/sop'
+  import UploadImage from '@/components/low-code/UPLOAD_IMAGE.vue'
+
+  export default {
+    name: 'PlanChangeDetail',
+    components: { RadiusCell, UploadImage },
+    props: {
+      curRow: {
+        type: Object
+      }
+    },
+    data() {
+      return {
+        sopInfo: {},
+        formData: {
+          attachmentIds: '',
+          remark: ''
+        },
+        tableData: [],
+        rules: {
+          remark: [
+            {
+              required: true,
+              message: '请填写跟进说明',
+              type: 'error',
+              trigger: 'blur'
+            }
+          ]
+        }
+      }
+    },
+    computed: {
+      title() {
+        return `${this.sopInfo.code}`
+      }
+    },
+    mounted() {
+      this.sopInfo = { ...(this.$Route.query.curRow || {}) }
+    },
+    methods: {
+      fileChange({ value }) {
+        this.formData.attachmentIds = value.map((item) => item.id).join(',')
+      },
+
+      async submitHandle() {
+        const valid = await this.$refs.formRef.validate()
+        if (!valid) return
+
+        const res = await flowDelayWarn({
+          ...this.formData,
+          delayWarnId: this.sopInfo.id
+        }).catch(() => {})
+        if (!res) return
+
+        this.$refs.uToast.show({
+          title: '提交成功',
+          type: 'success'
+        })
+        this.$Router.back()
+      },
+      cancelHandle() {
+        this.$Router.back()
+      },
+      toHistory() {
+        this.$Router.push({ path: '/pages/sop/delay-warning/delay-history', query: { id: this.sopInfo.id } })
+      }
+    }
+  }
+</script>
+
+<style lang="scss" scoped>
+  .sop-step {
+    height: 100vh;
+    .scroll-Y {
+      height: calc(100% - 88rpx);
+      .main {
+        padding: 24rpx;
+        .form-wrap {
+          background-color: #fff;
+          border-radius: 12rpx;
+          padding: 0 24rpx;
+          ::v-deep .u-form-item {
+            padding-top: 0 !important;
+          }
+        }
+      }
+    }
+    .popup-box2 {
+      padding: 24rpx;
+      padding-top: 60rpx;
+      .title {
+        color: #262626;
+        text-align: center;
+        font-size: 28rpx;
+        font-weight: bold;
+        margin-bottom: 30rpx;
+      }
+      .content {
+        background-color: #f7f7f7;
+        border-radius: 12rpx;
+        padding: 24rpx;
+        .item {
+          color: #262626;
+          font-size: 24rpx;
+          line-height: 60rpx;
+        }
+      }
+    }
+    .popup-box1 {
+      padding: 24rpx;
+      padding-top: 60rpx;
+      .title {
+        color: #262626;
+        text-align: center;
+        font-size: 28rpx;
+        font-weight: bold;
+        margin-bottom: 30rpx;
+      }
+      .content {
+        color: #262626;
+        font-size: 24rpx;
+        line-height: 44rpx;
+        &.red {
+          color: #f53f3f;
+        }
+      }
+    }
+  }
+</style>

+ 32 - 0
src/pages/sop/delay-warning/delay-history.vue

@@ -0,0 +1,32 @@
+<template>
+  <view class="delay-history dynamic-table">
+    <view v-for="(item, index) in tableData" :key="index" class="key-body">
+      <view class="key-title">{{ item.createName | timeFormat }}</view>
+      <view class="key-info">
+        <view class="key-value"> 跟进人:{{ item.createRealName }} </view>
+        <view class="key-value"> 备注:{{ item.remark }} </view>
+        <view class="key-value"> 附件说明: </view>
+        <attachment-view :ids="item.attachmentIds"></attachment-view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+  import { delayWarnDetailList } from '@/api/sop'
+
+  export default {
+    name: 'DelayHistory',
+    data() {
+      return {
+        tableData: []
+      }
+    },
+    methods: {
+      async initData() {
+        const res = await delayWarnDetailList(this.$Route.query.id)
+        this.tableData = res
+      }
+    }
+  }
+</script>

+ 283 - 0
src/pages/sop/delay-warning/index.vue

@@ -0,0 +1,283 @@
+<template>
+  <view class="my-page">
+    <scroll-view
+      :scroll-top="scrollTop"
+      scroll-y="true"
+      refresher-enabled="true"
+      class="scroll-Y"
+      :refresher-triggered="triggered"
+      refresher-background="transparent"
+      @refresherrefresh="onRefresh"
+      @scrolltolower="scrolltolower"
+    >
+      <view class="my-card" v-for="(item, index) in list" :key="index">
+        <view class="my-card-header">
+          <view>
+            <text class="title">{{ item.code }}</text>
+            <text class="title">{{ WARN_FLOW_STATUS[item.status] }}</text>
+          </view>
+          <view class="my-card-header-right">{{ item.warnTime | timeFormat }}</view>
+        </view>
+        <view class="my-card-body">
+          <view class="key-value"> 服务单元:{{ item.service }} </view>
+          <view class="key-value"> 预警时间:{{ item.warnTime | timeFormat }} </view>
+          <view class="key-value"> SOP流水号:{{ item.sopNo }} </view>
+          <view class="key-value"> 节点负责人:{{ item.userName }} </view>
+          <view class="key-value"> 客户名称:{{ item.custom }} </view>
+          <view class="key-value"> 项目单号:{{ item.crmNo }} </view>
+          <view class="key-value"> 项目名称:{{ item.crmName }} </view>
+          <view class="key-value"> 预警类型:{{ WARN_FLOW_STATUS[item.type] }} </view>
+          <view class="key-value"> 预警字段:{{ item.fieldObj }} </view>
+        </view>
+        <view class="my-card-footer">
+          <u-button
+            v-if="item.status !== 'CLOSE'"
+            class="u-button"
+            type="primary"
+            size="mini"
+            style="margin-right: 10rpx"
+            @click="closeHandler(item, 'view')"
+            >关闭</u-button
+          >
+          <u-button
+            v-if="item.status === 'CLOSE'"
+            class="u-button"
+            type="primary"
+            size="mini"
+            style="margin-right: 10rpx"
+            @click="restartHandler(item, 'view')"
+            >重启</u-button
+          >
+          <u-button type="primary" size="mini" class="u-button" style="margin-right: 10rpx" @click="toDetail(item, 'view')">查看</u-button>
+          <u-button v-if="item.status !== 'CLOSE'" class="u-button" type="primary" size="mini" @click="toDetail(item, 'audit')">跟进</u-button>
+        </view>
+      </view>
+      <view class="my-page-bottom">
+        <u-loading v-if="loadingFlag == 1" mode="flower" size="44"></u-loading>
+        <view v-if="loadingFlag == 2" class="text flex items-center"> <view class="line"></view>我是有底线的<view class="line"></view></view>
+      </view>
+    </scroll-view>
+
+    <u-toast ref="uToast" />
+    <u-modal v-model="showModal" :content="modalContent" @confirm="modalConfirm"></u-modal>
+  </view>
+</template>
+
+<script>
+  import { dictToOptionList } from '@/utils/utils'
+  import { WARN_TYPE, WARN_FLOW_STATUS } from '@/utils/constants'
+  import { getDelayWarnList, closeDelayWarn, restartDelayWarn } from '@/api/sop'
+
+  export default {
+    name: 'ProjectChangeReport',
+    data() {
+      return {
+        dictToOptionList,
+        WARN_TYPE,
+        WARN_FLOW_STATUS,
+        params: {},
+        pageNumber: 1,
+        pageSize: 10,
+        loadingFlag: 0,
+        list: [],
+        triggered: false,
+        curRow: {},
+        modalType: '',
+        modalContent: '',
+        showModal: false,
+        statusTheme: {
+          NOT_START: 'warning',
+          FOLLOW: 'success',
+          CLOSE: 'default',
+          RESTART: 'primary'
+        }
+      }
+    },
+    mounted() {
+      this.search()
+    },
+    methods: {
+      toDetail(item, type) {
+        console.log('iii', item, type)
+        this.$Router.push({ path: `/pages/sop/delay-warning/delay-detail`, query: { sop: item, type } })
+      },
+      scrolltolower() {
+        if (this.loadingFlag > 0) {
+          return
+        }
+
+        this.loadingFlag = 1
+        this.pageNumber++
+        this.getList()
+      },
+      search(bool) {
+        this.pageNumber = 1
+        this.list = []
+        this.getList(bool)
+      },
+      async getList(bool) {
+        const res = await getDelayWarnList({ ...this.params, pageNumber: this.pageNumber, pageSize: this.pageSize })
+        this.loadingFlag = res.pages == this.pageNumber ? 2 : 0
+        // console.log('this.loadingFlag:', this.loadingFlag)
+        this.list.push(...(res.records || []))
+        if (bool) {
+          this.triggered = false
+          this.$refs.uToast.show({
+            title: '刷新成功',
+            type: 'success'
+          })
+        }
+      },
+      restartHandler(row) {
+        this.curRow = row
+        this.modalType = 'restart'
+        this.modalContent = '您确定要重启当前预警信息吗?'
+        this.showModal = true
+      },
+      closeHandler(row) {
+        this.curRow = row
+        this.modalType = 'close'
+        this.modalContent = '您确定要关闭当前预警信息吗?'
+        this.showModal = true
+      },
+      async modalConfirm() {
+        if (this.modalType === 'restart') {
+          const res = await restartDelayWarn(this.curRow.id).catch(() => {})
+          this.showModal = false
+
+          if (!res) return
+          this.$refs.uToast.show({
+            title: '操作成功',
+            type: 'success'
+          })
+        }
+
+        if (this.modalType === 'close') {
+          const res = await closeDelayWarn(this.curRow.id).catch(() => {})
+          this.showModal = false
+
+          if (!res) return
+          this.$refs.uToast.show({
+            title: '操作成功',
+            type: 'success'
+          })
+        }
+      },
+      onRefresh() {
+        if (this.triggered) return
+        this.triggered = true
+        this.search(true)
+      }
+    }
+  }
+</script>
+
+<style lang="scss" scoped>
+  .office-sop-list {
+    height: calc(100% - 80rpx);
+    padding: 24rpx;
+    .search-box {
+      padding: 30rpx 20rpx;
+    }
+    .tag-box {
+      height: 84rpx;
+      display: flex;
+      .tag-item {
+        height: 60rpx;
+        width: 96rpx;
+        font-size: 24rpx;
+        text-align: center;
+        line-height: 60rpx;
+        border-radius: 30rpx;
+        display: flex;
+        justify-content: center;
+        align-items: center;
+        margin-right: 24rpx;
+        background: #fff;
+        position: relative;
+        color: #bfbfbf;
+        &.active {
+          background: #165dff;
+          color: #fff;
+        }
+        .red {
+          position: absolute;
+          width: 12rpx;
+          height: 12rpx;
+          right: 10rpx;
+          top: 10rpx;
+          background: red;
+          border-radius: 6px;
+        }
+      }
+    }
+    .scroll-Y {
+      height: calc(100% - 184rpx);
+      .msg-item {
+        padding: 24rpx;
+        border-radius: 12rpx;
+        background: #fff;
+        margin-bottom: 24rpx;
+        .m-foot {
+          padding-top: 20rpx;
+        }
+        .m-body {
+          background: #f7f7f7;
+          margin-top: 16rpx;
+          .key-value {
+            color: #8c8c8c;
+            font-size: 24rpx;
+            width: 100%;
+            &:not(:last-child) {
+              margin-bottom: 16rpx;
+            }
+          }
+        }
+        .m-head {
+          .m-time {
+            color: #8c8c8c;
+            font-size: 24rpx;
+          }
+          .m-title {
+            display: flex;
+            align-items: center;
+            .title {
+              color: #262626;
+              font-size: 32rpx;
+              margin-right: 10rpx;
+            }
+            // .tag {
+            //   background: #ffece8;
+            //   border-radius: 6rpx;
+            //   text-align: center;
+            //   line-height: 18px;
+            //   height: 18px;
+            //   color: #f53f3f;
+            //   font-size: 20rpx;
+            //   padding: 0 8rpx;
+            //   margin-left: 10rpx;
+            // }
+          }
+        }
+      }
+      .bottom {
+        padding: 20rpx 0 30rpx 0;
+        text-align: center;
+        .text {
+          color: #aaa;
+          font-size: 24rpx;
+          .line {
+            border-bottom: 1px dashed #ccc;
+            flex: 1;
+            &:first-child {
+              margin-right: 20rpx;
+            }
+            &:last-child {
+              margin-left: 20rpx;
+            }
+          }
+        }
+      }
+    }
+  }
+</style>

+ 0 - 0
src/pages/sop/plan-change.vue


+ 101 - 0
src/pages/sop/project-change-report/index.vue

@@ -0,0 +1,101 @@
+<template>
+  <view class="my-page">
+    <scroll-view
+      :scroll-top="scrollTop"
+      scroll-y="true"
+      refresher-enabled="true"
+      class="scroll-Y"
+      :refresher-triggered="triggered"
+      refresher-background="transparent"
+      @refresherrefresh="onRefresh"
+      @scrolltolower="scrolltolower"
+    >
+      <view class="my-card" v-for="(item, index) in list" :key="index">
+        <view class="my-card-header">
+          <text>{{ item.code }}</text>
+          <text class="my-card-header-right">{{ dateFormat(item.createTime, 'yyyy-MM-dd hh:mm') }}</text>
+        </view>
+        <view class="my-card-body">
+          <view class="key-value"> 服务单元:{{ item.serviceName }} </view>
+          <view class="key-value"> 客户类型:{{ item.customTypeStr }} </view>
+          <view class="key-value"> 客户名称:{{ item.customName }} </view>
+          <view class="key-value"> 项目单号:{{ item.crmNo }} </view>
+          <view class="key-value"> 项目名称:{{ item.crmName }} </view>
+          <view class="key-value"> 提交时间:{{ item.flowTime | timeFormat }} </view>
+        </view>
+        <view class="my-card-footer">
+          <u-button class="u-button" type="primary" size="mini" @click="toPlanChange(item, 'view')">查看</u-button>
+          <u-button class="u-button" type="primary" size="mini" @click="toPlanChange(item, 'audit')">处理申请</u-button>
+        </view>
+      </view>
+      <view class="my-page-bottom">
+        <u-loading v-if="loadingFlag == 1" mode="flower" size="44"></u-loading>
+        <view v-if="loadingFlag == 2" class="text flex items-center"> <view class="line"></view>我是有底线的<view class="line"></view></view>
+      </view>
+    </scroll-view>
+
+    <u-toast ref="uToast" />
+  </view>
+</template>
+
+<script>
+  import { planChangeList } from '@/api/sop'
+  import { dictToOptionList } from '@/utils/utils'
+  import { dateFormat } from '@/utils/utils'
+  export default {
+    name: 'ProjectChangeReport',
+    data() {
+      return {
+        dateFormat,
+        dictToOptionList,
+        params: {},
+        pageNumber: 1,
+        pageSize: 10,
+        loadingFlag: 0,
+        list: [],
+        triggered: false
+      }
+    },
+    mounted() {
+      this.search()
+    },
+    methods: {
+      toPlanChange(item, type) {
+        console.log('iii', item, type)
+        this.$Router.push({ path: `/pages/sop/project-change-report/plan-change`, query: { sop: item, type } })
+      },
+      scrolltolower() {
+        if (this.loadingFlag > 0) {
+          return
+        }
+
+        this.loadingFlag = 1
+        this.pageNumber++
+        this.getList()
+      },
+      search(bool) {
+        this.pageNumber = 1
+        this.list = []
+        this.getList(bool)
+      },
+      async getList(bool) {
+        const res = await planChangeList({ ...this.params, pageNumber: this.pageNumber, pageSize: this.pageSize })
+        this.loadingFlag = res.pages == this.pageNumber ? 2 : 0
+        // console.log('this.loadingFlag:', this.loadingFlag)
+        this.list.push(...(res.records || []))
+        if (bool) {
+          this.triggered = false
+          this.$refs.uToast.show({
+            title: '刷新成功',
+            type: 'success'
+          })
+        }
+      },
+      onRefresh() {
+        if (this.triggered) return
+        this.triggered = true
+        this.search(true)
+      }
+    }
+  }
+</script>

+ 271 - 0
src/pages/sop/project-change-report/plan-change.vue

@@ -0,0 +1,271 @@
+<template>
+  <view class="my-page">
+    <scroll-view :scroll-top="scrollTop" scroll-y="true" class="scroll-Y">
+      <view class="my-page-main">
+        <RadiusCell :title="title" @click="showPopup1 = true"></RadiusCell>
+        <RadiusCell title="SOP项目计划变更说明" @click="showPopup2 = true"></RadiusCell>
+        <view class="my-card">
+          <u-form :model="formData" ref="formRef" :border-bottom="false" label-position="top">
+            <template v-if="!IS_NEW_MODE">
+              <u-form-item label="报备申请人"> {{ sopInfo.createRealName }}</u-form-item>
+              <u-form-item label="报备申请时间">
+                {{ sopInfo.createTime | timeFormat }}
+              </u-form-item>
+            </template>
+
+            <u-form-item label="变更类型" requiredMark>
+              <view style="padding-top: 3px">
+                <my-select v-model="formData.type" :disabled="readonly" :list="dictToOptionList(PLAN_CHANGE_TYPE)"></my-select>
+                <div class="sub-title gray">
+                  <p>1.关键信息及计划变更:项目关键信息里填写的项目关键信息或时间计划内容调整;</p>
+                  <p>2.项目取消:若项目取消,则只需填写变更原因后提交。</p>
+                </div>
+              </view>
+            </u-form-item>
+
+            <u-form-item label="变更原因" prop="reason">
+              <u-input :disabled="readonly" v-model="formData.reason" placeholder="50字以内" :maxlength="50"></u-input>
+            </u-form-item>
+
+            <u-form-item label="项目信息及计划变更明细" prop="contentJson" requiredMark>
+              <dynamic-table ref="dTable" v-model="formData.contentJson" :columns="columns" :readonly="readonly"></dynamic-table>
+            </u-form-item>
+
+            <template v-if="!IS_NEW_MODE">
+              <view class="form-group-title">变更处理结果</view>
+              <u-form-item label="处理结果">
+                <my-select v-model="formData.projectExchangeApprove" :list="approveList"></my-select>
+              </u-form-item>
+
+              <u-form-item label="变更备注" prop="remark">
+                <u-input type="textarea" v-model="formData.remark"></u-input>
+              </u-form-item>
+            </template>
+          </u-form>
+        </view>
+      </view>
+      <view class="my-page-footer">
+        <u-row gutter="16">
+          <u-col :span="6"> <u-button type="default" @click="cancelHandle">取消</u-button> </u-col>
+          <u-col :span="6"><u-button type="primary" @click="submitHandle">保存</u-button></u-col>
+        </u-row>
+      </view>
+    </scroll-view>
+
+    <u-popup v-model="showPopup2" mode="bottom" :mask-close-able="false" :closeable="true" :border-radius="28">
+      <view class="popup-box1">
+        <view class="title">{{ title }}</view>
+        <view class="content">
+          <view
+            >SOP项目计划需要变更时,可手动发起一个或多个申请,质控专员审核后,在SOP流程中进行计划变更调整,完成变更后,申请流程结束并通知到发起申请人。</view
+          >
+        </view>
+      </view>
+    </u-popup>
+
+    <u-popup v-model="showPopup1" mode="bottom" :mask-close-able="false" :closeable="true" :border-radius="28">
+      <view class="popup-box2">
+        <view class="title">SOP项目计划变更说明</view>
+        <view class="content">
+          <view class="item">项目单号:{{ sopInfo.crmNo }}</view>
+          <view class="item">项目名称:{{ sopInfo.crmName }}</view>
+          <view class="item">派单时间:{{ sopInfo.crmBeginTime }}</view>
+          <view class="item">客户经理:{{ sopInfo.customManagerName }}</view>
+          <view class="item">客户类型:{{ CUSTOMER_TYPE[sopInfo.customType] }}</view>
+          <view class="item">客户名称:{{ sopInfo.customName }}</view>
+          <view class="item">考试时间:{{ sopInfo.examStartTime + ' 至 ' + sopInfo.examEndTime }}</view>
+          <view class="item">实施产品:{{ sopInfo.productName }}</view>
+          <view class="item">服务单元:{{ sopInfo.serviceUnitName }}</view>
+        </view>
+      </view>
+    </u-popup>
+
+    <u-toast ref="uToast" />
+  </view>
+</template>
+
+<script>
+  import { createPlanChange, approvePlanChange, planChangeDetail } from '@/api/sop'
+  import { objAssign, dictToOptionList } from '@/utils/utils'
+  import { omit } from 'lodash'
+  import { CUSTOMER_TYPE, PLAN_CHANGE_TYPE } from '@/utils/constants'
+  import RadiusCell from '@/components/radius-cell.vue'
+  import MySelect from '@/components/my-select.vue'
+  import DynamicTable from '@/components/dynamic-table.vue'
+
+  export default {
+    name: 'PlanChangeDetail',
+    components: { RadiusCell, MySelect, DynamicTable },
+    data() {
+      return {
+        sopInfo: {},
+        CUSTOMER_TYPE,
+        PLAN_CHANGE_TYPE,
+        formData: {
+          serviceId: '',
+          sopNo: '',
+          crmNo: '',
+          type: 'PLAN',
+          flowDeploymentId: '',
+          reason: '',
+          contentJson: [],
+          projectExchangeApprove: 'FINISH',
+          remark: ''
+        },
+        approveList: [
+          {
+            label: '已完成',
+            value: 'FINISH'
+          },
+          {
+            label: '经沟通,取消变更(原因请填写变更备注)',
+            value: 'NOT_UPDATE'
+          },
+          {
+            label: '经沟通,部分变更(详情请填写变更备注)',
+            value: 'PARTIAL_UPDATE'
+          }
+        ],
+        columns: [
+          {
+            colKey: 'before',
+            title: '变更字段(全称)',
+            comp: {
+              type: 'text',
+              attrs: {
+                clearable: true
+              }
+            },
+            rules: [{ required: true, message: '不能为空', trigger: 'change' }]
+          },
+          {
+            colKey: 'after',
+            title: '变更后内容',
+            comp: {
+              type: 'text',
+              attrs: {
+                clearable: true
+              }
+            },
+            rules: [
+              {
+                required: true,
+                message: '不能为空',
+                trigger: 'change'
+              }
+            ]
+          },
+          {
+            colKey: 'remark',
+            title: '备注',
+            comp: {
+              type: 'text',
+              attrs: {
+                clearable: true
+              }
+            }
+          }
+        ],
+        rules: {
+          reason: [
+            {
+              required: true,
+              message: '请填写变更原因',
+              type: 'error',
+              trigger: 'change'
+            }
+          ],
+          contentJson: [
+            {
+              validator: (rule, value, callback) => {
+                if (!value || !value.length) return callback(new Error('请至少填写一行数据'))
+
+                return callback()
+              }
+            }
+          ],
+          remark: [
+            {
+              validator: (rule, value, callback) => {
+                if (!value && this.formData.projectExchangeApprove !== 'FINISH') {
+                  return callback(new Error('请填写备注'))
+                }
+                return callback()
+              }
+            }
+          ]
+        }
+      }
+    },
+    computed: {
+      IS_NEW_MODE() {
+        return this.$Route.query.type === 'new'
+      },
+      readonly() {
+        return this.$Route.query.type !== 'new'
+      },
+      title() {
+        return `项目派单信息(${this.sopInfo.crmNo})`
+      }
+    },
+    mounted() {
+      this.sopInfo = { ...(this.$Route.query.sop || {}) }
+      this.initData()
+    },
+    methods: {
+      dictToOptionList,
+      async initData() {
+        if (this.IS_NEW_MODE) {
+          this.formData.serviceId = this.sopInfo.serviceId
+          this.formData.sopNo = this.sopInfo.sopNo
+          this.formData.crmNo = this.sopInfo.crmNo
+          this.formData.flowDeploymentId = this.sopInfo.flowDeploymentId
+          return
+        }
+
+        // audit
+        const res = await planChangeDetail(this.sopInfo.id)
+        this.sopInfo.beginTime = res.crmInfo.crmBeginTime
+        this.sopInfo.customManagerName = res.crmInfo.customManagerName
+        this.sopInfo.customManagerTypeStr = this.sopInfo.customTypeStr
+        this.sopInfo.examStartTime = ''
+        this.sopInfo.examEndTime = ''
+        this.sopInfo.productName = res.crmInfo.productName
+
+        this.formData = objAssign(this.formData, res.tbProjectExchange)
+        this.formData.contentJson = JSON.parse(this.formData.contentJson)
+      },
+      async submitHandle() {
+        const valid = await this.$refs.formRef.validate()
+        if (!valid) return
+
+        if (this.IS_NEW_MODE) {
+          const res = await createPlanChange({
+            ...omit(this.formData, ['projectExchangeApprove', 'remark']),
+            contentJson: JSON.stringify(this.formData.contentJson),
+            flowApprove: 'START'
+          }).catch(() => {})
+          if (!res) return
+        } else {
+          // 流程审批
+          const res = await approvePlanChange({
+            flowApprove: 'PASS',
+            projectExchangeApprove: this.formData.projectExchangeApprove,
+            remark: this.formData.remark,
+            taskId: this.sopInfo.taskId
+          }).catch(() => {})
+          if (!res) return
+        }
+
+        this.$refs.uToast.show({
+          title: '提交成功',
+          type: 'success'
+        })
+        this.$Router.back()
+      },
+      cancelHandle() {
+        this.$Router.back()
+      }
+    }
+  }
+</script>

+ 6 - 1
src/pages/sop/sop.vue

@@ -2,14 +2,19 @@
   <view class="sop">
     <u-tabs :list="tabList" :is-scroll="true" :current="current" @change="tabChange"></u-tabs>
     <officeSopList v-if="current == 0"></officeSopList>
+    <ProjectChangeReport v-if="current == 2"></ProjectChangeReport>
+    <DelayWarning v-if="current == 3"></DelayWarning>
   </view>
 </template>
 
 <script>
   import OfficeSopList from './office-sop-list.vue'
+  import ProjectChangeReport from './project-change-report/index.vue'
+  import DelayWarning from './delay-warning/index.vue'
+
   export default {
     name: 'Sop',
-    components: { OfficeSopList },
+    components: { OfficeSopList, ProjectChangeReport, DelayWarning },
     data() {
       return {
         tabList: [{ name: '教务处SOP' }, { name: '研究生SOP' }, { name: '项目计划变更' }, { name: '延期预警' }, { name: '违规登记' }],

+ 97 - 12
src/styles/global.scss

@@ -1,12 +1,97 @@
-.form-item{
-    padding-bottom:20rpx;
-}
-.label{
-    color:#595959;
-    font-size:28rpx;
-    line-height:44rpx;
-    padding-bottom:12rpx;
-}
-.u-drawer .u-drawer,.u-drawer .u-drawer-content{
-    overflow:visible !important;
-}
+.form-item {
+  padding-bottom: 20rpx;
+}
+.label {
+  color: #595959;
+  font-size: 28rpx;
+  line-height: 44rpx;
+  padding-bottom: 12rpx;
+}
+.u-drawer .u-drawer,
+.u-drawer .u-drawer-content {
+  overflow: visible !important;
+}
+
+.color-lighter {
+  color: #8c8c8c;
+}
+
+// my-page
+.my-page {
+  padding: 24rpx;
+  color: #262626;
+
+  &-bottom {
+    padding: 20rpx 0 30rpx 0;
+    text-align: center;
+    .text {
+      color: #aaa;
+      font-size: 24rpx;
+    }
+    .line {
+      border-bottom: 1px dashed #ccc;
+      flex: 1;
+      &:first-child {
+        margin-right: 20rpx;
+      }
+      &:last-child {
+        margin-left: 20rpx;
+      }
+    }
+  }
+  &-main {
+    padding-bottom: 176rpx;
+  }
+  &-footer {
+    position: fixed;
+    bottom: 0;
+    left: 0;
+    width: 100%;
+    height: 176rpx;
+    z-index: 99;
+    background-color: #fff;
+    padding: 16rpx 32rpx;
+    text-align: center;
+  }
+}
+// my-card
+.my-card {
+  background: #ffffff;
+  border-radius: 12rpx;
+  padding: 24rpx;
+  margin-bottom: 24rpx;
+
+  &-header {
+    line-height: 48rpx;
+    margin-bottom: 12rpx;
+    font-size: 32rpx;
+    font-weight: 500;
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+
+    &-right {
+      color: #8c8c8c;
+      font-size: 24rpx;
+    }
+  }
+  &-body {
+    background: #f7f7f7;
+    border-radius: 12rpx;
+    padding: 24rpx;
+    font-size: 24rpx;
+    color: #8c8c8c;
+  }
+  &-footer {
+    margin-top: 24rpx;
+    text-align: right;
+  }
+  .key-value {
+    margin-bottom: 12rpx;
+  }
+}
+
+// u reset
+.u-button + .u-button {
+  margin-left: 24rpx;
+}

+ 52 - 0
src/utils/utils.js

@@ -597,3 +597,55 @@ export const dateFormat = (date, fmt = 'yyyy-MM-dd hh:mm:ss', isDefault = '-') =
   }
   return fmt
 }
+
+/**
+ * 将目标对象中有的属性值与源对象中的属性值合并
+ * @param {Object} target 目标对象
+ * @param {Object} sources 源对象
+ */
+export function objAssign(target, sources) {
+  let targ = { ...target }
+  for (let k in targ) {
+    targ[k] = Object.prototype.hasOwnProperty.call(sources, k) ? sources[k] : targ[k]
+  }
+  return targ
+}
+
+/**
+ * 获取随机code,默认获取16位
+ * @param {Number} len 推荐8的倍数
+ *
+ */
+export function randomCode(len = 16) {
+  if (len <= 0) return
+  let steps = Math.ceil(len / 8)
+  let stepNums = []
+  for (let i = 0; i < steps; i++) {
+    let ranNum = Math.random().toString(32).slice(-8)
+    stepNums.push(ranNum)
+  }
+
+  return stepNums.join('')
+}
+
+/**
+ * 判断对象类型
+ * @param {*} obj 对象
+ */
+export function objTypeOf(obj) {
+  const toString = Object.prototype.toString
+  const map = {
+    '[object Boolean]': 'boolean',
+    '[object Number]': 'number',
+    '[object String]': 'string',
+    '[object Function]': 'function',
+    '[object Array]': 'array',
+    '[object Date]': 'date',
+    '[object RegExp]': 'regExp',
+    '[object Undefined]': 'undefined',
+    '[object Null]': 'null',
+    '[object Object]': 'object',
+    '[object Blob]': 'blob'
+  }
+  return map[toString.call(obj)]
+}