소스 검색

feat(views): 添加“实时监测”页面的设备控制菜单和操作面板

wangcong 1 개월 전
부모
커밋
aac71cf709

+ 19 - 1
src/views/real-time-monitor/RealTimeMonitor.vue

@@ -1,3 +1,21 @@
+<script setup lang="ts">
+import { useTemplateRef } from 'vue';
+
+import DeviceControl from './device-control/DeviceControl.vue';
+
+const deviceControlRef = useTemplateRef('deviceControl');
+</script>
+
 <template>
-  <div>实时监测</div>
+  <div class="real-time-monitor-container" @click="deviceControlRef?.closeCtrlPanel">
+    <DeviceControl ref="deviceControl" />
+  </div>
 </template>
+
+<style lang="scss" scoped>
+.real-time-monitor-container {
+  height: 100%;
+  background: #1e2530;
+  border-radius: 12px;
+}
+</style>

+ 233 - 0
src/views/real-time-monitor/device-control/AIOptimization.vue

@@ -0,0 +1,233 @@
+<script setup lang="ts">
+import { computed, ref, shallowRef } from 'vue';
+import { message, Modal } from 'ant-design-vue';
+
+import SvgIcon from '@/components/SvgIcon.vue';
+import { useViewVisible } from '@/hooks/view-visible';
+import { t } from '@/i18n';
+
+import ChillerUnitOptimize from '../device-optimize/ChillerUnitOptimize.vue';
+import CoolingTowerOptimize from '../device-optimize/CoolingTowerOptimize.vue';
+
+import type { Component } from 'vue';
+import type { ColumnType } from 'ant-design-vue/es/table';
+
+interface OptimizeAlgorithmItem {
+  algorithm: string;
+  setValue: number;
+  isEnabled: boolean;
+  unit?: string;
+  modalWidth?: number;
+  modalComponent?: Component;
+}
+
+const dataSource = ref<OptimizeAlgorithmItem[]>([
+  {
+    algorithm: '冷机水温寻优',
+    setValue: 10,
+    isEnabled: false,
+    unit: '°C',
+    modalComponent: shallowRef(ChillerUnitOptimize),
+  },
+  {
+    algorithm: '塔出水温度寻优',
+    setValue: 10,
+    isEnabled: true,
+    unit: '°C',
+    modalWidth: 460,
+    modalComponent: shallowRef(CoolingTowerOptimize),
+  },
+  {
+    algorithm: '冷冻泵寻优',
+    setValue: 10,
+    isEnabled: true,
+  },
+  {
+    algorithm: '冷却泵寻优',
+    setValue: 10,
+    isEnabled: true,
+  },
+]);
+
+const columns = computed<ColumnType<OptimizeAlgorithmItem>[]>(() => [
+  {
+    title: t('realTimeMonitor.optimizationAlgorithm'),
+    dataIndex: 'algorithm',
+    key: 'algorithm',
+  },
+  {
+    title: t('realTimeMonitor.setValue'),
+    dataIndex: 'setValue',
+    key: 'setValue',
+  },
+  {
+    title: t('realTimeMonitor.isEnabled'),
+    dataIndex: 'isEnabled',
+    key: 'isEnabled',
+  },
+  {
+    title: t('common.operation'),
+    dataIndex: 'operation',
+    key: 'operation',
+  },
+]);
+
+const handleSwitchClick = (record: OptimizeAlgorithmItem) => {
+  const titlePrefix = record.isEnabled ? t('common.confirmClose') : t('common.confirmOpen');
+
+  Modal.confirm({
+    title: titlePrefix + record.algorithm,
+    closable: true,
+    async onOk() {
+      try {
+        record.isEnabled = !record.isEnabled;
+      } catch (err) {
+        message.error((err as Error).message);
+        console.error(err);
+      }
+    },
+  });
+};
+
+const selectedAlgorithm = ref<OptimizeAlgorithmItem>();
+
+const handleSettingClick = (record: OptimizeAlgorithmItem) => {
+  selectedAlgorithm.value = record;
+  showView();
+};
+
+const { visible, showView } = useViewVisible();
+
+const modalTitle = computed(() => {
+  return selectedAlgorithm.value?.algorithm + t('common.settings');
+});
+
+const handleOk = () => {
+  // 实际场景中处理确定逻辑
+};
+</script>
+
+<template>
+  <div>
+    <ATable class="ai-optimize-table" :columns="columns" :data-source="dataSource" :pagination="false">
+      <template #bodyCell="{ column, record }">
+        <template v-if="column.key === 'algorithm'">
+          <span>{{ record.algorithm }}</span>
+          <span v-if="record.unit">({{ record.unit }})</span>
+        </template>
+        <template v-if="column.key === 'setValue'">
+          <AInput class="ai-optimize-input" v-model:value="record.setValue" />
+        </template>
+        <template v-if="column.key === 'isEnabled'">
+          <ASwitch
+            :checked="record.isEnabled"
+            :checked-children="$t('common.on')"
+            :un-checked-children="$t('common.off')"
+            @click="handleSwitchClick(record as OptimizeAlgorithmItem)"
+          />
+        </template>
+        <template v-else-if="column.key === 'operation'">
+          <div class="ai-optimize-setting">
+            <SvgIcon name="setting" @click="handleSettingClick(record as OptimizeAlgorithmItem)" />
+          </div>
+        </template>
+      </template>
+    </ATable>
+    <AModal
+      v-model:open="visible"
+      wrap-class-name="hvac-modal ai-optimize-modal"
+      :title="modalTitle"
+      :width="selectedAlgorithm?.modalWidth || 920"
+      destroy-on-close
+      @ok="handleOk"
+    >
+      <component :is="selectedAlgorithm?.modalComponent" />
+    </AModal>
+  </div>
+</template>
+
+<style lang="scss">
+.ai-optimize-modal {
+  .ant-modal-body {
+    max-height: 638px;
+    overflow-y: auto;
+  }
+}
+</style>
+
+<style lang="scss" scoped>
+:deep(.ai-optimize-table).ant-table-wrapper .ant-table {
+  color: rgba(255 255 255 / 85%);
+  background: transparent;
+
+  .ant-table-thead > tr > th {
+    padding: 12px;
+    font-size: 14px;
+    font-weight: 500;
+    color: rgba(255 255 255 / 85%);
+    background: rgba(255 255 255 / 8%);
+    border-bottom: none;
+  }
+
+  .ant-table-header {
+    border-radius: 0;
+  }
+
+  table > thead > tr:first-child > {
+    *:first-child {
+      border-start-start-radius: 0;
+    }
+
+    *:last-child {
+      border-start-end-radius: 0;
+    }
+  }
+
+  .ant-table-thead
+    > tr
+    > th.ant-table-cell:not(:last-child, .ant-table-selection-column, .ant-table-row-expand-icon-cell, [colspan]) {
+    &::before {
+      background-color: transparent;
+    }
+  }
+
+  .ant-table-tbody > tr {
+    > td {
+      padding-inline: 12px;
+      border-top-color: rgba(255 255 255 / 8%);
+    }
+
+    &:last-child > td {
+      border-bottom-color: rgba(255 255 255 / 8%);
+    }
+
+    &.ant-table-row:hover > td,
+    > td.ant-table-cell-row-hover {
+      background: rgba(255 255 255 / 8%);
+    }
+  }
+}
+
+.ai-optimize-input {
+  width: 96px;
+  color: #fff;
+  background: rgb(30 37 48 / 32%);
+  border-color: rgb(255 255 255 / 24%);
+  border-radius: 4px;
+
+  &.ant-input:hover,
+  &.ant-input:focus,
+  &.ant-input-focused {
+    border-color: var(--antd-color-primary-hover);
+  }
+}
+
+.ai-optimize-setting {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 24px;
+  color: var(--antd-color-primary);
+  cursor: pointer;
+}
+</style>

