haoxuan
2023-10-31 70ca3fe1992a1c7a4a5ba0e6f7f8fbc979462a4b
Merge branch 'dev' of http://192.168.5.5:10010/r/web/bulletin-board-style1 into wn
7个文件已添加
7个文件已修改
706 ■■■■■ 已修改文件
.gitignore 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
public/leaf-shape.png 补丁 | 查看 | 原始文档 | blame | 历史
public/modal-background-wider.png 补丁 | 查看 | 原始文档 | blame | 历史
public/modal-background.png 补丁 | 查看 | 原始文档 | blame | 历史
src/api/index.ts 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/task.ts 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/auto-imports.d.ts 4 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/common/composable/index.ts 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/common/composable/useCountDown.ts 79 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/common/utils/stepTimer.ts 142 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components.d.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/BaseModal.vue 89 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/components/TaskControl.vue 10 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/components/TaskControlModal.vue 328 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.gitignore
@@ -27,3 +27,6 @@
*.njsproj
*.sln
*.sw?
/src/auto-imports.d.ts
/src/components.d.ts
public/leaf-shape.png
public/modal-background-wider.png
public/modal-background.png
src/api/index.ts
@@ -1,5 +1,5 @@
import { request } from '@/common/utils'
import type { TasksGroupByChannel } from './task'
import type { CraftParamsResponse, TasksGroupByChannel } from './task'
import type { PLCResponse } from './plc'
export interface BaseResponse<T = any> {
@@ -36,6 +36,10 @@
  procedureId: number
}
/**
 * 获取PLC运行数据
 * @param params
 */
export function getProductProgress(params: ProductProgressParams) {
  return request<BaseResponse<PLCResponse>>({
    url: '/v1/plc/productProgress',
@@ -43,3 +47,29 @@
    data: params
  })
}
export interface CraftParamsParams {
  id: number
}
/**
 * 获取工艺参数
 * @param params
 */
export function getCraftParams(params: CraftParamsParams) {
  return request<BaseResponse<CraftParamsResponse>>({
    url: `v1/task/start/${params.id}`,
    method: 'get',
    data: params
  })
}
export interface SendProcessParamsParams {
  procedureId: number
}
export function sendProcessParams(params: SendProcessParamsParams) {
  return request<BaseResponse>({
    url: `v1/task/sendProcessParams`,
    method: 'post',
    data: params
  })
}
src/api/task.ts
@@ -87,3 +87,16 @@
export interface TasksGroupByChannel {
  [channel: number]: TasksResponse
}
/**
 * 工艺参数
 */
export interface CraftParam {
  Key: string
  Value: string
}
export interface CraftParamsResponse {
  number: string
  Params: CraftParam[]
}
src/auto-imports.d.ts
@@ -4,4 +4,6 @@
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
export {}
declare global {}
declare global {
}
src/common/composable/index.ts
@@ -1,3 +1,3 @@
// 本文件放置通用 composable (组合式函数), 注意与 utils 区分
export {}
import { useCountDown } from './useCountDown'
export { useCountDown }
src/common/composable/useCountDown.ts
New file
@@ -0,0 +1,79 @@
import { StepTimer } from '@/common/utils/stepTimer'
import { computed, onUnmounted, ref } from 'vue'
interface Options {
  onEnd?: Function
  onAbort?: Function
}
/**
 * 格式化计时
 * @param time
 */
function formatTime(time: number) {
  const minutes = Math.floor(time / 60)
  const seconds = time % 60
  const minutesString = minutes.toString().padStart(2, '0') // 在分钟数不足两位时,在前面补0
  const secondsString = seconds.toString().padStart(2, '0') // 在秒数不足两位时,在前面补0
  return `${minutesString}:${secondsString}`
}
function useCountDown(seconds: number, options?: Options) {
  const timer = new StepTimer(seconds * 1000, 1000)
  const remainingSeconds = ref<number>(seconds)
  const formattedTime = computed(() => {
    return formatTime(remainingSeconds.value)
  })
  const countdownStatus = ref<'stop' | 'running' | 'pause' | 'complete'>('stop')
  function startCountdown() {
    countdownStatus.value = 'running'
    timer
      .start((round) => {
        remainingSeconds.value = seconds - round
      })
      .then(
        (data) => {
          countdownStatus.value = 'complete'
          options?.onEnd?.(data)
        },
        (err) => {
          countdownStatus.value = 'stop'
          options?.onAbort?.(err)
        }
      )
  }
  onUnmounted(() => {
    countdownStatus.value = 'stop'
    timer.destroy()
  })
  function pauseCountdown() {
    countdownStatus.value = 'pause'
    timer.pause()
  }
  function continueCountdown() {
    countdownStatus.value = 'running'
    timer.continue()
  }
  function stopCountdown() {
    countdownStatus.value = 'stop'
    timer.abort()
  }
  return {
    startCountdown,
    remainingSeconds,
    pauseCountdown,
    continueCountdown,
    stopCountdown,
    formattedTime,
    countdownStatus
  }
}
export { useCountDown }
src/common/utils/stepTimer.ts
New file
@@ -0,0 +1,142 @@
type ResolveFn<T = string> = (value: T | PromiseLike<T>) => void
type RejectFn = (reason?: any) => void
type PerStepFn = (r: number) => void
/**
 * 步进计时器
 *
 * @example
 * const timer = new Timer(10_000, 1_000)
 * timer
 *   .start((round: number) => {
 *     console.log(round)
 *   })
 *   .then(
 *     () => {
 *       console.log('over')
 *     },
 *     (err) => {
 *       console.log(err)
 *     }
 *   )
 */
