新增小工具页面、SNTP对时功能集成到小工具页面
This commit is contained in:
@@ -1,5 +1,15 @@
|
||||
import http from '@/api'
|
||||
|
||||
export interface SntpTimeMessage {
|
||||
type: string
|
||||
deviceIp?: string
|
||||
computerTime?: string
|
||||
deviceTime?: string
|
||||
computerTimestampMs?: number
|
||||
deviceTimestampMs?: number
|
||||
errorMs?: number
|
||||
}
|
||||
|
||||
export const startSntpService = () => {
|
||||
return http.post('/sntp/start', {})
|
||||
}
|
||||
|
||||
@@ -7,75 +7,60 @@ import { useModeStore } from '@/stores/modules/mode'
|
||||
import { getLicense } from '@/api/activate'
|
||||
import type { Activate } from '@/api/activate/interface'
|
||||
|
||||
const CONTRAST_MODE_NAME = '比对式'
|
||||
|
||||
export const useAuthStore = defineStore(AUTH_STORE_KEY, {
|
||||
state: (): AuthState => ({
|
||||
// 按钮权限列表
|
||||
authButtonList: {},
|
||||
// 菜单权限列表
|
||||
authMenuList: [],
|
||||
// 当前页面的 router name,用来做按钮权限筛选
|
||||
routeName: '',
|
||||
//登录不显示菜单栏和导航栏,点击进入测试的时候显示
|
||||
showMenuFlag: JSON.parse(localStorage.getItem('showMenuFlag') as string),
|
||||
activateInfo: {} as Activate.ActivationCodePlaintext
|
||||
}),
|
||||
getters: {
|
||||
// 按钮权限列表
|
||||
authButtonListGet: state => state.authButtonList,
|
||||
// 菜单权限列表 ==> 这里的菜单没有经过任何处理
|
||||
authMenuListGet: state => state.authMenuList,
|
||||
// 菜单权限列表 ==> 左侧菜单栏渲染,需要剔除 isHide == true
|
||||
showMenuListGet: state => getShowMenuList(state.authMenuList),
|
||||
// 菜单权限列表 ==> 扁平化之后的一维数组菜单,主要用来添加动态路由
|
||||
flatMenuListGet: state => getFlatMenuList(state.authMenuList),
|
||||
// 递归处理后的所有面包屑导航列表
|
||||
breadcrumbListGet: state => getAllBreadcrumbList(state.authMenuList),
|
||||
//是否显示菜单和导航栏
|
||||
showMenuFlagGet: state => state.showMenuFlag,
|
||||
// 获取激活信息
|
||||
activateInfoGet: state => state.activateInfo
|
||||
},
|
||||
actions: {
|
||||
// Get AuthButtonList
|
||||
async getAuthButtonList() {
|
||||
const { data } = await getAuthButtonListApi()
|
||||
this.authButtonList = data
|
||||
},
|
||||
// Get AuthMenuList
|
||||
async getAuthMenuList() {
|
||||
const modeStore = useModeStore()
|
||||
|
||||
const { data: menuData } = await getAuthMenuListApi()
|
||||
// 根据不同模式过滤菜单
|
||||
const filteredMenu =
|
||||
modeStore.currentMode === '比对式'
|
||||
|
||||
const isContrastMode = modeStore.currentMode === CONTRAST_MODE_NAME
|
||||
const filteredMenu = isContrastMode
|
||||
? filterMenuByExcludedNames(menuData, ['testSource', 'testScript', 'controlSource'])
|
||||
: filterMenuByExcludedNames(menuData, ['standardDevice'])
|
||||
|
||||
this.authMenuList = filteredMenu
|
||||
this.authMenuList = filterMenuByExcludedNames(filteredMenu, ['sntp'])
|
||||
},
|
||||
// Set RouteName
|
||||
async setRouteName(name: string) {
|
||||
this.routeName = name
|
||||
},
|
||||
//重置权限
|
||||
async resetAuthStore() {
|
||||
this.showMenuFlag = false
|
||||
localStorage.removeItem('showMenuFlag')
|
||||
},
|
||||
//修改判断菜单栏/导航栏显示条件
|
||||
async setShowMenu() {
|
||||
this.showMenuFlag = true
|
||||
localStorage.setItem('showMenuFlag', 'true')
|
||||
},
|
||||
//更改模式
|
||||
changeModel() {
|
||||
this.showMenuFlag = false
|
||||
localStorage.removeItem('showMenuFlag')
|
||||
},
|
||||
async setActivateInfo() {
|
||||
const license_result = await getLicense()
|
||||
const licenseData = license_result.data as Activate.ActivationCodePlaintext
|
||||
const licenseResult = await getLicense()
|
||||
const licenseData = licenseResult.data as Activate.ActivationCodePlaintext
|
||||
|
||||
if (!licenseData.simulate) {
|
||||
licenseData.simulate = {
|
||||
permanently: 0
|
||||
@@ -91,24 +76,18 @@ export const useAuthStore = defineStore(AUTH_STORE_KEY, {
|
||||
permanently: 0
|
||||
}
|
||||
}
|
||||
|
||||
this.activateInfo = licenseData
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 通用菜单过滤函数
|
||||
* @param menuList 菜单列表
|
||||
* @param excludedNames 需要排除的菜单名称数组
|
||||
* @returns 过滤后的菜单列表
|
||||
*/
|
||||
function filterMenuByExcludedNames(menuList: any[], excludedNames: string[]): any[] {
|
||||
function filterMenuByExcludedNames(menuList: Menu.MenuOptions[], excludedNames: string[]): Menu.MenuOptions[] {
|
||||
return menuList.filter(menu => {
|
||||
// 如果当前项有 children,递归处理子项
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
menu.children = filterMenuByExcludedNames(menu.children, excludedNames)
|
||||
}
|
||||
// 过滤掉在排除列表中的菜单项
|
||||
|
||||
return !excludedNames.includes(menu.name)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,368 +0,0 @@
|
||||
<template>
|
||||
<div class="sntp-page">
|
||||
<section class="sntp-panel">
|
||||
<el-row :gutter="16" class="time-list">
|
||||
<el-col :xs="24" :md="12">
|
||||
<div class="time-item">
|
||||
<div class="time-label">当前电脑时间</div>
|
||||
<div class="time-content">{{ computerTime }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="24" :md="12">
|
||||
<div class="time-item">
|
||||
<div class="time-label">装置返回时间</div>
|
||||
<div class="time-content">{{ deviceTime }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<div class="action-row">
|
||||
<el-button type="primary" :loading="starting" :disabled="running || stopping" @click="handleStart">
|
||||
启动SNTP对时服务
|
||||
</el-button>
|
||||
<el-button type="danger" plain :loading="stopping" :disabled="!running || starting" @click="handleStop">
|
||||
停止SNTP对时服务
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="history-section">
|
||||
<div class="history-header">
|
||||
<span class="history-title">历史记录</span>
|
||||
<el-button
|
||||
plain
|
||||
type="danger"
|
||||
:disabled="historyList.length === 0"
|
||||
@click="clearHistory"
|
||||
>
|
||||
清空
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="history-table">
|
||||
<div class="history-table__head history-row">
|
||||
<div class="col-order">序号</div>
|
||||
<div>当前电脑时间</div>
|
||||
<div>装置返回时间</div>
|
||||
<div>误差(ms)</div>
|
||||
</div>
|
||||
<div v-if="historyList.length === 0" class="history-empty">
|
||||
<span>暂无数据</span>
|
||||
</div>
|
||||
<div v-else class="history-table__body">
|
||||
<div v-for="(item, index) in historyList" :key="item.id" class="history-row">
|
||||
<div class="col-order">{{ index + 1 }}</div>
|
||||
<div>{{ item.computerTime }}</div>
|
||||
<div>{{ item.deviceTime }}</div>
|
||||
<div>{{ formatErrorMs(item.errorMs) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="sntp">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { Delete } from '@element-plus/icons-vue'
|
||||
import socketClient from '@/utils/webSocketClient'
|
||||
import { startSntpService, stopSntpService } from '@/api/system/sntp'
|
||||
|
||||
interface SntpTimeMessage {
|
||||
type: string
|
||||
computerTime?: string
|
||||
deviceTime?: string
|
||||
computerTimestampMs?: number
|
||||
deviceTimestampMs?: number
|
||||
errorMs?: number
|
||||
}
|
||||
|
||||
interface SntpHistoryItem {
|
||||
id: string
|
||||
computerTime: string
|
||||
deviceTime: string
|
||||
computerTimestampMs: number | null
|
||||
deviceTimestampMs: number | null
|
||||
errorMs: number | null
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
name: 'sntp'
|
||||
})
|
||||
|
||||
const messageType = 'sntp_time_update'
|
||||
const maxHistoryCount = 50
|
||||
|
||||
const running = ref(false)
|
||||
const starting = ref(false)
|
||||
const stopping = ref(false)
|
||||
|
||||
const computerTimeValue = ref('--')
|
||||
const deviceTimeValue = ref('--')
|
||||
const historyList = ref<SntpHistoryItem[]>([])
|
||||
|
||||
const computerTime = computed(() => computerTimeValue.value)
|
||||
const deviceTime = computed(() => deviceTimeValue.value)
|
||||
|
||||
const formatErrorMs = (errorMs: number | null) => {
|
||||
if (errorMs === null || Number.isNaN(errorMs))
|
||||
return '--'
|
||||
|
||||
if (errorMs > 0)
|
||||
return `+${errorMs}`
|
||||
|
||||
return `${errorMs}`
|
||||
}
|
||||
|
||||
const appendHistory = (
|
||||
computerTimeText: string,
|
||||
deviceTimeText: string,
|
||||
computerTimestampMs: number | null,
|
||||
deviceTimestampMs: number | null,
|
||||
errorMs: number | null
|
||||
) => {
|
||||
const nextItem: SntpHistoryItem = {
|
||||
id: `${Date.now()}_${Math.random().toString(16).slice(2, 8)}`,
|
||||
computerTime: computerTimeText,
|
||||
deviceTime: deviceTimeText,
|
||||
computerTimestampMs,
|
||||
deviceTimestampMs,
|
||||
errorMs
|
||||
}
|
||||
historyList.value = [nextItem, ...historyList.value].slice(0, maxHistoryCount)
|
||||
}
|
||||
|
||||
const handleTimeUpdate = (message: SntpTimeMessage) => {
|
||||
const nextComputerTime = message.computerTime || '--'
|
||||
const nextDeviceTime = message.deviceTime || '--'
|
||||
const nextComputerTimestampMs = typeof message.computerTimestampMs === 'number' ? message.computerTimestampMs : null
|
||||
const nextDeviceTimestampMs = typeof message.deviceTimestampMs === 'number' ? message.deviceTimestampMs : null
|
||||
const nextErrorMs = typeof message.errorMs === 'number' ? message.errorMs : null
|
||||
computerTimeValue.value = nextComputerTime
|
||||
deviceTimeValue.value = nextDeviceTime
|
||||
appendHistory(
|
||||
nextComputerTime,
|
||||
nextDeviceTime,
|
||||
nextComputerTimestampMs,
|
||||
nextDeviceTimestampMs,
|
||||
nextErrorMs
|
||||
)
|
||||
}
|
||||
|
||||
const ensureSocketConnection = () => {
|
||||
socketClient.Instance.connect()
|
||||
socketClient.Instance.registerCallBack(messageType, (message: SntpTimeMessage) => {
|
||||
handleTimeUpdate(message)
|
||||
})
|
||||
}
|
||||
|
||||
const clearHistory = () => {
|
||||
historyList.value = []
|
||||
}
|
||||
|
||||
const handleStart = async () => {
|
||||
starting.value = true
|
||||
try {
|
||||
await startSntpService()
|
||||
running.value = true
|
||||
} finally {
|
||||
starting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleStop = async () => {
|
||||
stopping.value = true
|
||||
try {
|
||||
await stopSntpService()
|
||||
running.value = false
|
||||
} finally {
|
||||
stopping.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
ensureSocketConnection()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
socketClient.Instance.unRegisterCallBack(messageType)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.sntp-page {
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
padding: 16px;
|
||||
background: #f5f7fa;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.sntp-panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background: #ffffff;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.time-list {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.time-item {
|
||||
min-height: 168px;
|
||||
padding: 18px 20px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.time-label {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.time-content {
|
||||
font-size: 26px;
|
||||
line-height: 1.35;
|
||||
color: #303133;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.history-section {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.history-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
background: #fafafa;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.history-title {
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.history-empty {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.history-table {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.history-table__head {
|
||||
background: #fafafa;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.history-table__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.history-row {
|
||||
display: grid;
|
||||
grid-template-columns: 88px minmax(0, 1fr) minmax(0, 1fr) 200px;
|
||||
}
|
||||
|
||||
.history-row > div {
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid #f0f2f5;
|
||||
color: #303133;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.history-row > div + div {
|
||||
border-left: 1px solid #f0f2f5;
|
||||
}
|
||||
|
||||
.history-table__head > div {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.history-table__body .history-row:last-child > div {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.col-order {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.sntp-page {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.sntp-panel {
|
||||
padding: 16px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.time-item {
|
||||
min-height: 132px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.time-content {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.history-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.history-row > div + div {
|
||||
border-left: none;
|
||||
border-top: 1px solid #f0f2f5;
|
||||
}
|
||||
|
||||
.col-order {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
627
frontend/src/views/toolbox/components/SntpToolDialog.vue
Normal file
627
frontend/src/views/toolbox/components/SntpToolDialog.vue
Normal file
@@ -0,0 +1,627 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogTitle"
|
||||
width="1100px"
|
||||
:close-on-click-modal="false"
|
||||
:before-close="handleDialogClose"
|
||||
destroy-on-close
|
||||
@closed="handleDialogClosed"
|
||||
>
|
||||
<div class="sntp-tool">
|
||||
<aside class="device-panel">
|
||||
<div class="device-panel__header">
|
||||
<span>{{ devicePanelTitle }}</span>
|
||||
<span class="device-panel__count">{{ deviceItems.length }}</span>
|
||||
</div>
|
||||
<div v-if="deviceItems.length === 0" class="device-panel__empty">{{ emptyDeviceText }}</div>
|
||||
<div v-else class="device-list">
|
||||
<button
|
||||
v-for="item in deviceItems"
|
||||
:key="item.deviceIp"
|
||||
type="button"
|
||||
class="device-list__item"
|
||||
:class="{ 'is-active': item.deviceIp === activeDeviceIp }"
|
||||
@click="activeDeviceIp = item.deviceIp"
|
||||
>
|
||||
<span class="device-list__ip">{{ item.deviceIp }}</span>
|
||||
<span class="device-list__time">{{ item.deviceTime }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="content-panel">
|
||||
<el-row :gutter="16" class="time-list">
|
||||
<el-col :xs="24" :md="12">
|
||||
<div class="time-item">
|
||||
<div class="time-label">{{ computerTimeLabel }}</div>
|
||||
<div class="time-content">{{ activeDeviceState?.computerTime || '--' }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="24" :md="12">
|
||||
<div class="time-item">
|
||||
<div class="time-label">{{ deviceTimeLabel }}</div>
|
||||
<div class="time-content">{{ activeDeviceState?.deviceTime || '--' }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<div class="status-row">
|
||||
<div class="status-chip">
|
||||
<span class="status-chip__label">{{ activeDeviceLabel }}</span>
|
||||
<span class="status-chip__value">{{ activeDeviceIp || '--' }}</span>
|
||||
</div>
|
||||
<div class="status-chip">
|
||||
<span class="status-chip__label">{{ latestErrorLabel }}</span>
|
||||
<span class="status-chip__value">{{ activeErrorMs }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-row">
|
||||
<el-button type="primary" :loading="starting" :disabled="running || stopping" @click="handleStart">
|
||||
{{ startButtonText }}
|
||||
</el-button>
|
||||
<el-button type="danger" plain :loading="stopping" :disabled="!running || starting" @click="handleStop">
|
||||
{{ stopButtonText }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="history-section">
|
||||
<div class="history-header">
|
||||
<span class="history-title">{{ historyTitle }}</span>
|
||||
<el-button plain type="danger" :disabled="activeHistory.length === 0" @click="clearActiveHistory">
|
||||
{{ clearButtonText }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="history-table">
|
||||
<div class="history-table__head history-row">
|
||||
<div class="col-order">{{ orderColumnLabel }}</div>
|
||||
<div>{{ computerTimeLabel }}</div>
|
||||
<div>{{ deviceTimeLabel }}</div>
|
||||
<div>{{ errorColumnLabel }}</div>
|
||||
</div>
|
||||
<div v-if="activeHistory.length === 0" class="history-empty">
|
||||
<span>{{ emptyHistoryText }}</span>
|
||||
</div>
|
||||
<div v-else class="history-table__body">
|
||||
<div v-for="(item, index) in activeHistory" :key="item.id" class="history-row">
|
||||
<div class="col-order">{{ index + 1 }}</div>
|
||||
<div>{{ item.computerTime }}</div>
|
||||
<div>{{ item.deviceTime }}</div>
|
||||
<div>{{ formatErrorMs(item.errorMs) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="SntpToolDialog">
|
||||
import { computed, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import socketClient from '@/utils/webSocketClient'
|
||||
import type { SntpTimeMessage } from '@/api/system/sntp'
|
||||
import { startSntpService, stopSntpService } from '@/api/system/sntp'
|
||||
|
||||
interface SntpHistoryItem {
|
||||
id: string
|
||||
computerTime: string
|
||||
deviceTime: string
|
||||
computerTimestampMs: number | null
|
||||
deviceTimestampMs: number | null
|
||||
errorMs: number | null
|
||||
}
|
||||
|
||||
interface SntpDeviceState {
|
||||
deviceIp: string
|
||||
computerTime: string
|
||||
deviceTime: string
|
||||
errorMs: number | null
|
||||
history: SntpHistoryItem[]
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
name: 'SntpToolDialog'
|
||||
})
|
||||
|
||||
const messageType = 'sntp_time_update'
|
||||
const maxHistoryCount = 50
|
||||
const dialogTitle = 'SNTP对时'
|
||||
const devicePanelTitle = '设备列表'
|
||||
const emptyDeviceText = '暂无设备数据'
|
||||
const computerTimeLabel = '电脑时间'
|
||||
const deviceTimeLabel = '装置返回时间'
|
||||
const activeDeviceLabel = '当前设备'
|
||||
const latestErrorLabel = '最新误差'
|
||||
const startButtonText = '启动 SNTP 对时服务'
|
||||
const stopButtonText = '停止 SNTP 对时服务'
|
||||
const historyTitle = '历史记录'
|
||||
const clearButtonText = '清空'
|
||||
const orderColumnLabel = '序号'
|
||||
const errorColumnLabel = '误差(ms)'
|
||||
const emptyHistoryText = '暂无数据'
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const running = ref(false)
|
||||
const starting = ref(false)
|
||||
const stopping = ref(false)
|
||||
const activeDeviceIp = ref('')
|
||||
const deviceStateMap = ref<Record<string, SntpDeviceState>>({})
|
||||
const isClosing = ref(false)
|
||||
const shouldStopAfterStart = ref(false)
|
||||
|
||||
const deviceItems = computed(() => Object.values(deviceStateMap.value))
|
||||
const activeDeviceState = computed(() => {
|
||||
if (!activeDeviceIp.value) {
|
||||
return null
|
||||
}
|
||||
|
||||
return deviceStateMap.value[activeDeviceIp.value] || null
|
||||
})
|
||||
const activeHistory = computed(() => activeDeviceState.value?.history || [])
|
||||
const activeErrorMs = computed(() => formatErrorMs(activeDeviceState.value?.errorMs ?? null))
|
||||
|
||||
const formatErrorMs = (errorMs: number | null) => {
|
||||
if (errorMs === null || Number.isNaN(errorMs)) {
|
||||
return '--'
|
||||
}
|
||||
|
||||
if (errorMs > 0) {
|
||||
return `+${errorMs}`
|
||||
}
|
||||
|
||||
return `${errorMs}`
|
||||
}
|
||||
|
||||
const resetSession = () => {
|
||||
running.value = false
|
||||
starting.value = false
|
||||
stopping.value = false
|
||||
activeDeviceIp.value = ''
|
||||
deviceStateMap.value = {}
|
||||
isClosing.value = false
|
||||
shouldStopAfterStart.value = false
|
||||
}
|
||||
|
||||
const ensureSocketConnection = () => {
|
||||
socketClient.Instance.connect()
|
||||
socketClient.Instance.registerCallBack(messageType, (message: SntpTimeMessage) => {
|
||||
handleTimeUpdate(message)
|
||||
})
|
||||
}
|
||||
|
||||
const unregisterSocket = () => {
|
||||
socketClient.Instance.unRegisterCallBack(messageType)
|
||||
}
|
||||
|
||||
const handleTimeUpdate = (message: SntpTimeMessage) => {
|
||||
const deviceIp = message.deviceIp || 'unknown'
|
||||
const current = deviceStateMap.value[deviceIp] || {
|
||||
deviceIp,
|
||||
computerTime: '--',
|
||||
deviceTime: '--',
|
||||
errorMs: null,
|
||||
history: []
|
||||
}
|
||||
|
||||
const nextHistoryItem: SntpHistoryItem = {
|
||||
id: `${Date.now()}_${Math.random().toString(16).slice(2, 8)}`,
|
||||
computerTime: message.computerTime || '--',
|
||||
deviceTime: message.deviceTime || '--',
|
||||
computerTimestampMs: typeof message.computerTimestampMs === 'number' ? message.computerTimestampMs : null,
|
||||
deviceTimestampMs: typeof message.deviceTimestampMs === 'number' ? message.deviceTimestampMs : null,
|
||||
errorMs: typeof message.errorMs === 'number' ? message.errorMs : null
|
||||
}
|
||||
|
||||
deviceStateMap.value = {
|
||||
...deviceStateMap.value,
|
||||
[deviceIp]: {
|
||||
deviceIp,
|
||||
computerTime: nextHistoryItem.computerTime,
|
||||
deviceTime: nextHistoryItem.deviceTime,
|
||||
errorMs: nextHistoryItem.errorMs,
|
||||
history: [nextHistoryItem, ...current.history].slice(0, maxHistoryCount)
|
||||
}
|
||||
}
|
||||
|
||||
if (!activeDeviceIp.value) {
|
||||
activeDeviceIp.value = deviceIp
|
||||
}
|
||||
}
|
||||
|
||||
const clearActiveHistory = () => {
|
||||
if (!activeDeviceIp.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const current = deviceStateMap.value[activeDeviceIp.value]
|
||||
if (!current) {
|
||||
return
|
||||
}
|
||||
|
||||
deviceStateMap.value = {
|
||||
...deviceStateMap.value,
|
||||
[activeDeviceIp.value]: {
|
||||
...current,
|
||||
history: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleStart = async () => {
|
||||
shouldStopAfterStart.value = false
|
||||
starting.value = true
|
||||
try {
|
||||
await startSntpService()
|
||||
running.value = true
|
||||
if (isClosing.value || !dialogVisible.value || shouldStopAfterStart.value) {
|
||||
await stopService(false)
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('启动 SNTP 对时服务失败')
|
||||
} finally {
|
||||
starting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const stopService = async (showError = true) => {
|
||||
if (stopping.value) {
|
||||
return
|
||||
}
|
||||
|
||||
stopping.value = true
|
||||
try {
|
||||
await stopSntpService()
|
||||
running.value = false
|
||||
} catch (error) {
|
||||
if (showError) {
|
||||
ElMessage.error('停止 SNTP 对时服务失败')
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
stopping.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleStop = async () => {
|
||||
await stopService()
|
||||
}
|
||||
|
||||
const handleDialogClose = async (done: () => void) => {
|
||||
if (isClosing.value) {
|
||||
done()
|
||||
return
|
||||
}
|
||||
|
||||
isClosing.value = true
|
||||
shouldStopAfterStart.value = true
|
||||
try {
|
||||
if (starting.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (running.value) {
|
||||
await stopService(false)
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('停止 SNTP 对时服务失败')
|
||||
} finally {
|
||||
done()
|
||||
}
|
||||
}
|
||||
|
||||
const handleDialogClosed = () => {
|
||||
unregisterSocket()
|
||||
resetSession()
|
||||
}
|
||||
|
||||
const open = () => {
|
||||
resetSession()
|
||||
ensureSocketConnection()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
defineExpose({ open })
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
:deep(.el-dialog) {
|
||||
max-height: calc(100vh - 80px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
:deep(.el-dialog__body) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sntp-tool {
|
||||
display: grid;
|
||||
grid-template-columns: 240px minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
height: min(680px, calc(100vh - 160px));
|
||||
min-height: 560px;
|
||||
}
|
||||
|
||||
.device-panel,
|
||||
.content-panel {
|
||||
min-height: 0;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.device-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.device-panel__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
background: #fafafa;
|
||||
color: #303133;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.device-panel__count {
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
background: #ecf5ff;
|
||||
color: #409eff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.device-panel__empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.device-list {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.device-list__item {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.device-list__item:hover,
|
||||
.device-list__item.is-active {
|
||||
border-color: #409eff;
|
||||
box-shadow: 0 8px 24px rgba(64, 158, 255, 0.12);
|
||||
}
|
||||
|
||||
.device-list__ip {
|
||||
color: #303133;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.device-list__time {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.content-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.time-list {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.time-item {
|
||||
min-height: 150px;
|
||||
padding: 18px 20px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.time-label {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.time-content {
|
||||
font-size: 24px;
|
||||
line-height: 1.35;
|
||||
color: #303133;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.status-chip {
|
||||
min-width: 220px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
background: #fafafa;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.status-chip__label {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.status-chip__value {
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
font-weight: 600;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.history-section {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.history-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
background: #fafafa;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.history-title {
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.history-empty {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.history-table {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.history-table__head {
|
||||
background: #fafafa;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.history-table__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.history-row {
|
||||
display: grid;
|
||||
grid-template-columns: 88px minmax(0, 1fr) minmax(0, 1fr) 180px;
|
||||
}
|
||||
|
||||
.history-row > div {
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid #f0f2f5;
|
||||
color: #303133;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.history-row > div + div {
|
||||
border-left: 1px solid #f0f2f5;
|
||||
}
|
||||
|
||||
.history-table__head > div {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.history-table__body .history-row:last-child > div {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.col-order {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
:deep(.el-dialog) {
|
||||
width: calc(100vw - 24px) !important;
|
||||
max-height: calc(100vh - 24px);
|
||||
margin: 12px auto;
|
||||
}
|
||||
|
||||
.sntp-tool {
|
||||
grid-template-columns: 1fr;
|
||||
height: min(720px, calc(100vh - 120px));
|
||||
}
|
||||
|
||||
.content-panel {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.history-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.history-row > div + div {
|
||||
border-left: none;
|
||||
border-top: 1px solid #f0f2f5;
|
||||
}
|
||||
|
||||
.col-order {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
16
frontend/src/views/toolbox/config/tools.ts
Normal file
16
frontend/src/views/toolbox/config/tools.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export interface ToolboxToolItem {
|
||||
key: 'sntp'
|
||||
title: string
|
||||
description: string
|
||||
icon: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export const toolboxTools: ToolboxToolItem[] = [
|
||||
{
|
||||
key: 'sntp',
|
||||
title: 'SNTP对时',
|
||||
description: '内置SNTP服务器,可投入装置SNTP对时功能,进行装置对时',
|
||||
icon: 'Clock'
|
||||
}
|
||||
]
|
||||
126
frontend/src/views/toolbox/index.vue
Normal file
126
frontend/src/views/toolbox/index.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<div class="toolbox-page">
|
||||
<section class="toolbox-grid">
|
||||
<button
|
||||
v-for="tool in toolboxTools"
|
||||
:key="tool.key"
|
||||
type="button"
|
||||
class="toolbox-card"
|
||||
:disabled="tool.disabled"
|
||||
@click="handleOpenTool(tool.key)"
|
||||
>
|
||||
<div class="toolbox-card__top">
|
||||
<div class="toolbox-card__icon">
|
||||
<el-icon>
|
||||
<component :is="tool.icon" />
|
||||
</el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toolbox-card__title">{{ tool.title }}</div>
|
||||
<div class="toolbox-card__desc">{{ tool.description }}</div>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<SntpToolDialog ref="sntpToolDialogRef" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="toolbox">
|
||||
import { ref } from 'vue'
|
||||
import { toolboxTools } from './config/tools'
|
||||
import SntpToolDialog from './components/SntpToolDialog.vue'
|
||||
|
||||
const sntpToolDialogRef = ref<InstanceType<typeof SntpToolDialog> | null>(null)
|
||||
|
||||
const handleOpenTool = (toolKey: string) => {
|
||||
if (toolKey === 'sntp') {
|
||||
sntpToolDialogRef.value?.open()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.toolbox-page {
|
||||
min-height: 100%;
|
||||
padding: 24px;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(64, 158, 255, 0.16), transparent 28%),
|
||||
linear-gradient(180deg, #f7fafc 0%, #eef3f8 100%);
|
||||
}
|
||||
|
||||
.toolbox-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 320px));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.toolbox-card {
|
||||
padding: 20px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
box-shadow: 0 16px 30px rgba(15, 23, 42, 0.08);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.toolbox-card:hover {
|
||||
transform: translateY(-4px);
|
||||
border-color: rgba(64, 158, 255, 0.28);
|
||||
box-shadow: 0 24px 40px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.toolbox-card__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.toolbox-card__icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, #409eff 0%, #67c23a 100%);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #ffffff;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.toolbox-card__tag {
|
||||
border-radius: 999px;
|
||||
background: #ecf5ff;
|
||||
color: #409eff;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.toolbox-card__title {
|
||||
font-size: 20px;
|
||||
line-height: 1.3;
|
||||
color: #111827;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.toolbox-card__desc {
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.toolbox-page {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.toolbox-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user