+ 290 - 0
src/views/real-time-monitor/device-control/AIStartStop.vue

@@ -0,0 +1,290 @@
+<script setup lang="ts">
+import { computed, ref } from 'vue';
+import { message, Modal } from 'ant-design-vue';
+
+import SvgIcon from '@/components/SvgIcon.vue';
+import { t } from '@/i18n';
+
+import type { SegmentedBaseOption } from 'ant-design-vue/es/segmented/src/segmented';
+
+type ModeValue = 'fullAuto' | 'halfAuto' | 'jog';
+
+interface ModeTypeItem extends SegmentedBaseOption {
+  value: ModeValue;
+  payload: {
+    title: string;
+    confirmTip: string;
+  };
+}
+
+const modeTypes = computed<ModeTypeItem[]>(() => {
+  return [
+    {
+      value: 'fullAuto',
+      payload: {
+        title: t('realTimeMonitor.fullAutoMode'),
+        confirmTip: t('realTimeMonitor.fullAutoConfirmTip'),
+      },
+    },
+    {
+      value: 'halfAuto',
+      payload: {
+        title: t('realTimeMonitor.halfAutoMode'),
+        confirmTip: t('realTimeMonitor.halfAutoConfirmTip'),
+      },
+    },
+    {
+      value: 'jog',
+      disabled: true,
+      payload: {
+        title: t('realTimeMonitor.jogMode'),
+        confirmTip: '',
+      },
+    },
+  ];
+});
+
+const currentModeType = ref<ModeValue>('fullAuto');
+
+const handleModeClick = (option: ModeTypeItem) => {
+  if (option.disabled || option.value === currentModeType.value) {
+    return;
+  }
+
+  Modal.confirm({
+    title: t('realTimeMonitor.confirmSwitchToMode', { mode: option.payload.title }),
+    content: option.payload.confirmTip,
+    closable: true,
+    async onOk() {
+      try {
+        currentModeType.value = option.value;
+      } catch (err) {
+        message.error((err as Error).message);
+        console.error(err);
+      }
+    },
+  });
+};
+
+type DeviceStatus = 'on' | 'off';
+
+interface DeviceItem {
+  name: string;
+  status: DeviceStatus;
+}
+
+const deviceList = ref<DeviceItem[]>([
+  { name: '五期-1#冷水主机', status: 'off' },
+  { name: '五期-2#冷水主机', status: 'on' },
+  { name: '五期-3#冷水主机', status: 'off' },
+  { name: '五期-3#冷水主机', status: 'off' },
+]);
+</script>
+
+<template>
+  <div class="ai-start-stop-container">
+    <ASegmented :value="currentModeType" :options="modeTypes">
+      <template #label="option">
+        <span @click="handleModeClick(option as ModeTypeItem)">{{ option.payload.title }}</span>
+      </template>
+    </ASegmented>
+    <template v-if="currentModeType === 'fullAuto'">
+      <div class="power-button">
+        <div class="power-button-content">
+          <SvgIcon name="power-off" />
+          <div>{{ $t('realTimeMonitor.fullPowerOff') }}</div>
+        </div>
+        <div class="power-button-segmented-border"></div>
+      </div>
+    </template>
+    <template v-else-if="currentModeType === 'halfAuto'">
+      <div v-for="(item, index) in deviceList" :key="index" class="device-item">
+        <div>
+          <span :class="['device-item-tag', { 'device-tag-run': item.status === 'on' }]">
+            {{ item.status === 'on' ? $t('common.run') : $t('common.shutDown') }}
+          </span>
+          <span class="device-item-title">{{ item.name }}</span>
+        </div>
+        <ASwitch
+          v-model:checked="item.status"
+          checked-value="on"
+          un-checked-value="off"
+          :checked-children="$t('common.on')"
+          :un-checked-children="$t('common.off')"
+        />
+      </div>
+    </template>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+:deep(.ant-segmented) {
+  padding: 4px;
+  margin-bottom: 16px;
+  color: #fff;
+  background-color: rgb(255 255 255 / 8%);
+  border-radius: 4px;
+
+  .ant-segmented-group > div {
+    background-color: var(--antd-color-primary);
+  }
+
+  .ant-segmented-item-label {
+    display: flex;
+    align-items: center;
+    padding-inline: 0;
+
+    span {
+      padding-inline: 12px;
+    }
+  }
+
+  .ant-segmented-item-selected {
+    color: #fff;
+    background-color: var(--antd-color-primary);
+    box-shadow: none;
+  }
+
+  .ant-segmented-item {
+    + .ant-segmented-item {
+      margin-left: 8px;
+    }
+
+    &:hover:not(.ant-segmented-item-selected, .ant-segmented-item-disabled) {
+      color: #fff;
+
+      &::after {
+        background-color: var(--antd-color-primary-opacity-15);
+      }
+    }
+  }
+
+  .ant-segmented-item-disabled,
+  .ant-segmented-item-disabled:hover,
+  .ant-segmented-item-disabled:focus {
+    color: rgb(255 255 255 / 25%);
+  }
+}
+
+.power-button {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 214px;
+  color: #fff;
+  cursor: pointer;
+  background-color: rgba(var(--antd-color-primary-rgb), 0.04);
+  border: 1px solid rgba(var(--antd-color-primary-rgb), 0.08);
+
+  &,
+  &::before,
+  &::after {
+    position: absolute;
+    top: 50%;
+    left: 50%;
+    aspect-ratio: 1;
+    border-radius: 50%;
+    transform: translate(-50%, -50%);
+  }
+
+  &::before,
+  &::after {
+    display: block;
+    content: '';
+  }
+
+  &::before {
+    width: 166px;
+    background-color: rgba(var(--antd-color-primary-rgb), 0.1);
+    border: 1px solid rgba(var(--antd-color-primary-rgb), 0.12);
+  }
+
+  &::after {
+    width: 120px;
+    background: linear-gradient(
+      219deg,
+      var(--antd-color-primary-border-hover) 0%,
+      var(--antd-color-primary) 51%,
+      var(--antd-color-primary-active) 100%
+    );
+    box-shadow: inset 0 0 8px 0 var(--antd-color-primary-bg-hover);
+  }
+}
+
+.power-button-segmented-border {
+  position: absolute;
+  width: calc(100% + 4px);
+  height: calc(100% + 4px);
+  content: '';
+
+  /* 生成三段60度的深色边框,间隔60度 */
+  background: conic-gradient(
+    var(--antd-color-primary-active) 0deg 60deg,
+    transparent 60deg 120deg,
+    var(--antd-color-primary-active) 120deg 180deg,
+    transparent 180deg 240deg,
+    var(--antd-color-primary-active) 240deg 300deg,
+    transparent 300deg 360deg
+  );
+  border-radius: 50%;
+
+  /* 通过遮罩仅显示边框区域 */
+  mask: radial-gradient(
+    circle,
+    transparent calc(53% - 1px),
+    #000 calc(53% - 1px) calc(53% + 1px),
+    transparent calc(53% + 1px)
+  );
+}
+
+.power-button-content {
+  z-index: 5;
+  font-size: 16px;
+  font-weight: 500;
+  line-height: 22px;
+  color: #fff;
+  text-align: center;
+
+  i {
+    font-size: 24px;
+  }
+}
+
+.device-item {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 16px 24px;
+  background: rgb(255 255 255 / 8%);
+  border-radius: 4px;
+
+  & + & {
+    margin-top: 16px;
+  }
+}
+
+.device-item-tag {
+  padding: 2px 8px;
+  margin-right: 16px;
+  font-size: 12px;
+  font-weight: 500;
+  line-height: 20px;
+  color: rgb(255 255 255 / 65%);
+  background: rgb(255 255 255 / 16%);
+  border: 1px solid rgb(255 255 255 / 24%);
+  border-radius: 4px;
+}
+
+.device-tag-run {
+  color: #52c41a;
+  background: rgb(103 194 58 / 16%);
+  border-color: rgb(82 196 26 / 50%);
+}
+
+.device-item-title {
+  font-size: 14px;
+  font-weight: 500;
+  line-height: 22px;
+  color: #fff;
+}
+</style>