class StepTimer {
  private timer?: number
  /** 计时器总时长 单位毫秒 */
  private readonly duration: number = 0
  /** 计时步长 单位毫秒 */
  private readonly step: number = 1000
  /** 计时轮数 */
  private round: number = 0
  private blockFlag: boolean = false
  private reject?: RejectFn
  private perStep?: PerStepFn
  private resolve?: ResolveFn
  /**
   * 创建计时器
   * @param duration 计时器总持续时间
   * @param step 计时间隔
   */
  constructor(duration: number, step: number) {
    this.duration = duration
    this.step = step
    this.round = 0
  }
  /**
   * 启动计时器
   * 接受一个函数, 每轮触发一次
   * 返回 Promise, 在计时结束后 resolve
   * 重复调用会清除之前的计时器重新计时
   * @param perStep
   */
  start(perStep: PerStepFn): Promise<string> {
    if (this.timer) {
      this.reset()
    }
    this.perStep = perStep
    return new Promise((resolve, reject) => {
      this.resolve = resolve
      this.reject = reject
      this.createIntervalTimer(this.duration)
    })
  }
  private createIntervalTimer(duration: number) {
    this.timer = setInterval(() => {
      this.round++
      if (this.step * this.round < duration) {
        if (this.blockFlag) {
          this.round--
          clearInterval(this.timer)
          return
        }
        try {
          this.perStep!(this.round)
        } catch (e) {
          this.reject!(e)
        }
      } else if (this.step * this.round === duration) {
        if (this.blockFlag) {
          this.round--
          clearInterval(this.timer)
          return
        }
        try {
          this.perStep!(this.round)
        } catch (e) {
          this.reject!(e)
        }
        clearInterval(this.timer)
        this.resolve!('time over')
      } else {
        clearInterval(this.timer)
        this.resolve!('time over')
      }
    }, this.step)
  }
  pause() {
    if (!this.blockFlag) {
      this.blockFlag = true
    }
  }
  continue() {
    if (this.blockFlag) {
      this.blockFlag = false
      clearInterval(this.timer)
      const newDuration = this.duration - this.round * this.step
      this.createIntervalTimer(newDuration)
    }
  }
  /**
   * 提前终止计时器
   */
  abort() {
    this.reject?.('abort')
    this.reset()
  }
  /**
   * 销毁定时器
   */
  destroy() {
    this.reset()
  }
  private reset() {
    this.blockFlag = false
    this.round = 0
    clearInterval(this.timer)
    this.timer = undefined
    this.reject?.('restart')
  }
}
export { StepTimer }
src/components.d.ts
@@ -7,10 +7,12 @@
declare module 'vue' {
  export interface GlobalComponents {
    BaseModal: typeof import('./components/BaseModal.vue')['default']
    DashboardLayout: typeof import('./components/DashboardLayout.vue')['default']
    ElButton: typeof import('element-plus/es')['ElButton']
    ElCollapse: typeof import('element-plus/es')['ElCollapse']
    ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
    ElDialog: typeof import('element-plus/es')['ElDialog']
    ElIcon: typeof import('element-plus/es')['ElIcon']
    ElPopover: typeof import('element-plus/es')['ElPopover']
    ElProgress: typeof import('element-plus/es')['ElProgress']
src/components/BaseModal.vue
New file
@@ -0,0 +1,89 @@
<template>
  <div class="base-modal" :class="{ wider: props.wider }">
    <el-dialog v-model="modelData" :close-on-click-modal="false" width="753px" :show-close="false">
      <template #header>
        <div class="modal-title">
          <div class="modal-title-text">
            <slot name="title"></slot>
          </div>
          <div v-if="props.wider" class="modal-title-close" @click="closeModal">
            <el-icon :size="22"><CloseBold /></el-icon>
          </div>
        </div>
      </template>
      <template #default>
        <slot name="default"></slot>
      </template>
      <template #footer>
        <slot name="footer"></slot>
      </template>
    </el-dialog>
  </div>
</template>
<script setup lang="ts">
import { useVModel } from '@vueuse/core'
import { CloseBold } from '@element-plus/icons-vue'
export interface BaseModalProps {
  /** 是否展示模态框 */
  modelValue: boolean
  /** 更宽版本 */
  wider: boolean
}
const props = withDefaults(defineProps<BaseModalProps>(), {
  modelValue: false,
  wider: false
})
const emit = defineEmits<{
  'update:modelValue': [show: boolean]
}>()
const modelData = useVModel(props, 'modelValue', emit)
function closeModal() {
  modelData.value = false
}
</script>
<style scoped lang="scss">
:deep(.el-dialog) {
  background: transparent !important;
  background-image: url('/modal-background.png') !important;
  background-repeat: no-repeat;
  background-size: 100% 100% !important;
  width: 753px;
  height: 728px;
}
.wider {
  :deep(.el-dialog) {
    background: transparent !important;
    background-image: url('/modal-background-wider.png') !important;
    background-repeat: no-repeat;
    background-size: 100% 100% !important;
    width: 938px;
    height: 733px;
  }
}
.modal-title {
  position: relative;
  display: flex;
  align-items: center;
  &-text {
    padding-left: 12px;
    font-size: 26px;
    font-weight: 600;
  }
  &-close {
    height: 36px;
    width: 36px;
    border-radius: 50%;
    background-color: #00d7e9;
    position: absolute;
    display: flex;
    justify-content: center;
    align-items: center;
    top: -16px;
    right: -30px;
  }
}
</style>
src/views/dashboard/components/TaskControl.vue
@@ -27,23 +27,27 @@
      </template>
    </div>
  </div>
  <TaskControlModal v-model="showTaskControlModal" :task="task"></TaskControlModal>
</template>
<script setup lang="ts">
import type { Task } from '@/api/task'
import { toRefs } from 'vue'
import { ref, toRefs } from 'vue'
import BigButton from '@/views/dashboard/components/BigButton.vue'
import { useDateFormat } from '@vueuse/core'
import TaskControlModal from '@/views/dashboard/components/TaskControlModal.vue'
const props = defineProps<{
  task?: Task
}>()
const { task } = toRefs(props)
const showTaskControlModal = ref(false)
/**
 * 开始生产
 */
function startProduce() {
  // TODO:
  // console.log(1)
  showTaskControlModal.value = true
}
/**
src/views/dashboard/components/TaskControlModal.vue
New file
@@ -0,0 +1,328 @@
<template>
  <div class="task-control-modal">
    <BaseModal v-model="modelData" :wider="false">
      <template #title>新任务</template>
      <div class="modal-content">
        <div v-if="!messageError" class="content-title">
          <div class="content-title-item">当前任务:{{ task?.Procedure.procedure.procedureName || '' }}</div>
          <div class="content-title-item">
            生产数量:
            <div class="leaf-shape box">
              {{ task?.Order?.amount || 0 }}
            </div>
          </div>
        </div>
        <div v-if="!!messageError" class="content-tips">
          <div class="error-t">
            <span v-if="messageError === '下发成功!'" class="el-icon-success color_success"></span>
            <span v-else class="el-icon-error color_error"></span>
          </div>
          <div class="error-m">
            {{ messageError }}
          </div>
          <div class="font_size_20 color_fff" style="text-align: center; width: 100%; margin: 10px 0">
            <span v-if="messageError === '下发成功!'" style="font-size: 30px"></span>
            <span v-else>请重试</span>
          </div>
        </div>
        <div v-else class="content-scroll">
          <div class="scroll-container">
            <el-scrollbar always class="scroller">
              <template v-if="task">
                <div class="info">
                  <div class="info-item">订单编号:{{ task.Order.orderId || '' }}</div>
                  <div class="info-item">工单编号:{{ task.Order.workOrderId || '' }}</div>
                  <div class="info-item">产品名称:{{ task.Order.productName || '' }}</div>
                  <div class="info-item">数量:{{ task.Order.amount || 0 }}{{ task.Order.unit }}</div>
                  <div class="info-item">交货日期:{{ task.Order.deliverDate || '' }}</div>
                  <div class="info-item">工时: {{ task.Procedure.procedure.workHours || '' }}</div>
                  <div class="info-item">
                    计划时间: {{ formatDate(task.Procedure.startTime) || '' }}
                    -
                    {{ formatDate(task.Procedure.endTime) }}
                  </div>
                  <div class="info-item">客户名称:{{ task.Order.customer || '' }}</div>
                  <div class="info-item info-item-two">通道: {{ CHANNEL_NAME_MAP[task.Channel] || '' }}</div>
                  <div class="info-item info-item-two">参数要求:{{ task.Order.parameter || '' }}</div>
                  <div class="info-item-two">
                    <div style="color: #4efefa; font-size: 18px; margin-bottom: 10px; margin-top: 20px">工艺参数</div>
                    <div v-for="(item, index) in craftParams" :key="index" class="info-item info-item-two">
                      {{ item.Key }}:{{ item.Value || '' }}
                    </div>
                  </div>
                </div>
                <div class="title-auto-box"></div>
                <div v-if="getCraftParamsErrMsg" class="process-err-tip">
                  <div class="tip-icon">
                    <span class="el-icon-error color_error"></span>
                  </div>
                  <div class="tip-content">提示: {{ getCraftParamsErrMsg }}</div>
                </div>
                <div v-if="countdown30s.countdownStatus.value === 'running'" class="countdown">
                  {{ countdown30s.formattedTime.value }}
                </div>
                <!--              <div v-if="showBtn === 2 || showBtn === 3" class="process-box">-->
                <!--                <div-->
                <!--                  style="-->
                <!--                    color: red;-->
                <!--                    font-size: 26px;-->
                <!--                    width: 100%;-->
                <!--                    text-align: center;-->
                <!--                    margin-bottom: 15px;-->
                <!--                    line-height: 35px;-->
                <!--                  "-->
                <!--                  :class="showBtn === 3 && isLoading ? 'margin-top-10px' : 'margin-top-40px'"-->
                <!--                >-->
                <!--                  <div v-if="showBtn === 2 || (showBtn === 3 && !isLoading)" class="gif-box">-->
                <!--                    <template v-if="showBtn === 2">-->
                <!--                      <div class="gif">-->
                <!--                        <img src="../../public/shan.gif" />-->
                <!--                      </div>-->
                <!--                    </template>-->
                <!--                    <template v-if="showBtn === 3 && !isLoading">-->
                <!--                      <div class="gif">-->
                <!--                        <span class="yuandian"></span>-->
                <!--                      </div>-->
                <!--                    </template>-->
                <!--                    <div class="gif-right">-->
                <!--                      <div>-&#45;&#45;&#45;&#45; 剩余时间 -&#45;&#45;&#45;&#45;</div>-->
                <!--                      <div>-->
                <!--                        <span>00:{{ countdown30s.formattedTime }}</span>-->
                <!--                      </div>-->
                <!--                    </div>-->
                <!--                  </div>-->
                <!--                  {{ message }}-->
                <!--                </div>-->
                <!--                <template v-if="showBtn === 3 && isLoading">-->
                <!--                  <div class="progress-item">-->
                <!--                    <span>{{ (+num / 30) * 100 }}%</span>-->
                <!--                    <el-progress-->
                <!--                      style="width: calc(100% - 50px); float: right"-->
                <!--                      define-back-color="#CDC6C6"-->
                <!--                      color="#00cc66"-->
                <!--                      text-color="#fff"-->
                <!--                      :text-inside="true"-->
                <!--                      :stroke-width="20"-->
                <!--                      :percentage="(+num / 30) * 100"-->
                <!--                    ></el-progress>-->
                <!--                  </div>-->
                <!--                </template>-->
                <!--              </div>-->
              </template>
            </el-scrollbar>
          </div>
        </div>
      </div>
      <template #footer>
        <div class="btn">
          <BigButton bg-color="#4765c0" @click="closeModal">暂缓生产</BigButton>
          <BigButton
            v-if="countdown30s.countdownStatus.value !== 'complete'"
            color="#0d0d0d"
            :disabled="countdown30s.countdownStatus.value === 'running'"
            @click="startCountdown30s"
          >
            生产准备
          </BigButton>
          <BigButton v-if="countdown30s.countdownStatus.value === 'complete'" bg-color="#4efefa" @click="startProduce">
            开始生产
          </BigButton>
        </div>
      </template>
    </BaseModal>
  </div>
</template>
<script setup lang="ts">
import type { CraftParam, Task } from '@/api/task'
import { useDateFormat, useVModel } from '@vueuse/core'
import { computed, ref, toRefs, watch } from 'vue'
import BigButton from '@/views/dashboard/components/BigButton.vue'
import { CHANNEL_NAME_MAP } from '@/common/constants'
import { getCraftParams, sendProcessParams } from '@/api'
import { useCountDown } from '@/common/composable'
import { storeToRefs } from 'pinia'
import { useTasksStore } from '@/stores/tasks'
export interface TaskControlModalProps {
  task?: Task
  /** 是否展示模态框 */
  modelValue: boolean
}
const props = withDefaults(defineProps<TaskControlModalProps>(), {
  task: undefined,
  modelValue: false
})
const emit = defineEmits<{
  'update:modelValue': [show: boolean]
}>()
const modelData = useVModel(props, 'modelValue', emit)
function closeModal() {
  modelData.value = false
}
const { task } = toRefs(props)
const messageError = ref('')
/**
 * 格式化时间戳
 * @param timestamp 后端返的10位时间戳
 */
function formatDate(timestamp?: number) {
  if (!timestamp) {
    return '--'
  }
  const time = useDateFormat(timestamp * 1000, 'YYYY-MM-DD', { locales: 'zh-cn' })
  return time.value
}
// 工艺参数
const craftParams = ref<CraftParam[]>()
// 获取工艺参数失败信息
const getCraftParamsErrMsg = ref('')
/**
 * 获取当前展示的任务的工艺参数
 */
function getTaskProduceParams(taskId?: number) {
  if (taskId) {
    craftParams.value = []
    getCraftParamsErrMsg.value = ''
    getCraftParams({ id: taskId }).then(
      (res) => {
        craftParams.value = res.data.Params ?? []
        getCraftParamsErrMsg.value = ''
        // TODO: 处理各个按钮显隐
        // this.getInfo()
        console.log('processParams', craftParams.value)
      },
      (err) => {
        console.error(err)
        craftParams.value = []
        getCraftParamsErrMsg.value = '获取工艺参数失败!'
      }
    )
  }
}
watch(modelData, () => {
  // 弹窗显示时获取工艺参数
  if (modelData.value) {
    getTaskProduceParams(task?.value?.Procedure?.ID)
  }
})
const countdown30s = useCountDown(3)
function startCountdown30s() {
  countdown30s.startCountdown()
}
/**
 * 下发工艺参数
 */
function startProduce() {
  if (task.value?.Procedure?.ID) {
    message.value = '工艺参数下发中...'
    isLoading.value = true
    sendProcessParams({
      procedureId: task.value.Procedure.ID
    })
      .then(
        (res) => {
          console.log(res)
          messageError.value = '下发成功'
        },
        (err) => {
          console.error(err)
          messageError.value = err.msg ? err.msg : '抱歉,工序下发失败!'
        }
      )
      .finally(() => {
        isLoading.value = false
      })
  }
}
const { channels } = storeToRefs(useTasksStore())
const safeProduce = computed(() => {
  if (task?.value?.Channel) {
    return channels?.value?.[task.value.Channel]?.Prompt?.safeProduce
  }
  return ''
})
const message = ref(safeProduce.value)
const isLoading = ref(false)
</script>
<style scoped lang="scss">
.modal-content {
  height: 550px;
}
.content-scroll {
  height: 400px;
  overflow: hidden;
}
:deep(.el-dialog__body) {
  padding: 0 20px;
}
.btn {
  display: flex;
  align-items: center;
  justify-content: space-around;
}
.content-title {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 10px 40px;
}
.content-title-item {
  width: 50%;
  font-size: 20px;
  color: #4efefa;
}
.leaf-shape {
  position: relative;
  display: inline-block;
  width: 140px;
  height: 46px;
  border-radius: 23px 0 23px 0;
  text-align: center;
  line-height: 46px;
  color: #fff;
  background: url('/leaf-shape.png') no-repeat center center / cover;
}
.scroll-container {
  margin: 0 auto;
  padding: 10px 20px;
  width: calc(100% - 40px);
  height: 400px;
}
.info {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  background-color: #0e246a;
  padding: 10px 20px;
}
.info-item {
  width: 50%;
  height: 35px;
  line-height: 35px;
  font-size: 16px;
  color: #fff;
}
.info-item-two {
  width: 100%;
}
</style>