Browse Source

feat: 功能完成

main
betaqi 3 months ago
parent
commit
fcbfab2a65
  1. 4
      .env
  2. 4
      .gitignore
  3. 7
      global.types/components.d.ts
  4. 8246
      package-lock.json
  5. 5
      package.json
  6. 5100
      pnpm-lock.yaml
  7. 18
      src/api/module/channel/index.d.ts
  8. 18
      src/api/module/channel/index.ts
  9. 24
      src/api/module/device/category.ts
  10. 32
      src/api/module/device/index.d.ts
  11. 16
      src/api/module/device/index.ts
  12. 9
      src/api/module/engineering/index.d.ts
  13. 25
      src/api/module/engineering/index.ts
  14. 177
      src/components/Edfs-number-item-input.vue
  15. 158
      src/components/FormItemInput.vue
  16. 175
      src/composables/useZMQJsonWorker.ts
  17. 14
      src/lib/zmq/zmqClient.d.ts
  18. 10
      src/lib/zmq/zmqClient.d.ts.map
  19. 118
      src/lib/zmq/zmqClient.js
  20. 10
      src/lib/zmq/zmqClient.js.map
  21. 32
      src/stores/engineering.ts
  22. 270
      src/stores/transferData.ts
  23. 18
      src/utils/validate.ts
  24. 174
      src/utils/zmq.ts
  25. 166
      src/utils/zmqJsonWorker.ts
  26. 216
      src/views/engineering/components/channel-dlg.vue
  27. 20
      src/views/engineering/components/create-engineering-dlg.vue
  28. 218
      src/views/engineering/components/device-dlg.vue
  29. 6
      src/views/engineering/components/types.ts
  30. 382
      src/views/engineering/config/components/StepChannel.vue
  31. 357
      src/views/engineering/config/components/StepDevice.vue
  32. 386
      src/views/engineering/config/components/StepDeviceCategory.vue
  33. 202
      src/views/engineering/config/index.vue
  34. 81
      src/views/engineering/index.vue
  35. 150
      src/views/layout/index.vue

4
.env

@ -1,3 +1,3 @@ @@ -1,3 +1,3 @@
VITE_BASE_API = '/remoteServer'
VITE_BASE_API = '/remoteServer/admin-api/'
VITE_SHOW_ONLINE_DEVICE = true
VITE_BASE_URL = 'http://43.140.245.32:48081'

4
.gitignore vendored