+ 10 - 0
src/views/real-time-monitor/device-control/AdvancedSettings.vue

@@ -0,0 +1,10 @@
+<script setup lang="ts">
+// 这里可以添加高级设置的逻辑
+</script>
+
+<template>
+  <div>
+    <h2>Advanced Settings</h2>
+    <!-- 这里可以添加高级设置的具体内容 -->
+  </div>
+</template>

+ 149 - 0
src/views/real-time-monitor/device-control/DeviceControl.vue

@@ -0,0 +1,149 @@
+<script setup lang="ts">
+import { computed, ref } from 'vue';
+
+import { t } from '@/i18n';
+import { addUnit } from '@/utils';
+
+import AdvancedSettings from './AdvancedSettings.vue';
+import AIOptimization from './AIOptimization.vue';
+import AIStartStop from './AIStartStop.vue';
+
+import type { Component, CSSProperties } from 'vue';
+
+interface ConfigItem {
+  label: string;
+  component: Component;
+  style?: CSSProperties;
+}
+
+const activeConfigIndex = ref(-1);
+
+const configs = computed<ConfigItem[]>(() => {
+  return [
+    { label: t('realTimeMonitor.aiStartStop'), component: AIStartStop },
+    { label: t('realTimeMonitor.aiOptimization'), component: AIOptimization },
+    {
+      label: t('common.advancedSettings'),
+      component: AdvancedSettings,
+      style: {
+        paddingBlock: addUnit(16),
+      },
+    },
+  ];
+});
+
+const showCtrlPanel = computed(() => {
+  return activeConfigIndex.value !== -1;
+});
+
+const toggleConfig = (index: number) => {
+  if (activeConfigIndex.value === index) {
+    closeCtrlPanel();
+  } else {
+    activeConfigIndex.value = index;
+  }
+};
+
+const closeCtrlPanel = () => {
+  activeConfigIndex.value = -1;
+};
+
+defineExpose({
+  closeCtrlPanel,
+});
+</script>
+
+<template>
+  <div class="ctrl-button-conatiner top-1/2 flex flex-col -translate-y-1/2" @click.stop>
+    <div
+      :class="['ctrl-button', { 'ctrl-button-active': activeConfigIndex === index }]"
+      v-for="(item, index) in configs"
+      :key="index"
+      :style="item.style"
+      @click="toggleConfig(index)"
+    >
+      <div class="corner-border lt-border"></div>
+      <div class="corner-border rt-border"></div>
+      <div class="corner-border lb-border"></div>
+      <div class="corner-border rb-border"></div>
+      {{ item.label }}
+    </div>
+    <div class="ctrl-panel" v-if="showCtrlPanel">
+      <div class="ctrl-panel-title">{{ configs[activeConfigIndex].label }}</div>
+      <component :is="configs[activeConfigIndex].component" />
+    </div>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.ctrl-button-conatiner {
+  position: fixed;
+  right: 16px;
+  margin-inline: 16px;
+}
+
+.ctrl-button {
+  position: relative;
+  width: 32px;
+  height: 96px;
+  padding: 8px;
+  font-size: 14px;
+  line-height: 16px;
+  color: #fff;
+  cursor: pointer;
+  background-color: #323a47;
+  border: 1px solid rgb(134 141 152 / 40%);
+  box-shadow: inset 0 0 3px 0 rgb(255 255 255 / 24%);
+
+  & + & {
+    margin-top: 16px;
+  }
+}
+
+.ctrl-button-active {
+  background-color: var(--antd-color-primary);
+  border: 1px solid rgb(255 255 255 / 50%);
+  box-shadow: inset 0 0 6px 1px rgb(255 255 255 / 70%);
+
+  .corner-border {
+    --corner-border-color: #fff;
+  }
+}
+
+.ctrl-panel {
+  position: absolute;
+  top: -147px;
+  right: 48px;
+  width: 464px;
+  height: 630px;
+  padding: 24px;
+  background: rgb(30 37 48 / 50%);
+  backdrop-filter: blur(12px);
+  border: 1px solid rgb(255 255 255 / 24%);
+  border-radius: 8px;
+}
+
+.ctrl-panel-title {
+  margin-bottom: 16px;
+  font-size: 16px;
+  font-weight: 500;
+  line-height: 22px;
+  color: #fff;
+}
+
+:deep(.ant-switch) {
+  background: rgb(255 255 255 / 25%);
+
+  &:hover:not(.ant-switch-disabled) {
+    background: rgb(255 255 255 / 45%);
+  }
+
+  &.ant-switch-checked {
+    background: var(--antd-color-primary);
+
+    &:hover:not(.ant-switch-disabled) {
+      background: var(--antd-color-primary-hover);
+    }
+  }
+}
+</style>

+ 261 - 0
src/views/real-time-monitor/device-optimize/ChillerUnitOptimize.vue

@@ -0,0 +1,261 @@
+<script setup lang="ts">
+import { computed, ref } from 'vue';
+import dayjs from 'dayjs';
+
+import { t } from '@/i18n';
+
+import type { ColumnType } from 'ant-design-vue/es/table';
+import type { RangeValue } from '@/types';
+
+interface DeviceAlgorithmItem {
+  deviceName: string;
+  deviceStatus: 'on' | 'off';
+  isInAlgorithm: boolean;
+}
+
+const devices = ref<DeviceAlgorithmItem[]>([
+  { deviceName: '五期-1#冷水主机', deviceStatus: 'off', isInAlgorithm: true },
+  { deviceName: '五期-2#冷水主机', deviceStatus: 'on', isInAlgorithm: true },
+  { deviceName: '五期-3#冷水主机', deviceStatus: 'off', isInAlgorithm: true },
+]);
+
+const deviceColumns = computed<ColumnType<DeviceAlgorithmItem>[]>(() => [
+  {
+    title: t('realTimeMonitor.deviceName'),
+    dataIndex: 'deviceName',
+    key: 'deviceName',
+  },
+  {
+    title: t('realTimeMonitor.deviceStatus'),
+    dataIndex: 'deviceStatus',
+    key: 'deviceStatus',
+    width: 128,
+  },
+  {
+    title: t('realTimeMonitor.isInAlgorithm'),
+    dataIndex: 'isInAlgorithm',
+    key: 'isInAlgorithm',
+    width: 128,
+  },
+]);
+
+interface TemperatureRange {
+  timeSegment: RangeValue;
+  minTemp?: number;
+  maxTemp?: number;
+}
+
+const temperatureRanges = ref<TemperatureRange[]>([
+  {
+    timeSegment: [dayjs('00:00', 'HH:mm'), dayjs('23:59', 'HH:mm')],
+    minTemp: 10.0,
+    maxTemp: 12.0,
+  },
+]);
+
+const addTimeRange = () => {
+  temperatureRanges.value.push({
+    timeSegment: [dayjs('00:00', 'HH:mm'), dayjs('23:59', 'HH:mm')],
+  });
+};
+
+const deleteTimeRange = (index: number) => {
+  temperatureRanges.value.splice(index, 1);
+};
+</script>
+
+<template>
+  <div>
+    <div class="chiller-optimize-device-title">{{ t('realTimeMonitor.settingParticipatingDevices') }}</div>
+    <ATable
+      class="chiller-optimize-table hvac-table"
+      :columns="deviceColumns"
+      :data-source="devices"
+      :pagination="false"
+    >
+      <template #bodyCell="{ column, record }">
+        <template v-if="column.key === 'deviceStatus'">
+          <span :class="['device-status-tag', { 'device-status-run': record.deviceStatus === 'on' }]">
+            {{ record.deviceStatus === 'on' ? $t('common.run') : $t('common.shutDown') }}
+          </span>
+        </template>
+        <template v-if="column.key === 'isInAlgorithm'">
+          <ASwitch v-model:checked="record.isInAlgorithm" />
+        </template>
+      </template>
+    </ATable>
+    <div class="chiller-optimize-time-title">
+      <span>{{ t('realTimeMonitor.waterTemperatureControlInterval') }}</span>
+      <AButton class="icon-button" @click="addTimeRange">
+        <SvgIcon name="plus" />
+        {{ t('realTimeMonitor.addTimePeriod') }}
+      </AButton>
+    </div>
+    <div class="chiller-optimize-time-temp-item" v-for="(item, index) in temperatureRanges" :key="index">
+      <div>
+        <span class="chiller-optimize-time-temp-label">{{ $t('realTimeMonitor.timeSegment') }}</span>
+        <ATimeRangePicker v-model:value="item.timeSegment" format="HH:mm" :allow-clear="false" separator="-">
+          <template #suffixIcon>
+            <SvgIcon name="clock-circle-o" :size="16" color="var(--antd-color-text-secondary)" />
+          </template>
+        </ATimeRangePicker>
+        <span class="chiller-optimize-time-temp-label">{{ $t('realTimeMonitor.outletTemperature') }}</span>
+        <AInputNumber
+          class="chiller-optimize-temp-min"
+          v-model:value="item.minTemp"
+          :min="0"
+          :max="item.maxTemp"
+          :precision="1"
+          :controls="false"
+        />
+        <span class="chiller-optimize-temp-separator">-</span>
+        <AInputNumber
+          class="chiller-optimize-temp-max hvac-input-number"
+          v-model:value="item.maxTemp"
+          :min="0"
+          :max="25"
+          :precision="1"
+          :controls="false"
+          addon-after="℃"
+        />
+      </div>
+      <SvgIcon name="delete" @click="deleteTimeRange(index)" />
+    </div>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.chiller-optimize-device-title {
+  margin-bottom: 16px;
+  font-size: 14px;
+  font-weight: 500;
+  line-height: 22px;
+}
+
+:deep(.chiller-optimize-table).ant-table-wrapper .ant-table-container {
+  .ant-table-thead > tr > th:first-child {
+    padding-left: 40px;
+  }
+
+  .ant-table-tbody > tr > td,
+  tfoot > tr > th,
+  tfoot > tr > td {
+    &:first-child {
+      padding-left: 40px;
+    }
+  }
+}
+
+.device-status-tag {
+  padding: 2px 8px;
+  font-size: 12px;
+  line-height: 20px;
+  color: #666;
+  background: #f8f8f8;
+  border: 1px solid #e5e5e5;
+  border-radius: 4px;
+}
+
+.device-status-run {
+  color: #52c41a;
+  background: #f6ffed;
+  border-color: #b7eb8f;
+}
+
+.chiller-optimize-time-title {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-top: 24px;
+  margin-bottom: 16px;
+
+  > span {
+    font-size: 14px;
+    font-weight: 500;
+    line-height: 22px;
+  }
+
+  button {
+    color: var(--antd-color-primary);
+    background: rgb(255 255 255 / 0%);
+    border: 1px solid var(--antd-color-primary);
+    border-radius: 4px;
+
+    i {
+      font-weight: 900;
+    }
+  }
+}
+
+.chiller-optimize-time-temp-item {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 16px 32px;
+  background: #f5f7fa;
+
+  & + & {
+    margin-top: 8px;
+  }
+
+  > div {
+    display: flex;
+    align-items: center;
+  }
+
+  .ant-picker {
+    width: 256px;
+    margin-right: 40px;
+  }
+
+  > i {
+    font-size: 21px;
+    color: var(--antd-color-primary);
+    cursor: pointer;
+  }
+
+  :deep(input) {
+    text-align: center;
+  }
+}
+
+.chiller-optimize-time-temp-label {
+  margin-right: 16px;
+}
+
+.chiller-optimize-temp-min,
+:deep(.chiller-optimize-temp-max) .ant-input-number {
+  width: 102px;
+
+  &:hover,
+  &:focus,
+  &.ant-input-number-focused {
+    border-color: var(--antd-color-primary-hover);
+  }
+}
+
+.chiller-optimize-temp-min {
+  border-right-color: transparent;
+  border-top-right-radius: 0;
+  border-bottom-right-radius: 0;
+}
+
+:deep(.chiller-optimize-temp-max) {
+  .ant-input-number {
+    border-left-color: transparent;
+    border-top-left-radius: 0;
+    border-bottom-left-radius: 0;
+  }
+}
+
+.chiller-optimize-temp-separator {
+  display: block;
+  width: 12.5px;
+  height: 32px;
+  line-height: 32px;
+  text-align: center;
+  background-color: #fff;
+  border-top: 1px solid var(--antd-color-border);
+  border-bottom: 1px solid var(--antd-color-border);
+}
+</style>