@ -26,4 +26,6 @@ yarn-error.log* @@ -26,4 +26,6 @@ yarn-error.log*
/dist-cloud
/docker_output/
/node_modules/
/*.tar
/*.tar
.idea

7
global.types/components.d.ts vendored

@ -13,6 +13,7 @@ declare module 'vue' { @@ -13,6 +13,7 @@ declare module 'vue' {
EdfsException: typeof import('./../src/components/Edfs-exception.vue')['default']
EdfsInput: typeof import('./../src/components/Edfs-Input.vue')['default']
EdfsNumberInput: typeof import('./../src/components/Edfs-number-input.vue')['default']
EdfsNumberItemInput: typeof import('./../src/components/Edfs-number-item-input.vue')['default']
EdfsTable: typeof import('./../src/components/Edfs-table/index.vue')['default']
EdfsWrap: typeof import('./../src/components/Edfs-wrap.vue')['default']
ElAside: typeof import('element-plus/es')['ElAside']
@ -31,6 +32,7 @@ declare module 'vue' { @@ -31,6 +32,7 @@ declare module 'vue' {
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption']
ElPageHeader: typeof import('element-plus/es')['ElPageHeader']
ElProgress: typeof import('element-plus/es')['ElProgress']
ElRow: typeof import('element-plus/es')['ElRow']
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
ElSelect: typeof import('element-plus/es')['ElSelect']
@ -39,8 +41,13 @@ declare module 'vue' { @@ -39,8 +41,13 @@ declare module 'vue' {
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
ElTree: typeof import('element-plus/es')['ElTree']
ElUpload: typeof import('element-plus/es')['ElUpload']
FormItemInput: typeof import('./../src/components/FormItemInput.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}

8246
package-lock.json generated

File diff suppressed because it is too large Load Diff

5
package.json

@ -14,6 +14,7 @@ @@ -14,6 +14,7 @@
"@antv/g": "^6.1.28",
"@antv/g-svg": "^2.0.42",
"@antv/g6": "^5.0.49",
"@element-plus/icons-vue": "^2.3.2",
"@types/qs": "^6.9.18",
"@unocss/reset": "^66.0.0",
"axios": "^1.8.4",
@ -22,7 +23,6 @@ @@ -22,7 +23,6 @@
"echarts": "^5.6.0",
"element-plus": "^2.9.5",
"g6-extension-vue": "^0.1.0",
"jszmq": "^0.1.2",
"lodash-es": "^4.17.21",
"mockjs": "^1.1.0",
"p-limit": "^7.2.0",
@ -36,6 +36,7 @@ @@ -36,6 +36,7 @@
"devDependencies": {
"@iconify/json": "^2.2.310",
"@tsconfig/node22": "^22.0.0",
"@types/lodash-es": "^4.17.12",
"@types/mockjs": "^1.0.10",
"@types/node": "^24.2.1",
"@unocss/preset-icons": "^66.0.0",
@ -55,4 +56,4 @@ @@ -55,4 +56,4 @@
"vite-plugin-vue-devtools": "^7.7.2",
"vue-tsc": "^2.2.2"
}
}
}

5100
pnpm-lock.yaml

File diff suppressed because it is too large Load Diff

18
src/api/module/channel/index.d.ts vendored

@ -1,8 +1,11 @@ @@ -1,8 +1,11 @@
export type Channel = 'modbusTcp' | 'mqtt'
export type Channel = 'modbus' | 'mqtt'
export interface IChannelBase {
projectName: string
}
export type IChannel =
| (IModbusTcpChannelOV & { channel: 'modbusTcp' })
| (IMqttChannelOV & { channel: 'mqtt' })
export type IChannel = IModbusTcpChannelOV | IMqttChannelOV
interface IChannelBaseOV {
name: string
@ -16,3 +19,10 @@ export interface IModbusTcpChannelOV extends IChannelBaseOV { @@ -16,3 +19,10 @@ export interface IModbusTcpChannelOV extends IChannelBaseOV {
export interface IMqttChannelOV extends IChannelBaseOV {
broker: string
}
export type IChannelList = IChannel
export type IChannelOV = {
[key in Channel]?: IChannel[]
}

18
src/api/module/channel/index.ts

@ -1,16 +1,22 @@ @@ -1,16 +1,22 @@
import { globalServer } from '../index'
import type { IChannel } from './index.d'
import type { IChannelBase, Channel, IChannelOV } from './index.d'
export const getChannelList = () => {
return globalServer<IChannel[]>({
url: '/channel/list',
export const ChannelEnum: Record<string, Channel> = {
MODBUS_TCP: 'modbus',
// MQTT: 'mqtt',
}
export const getChannelList = (params: IChannelBase) => {
return globalServer<IChannelOV>({
url: '/project/channel/list-all',
method: 'get',
params
})
}
export const saveChannel = (params: IChannel[]) => {
export const saveChannel = (params: IChannelOV & IChannelBase) => {
return globalServer({
url: '/channel/save',
url: '/project/channel/generate-file',
method: 'post',
data: params,
})

24
src/api/module/device/category.ts

@ -1,17 +1,29 @@ @@ -1,17 +1,29 @@
import { globalServer } from '../index'
import type { IDeviceCategory } from './index.d'
import type { IDeviceCategoryOV, IDeviceBase, IDeviceCategoryFileOV, IDeviceCategoryList } from './index.d'
export const getDeviceTypeList = () => {
return globalServer<IDeviceCategory[]>({
url: '/device/type/list',
export const getDeviceTypeList = (params: IDeviceBase) => {
return globalServer<IDeviceCategoryList>({
url: '/project/point/list-all-with-status',
method: 'get',
params
})
}
export const createDeviceType = (params: IDeviceCategory) => {
export const createDeviceType = (params: IDeviceCategoryOV & IDeviceBase) => {
return globalServer({
url: '/device/type/create',
url: '/project/point/generate-file',
method: 'post',
data: params,
})
}
export const uploadDeviceTypeFile = (params: { file: File, projectName: string, fileName: string }, abort: AbortController) => {
return globalServer({
url: '/project/point/import-file',
method: 'post',
data: params,
signal: abort.signal,
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 0,
})
}

32
src/api/module/device/index.d.ts vendored

@ -1,3 +1,8 @@ @@ -1,3 +1,8 @@
import type { Channel } from "../channel/index.d"
export interface IDeviceBase {
projectName: string
}
export interface IDevice {
name: string
ch: string
@ -5,7 +10,28 @@ export interface IDevice { @@ -5,7 +10,28 @@ export interface IDevice {
addr: number
}
export interface IDeviceCategory {
name: string,
fileName?: string,
export interface IDeviceCategoryOV {
pointName: string
fileName?: string
groupName?: string
}
export interface IDeviceCategoryFileOV extends IDeviceBase {
file: File
fileName: string
}
export type IDeviceOV = {
[key in Channel]?: IDevice[]
}
export type IDeviceCategoryList = {
[key in Channel]?: {
name: string,
status: number,
fileName: string
}[]
}

16
src/api/module/device/index.ts

@ -1,18 +1,18 @@ @@ -1,18 +1,18 @@
import { globalServer } from '../index'
import type { IDevice } from './index.d'
import type { IDevice, IDeviceBase, IDeviceOV } from './index.d'
export const getDeviceList = () => {
return globalServer<IDevice[]>({
url: '/device/list',
export const getDeviceList = (params: IDeviceBase) => {
return globalServer<IDeviceOV>({
url: '/project/device/list-all',
method: 'get',
params,
})
}
export const saveDevice = (params: IDevice[]) => {
export const saveDevice = (params: IDeviceOV & IDeviceBase) => {
return globalServer({
url: '/device/update',
method: 'put',
url: `/project/device/generate-file`,
method: 'post',
data: params,
})
}

9
src/api/module/engineering/index.d.ts vendored

@ -1,8 +1,7 @@ @@ -1,8 +1,7 @@
export interface IEngineering {
id: string
versions: string
export interface IEngineeringOV {
name: string
description: string
description?: string
downloadPath?: string
isCreate: boolean
}
export type IEngineeringOV = Omit<IEngineering, 'id' | 'versions'>

25
src/api/module/engineering/index.ts

@ -1,14 +1,23 @@ @@ -1,14 +1,23 @@
import { globalServer } from '../index'
import { getEngineeringListMock, createEngineeringMock } from '@/api/modkJs/engineering'
import type { IEngineeringOV, IEngineering } from './index.d'
import type { IEngineeringOV } from './index.d'
export const getEngineeringList = () => {
// Use mock data
return getEngineeringListMock()
export const createEngineering = (params: IEngineeringOV) => {
return globalServer({
url: '/project/generate',
method: 'POST',
data: params,
})
}
export const createEngineering = (params: IEngineeringOV) => {
// Use mock data
return createEngineeringMock(params)
export const getEngineeringList = (
params?: Pick<IEngineeringOV, 'name' | 'isCreate'>,
) => {
return globalServer<{
list: IEngineeringOV[]
}>({
url: '/project/page',
method: 'get',
params,
})
}

177
src/components/Edfs-number-item-input.vue

@ -0,0 +1,177 @@ @@ -0,0 +1,177 @@
<template>
<div class="edfs-number-input">
<div class="label" v-if="labelName">
<span v-if="props.require" class="require">*</span>
{{ labelName }}
</div>
<el-tooltip
popper-class="number-tips"
:content="tipText"
placement="top"
:visible="isShowTip"
:disabled="!isUseTip"
>
<el-input
class="number-input"
v-model="inputValue"
type="text"
@input="handleInput"
@wheel="onWheel"
:placeholder="placeholder"
@focus="onFocus"
@blur="onBlur"
/>
</el-tooltip>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
interface Props {
tipText?: string
isUseTip?: boolean
labelName?: string
require?: boolean
placeholder?: string
modelValue: number | string
numMax?: number | undefined
numMin?: number | undefined
useWheel?: boolean
isShowLabel?: boolean
}
const props = withDefaults(defineProps<Props>(), {
labelName: '',
modelValue: '',
require: false,
placeholder: '请输入',
numMax: undefined,
numMin: undefined,
isUseTip: false,
tipText: '',
useWheel: false,
})
const emit = defineEmits(['update:modelValue', 'change'])
const inputValue = ref(props.modelValue.toString())
watch(
() => props.modelValue,
val => {
inputValue.value = val !== undefined ? val.toString() : ''
},
)
const handleInput = (val: string) => {
let newValue = val.replace(/[^0-9.-]/g, '').replace(/(\..*)\./g, '$1')
if (newValue.indexOf('-') > 0) {
newValue = newValue.replace(/-/g, '')
}
isShowTip.value && (isShowTip.value = false)
if (newValue.length === 0) isShowTip.value = true
inputValue.value = newValue
}
const onWheel = (event: WheelEvent) => {
// event.preventDefault() // el-input root div might not support preventDefault on wheel the same way for input value, but let's see.
// Actually, preventing default on the wrapper div prevents scrolling the page, which is desired if we are changing value.
// But we need to check if we are focused or hovering.
if (!props.useWheel) return
event.preventDefault()
let currentValue = Number(inputValue.value) || 0
if (event.deltaY < 0) {
currentValue++
} else {
currentValue--
}
inputValue.value = currentValue.toString()
// Trigger update
emit('update:modelValue', currentValue)
}
watch(inputValue, newValue => {
if (!newValue) {
emit('update:modelValue', undefined)
return
}
let numericValue = Number(newValue)
emit('update:modelValue', numericValue)
})
const isShowTip = ref(false)
function onBlur() {
onChangeTip(false)
}
function onFocus() {
onChangeTip(true)
}
function onChangeTip(visible: boolean) {
isShowTip.value = visible
}
</script>
<style lang="scss">
.number-tips {
font-size: 12px;
padding: 5px 11px;
}
</style>
<style lang="scss" scoped>
.edfs-number-input {
display: flex;
align-items: center;
width: 100%;
height: 100%;
box-sizing: border-box;
.label {
color: #4d4d4d;
white-space: nowrap;
text-align: right;
width: 110px;
.require {
color: red;
}
}
.number-input {
flex: 1;
width: 100%;
height: 100%;
// padding: 6px 12px; // el-input handles padding
// font-size: 12px;
box-sizing: border-box;
// border: 1px solid #899ed0; // el-input handles border
// border-radius: 3px;
// outline: none;
// transition: all 0.3s;
// box-shadow: 0 0 0 0.009rem var(--el-border-color) inset;
// &:focus {
// border-color: #409eff;
// }
// &:hover {
// border-color: #c0c4cc;
// }
// &::placeholder {
// font-size: 14px;
// color: #a8abb2;
// }
:deep(.el-input__inner) {
text-align: left;
}
}
}
</style>

158
src/components/FormItemInput.vue

@ -0,0 +1,158 @@ @@ -0,0 +1,158 @@
<template>
<div class="additem-item-input-root">
<div class="label" :class="labelWidth" v-if="label">
<span v-if="props.require" class="require">*</span>
{{ label }}
</div>
<el-input
v-model="localValue"
:disabled="!canEdit"
:show-password="showPassword"
class="input"
:placeholder="placeholder"
@input="inputChange"
resize="none"
:rows="rows"
@blur="handleBlur"
:formatter="handleFormatter"
></el-input>
</div>
</template>
<script setup lang="ts">
export interface Props {
type?: string
require?: boolean
label?: string
modelValue?: string | number
canEdit?: boolean
widthType?: number
stringNum?: boolean //stringnumbervaluestringid
placeholder?: string
showPassword?: boolean
rows?: number
}
const props = withDefaults(defineProps<Props>(), {
type: 'text',
require: false,
label: '',
modelValue: '',
canEdit: true,
widthType: 0,
stringNum: false,
cmd: false,
placeholder: '',
showPassword: false,
rows: 5,
})
const emit = defineEmits(['update:modelValue', 'input'])
const localValue = ref<string | number>('')
const labelWidth = computed(() => {
switch (props.widthType) {
case 0:
return 'width-normal'
case 1:
return ''
default:
return 'width-normal'
}
})
const inputChange = (val: string | number) => {
const value = props.type === 'number' ? Number(val) : val
emit('update:modelValue', value)
emit('input', value)
}
const handleBlur = () => {
if (props.type === 'number') {
if (
String(localValue.value).length === 0 ||
String(localValue.value).match(/^[0]+$/) !== null
) {
localValue.value = 0
}
}
}
const handleFormatter = (value: string | number) => {
if (props.stringNum && typeof value === 'string') {
return value.replace(/([^\d])+/g, '')
} else if (props.type === 'url' && typeof value === 'string') {
return value.replace(/([^\d., ])+/g, '')
}
return value
}
watch(
() => props.modelValue,
modelValue => {
localValue.value = modelValue
},
{ immediate: true },
)
</script>
<style lang="scss" scoped>
.additem-item-input-root {
display: flex;
align-items: center;
width: 100%;
height: 100%;
box-sizing: border-box;
// font-size: 0.18rem;
// padding-right: 42px;
.label {
// font-size: 14px;
color: #4d4d4d;
line-height: 33px;
text-align: right;
// margin-right: 24px;
.require {
color: red;
}
}
.input {
flex-grow: 1;
width: 0;
height: 100%;
line-height: 100%;
}
.width-normal {
width: 110px;
}
:deep(input::-webkit-outer-spin-button),
:deep(input::-webkit-inner-spin-button) {
-webkit-appearance: none;
}
:deep(input[type='number']) {
-moz-appearance: textfield;
}
:deep(.el-input) {
font-size: inherit;
}
:deep(.el-input__inner) {
color: #4d4d4d;
background-color: transparent;
height: 100%;
line-height: 100%;
// @include border_color("ws_dialog_input");
}
:deep(.el-input__inner::-webkit-input-placeholder) {
// color: #4d4d4d;
}
}
</style>

175
src/composables/useZMQJsonWorker.ts

@ -1,175 +0,0 @@ @@ -1,175 +0,0 @@
import type {
ManualAction,
PublishMsg,
PubMsgData,
SubMsgData,
TimeoutMsg,
ZmqMessage
} from '@/utils/zmq'
import { WorkerCMD, ZmqCMD, } from '@/utils/zmq'
import webWorker from '@/utils/zmqJsonWorker?worker'
let defaultHost = env.VITE_ZMQ_BASE_URL
const SUBDEFAULTKEY = 'default'
type Handler = (msg: SubMsgData | PubMsgData) => void
class ZMQJsonWorker {
private static instance: ZMQJsonWorker | null = null; // ➤ 单例实例
private worker: Worker;
private scribeHandlers: Map<string, Map<string, Handler>> = new Map();
private pubTimeoutHandlers: Map<string, (msg: TimeoutMsg) => void> = new Map();
private readonly host: string;
private statusCallback: ((status: string) => void) | null = null;
private isAlwaysListenMsgMap: Map<string, PublishMsg<any>> = new Map();
private constructor(host: string = defaultHost) {
this.host = host;
this.worker = new webWorker();
this.worker.onmessage = this.handleMessage.bind(this);
}
public static getInstance(host: string = defaultHost): ZMQJsonWorker {
if (!ZMQJsonWorker.instance) {
ZMQJsonWorker.instance = new ZMQJsonWorker(host);
}
return ZMQJsonWorker.instance;
}
start() {
this.worker.postMessage({ cmd: WorkerCMD.START, msg: this.host });
}
stop() {
this.worker.postMessage({ cmd: WorkerCMD.STOP });
this.worker.terminate();
ZMQJsonWorker.instance = null; // ➤ 释放实例,允许重新创建
}
subscribe(topic: string, handler: (msg: any) => void, id?: string) {
const key = id ?? SUBDEFAULTKEY;
let topicMap = this.scribeHandlers.get(topic);
if (!topicMap) {
topicMap = new Map<string, Handler>();
this.scribeHandlers.set(topic, topicMap);
}
// 添加 handler,不会覆盖其他 id 的 handler
topicMap.set(key, handler);
this.worker.postMessage({ cmd: WorkerCMD.SUBSCRIBE, topic });
}
unsubscribe(topic: string, id?: string) {
const topicMap = this.scribeHandlers.get(topic);
if (!topicMap) return;
if (id) {
topicMap.delete(id);
} else {
topicMap.delete(SUBDEFAULTKEY);
}
if (topicMap.size === 0) {
this.scribeHandlers.delete(topic);
this.worker.postMessage({ cmd: WorkerCMD.UNSUBSCRIBE, topic });
}
}
publish<T extends ManualAction>(topic: string, msg: PublishMsg<T>, isTimeout: boolean = false, handler?: (msg: TimeoutMsg) => void, isAlwaysListen: boolean = false) {
if (isTimeout) {
const timeoutId = msg.id
if (typeof handler !== 'function') {
console.warn(`发布主题${topic}失败, 回调函数handler为空`)
return
}
this.pubTimeoutHandlers.set(timeoutId, handler)
}
if (isAlwaysListen) {
this.isAlwaysListenMsgMap.set(`${msg.id}`, msg)
}
this.worker.postMessage({
cmd: WorkerCMD.PUBLISH,
topic,
msg: JSON.stringify(msg),
isTimeout,
isAlwaysListen
});
}
setStatusCallback(callback: (status: string) => void) {
this.statusCallback = callback;
}
private handleSubscribeMessage(topic: string, json: PubMsgData & SubMsgData) {
const topicMap = this.scribeHandlers.get(topic);
if (!topicMap) return;
topicMap.forEach((handler, id) => {
try {
handler(json);
} catch (error) {
console.error(`主题: ${topic} 的 handler ${id} 执行失败:`, error);
}
});
}
private handleTimeoutMessage(timeoutTopic: string, timeoutId: string) {
const handler = this.pubTimeoutHandlers.get(timeoutId);
if (handler) {
handler({
timeoutId,
timeoutTopic
});
}
}
// 回收发布后订阅的回调
private GC_pubReleaseSub(key: string) {
this.scribeHandlers.delete(key);
}
// 回收超时消息的回调
private GC_pubReleaseTimeout(key: string) {
this.pubTimeoutHandlers.delete(key);
}
private handleMessage(e: MessageEvent<ZmqMessage>) {
const { cmd, msg, topic, community } = e.data;
// const now = dayjs().format('YYYY-MM-DD HH:mm:ss')
// console.log(now, e.data)
if (cmd === ZmqCMD.STATUS) {
const status = community ? 'disconnected' : 'connected';
if (this.statusCallback) {
this.statusCallback(status);
}
} else if (cmd === ZmqCMD.JSON_MSG) {
const json = JSON.parse(msg) as PubMsgData & SubMsgData;
if (json.action) {
// 处理订阅消息
this.handleSubscribeMessage(topic, json);
} else {
// 处理需要发布消息并有返回的订阅消息
this.handleSubscribeMessage(`${topic}-${json.id}`, json);
// 删除发布消息的回调
if (Object.keys(json).includes('result') && json.result !== 'progress') {
if (!this.isAlwaysListenMsgMap.has(`${json.id}`)) {
this.GC_pubReleaseSub(`${topic}-${json.id}`)
this.GC_pubReleaseTimeout(`${json.id}`)
}
}
}
} else if (cmd === ZmqCMD.TIMEOUT) {
this.handleTimeoutMessage(topic, msg);
this.GC_pubReleaseTimeout(msg)
// console.log('pubTimeoutHandlers=>', this.pubTimeoutHandlers)
}
}
}
export default ZMQJsonWorker;

14
src/lib/zmq/zmqClient.d.ts vendored

@ -1,14 +0,0 @@ @@ -1,14 +0,0 @@
import * as zmq from "jszmq";
export default class ZmqClient {
sock: zmq.Dealer | zmq.Router | zmq.XSub | zmq.XPub | zmq.Pull | zmq.Push | zmq.Pair;
constructor(type: string);
zmqReq(host: string, request_type: string, dataStr: string, timeout: number): Promise<string>;
zmqSub(host: string, callback: (...args: any[]) => void): void;
zmqPub(host: string): void;
subscribe(topic: string): void;
unsubscribe(topic: string): void;
publishHex(topic: string, dataStr: string): Promise<void>;
publishStr(topic: string, dataStr: string): Promise<void>;
close(host: string, callback?: (...args: any[]) => void): void;
}
//# sourceMappingURL=zmqClient.d.ts.map

10
src/lib/zmq/zmqClient.d.ts.map

@ -1,10 +0,0 @@ @@ -1,10 +0,0 @@
{
"version": 3,
"file": "zmqClient.d.ts",
"sourceRoot": "",
"sources": [
"../src/zmqClient.ts"
],
"names": [],
"mappings": "AAAA,OAAO,KAAK,GAAG,MAAM,OAAO,CAAC;AAG7B,MAAM,CAAC,OAAO,OAAO,SAAS;IAC1B,IAAI,EACE,GAAG,CAAC,MAAM,GACV,GAAG,CAAC,MAAM,GACV,GAAG,CAAC,IAAI,GACR,GAAG,CAAC,IAAI,GACR,GAAG,CAAC,IAAI,GACR,GAAG,CAAC,IAAI,GACR,GAAG,CAAC,IAAI,CAAC;gBACH,IAAI,EAAE,MAAM;IAgBlB,MAAM,CACR,IAAI,EAAE,MAAM,EACZ,YAAY,EAAE,MAAM,EACpB,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM,GAChB,OAAO,CAAC,MAAM,CAAC;IA6BlB,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI;IAKvD,MAAM,CAAC,IAAI,EAAE,MAAM;IAInB,SAAS,CAAC,KAAK,EAAE,MAAM;IAIvB,WAAW,CAAC,KAAK,EAAE,MAAM;IAInB,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM;IAOzC,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM;IAO/C,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI;CAO1D"
}

118
src/lib/zmq/zmqClient.js

@ -1,118 +0,0 @@ @@ -1,118 +0,0 @@
var __awaiter =
(this && this.__awaiter) ||
function (thisArg, _arguments, P, generator) {
function adopt(value) {
return value instanceof P
? value
: new P(function (resolve) {
resolve(value)
})
}
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) {
try {
step(generator.next(value))
} catch (e) {
reject(e)
}
}
function rejected(value) {
try {
step(generator['throw'](value))
} catch (e) {
reject(e)
}
}
function step(result) {
result.done
? resolve(result.value)
: adopt(result.value).then(fulfilled, rejected)
}
step((generator = generator.apply(thisArg, _arguments || [])).next())
})
}
import * as zmq from 'jszmq'
import { Buffer } from 'buffer'
export default class ZmqClient {
constructor(type) {
switch (type) {
case 'req':
this.sock = zmq.socket('req')
break
case 'sub':
this.sock = zmq.socket('sub')
break
case 'pub':
this.sock = zmq.socket('pub')
break
default:
throw new Error('unsupported client type')
}
}
zmqReq(host, request_type, dataStr, timeout) {
return __awaiter(this, void 0, void 0, function* () {
this.sock.connect(host)
var request = new Array()
request[0] = request_type
request[1] = dataStr
this.sock.send(request)
return Promise.race([
new Promise((resolve, reject) => {
this.sock.on('message', function (message) {
resolve(message.toString())
})
}),
new Promise((resolve, reject) => {
setTimeout(() => {
reject('timeout error')
}, timeout)
}),
])
// var isMessageArrived = false;
// const result: string = "";
// var msgReceiveTimer = setTimeout(() => {
// if (!isMessageArrived) {
// console.log("msg receive timed out");
// }
// }, timeout); // 设置超时时间为5秒
})
}
zmqSub(host, callback) {
this.sock.connect(host)
this.sock.on('message', callback)
}
zmqPub(host) {
this.sock.connect(host)
}
subscribe(topic) {
this.sock.subscribe(topic)
}
unsubscribe(topic) {
this.sock.unsubscribe(topic)
}
publishHex(topic, dataStr) {
return __awaiter(this, void 0, void 0, function* () {
var request = new Array()
request[0] = topic
const buf = Buffer.from(dataStr, 'hex')
request[1] = buf
this.sock.send(request)
})
}
publishStr(topic, dataStr) {
return __awaiter(this, void 0, void 0, function* () {
var request = new Array()
request[0] = topic
request[1] = dataStr
this.sock.send(request)
})
}
close(host, callback) {
if (callback) {
this.sock.removeListener('message', callback)
}
this.sock.disconnect(host)
this.sock.close()
}
}
//# sourceMappingURL=zmqClient.js.map

10
src/lib/zmq/zmqClient.js.map

@ -1,10 +0,0 @@ @@ -1,10 +0,0 @@
{
"version": 3,
"file": "zmqClient.js",
"sourceRoot": "",
"sources": [
"../src/zmqClient.ts"
],
"names": [],
"mappings": ";;;;;;;;;AAAA,OAAO,KAAK,GAAG,MAAM,OAAO,CAAC;AAC7B,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEhC,MAAM,CAAC,OAAO,OAAO,SAAS;IAS1B,YAAY,IAAY;QACpB,QAAQ,IAAI,EAAE;YACV,KAAK,KAAK;gBACN,IAAI,CAAC,IAAI,GAAG,GAAG,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;gBAC9B,MAAM;YACV,KAAK,KAAK;gBACN,IAAI,CAAC,IAAI,GAAG,GAAG,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;gBAC9B,MAAM;YACV,KAAK,KAAK;gBACN,IAAI,CAAC,IAAI,GAAG,GAAG,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;gBAC9B,MAAM;YACV;gBACI,MAAM,IAAI,KAAK,CAAC,yBAAyB,CAAC,CAAC;SAClD;IACL,CAAC;IAEK,MAAM,CACR,IAAY,EACZ,YAAoB,EACpB,OAAe,EACf,OAAe;;YAEf,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;YACxB,IAAI,OAAO,GAAG,IAAI,KAAK,EAAE,CAAC;YAC1B,OAAO,CAAC,CAAC,CAAC,GAAG,YAAY,CAAC;YAC1B,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC;YACrB,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;YAExB,OAAO,OAAO,CAAC,IAAI,CAAC;gBAChB,IAAI,OAAO,CAAS,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;oBACpC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,SAAS,EAAE,UAAU,OAAO;wBACrC,OAAO,CAAC,OAAO,CAAC,QAAQ,EAAE,CAAC,CAAC;oBAChC,CAAC,CAAC,CAAC;gBACP,CAAC,CAAC;gBACF,IAAI,OAAO,CAAS,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;oBACpC,UAAU,CAAC,GAAG,EAAE;wBACZ,MAAM,CAAC,eAAe,CAAC,CAAC;oBAC5B,CAAC,EAAE,OAAO,CAAC,CAAC;gBAChB,CAAC,CAAC;aACL,CAAC,CAAC;YAEH,gCAAgC;YAChC,6BAA6B;YAC7B,2CAA2C;YAC3C,+BAA+B;YAC/B,gDAAgD;YAChD,QAAQ;YACR,4BAA4B;QAChC,CAAC;KAAA;IAED,MAAM,CAAC,IAAY,EAAE,QAAkC;QACnD,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;QACxB,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC;IACtC,CAAC;IAED,MAAM,CAAC,IAAY;QACf,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5B,CAAC;IAED,SAAS,CAAC,KAAa;QACnB,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC;IAC/B,CAAC;IAED,WAAW,CAAC,KAAa;QACrB,IAAI,CAAC,IAAI,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC;IACjC,CAAC;IAEK,UAAU,CAAC,KAAa,EAAE,OAAe;;YAC3C,IAAI,OAAO,GAAG,IAAI,KAAK,EAAE,CAAC;YAC1B,OAAO,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC;YACnB,MAAM,GAAG,GAAG,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC;YACxC,OAAO,CAAC,CAAC,CAAC,GAAG,GAAG,CAAC;YACjB,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;QAC5B,CAAC;KAAA;IACK,UAAU,CAAC,KAAa,EAAE,OAAe;;YAC3C,IAAI,OAAO,GAAG,IAAI,KAAK,EAAE,CAAC;YAC1B,OAAO,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC;YACnB,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC;YACrB,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;QAC5B,CAAC;KAAA;IAED,KAAK,CAAC,IAAY,EAAE,QAAmC;QACnD,IAAI,QAAQ,EAAE;YACV,IAAI,CAAC,IAAI,CAAC,cAAc,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC;SACjD;QACD,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC;QAC3B,IAAI,CAAC,IAAI,CAAC,KAAK,EAAE,CAAC;IACtB,CAAC;CACJ"
}

32
src/stores/engineering.ts

@ -0,0 +1,32 @@ @@ -0,0 +1,32 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { getEngineeringList } from '@/api/module/engineering'
import type { IEngineeringOV } from '@/api/module/engineering/index.d'
export const useEngineeringStore = defineStore('engineering', () => {
// State
const engineeringList = ref<IEngineeringOV[]>([])
const loading = ref(false)
// Actions
const fetchEngineeringList = async () => {
loading.value = true
try {
const res = await getEngineeringList()
engineeringList.value = Array.isArray(res.data?.list) ? res.data.list : []
} catch (error) {
console.error(error)
engineeringList.value = []
} finally {
loading.value = false
}
}
return {
// State
engineeringList,
loading,
// Actions
fetchEngineeringList,
}
})

270
src/stores/transferData.ts

@ -1,270 +0,0 @@ @@ -1,270 +0,0 @@
import { ref, computed, reactive } from 'vue'
import { defineStore } from 'pinia'
import type { IOnlineDevice, IUpFirmwareStatus } from '@/views/stationData/type'
import ZMQWorker from '@/composables/useZMQJsonWorker'
import { getSubTopic, type SubMsgData } from '@/utils/zmq'
import { getDeviceTopic } from '@/views/stationData/utils'
export interface SiteInfo {
id: string
onlineCount: number
offlineCount: number
devices: Map<string, IOnlineDevice>
}
export const useTransferDataStore = defineStore('transfer', () => {
const subDevices = getSubTopic('client', 'status', 'transfer')
const worker = ZMQWorker.getInstance()
const isConnected = ref(false)
const connectSite = ref<any>(null)
const checkDeviceStatusInterval = ref<NodeJS.Timeout>()
const siteMap = reactive(new Map<string, SiteInfo>())
const devicesMap = reactive(new Map<string, IOnlineDevice>())
// =========== mock =================
// let i = 0
//
// function mockSubDeviceMsg() {
// // 随机生成 SN
// const sn = `SN-${Math.floor(Math.random() * 1000)}`
// // const siteId = `${i % 2 === 0 ? 'site1' : 'site2'}`
// const siteId = `siteId-${Math.floor(Math.random() * 1000)}`
// const clientIp = `192.168.0.${Math.floor(Math.random() * 255)}`
// const version = `v${(Math.random() * 2 + 1).toFixed(2)}`
// const footprint = (Math.random() * 50000).toFixed(2) // KB
//
// // const sn = 123
// // const siteId = 123123
// // const clientIp = `192.168.0.${Math.floor(Math.random() * 255)}`
// // const version = `v${(Math.random() * 2 + 1).toFixed(2)}`
// // const footprint = (Math.random() * 50000).toFixed(2) // KB
//
// const msg: any = {
// feedback: [clientIp, sn, siteId, version, footprint],
// }
//
// getSubDevicesCb(msg)
// }
//
//
// let a = setInterval(() => {
// i++
// mockSubDeviceMsg()
// }, 1000)
// setTimeout(() => {
// clearInterval(a)
// }, 8000)
// =========== mock end =================
function startCheckStatus() {
clearInterval(checkDeviceStatusInterval.value)
checkDeviceStatusInterval.value = setInterval(checkDeviceStatusFn, 1000);
}
const checkDeviceStatusFn = () => {
const now = Date.now();
devicesMap.forEach((device: IOnlineDevice, sn) => {
// console.log(device, dayjs(now).format('HH:mm:ss'), dayjs(device.lastUpdated).format('HH:mm:ss'), now - device.lastUpdated)
if (now - device.lastUpdated > 5500) {
device.status = '离线';
}
});
reassignDevicesToSites()
}
function formatSizeFromKB(num: number): string {
const sizeKB = Number(num)
const units = ['KB', 'MB', 'GB', 'TB', 'PB']
let size = sizeKB
let unitIndex = 0
while (size >= 1024 && unitIndex < units.length - 1) {
size = size / 1024
unitIndex++
}
return `${size.toFixed(2)} ${units[unitIndex]}`
}
function getSubDevicesCb(msg: SubMsgData) {
const { feedback } = msg
const sn = feedback[1]
const hasDevice = devicesMap.get(sn)
if (hasDevice) {
hasDevice.lastUpdated = Date.now()
hasDevice.status = '在线'
hasDevice.footprint = formatSizeFromKB(Number(feedback[4] || 0))
hasDevice.clientIp = feedback[0]
hasDevice.versions = feedback[3] ?? '--'
} else {
const num = feedback[4] || 0
const device: IOnlineDevice = {
clientIp: feedback[0],
sn: sn,
site_id: feedback[2],
versions: feedback[3] ?? '--',
footprint: formatSizeFromKB(Number(num)),
lastUpdated: Date.now(),
status: '在线', // 初始状态为在线
isChecked: false,
}
devicesMap.set(sn, device)
}
}
function reassignDevicesToSites() {
devicesMap.forEach((device) => {
const siteId = device.site_id
if (!siteId) return
// 若该站点还未创建,则初始化
if (!siteMap.has(siteId)) {
siteMap.set(siteId, {
id: siteId,
onlineCount: 0,
offlineCount: 0,
devices: reactive(new Map<string, IOnlineDevice>()),
})
}
// 获取站点信息并更新
const siteInfo = siteMap.get(siteId)!
siteInfo.devices.set(device.sn, device)
})
// 更新site 在线数量和离线数量
siteMap.forEach((siteInfo) => {
const devices = Array.from(siteInfo.devices.values())
siteInfo.onlineCount = devices.filter(item => item.status === '在线').length
siteInfo.offlineCount = devices.filter(item => item.status === '离线').length
})
}
const onlineCount = computed(() => {
return Array.from(devicesMap.values()).filter(item => item.status === '在线')
.length
})
const offlineCount = computed(() => {
return Array.from(devicesMap.values()).filter(item => item.status === '离线')
.length
})
onMounted(() => {
worker.subscribe(getDeviceTopic, getSubDevicesCb)
startCheckStatus()
document.addEventListener('visibilitychange', handleVisibilityChange)
})
const route = useRoute()
watch(() => route.path, (val) => {
if (!['/station/data-transfer', '/station'].includes(val)) {
clearInterval(checkDeviceStatusInterval.value)
document.removeEventListener('visibilitychange', handleVisibilityChange)
worker.unsubscribe(subDevices)
}
})
function handleVisibilityChange() {
if (document.hidden) {
clearInterval(checkDeviceStatusInterval.value)
} else {
startCheckStatus()
}
}
function upFirmwareStatus(sn: string, feedback: any[]) {
const device = devicesMap.get(sn)
if (device) {
device.upFirmware = 'updating'
const step = feedback[1]
const progress = feedback[2] || undefined
const errMsg = feedback[3] || undefined
if (step < (device.upFirmwareStatus?.step ?? -100)) return
device.upFirmwareStatus = {
step,
progress: progress === -1 ? 100 : progress,
errMsg,
}
}
}
function upFirmwareStatusReject(sn: string, feedback: any[]) {
const device = devicesMap.get(sn)
if (device) {
device.upFirmware = 'rejected'
const step = feedback[1]
const progress = feedback[2] || undefined
const errMsg = feedback[3] || undefined
if (step < (device.upFirmwareStatus?.step ?? -100)) return
device.upFirmwareStatus = {
step,
progress: progress === -1 ? 100 : progress,
errMsg,
}
}
}
function upFirmwarePending(snList: string[]) {
for (const sn of snList) {
const device = devicesMap.get(sn)
if (device) {
device.upFirmware = 'pending'
device.upFirmwareStatus = {
step: 0,
progress: undefined,
errMsg: undefined,
}
}
}
}
function upFirmwareReset(snList: string[]) {
for (const sn of snList) {
const device = devicesMap.get(sn)
if (device) {
upFirmwareSucceed(sn)
}
}
}
function upFirmwareSucceed(sn: string) {
const device = devicesMap.get(sn)
if (device) {
device.upFirmware = undefined
device.upFirmwareStatus = undefined
}
}
function upFirmwareTimeout(sn: string) {
const device = devicesMap.get(sn)
if (device) {
device.upFirmware = 'timeout'
device.upFirmwareStatus = undefined
}
}
return {
siteMap,
isConnected,
devicesMap,
connectSite,
checkDeviceStatusInterval,
onlineCount,
offlineCount,
upFirmwarePending,
upFirmwareReset,
upFirmwareStatus,
upFirmwareSucceed,
upFirmwareStatusReject,
upFirmwareTimeout
}
})

18
src/utils/validate.ts

@ -0,0 +1,18 @@ @@ -0,0 +1,18 @@
export function validateName(name: string): string | null {
if (!name) return '名称不能为空'
// Check for Chinese and special characters (allow alphanumeric, _, -)
const regex = /^[a-zA-Z0-9_-]+$/
if (!regex.test(name)) {
return '名称不能包含中文或特殊符号'
}
// Check byte length (max 32)
const byteLength = new Blob([name]).size
if (byteLength > 32) {
return '名称长度不能超过32字节'
}
return null
}

174
src/utils/zmq.ts

@ -1,174 +0,0 @@ @@ -1,174 +0,0 @@
import { v4 as uuidv4 } from 'uuid';
export type ManualAction =
'init' | 'release' | 'write' | 'report' | 'lock' | 'unlock' |
'export' | 'cancel' | 'import' | 'upgrade'
export type ZmqStatus = 'disconnected' | 'connected'
export enum WorkerCMD {
START,
SUBSCRIBE,
UNSUBSCRIBE,
PUBLISH,
STOP,
SET_TIMEOUT
}
export enum ZmqMsgResultType {
SUCCESS = 200,
PROGRESS = 1002,
ERROR = 1003,
}
export enum ZmqCMD {
MSG,
JSON_MSG,
STATUS,
TIMEOUT
}
export interface TimeoutMsg {
timeoutId: string
timeoutTopic: string
}
export interface ZmqMessage {
cmd: ZmqCMD
msg: string
community: boolean
topic: string
}
type TopicType = 'event' | 'status'
// web端发布消息类型
export interface PublishMsg<A> {
id: string // 每条消息的唯一标识
action: A // 消息的动作
reply: 'yes' | 'no' // 是否需要回复
params: any // 消息的参数
}
// web端发布消息服务端返回的数据类型
export interface PubMsgData {
code: number
feedback: any,
id: string
result: 'success' | 'progress' | 'failure' | 'refuse' | 'error'
}
// 订阅消息服务端返回的数据类型
export interface SubMsgData {
id: string
action: 'report'
feedback: any,
reply: 'yes' | 'no' // 是否需要回复
}
/*
* @Description:
* type:
* module: 模块名称
* userid: 用户唯一标识
*/
export function getPubTopic(type: TopicType, module: string,) {
return `web/${type}/${module}/`
}
export function getSubTopic(server: string, type: TopicType, module: string,) {
return `${server}/${type}/${module}/`
}
// 获取随机id
export function getRandomId() {
const uniqueId = `${Date.now()}-${uuidv4()}`;
return uniqueId
}
export function generatePubMsg<A>(massage: Omit<PublishMsg<A>, 'id'>): PublishMsg<A> {
return {
id: getRandomId(),
action: massage.action,
reply: massage.reply,
params: massage.params
}
}
export function getLockPubMsg(action: 'lock' | 'unlock'): PublishMsg<'lock' | 'unlock'> {
return {
id: getRandomId(),
action: action,
params: [],
reply: 'yes'
}
}
export function pollingWithCallback(intervalInSeconds: number, callback: Function) {
let counter = 0
const fps = 60
function poll() {
counter++
if (counter >= intervalInSeconds * fps) {
callback()
counter = 0
}
requestAnimationFrame(poll)
}
poll()
}
export function getPubInitData<T extends ManualAction>(action: T, params: any, reply: 'yes' | 'no' = 'yes') {
return generatePubMsg<T>({
action,
reply,
params
})
}
export function isHexadecimal(text: string) {
const hexRegex = /^[0-9A-Fa-f]+$/
return hexRegex.test(text)
}
export function stringToHex(str: string) {
return Array.from(str)
.map(char => char.charCodeAt(0).toString(16).toUpperCase())
.join('')
}
export function stringToDecimalismNumbers(str: string): number[] {
return Array.from(str).map(char => char.charCodeAt(0))
}
export function hexToArray(num: string) {
const numStr = num.toString()
let paddedNumStr = numStr
if (numStr.length % 2 !== 0) {
paddedNumStr = numStr.slice(0, -1) + '0' + numStr.slice(-1)
}
const result = []
for (let i = 0; i < paddedNumStr.length; i += 2) {
result.push(paddedNumStr.slice(i, i + 2))
}
return result
}
export function hexToDecimal(hex: string[]) {
return hex.map(item => parseInt(item, 16))
}
export function decimalToHexArray(decimalArray: number[]) {
return decimalArray.map(num => num.toString(16).toUpperCase()).join('')
}
export function decimalToString(arr: number[]) {
return arr.map(num => String.fromCharCode(num)).join('')
}