+ 65 - 0
src/views/real-time-monitor/device-optimize/CoolingTowerOptimize.vue

@@ -0,0 +1,65 @@
+<script setup lang="ts">
+import { computed, reactive } from 'vue';
+
+import { t } from '@/i18n';
+
+import type { FormRules } from '@/types';
+
+interface CoolingTowerForm {
+  maxTemp?: number;
+  minTemp?: number;
+}
+
+const coolingTowerForm = reactive<CoolingTowerForm>({
+  maxTemp: undefined,
+  minTemp: undefined,
+});
+
+const rules = computed<FormRules<CoolingTowerForm>>(() => {
+  return {
+    maxTemp: [
+      {
+        required: true,
+        message: t('common.plzEnter', { name: t('realTimeMonitor.towerOutWaterTempUpperLimit') }),
+        trigger: 'blur',
+      },
+    ],
+    minTemp: [
+      {
+        required: true,
+        message: t('common.plzEnter', { name: t('realTimeMonitor.towerOutWaterTempLowerLimit') }),
+        trigger: 'blur',
+      },
+    ],
+  };
+});
+</script>
+
+<template>
+  <AForm ref="formRef" :model="coolingTowerForm" :rules="rules" layout="vertical">
+    <AFormItem :label="t('realTimeMonitor.towerOutWaterTempUpperLimit')" name="maxTemp">
+      <AInputNumber
+        class="hvac-input-number tower-temp-input"
+        v-model:value="coolingTowerForm.maxTemp"
+        :min="0"
+        :max="50"
+        addon-after="℃"
+      />
+    </AFormItem>
+    <AFormItem :label="t('realTimeMonitor.towerOutWaterTempLowerLimit')" name="minTemp">
+      <AInputNumber
+        class="hvac-input-number tower-temp-input"
+        v-model:value="coolingTowerForm.minTemp"
+        :min="0"
+        :max="50"
+        addon-after="℃"
+      />
+    </AFormItem>
+  </AForm>
+</template>
+
+<style lang="scss" scoped>
+.tower-temp-input {
+  width: 100%;
+}
+</style>