166
src/utils/zmqJsonWorker.ts

@ -1,166 +0,0 @@ @@ -1,166 +0,0 @@
import ZmqClient from '@/lib/zmq/zmqClient'
import { type PublishMsg, type PubMsgData, WorkerCMD, ZmqCMD, } from './zmq'
const HEARTBEAT_TOPIC = 'HEARTBEAT'
const HEARTBEAT_INTERVAL = 3000
const STATUS_CHECK_INTERVAL = 1000
let messageTimeout = 20000
let heartClient: ZmqClient | null, subClient: ZmqClient | null, pubClient: ZmqClient | null
let subHost = '', pubHost = ''
let lastHeartbeatTime = 0
let statusTimerId: ReturnType<typeof setInterval> | null
let isConnectionError = false
function decodeMessage(data: Uint8Array) {
return new TextDecoder().decode(data)
}
function updateHeartbeat() {
lastHeartbeatTime = Date.now()
}
function changeConnectionStatus(hasError: boolean) {
if (isConnectionError !== hasError) {
isConnectionError = hasError
console.log('ZMQ连接状态更新:', hasError ? '断开' : '连接')
postMessage({ cmd: ZmqCMD.STATUS, community: hasError })
}
}
function monitorConnection() {
if (statusTimerId) return
lastHeartbeatTime = Date.now()
statusTimerId = setInterval(() => {
const currentTime = Date.now()
const hasError = currentTime - lastHeartbeatTime > HEARTBEAT_INTERVAL
changeConnectionStatus(hasError)
}, STATUS_CHECK_INTERVAL)
}
function stopMonitoringConnection() {
if (statusTimerId) {
clearInterval(statusTimerId)
statusTimerId = null
}
changeConnectionStatus(false)
}
function disconnect() {
if (subClient) {
subClient.close(subHost, handleZmqMessage)
subClient = null
}
if (heartClient) {
heartClient.unsubscribe(HEARTBEAT_TOPIC)
heartClient.close(subHost, updateHeartbeat)
heartClient = null
}
if (pubClient) {
pubClient.close(pubHost)
pubClient = null
}
stopMonitoringConnection()
}
function handleZmqMessage(topic: Uint8Array, msg: Uint8Array) {
try {
if (msg instanceof Uint8Array) {
const jsonMessage = decodeMessage(msg) as string
postMessage({
topic: decodeMessage(topic),
cmd: ZmqCMD.JSON_MSG,
msg: jsonMessage
})
const parsedMessage = JSON.parse(jsonMessage) as PubMsgData
// traceMessages.get(parsedMessage.id)
const curTraceMessages = traceMessages.get(parsedMessage.id)
if (parsedMessage.id && !!curTraceMessages) {
if (curTraceMessages.isAlwaysListen || parsedMessage.result === 'progress') {
// 重置消息超时时间
const val = traceMessages.get(parsedMessage.id)
if (val) {
val.timestamp = Date.now()
}
traceMessages.set(parsedMessage.id, val)
} else {
traceMessages.delete(parsedMessage.id)
}
}
}
} catch (e) {
console.error('handleZmqMessage error:', e)
}
}
function connect(host: string) {
disconnect()
subHost = `ws://${host}:15555`
subClient = new ZmqClient('sub')
subClient.zmqSub(subHost, handleZmqMessage)
heartClient = new ZmqClient('sub')
heartClient.zmqSub(subHost, updateHeartbeat)
heartClient.subscribe(HEARTBEAT_TOPIC)
monitorConnection()
pubHost = `ws://${host}:15556`
pubClient = new ZmqClient('pub')
pubClient.zmqPub(pubHost)
setInterval(() => {
const now = Date.now()
traceMessages.forEach((val, id) => {
if (now - val.timestamp > messageTimeout) {
console.warn(`Message ${id} timed out.`)
postMessage({
cmd: ZmqCMD.TIMEOUT,
topic: val.topic.replace(/^web\//, 'server/'),
msg: id
})
traceMessages.delete(id)
}
})
}, 1000)
}
const traceMessages = new Map<string, any>()
self.onmessage = function (event) {
const { cmd, topic, msg, isTimeout = false, isAlwaysListen } = event.data
switch (cmd) {
case WorkerCMD.START:
connect(msg)
break
case WorkerCMD.SET_TIMEOUT:
messageTimeout = msg
break
case WorkerCMD.SUBSCRIBE:
subClient?.subscribe(topic)
break
case WorkerCMD.UNSUBSCRIBE:
subClient?.unsubscribe(topic)
break
case WorkerCMD.PUBLISH:
if (!msg) throw new Error('msg is required')
if (isTimeout) {
const parseMsg = JSON.parse(msg) as PublishMsg<string>
traceMessages.set(parseMsg.id, {
timestamp: Date.now(),
topic: topic,
isAlwaysListen,
})
}
pubClient?.publishStr(topic, msg)
break
case WorkerCMD.STOP:
disconnect()
break
}
}

216
src/views/engineering/components/channel-dlg.vue

@ -0,0 +1,216 @@ @@ -0,0 +1,216 @@
<template>
<EdfsDialog
:title="'新增通道'"
:is-show="visible"
width="580px"
@on-close="close"
@on-save="onSave"
>
<div class="flex-col gap-10 w-80% m-x-30px">
<el-row>
<div class="label"><span class="require">*</span>通道名称</div>
<el-input class="flex-1" v-model="form.name" placeholder="请输入通道名称" />
</el-row>
<el-row>
<div class="label">通道类型</div>
<el-select v-model="form.channel" placeholder="请选择通道类型" class="flex-1">
<el-option
v-for="[key, val] in Object.entries(ChannelEnum)"
:key="key"
:label="val"
:value="val"
/>
</el-select>
</el-row>
<template v-for="item in commTypeInputs[form.channel]">
<el-row>
<NumberItemInput
v-if="item.type === 'input'"
v-model="form[item.key as keyof IFormData]"
:labelName="item.label + ':'"
:numMax="item.max"
:numMin="item.min"
:require="item.require"
:placeholder="`范围${item.min}~${item.max}`"
class="item-input"
/>
<FormItemInput
v-if="item.type === 'string-input'"
v-model="form[item.key as keyof IFormData]"
:require="item.require"
:label="item.label + ':'"
class="item-input"
/>
</el-row>
</template>
</div>
</EdfsDialog>
</template>
<script setup lang="ts">
import { cloneDeep } from 'lodash-es'
import { ChannelEnum } from '@/api/module/channel/index'
import FormItemInput from '@/components/FormItemInput.vue'
import NumberItemInput from '@/components/Edfs-number-item-input.vue'
import { useMessage } from '@/composables/useMessage'
import EdfsDialog from '@/components/Edfs-dialog.vue'
import type { ICustomFormData, IFormData } from './types'
import { validateName } from '@/utils/validate'
const message = useMessage()
const props = defineProps<{
existingNames?: string[]
}>()
const emit = defineEmits<{
'on-save': [form: ICustomFormData]
}>()
const baseFormData = {
name: '',
channel: ChannelEnum.MODBUS_TCP,
}
const customFormData: ICustomFormData = {
...baseFormData,
ip: '',
port: 502,
broker: '',
}
const MODBUS_TCPInput = [
{
label: 'IP地址',
max: 255,
min: 0,
type: 'string-input',
require: true,
key: 'ip',
},
{
label: '端口',
max: 65535,
min: 0,
type: 'input',
require: true,
key: 'port',
},
]
const MQTTInput = [
{
label: 'Broker',
max: 7,
min: 0,
key: 'broker',
require: true,
type: 'input',
},
]
const commTypeInputs = {
[ChannelEnum.MODBUS_TCP]: MODBUS_TCPInput,
[ChannelEnum.MQTT]: MQTTInput,
}
const form = ref<ICustomFormData>(cloneDeep(customFormData))
const visible = ref(false)
const originalName = ref<string>('')
function open(item?: ICustomFormData) {
visible.value = true
if (item) {
form.value = cloneDeep(item)
originalName.value = item.name
} else {
form.value = cloneDeep(customFormData)
originalName.value = ''
}
}
async function onSave() {
if (verifyData()) return
emit('on-save', form.value)
message.success('保存成功')
close()
}
function close() {
form.value = cloneDeep(customFormData)
console.log(form.value)
visible.value = false
}
const ipPattern =
/^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/
function verifyData() {
if (!form.value.name) {
message.warning('请输通道名称')
return true
}
const nameError = validateName(form.value.name)
if (nameError) {
message.warning(nameError)
return true
}
if (props.existingNames && props.existingNames.length > 0) {
const isDuplicate = props.existingNames.some(
name => name === form.value.name && name !== originalName.value,
)
if (isDuplicate) {
message.warning('名称已存在')
return true
}
}
for (const key of commTypeInputs[form.value.channel]) {
if (!form.value[key.key as keyof IFormData]) {
message.warning(`请输入${key.label}`)
return true
}
}
if (form.value.channel === ChannelEnum.MODBUS_TCP) {
if (!ipPattern.test(form.value.ip as string)) {
message.warning('请输入正确的IP地址')
return true
}
}
return false
}
defineExpose({
open,
})
</script>
<style lang="scss" scoped>
.el-row {
column-gap: 8px;
.label {
color: var(--label-color);
line-height: 33px;
text-align: right;
width: 110px;
.require {
color: red;
}
}
.item-input {
height: 32px;
margin-bottom: 0px;
:deep(.label) {
margin-right: 10px;
}
:deep(.label) {
margin-right: 10px;
}
}
}
</style>

20
src/views/engineering/components/create-engineering-dlg.vue

@ -33,7 +33,6 @@ @@ -33,7 +33,6 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { cloneDeep } from 'lodash-es'
import { useRouter } from 'vue-router'
@ -41,17 +40,23 @@ import { useMessage } from '@/composables/useMessage' @@ -41,17 +40,23 @@ import { useMessage } from '@/composables/useMessage'
import EdfsDialog from '@/components/Edfs-dialog.vue'
import type { IEngineeringOV } from '@/api/module/engineering/index.d'
import { createEngineering } from '@/api/module/engineering'
import { validateName } from '@/utils/validate'
const message = useMessage()
const router = useRouter()
const props = defineProps<{
existingNames?: string[]
}>()
const emit = defineEmits<{
'on-save': []
'on-save': [name: string]
}>()
const fromData: IEngineeringOV = {
name: '',
description: '',
isCreate: true,
}
const form = ref(cloneDeep(fromData))
@ -69,11 +74,10 @@ async function onSave() { @@ -69,11 +74,10 @@ async function onSave() {
try {
await createEngineering(form.value)
message.success('创建成功')
emit('on-save')
emit('on-save', form.value.name)
close()
router.push({ path: '/engineering-config/created' })
} catch (error) {
console.error(error)
message.error('创建失败')
} finally {
btnLoading.value = false
}
@ -89,6 +93,12 @@ function verifyData() { @@ -89,6 +93,12 @@ function verifyData() {
message.warning('请输入工程名称')
return true
}
if (props.existingNames && props.existingNames.includes(form.value.name)) {
message.warning('名称已存在')
return true
}
return false
}

218
src/views/engineering/components/device-dlg.vue

@ -0,0 +1,218 @@ @@ -0,0 +1,218 @@
<template>
<EdfsDialog
:title="'新增通道'"
:is-show="visible"
width="580px"
@on-close="close"
@on-save="onSave"
>
<div class="flex-col gap-10 w-80% m-x-30px">
<el-row>
<div class="label"><span class="require">*</span>设备名称:</div>
<el-input v-model="form.name" placeholder="请输入设备名称" class="flex-1" />
</el-row>
<el-row>
<div class="label"><span class="require">*</span>通道类型</div>
<el-select
v-model="form.channel"
placeholder="请选择通道类型"
class="flex-1"
@change="handleChannelChange"
>
<el-option
v-for="[key, val] in Object.entries(ChannelEnum)"
:key="key"
:label="val"
:value="val"
/>
</el-select>
</el-row>
<el-row label="" prop="ch">
<div class="label"><span class="require">*</span>所属通道:</div>
<el-select v-model="form.ch" placeholder="请选择通道" class="flex-1">
<el-option
v-for="item in computedChannels"
:key="item.name"
:label="item.name"
:value="item.name"
/>
</el-select>
</el-row>
<el-row label="设备类别" prop="point">
<div class="label"><span class="require">*</span>设备类别:</div>
<el-select v-model="form.point" placeholder="请选择类别" class="flex-1">
<el-option
v-for="item in computedCategories"
:key="item.fileName"
:label="item.name"
:value="item.name"
/>
</el-select>
</el-row>
<el-row label="地址" prop="addr">
<NumberItemInput
v-model="form.addr"
:labelName="'地址' + ':'"
:numMax="255"
:numMin="0"
:require="true"
:placeholder="`范围0~255`"
class="item-input"
/>
</el-row>
</div>
</EdfsDialog>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { cloneDeep } from 'lodash-es'
import { ChannelEnum } from '@/api/module/channel/index'
import { useMessage } from '@/composables/useMessage'
import EdfsDialog from '@/components/Edfs-dialog.vue'
import type {
IDevice,
IDeviceCategoryList,
IDeviceCategoryOV,
} from '@/api/module/device/index.d'
import type { Channel, IChannelOV } from '@/api/module/channel/index.d'
import NumberItemInput from '@/components/Edfs-number-item-input.vue'
import { validateName } from '@/utils/validate'
const message = useMessage()
const props = defineProps<{
channels: IChannelOV
categories: IDeviceCategoryList
existingNames?: string[]
}>()
const emit = defineEmits<{
'on-save': [form: IDeviceFormData]
}>()
type IDeviceFormData = IDevice & { channel: Channel }
const FormData: IDeviceFormData = {
name: '',
channel: ChannelEnum.MODBUS_TCP,
ch: '',
point: '',
addr: 0,
}
function handleChannelChange() {
form.value.ch = ''
form.value.point = ''
}
const computedChannels = computed(() => {
return props.channels[form.value.channel]
})
const computedCategories = computed(() => {
return props.categories[form.value.channel]
})
const form = ref<IDeviceFormData>(cloneDeep(FormData))
const visible = ref(false)
const originalName = ref<string>('')
function open(item?: IDeviceFormData) {
visible.value = true
if (item) {
form.value = cloneDeep(item)
originalName.value = item.name
} else {
form.value = cloneDeep(FormData)
originalName.value = ''
}
}
async function onSave() {
if (verifyData()) return
emit('on-save', form.value)
close()
}
function close() {
form.value = cloneDeep(FormData)
visible.value = false
}
function verifyData() {
if (!form.value.name) {
message.warning('请输通道名称')
return true
}
const nameError = validateName(form.value.name)
if (nameError) {
message.warning(nameError)
return true
}
if (props.existingNames && props.existingNames.length > 0) {
const isDuplicate = props.existingNames.some(
name => name === form.value.name && name !== originalName.value,
)
if (isDuplicate) {
message.warning('名称已存在')
return true
}
}
if (!form.value.channel) {
message.warning('请选择通道类型')
return true
}
if (!form.value.ch) {
message.warning('请选择所属通道')
return true
}
if (!form.value.point) {
message.warning('请选择设备类别')
return true
}
if (form.value.addr < 0 || form.value.addr > 255) {
message.warning('地址范围0~255')
return true
}
return false
}
defineExpose({
open,
})
</script>
<style lang="scss" scoped>
.el-row {
column-gap: 8px;
.label {
color: var(--label-color);
line-height: 33px;
text-align: right;
width: 110px;
.require {
color: red;
}
}
.item-input {
height: 32px;
margin-bottom: 0px;
:deep(.label) {
margin-right: 10px;
}
:deep(.label) {
margin-right: 10px;
}
}
}
</style>

6
src/views/engineering/components/types.ts

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
import type { Channel, IMqttChannelOV } from "@/api/module/channel/index.d"
import type { IModbusTcpChannelOV } from "@/api/module/channel/index.d"
export type IFormData = IModbusTcpChannelOV & IMqttChannelOV
export type ICustomFormData = IFormData & { channel: Channel }

382
src/views/engineering/config/components/StepChannel.vue

@ -1,223 +1,233 @@ @@ -1,223 +1,233 @@
<template>
<div class="step-channel h-full flex flex-col">
<div class="mb-4 flex justify-end">
<el-button type="primary" @click="handleAdd">新增通道</el-button>
</div>
<div class="flex-1 overflow-y-auto">
<div class="grid grid-cols-[repeat(auto-fill,minmax(300px,1fr))] gap-4">
<div
v-for="(item, index) in modelValue"
:key="index"
class="bg-white rounded-lg shadow-sm p-12 border border-gray-200 hover:shadow-md transition-all duration-300 mb-4"
<el-tabs
v-if="Object.keys(modelValue).length > 0"
v-model="activeTab"
type="card"
class="h-full flex flex-col"
>
<el-tab-pane
v-for="(channels, channelType) in modelValue"
:key="channelType"
:label="
Object.entries(ChannelEnum).find(([key, val]) => val === channelType)?.[1]
"
:name="channelType"
>
<div class="flex justify-between items-start">
<span class="font-bold text-lg text-gray-800">{{ item.name }}</span>
<el-tag size="small" type="primary">
{{ item.channel === 'modbusTcp' ? 'Modbus TCP' : 'MQTT' }}
</el-tag>
</div>
<div class="space-y-2 mb-4">
<!-- Modbus TCP Section -->
<div v-if="item.channel === 'modbusTcp'" class="text-sm">
<div class="flex items-center text-gray-600 mb-2">
<span class="font-medium">IP地址:</span>
<span class="text-gray-800 ml-2">{{ item.ip }}</span>
<el-scrollbar>
<div class="grid grid-cols-[repeat(auto-fill,minmax(300px,1fr))] gap-4 p-4">
<!-- Add Channel Card -->
<div
class="bg-white rounded-xl shadow-sm p-12 border-2 border-dashed border-gray-200 hover:border-blue-400 transition-all duration-300 cursor-pointer flex items-center justify-center min-h-[200px]"
@click="handleAdd"
>
<div class="flex flex-col items-center gap-3">
<el-icon :size="48" class="text-gray-400">
<Plus />
</el-icon>
<span class="text-base font-medium text-gray-600">新增通道</span>
</div>
</div>
<div class="flex items-center text-gray-600">
<span class="font-medium">端口:</span>
<span class="text-gray-800 ml-2">{{ item.port }}</span>
</div>
</div>
<!-- MQTT Section -->
<div v-else-if="item.channel === 'mqtt'" class="text-sm">
<div class="flex items-center text-gray-600">
<span class="font-medium">Broker:</span>
<span class="text-gray-800">{{ item.broker }}</span>
<div
v-for="(item, index) in channels"
:key="index"
class="bg-white rounded-xl shadow-sm p-12 border border-gray-100 hover:shadow-lg transition-all duration-300 flex flex-col"
>
<div class="flex justify-between items-start mb-3">
<span class="font-bold text-lg text-gray-900"
>通道名称{{ item.name }}</span
>
</div>
<div class="space-y-2 mb-3 flex-1">
<div v-if="channelType === ChannelEnum.MODBUS_TCP" class="text-sm">
<div class="flex items-center justify-between mb-1.5">
<span class="text-gray-500">IP地址</span>
<span class="text-gray-900 font-medium font-mono">{{
(item as any).ip
}}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-gray-500">端口</span>
<span class="text-gray-900 font-medium font-mono">{{
(item as any).port
}}</span>
</div>
</div>
<div v-else-if="channelType === ChannelEnum.MQTT" class="text-sm">
<div class="flex items-center justify-between">
<span class="text-gray-500">Broker</span>
<span class="text-gray-900 font-medium">{{
(item as any).broker
}}</span>
</div>
</div>
</div>
<div class="flex justify-end pt-3 border-t border-gray-50">
<el-button
type="primary"
text
@click="
handleEdit(
Object.assign({ channel: channelType }, item) as ICustomFormData,
index,
)
"
>
编辑
</el-button>
<el-button type="danger" text @click="handleDelete(channelType, index)">
删除
</el-button>
</div>
</div>
</div>
</div>
<div class="flex justify-end pt-3 border-t border-gray-100">
<el-button
type="primary"
text
@click="handleEdit(index)"
class="hover:bg-blue-500 hover:text-white transition-colors duration-200"
>
编辑
</el-button>
<el-button
type="danger"
text
@click="handleDelete(index)"
class="hover:bg-red-500 hover:text-white transition-colors duration-200"
>
删除
</el-button>
</div>
</div>
</div>
<el-empty
v-if="modelValue.length === 0"
description="暂无通道,请点击上方按钮添加"
/>
</el-scrollbar>
</el-tab-pane>
</el-tabs>
<el-empty v-else description="暂无通道,请点击上方按钮添加" />
</div>
<el-dialog
v-model="dialogVisible"
:title="editingIndex !== null ? '编辑通道' : '新增通道'"
width="500px"
append-to-body
destroy-on-close
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="通道名称" prop="name">
<el-input v-model="form.name" placeholder="请输入通道名称" />
</el-form-item>
<el-form-item label="通道类型" prop="channel">
<el-select v-model="form.channel" placeholder="请选择通道类型" class="w-full">
<el-option label="Modbus TCP" value="modbusTcp" />
<!-- <el-option label="MQTT" value="mqtt" /> -->
</el-select>
</el-form-item>
<template v-if="form.channel === 'modbusTcp'">
<el-form-item label="IP地址" prop="ip">
<el-input v-model="form.ip" placeholder="请输入IP地址" />
</el-form-item>
<el-form-item label="端口" prop="port">
<el-input-number
v-model="form.port"
:min="1"
:max="65535"
placeholder="请输入端口"
class="w-full"
/>
</el-form-item>
</template>
<!-- <template v-else-if="form.channel === 'mqtt'">
<el-form-item label="Broker" prop="broker">
<el-input v-model="form.broker" placeholder="请输入Broker地址" />
</el-form-item>
</template> -->
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">确定</el-button>
</span>
</template>
</el-dialog>
<ChannelDlg
ref="ChannelDlgRef"
:existingNames="existingChannelNames"
@on-save="handleSave"
/>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import type {
IChannel,
IModbusTcpChannelOV,
IMqttChannelOV,
} from '@/api/module/channel/index.d'
import type { FormInstance, FormRules } from 'element-plus'
import type { IChannelOV, Channel } from '@/api/module/channel/index.d'
import { ChannelEnum } from '@/api/module/channel/index'
import ChannelDlg from '../../components/channel-dlg.vue'
import type { ICustomFormData } from '../../components/types'
import { Plus } from '@element-plus/icons-vue'
const props = defineProps<{
modelValue: IChannel[]
modelValue: IChannelOV
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: IChannel[]): void
(e: 'update:modelValue', value: IChannelOV): void
}>()
const dialogVisible = ref(false)
const formRef = ref<FormInstance>()
const editingIndex = ref<number | null>(null)
type ChannelForm = Partial<
IModbusTcpChannelOV & IMqttChannelOV & { channel: 'modbusTcp' | 'mqtt' }
>
const ChannelDlgRef = ref<typeof ChannelDlg | null>(null)
const form = reactive<ChannelForm>({
name: '',
channel: 'modbusTcp',
ip: '',
port: 502,
broker: '',
})
const activeTab = ref<string>('modbus')
const editingIndex = ref<number | null>(null)
const editingType = ref<Channel | null>(null)
const rules = reactive<FormRules>({
name: [{ required: true, message: '请输入通道名称', trigger: 'blur' }],
channel: [{ required: true, message: '请选择通道类型', trigger: 'change' }],
ip: [{ required: true, message: '请输入IP地址', trigger: 'blur' }],
port: [{ required: true, message: '请输入端口', trigger: 'blur' }],
broker: [{ required: true, message: '请输入Broker地址', trigger: 'blur' }],
const existingChannelNames = computed(() => {
const currentChannels = props.modelValue[activeTab.value as Channel]
return currentChannels ? currentChannels.map(ch => ch.name) : []
})
const handleAdd = () => {
editingIndex.value = null
form.name = ''
form.channel = 'modbusTcp'
form.ip = ''
form.port = 502
form.broker = ''
dialogVisible.value = true
}
watch(
() => props.modelValue,
val => {
if (
val &&
Object.keys(val).length > 0 &&
!Object.keys(val).includes(activeTab.value)
) {
activeTab.value = Object.keys(val)[0]
}
},
{ immediate: true, deep: true },
)
function handleSave(form: ICustomFormData) {
const newModelValue = { ...props.modelValue }
const channelType = form.channel as Channel
const channelData =
channelType === ChannelEnum.MODBUS_TCP
? {
name: form.name!,
ip: form.ip!,
port: form.port!,
}
: {
name: form.name!,
broker: form.broker!,
}
if (editingIndex.value !== null && editingType.value) {
if (editingType.value === channelType) {
const list = newModelValue[editingType.value]
if (list) {
list[editingIndex.value] = channelData
}
} else {
const oldList = newModelValue[editingType.value]
if (oldList) {
oldList.splice(editingIndex.value, 1)
if (oldList.length === 0) {
delete newModelValue[editingType.value]
}
}
const handleEdit = (index: number) => {
editingIndex.value = index
const item = props.modelValue[index]
form.name = item.name
form.channel = item.channel
if (item.channel === 'modbusTcp') {
form.ip = item.ip
form.port = item.port
if (!newModelValue[channelType]) {
newModelValue[channelType] = []
}
newModelValue[channelType]!.push(channelData)
}
} else {
form.broker = item.broker
if (!newModelValue[channelType]) {
newModelValue[channelType] = []
}
newModelValue[channelType]!.push(channelData)
}
dialogVisible.value = true
activeTab.value = channelType
emit('update:modelValue', newModelValue)
}
const handleDelete = (index: number) => {
const newList = [...props.modelValue]
newList.splice(index, 1)
emit('update:modelValue', newList)
const handleDelete = (type: Channel, index: number) => {
const newModelValue = { ...props.modelValue }
const list = newModelValue[type]
if (list) {
list.splice(index, 1)
if (list.length === 0) {
delete newModelValue[type]
if (activeTab.value === type) {
const remainingKeys = Object.keys(newModelValue)
if (remainingKeys.length > 0) {
activeTab.value = remainingKeys[0]
}
}
}
emit('update:modelValue', newModelValue)
}
}
const handleSave = async () => {
if (!formRef.value) return
await formRef.value.validate(valid => {
if (valid) {
const newList = [...props.modelValue]
const channelData =
form.channel === 'modbusTcp'
? {
name: form.name!,
channel: 'modbusTcp' as const,
ip: form.ip!,
port: form.port!,
}
: {
name: form.name!,
channel: 'mqtt' as const,
broker: form.broker!,
}
if (editingIndex.value !== null) {
//
newList[editingIndex.value] = channelData
} else {
//
newList.push(channelData)
}
function handleAdd() {
editingIndex.value = null
editingType.value = null
ChannelDlgRef.value?.open()
}
emit('update:modelValue', newList)
dialogVisible.value = false
}
})
function handleEdit(item: ICustomFormData, index: number) {
editingIndex.value = index
editingType.value = item.channel
ChannelDlgRef.value?.open(item)
}
</script>
<style scoped>
/* UnoCSS handled classes */
:deep(.el-tabs__content) {
flex: 1;
overflow: hidden;
height: 0;
}
:deep(.el-tab-pane) {
height: 100%;
}
:deep(.el-scrollbar__view) {
height: 100%;
}
</style>

357
src/views/engineering/config/components/StepDevice.vue

@ -1,187 +1,234 @@ @@ -1,187 +1,234 @@
<template>
<div class="step-device h-full flex flex-col">
<div class="mb-4 flex justify-end">
<el-button type="primary" @click="handleAdd">新增设备</el-button>
</div>
<div class="flex-1 overflow-y-auto">
<div class="grid grid-cols-[repeat(auto-fill,minmax(300px,1fr))] gap-4">
<div
v-for="(item, index) in modelValue"
:key="index"
class="bg-white rounded-lg shadow-sm p-12 border border-gray-200 hover:shadow-md transition-all duration-300 mb-4"
<el-tabs
v-if="Object.keys(modelValue).length > 0"
v-model="activeTab"
class="h-full flex flex-col"
type="card"
>
<el-tab-pane
v-for="(devices, channelType) in modelValue"
:key="channelType"
:label="
Object.entries(ChannelEnum).find(([key, val]) => val === channelType)?.[1]
"
:name="channelType"
>
<div class="flex justify-between items-start mb-2">
<span class="font-bold text-lg truncate pr-8" :title="item.name">{{
item.name
}}</span>
</div>
<div class="space-y-2 mb-4">
<div class="flex items-center text-gray-600">
<span class="font-medium">通道:</span>
<span class="text-gray-800 ml-2">{{ item.ch }}</span>
</div>
<div class="flex items-center text-gray-600">
<span class="font-medium">类别:</span>
<span class="text-gray-800 ml-2">{{ item.point }}</span>
</div>
<div class="flex items-center text-gray-600">
<span class="font-medium">地址:</span>
<span class="text-gray-800 ml-2">{{ item.addr }}</span>
<el-scrollbar>
<div class="grid grid-cols-[repeat(auto-fill,minmax(300px,1fr))] gap-4 p-4">
<!-- Add Device Card -->
<div
class="bg-white rounded-xl shadow-sm p-12 border-2 border-dashed border-gray-200 hover:border-blue-400 transition-all duration-300 cursor-pointer flex items-center justify-center min-h-[200px]"
@click="handleAdd"
>
<div class="flex flex-col items-center gap-3">
<el-icon :size="48" class="text-gray-400">
<Plus />
</el-icon>
<span class="text-base font-medium text-gray-600">新增设备</span>
</div>
</div>
<div
v-for="(item, index) in devices"
:key="index"
class="bg-white rounded-xl shadow-sm p-12 border border-gray-100 hover:shadow-lg transition-all duration-300"
>
<div class="flex justify-between items-start mb-3">
<span
:title="item.name"
class="font-bold text-lg text-gray-900 truncate pr-8"
>设备名称{{ item.name }}</span
>
</div>
<div class="space-y-2 mb-3">
<div class="flex items-center justify-between text-sm">
<span class="text-gray-500">通道</span>
<span class="text-gray-900 font-medium">{{ item.ch }}</span>
</div>
<div class="flex items-center justify-between text-sm">
<span class="text-gray-500">类别</span>
<span class="text-gray-900 font-medium">{{ item.point }}</span>
</div>
<div class="flex items-center justify-between text-sm">
<span class="text-gray-500">地址</span>
<span class="text-gray-900 font-medium font-mono">{{
item.addr
}}</span>
</div>
</div>
<div class="flex justify-end pt-3 border-t border-gray-50">
<el-button
class="hover:bg-blue-50 transition-colors duration-200"
text
type="primary"
@click="
handleEdit(
Object.assign({ channel: channelType }, item) as IDeviceFormData,
index,
)
"
>
编辑
</el-button>
<el-button
class="hover:bg-red-50 transition-colors duration-200"
text
type="danger"
@click="handleDelete(channelType, index)"
>
删除
</el-button>
</div>
</div>
</div>
</div>
<div class="flex justify-end pt-3 border-t border-gray-100">
<el-button
type="primary"
text
@click="handleEdit(index)"
class="hover:bg-blue-500 hover:text-white transition-colors duration-200"
>
编辑
</el-button>
<el-button
type="danger"
text
@click="handleDelete(index)"
class="hover:bg-red-500 hover:text-white transition-colors duration-200"
>
删除
</el-button>
</div>
</div>
</div>
<el-empty
v-if="modelValue.length === 0"
description="暂无设备,请点击上方按钮添加"
/>
</el-scrollbar>
</el-tab-pane>
</el-tabs>
<el-empty v-else description="暂无设备,请点击上方按钮添加" />
</div>
<el-dialog
v-model="dialogVisible"
title="新增设备"
width="500px"
append-to-body
destroy-on-close
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="设备名称" prop="name">
<el-input v-model="form.name" placeholder="请输入设备名称" />
</el-form-item>
<el-form-item label="所属通道" prop="ch">
<el-select v-model="form.ch" placeholder="请选择通道" class="w-full">
<el-option
v-for="item in channels"
:key="item.name"
:label="item.name"
:value="item.name"
/>
</el-select>
</el-form-item>
<el-form-item label="设备类别" prop="point">
<el-select v-model="form.point" placeholder="请选择类别" class="w-full">
<el-option
v-for="item in categories"
:key="item.name"
:label="item.name"
:value="item.name"
/>
</el-select>
</el-form-item>
<el-form-item label="地址" prop="addr">
<el-input-number
v-model="form.addr"
:min="1"
placeholder="请输入地址"
class="w-full"
/>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">确定</el-button>
</span>
</template>
</el-dialog>
<DeviceDlg
ref="DeviceDlgRef"
:categories="categories"
:channels="channels"
:existing-names="existingDeviceNames"
@on-save="handleSave"
/>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { Delete } from '@element-plus/icons-vue'
import type { IDevice } from '@/api/module/device/index.d'
import type { IChannel } from '@/api/module/channel/index.d'
import type { IDeviceCategory } from '@/api/module/device/index.d'
import type { FormInstance, FormRules } from 'element-plus'
<script lang="ts" setup>
import type { IDevice, IDeviceCategoryList, IDeviceOV } from '@/api/module/device/index.d'
import type { Channel, IChannelOV } from '@/api/module/channel/index.d'
import { ChannelEnum } from '@/api/module/channel/index'
import DeviceDlg from '../../components/device-dlg.vue'
import { ref, watch, computed } from 'vue'
import { Plus } from '@element-plus/icons-vue'
const props = defineProps<{
modelValue: IDevice[]
channels: IChannel[]
categories: IDeviceCategory[]
modelValue: IDeviceOV
channels: IChannelOV
categories: IDeviceCategoryList
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: IDevice[]): void
(e: 'update:modelValue', value: IDeviceOV): void
}>()
const dialogVisible = ref(false)
const formRef = ref<FormInstance>()
const editingIndex = ref<number | null>(null)
type IDeviceFormData = IDevice & { channel: Channel }
const form = reactive<IDevice>({
name: '',
ch: '',
point: '',
addr: 1,
})
const DeviceDlgRef = ref<InstanceType<typeof DeviceDlg> | null>(null)
const activeTab = ref<string>('modbus')
const rules = reactive<FormRules>({
name: [{ required: true, message: '请输入设备名称', trigger: 'blur' }],
ch: [{ required: true, message: '请选择通道', trigger: 'change' }],
point: [{ required: true, message: '请选择类别', trigger: 'change' }],
addr: [{ required: true, message: '请输入地址', trigger: 'blur' }],
const existingDeviceNames = computed(() => {
const currentDevices = props.modelValue[activeTab.value as Channel]
return currentDevices ? currentDevices.map(device => device.name) : []
})
const editingIndex = ref<number | null>(null)
const editingType = ref<Channel | null>(null)
const handleAdd = () => {
editingIndex.value = null
form.name = ''
form.ch = ''
form.point = ''
form.addr = 1
dialogVisible.value = true
watch(
() => props.modelValue,
val => {
if (
val &&
Object.keys(val).length > 0 &&
!Object.keys(val).includes(activeTab.value)
) {
activeTab.value = Object.keys(val)[0]
}
},
{ immediate: true, deep: true },
)
function handleSave(form: IDeviceFormData) {
const newModelValue = { ...props.modelValue }
const channelType = form.channel
const deviceData: IDevice = {
name: form.name,
ch: form.ch,
point: form.point,
addr: form.addr,
}
if (editingIndex.value !== null && editingType.value) {
if (editingType.value === channelType) {
const list = newModelValue[editingType.value]
if (list) {
list[editingIndex.value] = deviceData
}
} else {
const oldList = newModelValue[editingType.value]
if (oldList) {
oldList.splice(editingIndex.value, 1)
if (oldList.length === 0) {
delete newModelValue[editingType.value]
}
}
if (!newModelValue[channelType]) {
newModelValue[channelType] = []
}
newModelValue[channelType]!.push(deviceData)
}
} else {
if (!newModelValue[channelType]) {
newModelValue[channelType] = []
}
newModelValue[channelType]!.push(deviceData)
}
activeTab.value = channelType
emit('update:modelValue', newModelValue)
}
const handleDelete = (index: number) => {
const newList = [...props.modelValue]
newList.splice(index, 1)
emit('update:modelValue', newList)
const handleDelete = (type: Channel, index: number) => {
const newModelValue = { ...props.modelValue }
const list = newModelValue[type]
if (list) {
list.splice(index, 1)
if (list.length === 0) {
delete newModelValue[type]
if (activeTab.value === type) {
const remainingKeys = Object.keys(newModelValue)
if (remainingKeys.length > 0) {
activeTab.value = remainingKeys[0]
}
}
}
emit('update:modelValue', newModelValue)
}
}
const handleEdit = (index: number) => {
editingIndex.value = index
const item = props.modelValue[index]
form.name = item.name
form.ch = item.ch
form.point = item.point
form.addr = item.addr
dialogVisible.value = true
function handleAdd() {
editingIndex.value = null
editingType.value = null
DeviceDlgRef.value?.open()
}
const handleSave = async () => {
if (!formRef.value) return
await formRef.value.validate(valid => {
if (valid) {
const newList = [...props.modelValue]
newList.push({ ...form })
emit('update:modelValue', newList)
dialogVisible.value = false
}
})
function handleEdit(item: IDeviceFormData, index: number) {
editingIndex.value = index
editingType.value = item.channel
DeviceDlgRef.value?.open(item)
}
</script>
<style scoped>
/* UnoCSS handled classes */
:deep(.el-tabs__content) {
flex: 1;
overflow: hidden;
height: 0;
}
:deep(.el-tab-pane) {
height: 100%;
}
:deep(.el-scrollbar__view) {
height: 100%;
}
</style>

386
src/views/engineering/config/components/StepDeviceCategory.vue

@ -1,79 +1,127 @@ @@ -1,79 +1,127 @@
<template>
<div class="step-device-category h-full flex flex-col">
<div class="mb-4 flex justify-end">
<el-button type="primary" @click="handleAdd">新增设备类别</el-button>
</div>
<div class="flex-1 overflow-y-auto">
<div class="grid grid-cols-[repeat(auto-fill,minmax(300px,1fr))] gap-4">
<div
v-for="(item, index) in modelValue"
:key="index"
class="bg-white rounded-lg shadow-sm p-4 border border-gray-200 hover:shadow-md transition-all duration-300"
<el-tabs
v-if="Object.keys(modelValue).length > 0"
v-model="activeTab"
type="card"
class="h-full flex flex-col"
>
<el-tab-pane
v-for="(categoriesMap, channelType) in modelValue"
:key="channelType"
:label="
Object.entries(ChannelEnum).find(([key, val]) => val === channelType)?.[1]
"
:name="channelType"
>
<div class="flex justify-between items-start mb-3">
<div class="flex-1 min-w-0 flex items-center justify-between">
<h3
class="font-bold text-lg text-gray-900 truncate mb-1"
:title="item.name"
<el-scrollbar>
<div class="grid grid-cols-[repeat(auto-fill,minmax(300px,1fr))] gap-4 p-4">
<div
class="bg-white rounded-xl shadow-sm p-12 border-2 border-dashed border-gray-200 hover:border-blue-400 transition-all duration-300 cursor-pointer flex items-center justify-center min-h-[200px]"
@click="handleAdd"
>
{{ item.name }}
</h3>
<el-tag v-if="item.fileName" size="small" type="success" effect="light">
已上传
</el-tag>
<el-tag v-else size="small" type="info" effect="light"> 未上传 </el-tag>
</div>
</div>
<!-- Upload Area or File Display -->
<div>
<div class="space-y-2">
<div class="text-sm">
<div class="flex items-center">
<div class="flex-1 flex gap-6">
<p class="text-gray-500 mb-0.5">点表文件:</p>
<p
class="text-sm font-medium text-gray-900 truncate"
:title="item.fileName"
>
{{ item.fileName }}
</p>
</div>
<div class="flex flex-col items-center gap-3">
<el-icon :size="48" class="text-gray-400">
<Plus />
</el-icon>
<span class="text-base font-medium text-gray-600">新增设备类别</span>
</div>
</div>
</div>
<div class="upload-area">
<el-upload
class="upload-demo"
drag
:auto-upload="false"
:limit="1"
:show-file-list="false"
:on-change="file => handleFileChange(file, index)"
<div
v-for="{ fileName, name: categoryName, status } in categoriesMap"
:key="fileName"
class="bg-white rounded-xl shadow-sm p-12 border border-gray-100 hover:shadow-lg transition-all duration-300"
>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text">
拖拽文件到此处<br /><em>点击{{ item.fileName ? '重新' : '' }}上传</em>
<div class="flex justify-between items-start mb-3">
<div class="flex-1 min-w-0 flex items-center justify-between">
<h3
class="font-bold text-lg text-gray-900 truncate"
:title="categoryName as string"
>
类别名称{{ categoryName }}
</h3>
<el-tag
v-if="!!status"
size="small"
type="success"
effect="light"
class="rounded-md"
>
已上传
</el-tag>
<el-tag
v-else
size="small"
type="info"
effect="light"
class="rounded-md"
>
未上传
</el-tag>
</div>
</div>
</el-upload>
</div>
</div>
<div class="flex justify-end pt-3 border-t border-gray-100">
<div class="flex gap-2">
<el-button type="primary" size="small" text @click="handleEdit(index)">
编辑
</el-button>
<el-button type="danger" size="small" text @click="handleDelete(index)">
删除
</el-button>
<div>
<div class="space-y-2 mb-3">
<div class="text-sm">
<div class="flex items-center justify-between">
<span class="text-gray-500 whitespace-nowrap">点表文件</span>
<el-tooltip
effect="dark"
:content="fileName"
placement="top"
:disabled="!fileName"
>
<span class="text-gray-900 font-medium truncate max-w-[200px]">
{{ fileName || '-' }}
</span>
</el-tooltip>
</div>
</div>
</div>
<div class="upload-area relative">
<div
v-if="uploadingStates[fileName]"
class="absolute inset-0 z-10 bg-white/90 flex items-center justify-center rounded-lg"
>
<el-button
type="danger"
size="small"
@click="handleCancelUpload(fileName as string)"
>
取消上传
</el-button>
</div>
<el-upload
:ref="(el) => setUploadRef(el, fileName as string)"
class="upload-demo"
drag
:auto-upload="false"
:limit="1"
:show-file-list="false"
:disabled="uploadingStates[fileName]"
:on-change="
(file: any) => handleFileChange(file, fileName as string)
"
>
<el-icon class="el-icon--upload !text-3xl !mb-2 !text-gray-400"
><upload-filled
/></el-icon>
<div class="el-upload__text text-xs text-gray-500">
拖拽文件或<em class="text-blue-500 not-italic cursor-pointer"
>点击/{{ !!status ? '重新' : '' }}上传</em
>
</div>
</el-upload>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</el-scrollbar>
</el-tab-pane>
</el-tabs>
<el-empty
v-if="modelValue.length === 0"
v-if="Object.keys(modelValue).length === 0"
description="暂无设备类别,请点击上方按钮添加"
/>
</div>
@ -81,14 +129,26 @@ @@ -81,14 +129,26 @@
<!-- Create/Edit Category Dialog -->
<el-dialog
v-model="dialogVisible"
:title="editingIndex !== null ? '编辑设备类别' : '新增设备类别'"
:title="'新增设备类别'"
width="500px"
append-to-body
destroy-on-close
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="类别名称" prop="name">
<el-input v-model="form.name" placeholder="请输入类别名称" />
<el-form-item label="类别名称:" prop="pointName">
<el-input v-model="form.pointName" placeholder="请输入类别名称" />
</el-form-item>
</el-form>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="通道类型:" prop="groupName">
<el-select v-model="form.groupName" placeholder="请选择通道类型">
<el-option
v-for="[key, val] in Object.entries(ChannelEnum)"
:key="key"
:label="val"
:value="val"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
@ -102,113 +162,173 @@ @@ -102,113 +162,173 @@
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { UploadFilled } from '@element-plus/icons-vue'
import type { IDeviceCategory } from '@/api/module/device/index.d'
import { ref, reactive, computed, watch } from 'vue'
import { useRoute } from 'vue-router'
import { UploadFilled, Plus } from '@element-plus/icons-vue'
import type { IDeviceCategoryList, IDeviceCategoryOV } from '@/api/module/device/index.d'
import type { FormInstance, FormRules, UploadFile } from 'element-plus'
import { ElMessage } from 'element-plus'
import { createDeviceType, uploadDeviceTypeFile } from '@/api/module/device/category'
import { ChannelEnum } from '@/api/module/channel/index'
import type { IChannelOV } from '@/api/module/channel/index.d'
import { validateName } from '@/utils/validate'
const props = defineProps<{
modelValue: IDeviceCategory[]
modelValue: IDeviceCategoryList
channels: IChannelOV
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: IDeviceCategory[]): void
(e: 'update:modelValue', value: IDeviceCategoryList): void
(e: 'on-load-device-categoty'): void
}>()
const route = useRoute()
const projectName = computed(() => route.query.name as string)
const dialogVisible = ref(false)
const formRef = ref<FormInstance>()
const editingIndex = ref<number | null>(null)
const form = reactive<IDeviceCategory>({
name: '',
const uploadControllers = reactive<{ [key: string]: AbortController }>({})
const uploadingStates = reactive<{ [key: string]: boolean }>({})
const activeTab = ref<string>('modbus')
watch(
() => props.modelValue,
val => {
if (
val &&
Object.keys(val).length > 0 &&
!Object.keys(val).includes(activeTab.value)
) {
activeTab.value = Object.keys(val)[0]
}
},
{ immediate: true, deep: true },
)
const form = reactive<IDeviceCategoryOV>({
pointName: '',
fileName: '',
groupName: '',
})
const rules = reactive<FormRules>({
name: [{ required: true, message: '请输入类别名称', trigger: 'blur' }],
pointName: [
{ required: true, message: '请输入类别名称', trigger: 'blur' },
{
validator: (rule: any, value: any, callback: any) => {
const error = validateName(value)
if (error) {
callback(new Error(error))
} else {
callback()
}
},
trigger: 'blur',
},
],
groupName: [{ required: true, message: '请输入通道类型', trigger: 'blur' }],
})
const handleAdd = () => {
editingIndex.value = null
form.name = ''
form.pointName = ''
form.fileName = ''
form.groupName = ''
dialogVisible.value = true
}
const handleEdit = (index: number) => {
editingIndex.value = index
const item = props.modelValue[index]
form.name = item.name
form.fileName = item.fileName || ''
dialogVisible.value = true
}
const handleDelete = (index: number) => {
const newList = [...props.modelValue]
newList.splice(index, 1)
emit('update:modelValue', newList)
}
const handleSave = async () => {
if (!formRef.value) return
await formRef.value.validate(valid => {
await formRef.value.validate(async valid => {
if (valid) {
const newList = [...props.modelValue]
if (editingIndex.value !== null) {
// Edit mode - keep existing fileName
newList[editingIndex.value] = {
name: form.name,
fileName: newList[editingIndex.value].fileName || '',
}
} else {
// Add mode - no fileName yet
newList.push({
name: form.name,
fileName: '',
})
}
emit('update:modelValue', newList)
dialogVisible.value = false
await addDeviceType()
}
})
}
const handleFileChange = async (file: UploadFile, index: number) => {
if (!file.raw) return
async function addDeviceType() {
const res = await createDeviceType({
pointName: form.pointName,
projectName: projectName.value,
groupName: form.groupName,
})
if (res.code !== 0) {
ElMessage.error(res?.msg || '添加失败')
dialogVisible.value = false
return
}
emit('on-load-device-categoty')
dialogVisible.value = false
}
try {
// Mock: extract filename from the uploaded file
const fileName = file.name
const uploadRefs = ref<Record<string, any>>({})
const setUploadRef = (el: any, key: string) => {
if (el) {
uploadRefs.value[key] = el
}
}
const handleFileChange = async (file: UploadFile, fileName: string) => {
if (!file.raw) return
// Simulate upload delay
await new Promise(resolve => setTimeout(resolve, 500))
const controller = new AbortController()
uploadControllers[fileName] = controller
uploadingStates[fileName] = true
// Update the category with the filename
const newList = [...props.modelValue]
newList[index] = {
...newList[index],
fileName: fileName,
try {
const res = await uploadDeviceTypeFile(
{
file: file.raw,
projectName: projectName.value,
fileName: fileName,
},
controller,
)
if (res.code === 0) {
ElMessage.success('上传成功')
emit('on-load-device-categoty')
} else {
ElMessage.error(res.msg || '上传失败')
}
} catch (error: any) {
if (error.name === 'CanceledError' || error.message === 'canceled') {
ElMessage.info('上传已取消')
} else {
ElMessage.error('上传失败')
}
emit('update:modelValue', newList)
ElMessage.success('文件上传成功')
} catch (error) {
ElMessage.error('文件上传失败')
} finally {
delete uploadControllers[fileName]
uploadingStates[fileName] = false
uploadRefs.value[fileName]?.clearFiles()
}
}
const handleReupload = (index: number) => {
// Clear the filename to show upload area again
const newList = [...props.modelValue]
newList[index] = {
...newList[index],
fileName: '',
const handleCancelUpload = (fileName: string) => {
const controller = uploadControllers[fileName]
if (controller) {
controller.abort()
delete uploadControllers[fileName]
uploadingStates[fileName] = false
}
emit('update:modelValue', newList)
}
</script>
<style scoped>
:deep(.el-tabs__content) {
flex: 1;
overflow: hidden;
height: 0;
}
:deep(.el-tab-pane) {
height: 100%;
}
:deep(.el-scrollbar__view) {
height: 100%;
}
.upload-demo {
width: 100%;
}

202
src/views/engineering/config/index.vue

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
<template>
<div class="engineering-config h-full flex flex-col bg-white">
<div class="border-b border-gray-200">
<div class="border-gray-200">
<div class="px-6 py-4">
<el-page-header @back="goBack" title="返回">
<template #content>
@ -9,7 +9,6 @@ @@ -9,7 +9,6 @@
<span class="text-xl font-bold text-gray-900">{{
projectName || '未命名工程'
}}</span>
<el-tag type="success" v-if="projectVersion">{{ projectVersion }}</el-tag>
</div>
<span class="text-xs text-gray-500">工程配置</span>
</div>
@ -20,11 +19,42 @@ @@ -20,11 +19,42 @@
<div class="px-6 pb-4">
<div class="w-full">
<el-steps :active="activeStep" finish-status="success" simple align-center>
<el-step title="通道管理" :icon="Connection" />
<el-step title="设备类别" :icon="Collection" />
<el-step title="设备管理" :icon="Monitor" />
<el-step
title="1.通道管理"
:icon="Connection"
@click="currentStep = 'channel'"
class="cursor-pointer"
/>
<el-step
title="2.设备类别"
:icon="Collection"
@click="currentStep = 'category'"
class="cursor-pointer"
/>
<el-step
title="3.设备管理"
:icon="Monitor"
@click="currentStep = 'device'"
class="cursor-pointer"
/>
</el-steps>
</div>
<div class="flex justify-end gap-4 p-t-16">
<el-button v-if="activeStep > 0" @click="prevStep" :icon="ArrowLeft"
>上一步</el-button
>
<el-button v-if="activeStep < 2" type="primary" @click="nextStep">
下一步<el-icon class="el-icon--right"><ArrowRight /></el-icon>
</el-button>
<el-button
v-if="activeStep === 2"
type="success"
@click="handleFinish"
:icon="Check"
:loading="loading"
>完成</el-button
>
</div>
</div>
</div>
@ -34,28 +64,13 @@ @@ -34,28 +64,13 @@
<keep-alive>
<component
:is="currentStepComponent"
v-model="currentModel"
v-model="currentModel as any"
v-bind="currentProps"
@on-load-device-categoty="loadCategoryList"
/>
</keep-alive>
</transition>
</div>
<div class="p-4 border-t border-gray-100 flex justify-center gap-4 bg-white">
<el-button v-if="activeStep > 0" @click="prevStep" :icon="ArrowLeft"
>上一步</el-button
>
<el-button v-if="activeStep < 2" type="primary" @click="nextStep">
下一步<el-icon class="el-icon--right"><ArrowRight /></el-icon>
</el-button>
<el-button
v-if="activeStep === 2"
type="success"
@click="handleFinish"
:icon="Check"
>完成</el-button
>
</div>
</div>
</div>
</template>
@ -75,28 +90,36 @@ import { @@ -75,28 +90,36 @@ import {
import StepChannel from './components/StepChannel.vue'
import StepDeviceCategory from './components/StepDeviceCategory.vue'
import StepDevice from './components/StepDevice.vue'
import type { IChannel } from '@/api/module/channel/index.d'
import type { IDevice, IDeviceCategory } from '@/api/module/device/index.d'
import type { IChannelOV } from '@/api/module/channel/index.d'
import type { IDeviceCategoryList, IDeviceOV } from '@/api/module/device/index.d'
import { getChannelList, saveChannel } from '@/api/module/channel'
import { getDeviceList, saveDevice } from '@/api/module/device/index.ts'
import { getDeviceTypeList } from '@/api/module/device/category.ts'
import { isObject } from '@/utils/is.ts'
import { createEngineering } from '@/api/module/engineering'
const router = useRouter()
const route = useRoute()
const projectName = computed(() => route.query.name as string)
const projectVersion = computed(() => route.query.version as string)
const isCreate = computed(() => route.query.isCreate as string)
const activeStep = ref(0)
const channels = ref<IChannel[]>([])
const categories = ref<IDeviceCategory[]>([])
const devices = ref<IDevice[]>([])
type Step = 'channel' | 'category' | 'device'
const steps: Step[] = ['channel', 'category', 'device']
const currentStep = ref<Step>('channel')
const activeStep = computed(() => steps.indexOf(currentStep.value))
const channels = ref<IChannelOV>({})
const categories = ref<IDeviceCategoryList>({})
const devices = ref<IDeviceOV>({})
const loading = ref(false)
const currentStepComponent = computed(() => {
switch (activeStep.value) {
case 0:
switch (currentStep.value) {
case 'channel':
return StepChannel
case 1:
case 'category':
return StepDeviceCategory
case 2:
case 'device':
return StepDevice
default:
return null
@ -105,26 +128,26 @@ const currentStepComponent = computed(() => { @@ -105,26 +128,26 @@ const currentStepComponent = computed(() => {
const currentModel = computed({
get: () => {
switch (activeStep.value) {
case 0:
switch (currentStep.value) {
case 'channel':
return channels.value
case 1:
case 'category':
return categories.value
case 2:
case 'device':
return devices.value
default:
return []
return {}
}
},
set: (val: any) => {
switch (activeStep.value) {
case 0:
switch (currentStep.value) {
case 'channel':
channels.value = val
break
case 1:
case 'category':
categories.value = val
break
case 2:
case 'device':
devices.value = val
break
}
@ -132,11 +155,15 @@ const currentModel = computed({ @@ -132,11 +155,15 @@ const currentModel = computed({
})
const currentProps = computed(() => {
if (activeStep.value === 2) {
if (currentStep.value === 'device') {
return {
channels: channels.value,
categories: categories.value,
}
} else if (currentStep.value === 'category') {
return {
channels: channels.value,
}
}
return {}
})
@ -145,20 +172,91 @@ const goBack = () => { @@ -145,20 +172,91 @@ const goBack = () => {
router.push('/engineering')
}
const prevStep = () => {
if (activeStep.value > 0) {
activeStep.value--
const prevStep = async () => {
const index = steps.indexOf(currentStep.value)
if (currentStep.value === 'channel') {
await loadChannelList()
}
if (index > 0) {
currentStep.value = steps[index - 1]
}
}
const nextStep = async () => {
const index = steps.indexOf(currentStep.value)
if (currentStep.value === 'channel') {
await saveChannelList()
}
if (index < steps.length - 1) {
currentStep.value = steps[index + 1]
}
}
const handleFinish = async () => {
loading.value = true
try {
const resDevice = await saveDeviceList()
if (resDevice.code !== 0) {
ElMessage.error('保存设备失败')
return
}
const res = await createEngineering({
name: projectName.value,
isCreate: false,
})
if (res.code !== 0) {
ElMessage.error('配置工程失败')
return
}
ElMessage.success('配置完成')
goBack()
} finally {
loading.value = false
}
}
const nextStep = () => {
activeStep.value++
async function saveDeviceList() {
const res = await saveDevice({
projectName: projectName.value,
...devices.value,
})
if (res.code === 0) {
// ElMessage.success('')
}
return Promise.resolve(res)
}
const handleFinish = () => {
ElMessage.success('配置完成')
goBack()
async function saveChannelList() {
const res = await saveChannel({
projectName: projectName.value,
...channels.value,
})
if (res.code === 0) {
ElMessage.success('保存成功')
}
}
async function loadChannelList() {
const res = await getChannelList({ projectName: projectName.value })
if (isObject(res.data)) channels.value = res.data
}
async function loadCategoryList() {
const res = await getDeviceTypeList({ projectName: projectName.value })
if (isObject(res.data)) categories.value = res.data
}
async function loadDeviceList() {
const res = await getDeviceList({ projectName: projectName.value })
if (isObject(res.data)) devices.value = res.data
}
onMounted(async () => {
if (!isCreate.value) {
await loadChannelList()
await loadDeviceList()
await loadCategoryList()
}
})
</script>
<style scoped>

81
src/views/engineering/index.vue

@ -1,11 +1,7 @@ @@ -1,11 +1,7 @@
<template>
<EdfsWrap class="wh-full" :title="'工程列表'" :use-scroll-bar="false">
<template #title-right>
<!-- <el-button type="primary" @click="addEngineering" :icon="Plus">新增工程</el-button> -->
</template>
<div class="engineering-list-container">
<div class="engineering-grid">
<!-- Add Engineering Card -->
<div class="engineering-card add-card" @click="addEngineering">
<div class="add-card-content">
<el-icon :size="48" class="add-icon">
@ -15,22 +11,20 @@ @@ -15,22 +11,20 @@
</div>
</div>
<!-- Engineering List -->
<div v-for="item in list" :key="item.id" class="engineering-card">
<div v-for="item in list" :key="item.name" class="engineering-card">
<div class="card-header">
<span class="card-title">{{ item.name }}</span>
<el-tag type="success">{{ item.versions }}</el-tag>
<span class="card-title">工程名称{{ item.name }}</span>
</div>
<div class="card-body">
<p class="card-desc">{{ item.description }}</p>
<p class="card-desc">工程描述{{ item.description }}</p>
</div>
<div class="card-footer">
<el-button type="primary" text @click="handleDownload(item)"
>下载工程</el-button
>
<el-button type="primary" text @click="enterEngineering(item)"
>进入工程</el-button
>
<el-button type="danger" text @click="deleteEngineering(item)"
>删除</el-button
>
</div>
</div>
</div>
@ -38,55 +32,69 @@ @@ -38,55 +32,69 @@
<el-empty v-if="!loading && list.length === 0" description="暂无数据" />
</div>
</EdfsWrap>
<CreateEngineeringDlg ref="createEngineeringDlgRef" />
<CreateEngineeringDlg
ref="createEngineeringDlgRef"
:existing-names="existingEngineeringNames"
@on-save="handleSave"
/>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import EdfsWrap from '@/components/Edfs-wrap.vue'
import { getEngineeringList } from '@/api/module/engineering'
import type { IEngineering } from '@/api/module/engineering/index.d'
import type { IEngineeringOV } from '@/api/module/engineering/index.d'
import CreateEngineeringDlg from './components/create-engineering-dlg.vue'
import { Plus } from '@element-plus/icons-vue'
import { useEngineeringStore } from '@/stores/engineering'
import { storeToRefs } from 'pinia'
const router = useRouter()
const engineeringStore = useEngineeringStore()
const list = ref<IEngineering[]>([])
const loading = ref(false)
const createEngineeringDlgRef = ref<typeof CreateEngineeringDlg | null>(null)
onMounted(() => {
fetchData()
const { engineeringList: list, loading } = storeToRefs(engineeringStore)
const existingEngineeringNames = computed(() => {
return list.value.map(item => item.name)
})
const fetchData = async () => {
loading.value = true
try {
const res = await getEngineeringList()
list.value = res.list
} catch (error) {
console.error(error)
} finally {
loading.value = false
}
}
onMounted(() => {
engineeringStore.fetchEngineeringList()
})
function addEngineering() {
createEngineeringDlgRef.value?.open()
}
function enterEngineering(item: IEngineering) {
function handleSave(name: string) {
enterEngineering({ name, description: '', isCreate: true })
}
function enterEngineering(item: IEngineeringOV) {
router.push({
path: '/engineering-config/edit',
query: {
name: item.name,
version: item.versions,
type: item.isCreate ? 'create' : 'edit',
},
})
}
function deleteEngineering(item: IEngineering) {}
const createEngineeringDlgRef = ref<typeof CreateEngineeringDlg | null>(null)
function handleDownload(item: IEngineeringOV) {
if (!item.downloadPath) {
ElMessage.warning('下载地址为空')
return
}
const link = document.createElement('a')
link.style.display = 'none'
link.href = item.downloadPath
link.setAttribute('download', item.name)
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
</script>
<style scoped lang="scss">
@ -108,6 +116,7 @@ const createEngineeringDlgRef = ref<typeof CreateEngineeringDlg | null>(null) @@ -108,6 +116,7 @@ const createEngineeringDlgRef = ref<typeof CreateEngineeringDlg | null>(null)
padding: 16px;
display: flex;
flex-direction: column;
min-height: 200px;
transition: all 0.3s;
&:hover {

150
src/views/layout/index.vue

@ -36,41 +36,30 @@ @@ -36,41 +36,30 @@
>
<template v-for="router in menuList">
<template v-if="router.meta?.isShow">
<el-sub-menu
v-if="router?.children?.filter((item: any) => item.meta?.isShow).length"
:index="router.path"
:key="router.path"
>
<template #title>
<div
:class="router.meta.icon"
class="menu-icon"
@click.stop="
router.path === '/engineering' ? menuSelect(router.path) : null
"
></div>
<span
@click.stop="
router.path === '/engineering' ? menuSelect(router.path) : null
"
>{{ router.meta.title }}</span
<!-- Engineering section with el-tree -->
<template v-if="router.path === '/engineering'">
<div class="engineering-tree-wrapper">
<el-tree
:data="engineeringTreeData"
:props="{ label: 'label', children: 'children' }"
:default-expanded-keys="['/engineering']"
:current-node-key="activeMenu"
:highlight-current="true"
node-key="path"
@node-click="handleTreeNodeClick"
class="engineering-tree"
>
</template>
<template v-for="child in router?.children">
<el-menu-item
v-if="child?.meta?.isShow"
:key="child.path"
:index="
child.path.startsWith('/')
? child.path
: `${router.path}/${child.path}`
"
>
<div :class="child.meta.icon" class="menu-icon"></div>
<span class="truncate">{{ child.meta.title }}</span>
</el-menu-item>
</template>
</el-sub-menu>
<template #default="{ node, data }">
<div class="tree-node-content">
<div :class="data.icon" class="menu-icon"></div>
<span class="tree-node-label">{{ node.label }}</span>
</div>
</template>
</el-tree>
</div>
</template>
<!-- Other menu items -->
<el-menu-item v-else :index="router.path" :key="router?.path">
<div :class="router.meta.icon" class="menu-icon"></div>
<span>{{ router.meta.title }}</span>
@ -91,7 +80,7 @@ @@ -91,7 +80,7 @@
</div>
</el-header>
<main class="main-wrap">
<RouterView />
<RouterView :key="currentRoute.fullPath" />
</main>
</el-container>
</el-container>
@ -102,15 +91,16 @@ @@ -102,15 +91,16 @@
import { useTheme } from '@/composables/useTheme'
import { defaultRouter } from '@/router'
import dayjs from 'dayjs'
import { getEngineeringList } from '@/api/module/engineering'
import type { IEngineering } from '@/api/module/engineering/index.d'
import { useEngineeringStore } from '@/stores/engineering'
import { storeToRefs } from 'pinia'
const unfold = 'i-icon-park-outline:menu-unfold'
const fold = 'i-icon-park-outline:menu-fold'
const { theme } = useTheme()
const engineeringList = ref<IEngineering[]>([])
const engineeringStore = useEngineeringStore()
const { engineeringList } = storeToRefs(engineeringStore)
const menuList = computed(() => {
const routes = JSON.parse(JSON.stringify(defaultRouter[0].children))
@ -131,7 +121,7 @@ const menuList = computed(() => { @@ -131,7 +121,7 @@ const menuList = computed(() => {
// Add engineering projects as children
const projectItems = engineeringList.value.map(item => ({
path: `/engineering-config/edit?name=${item.name}&version=${item.versions}`,
path: `/engineering-config/edit?name=${item.name}&type=edit`,
meta: {
title: item.name,
isShow: true,
@ -144,6 +134,19 @@ const menuList = computed(() => { @@ -144,6 +134,19 @@ const menuList = computed(() => {
return routes
})
const engineeringTreeData = computed(() => [
{
path: '/engineering',
label: '工程管理',
icon: 'i-icon-park-outline:all-application',
children: engineeringList.value.map(item => ({
path: `/engineering-config/edit?name=${item.name}&type=edit`,
label: item.name,
icon: 'i-icon-park-outline:setting-two',
})),
},
])
const circleUrl = ref(
'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png',
)
@ -170,24 +173,19 @@ function menuSelect(path: string) { @@ -170,24 +173,19 @@ function menuSelect(path: string) {
push(path)
}
function handleTreeNodeClick(data: any) {
push(data.path)
}
const currentTime = ref('123')
const updateTime = () => {
currentTime.value = dayjs().format('YYYY-MM-DD HH:mm:ss')
requestAnimationFrame(updateTime)
}
const fetchEngineeringList = async () => {
try {
const res = await getEngineeringList()
engineeringList.value = res.list
} catch (error) {
console.error(error)
}
}
onMounted(() => {
updateTime()
fetchEngineeringList()
engineeringStore.fetchEngineeringList()
})
</script>
<style lang="scss" scoped>
@ -293,5 +291,59 @@ onMounted(() => { @@ -293,5 +291,59 @@ onMounted(() => {
height: calc(100vh - 56px);
@apply p-16;
}
// Engineering tree styles
.engineering-tree-wrapper {
@apply px-1;
}
.engineering-tree {
@apply bg-transparent border-none;
:deep(.el-tree-node) {
@apply mb-1;
}
:deep(.el-tree-node__content) {
@apply h-56px px-16px rounded-8px;
background-color: transparent !important;
transition: all 0.3s;
display: flex;
align-items: center;
&:hover {
background-color: transparent !important;
color: var(--el-color-primary);
}
}
:deep(.el-tree-node.is-current > .el-tree-node__content) {
background-color: rgba(64, 158, 255, 0.1) !important;
border-radius: 8px;
color: var(--el-color-primary);
}
:deep(.el-tree-node__expand-icon) {
@apply text-16px;
color: var(--el-text-color-regular);
order: 2;
margin-left: auto;
margin-right: 0;
}
.tree-node-content {
@apply flex items-center w-full p-l-16;
flex: 1;
order: 1;
min-width: 0;
}
.tree-node-label {
@apply flex-1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
</style>

Loading…
Cancel
Save