新增小工具页面、SNTP对时功能集成到小工具页面
This commit is contained in:
@@ -1,5 +1,15 @@
|
|||||||
import http from '@/api'
|
import http from '@/api'
|
||||||
|
|
||||||
|
export interface SntpTimeMessage {
|
||||||
|
type: string
|
||||||
|
deviceIp?: string
|
||||||
|
computerTime?: string
|
||||||
|
deviceTime?: string
|
||||||
|
computerTimestampMs?: number
|
||||||
|
deviceTimestampMs?: number
|
||||||
|
errorMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
export const startSntpService = () => {
|
export const startSntpService = () => {
|
||||||
return http.post('/sntp/start', {})
|
return http.post('/sntp/start', {})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,75 +7,60 @@ import { useModeStore } from '@/stores/modules/mode'
|
|||||||
import { getLicense } from '@/api/activate'
|
import { getLicense } from '@/api/activate'
|
||||||
import type { Activate } from '@/api/activate/interface'
|
import type { Activate } from '@/api/activate/interface'
|
||||||
|
|
||||||
|
const CONTRAST_MODE_NAME = '比对式'
|
||||||
|
|
||||||
export const useAuthStore = defineStore(AUTH_STORE_KEY, {
|
export const useAuthStore = defineStore(AUTH_STORE_KEY, {
|
||||||
state: (): AuthState => ({
|
state: (): AuthState => ({
|
||||||
// 按钮权限列表
|
|
||||||
authButtonList: {},
|
authButtonList: {},
|
||||||
// 菜单权限列表
|
|
||||||
authMenuList: [],
|
authMenuList: [],
|
||||||
// 当前页面的 router name,用来做按钮权限筛选
|
|
||||||
routeName: '',
|
routeName: '',
|
||||||
//登录不显示菜单栏和导航栏,点击进入测试的时候显示
|
|
||||||
showMenuFlag: JSON.parse(localStorage.getItem('showMenuFlag') as string),
|
showMenuFlag: JSON.parse(localStorage.getItem('showMenuFlag') as string),
|
||||||
activateInfo: {} as Activate.ActivationCodePlaintext
|
activateInfo: {} as Activate.ActivationCodePlaintext
|
||||||
}),
|
}),
|
||||||
getters: {
|
getters: {
|
||||||
// 按钮权限列表
|
|
||||||
authButtonListGet: state => state.authButtonList,
|
authButtonListGet: state => state.authButtonList,
|
||||||
// 菜单权限列表 ==> 这里的菜单没有经过任何处理
|
|
||||||
authMenuListGet: state => state.authMenuList,
|
authMenuListGet: state => state.authMenuList,
|
||||||
// 菜单权限列表 ==> 左侧菜单栏渲染,需要剔除 isHide == true
|
|
||||||
showMenuListGet: state => getShowMenuList(state.authMenuList),
|
showMenuListGet: state => getShowMenuList(state.authMenuList),
|
||||||
// 菜单权限列表 ==> 扁平化之后的一维数组菜单,主要用来添加动态路由
|
|
||||||
flatMenuListGet: state => getFlatMenuList(state.authMenuList),
|
flatMenuListGet: state => getFlatMenuList(state.authMenuList),
|
||||||
// 递归处理后的所有面包屑导航列表
|
|
||||||
breadcrumbListGet: state => getAllBreadcrumbList(state.authMenuList),
|
breadcrumbListGet: state => getAllBreadcrumbList(state.authMenuList),
|
||||||
//是否显示菜单和导航栏
|
|
||||||
showMenuFlagGet: state => state.showMenuFlag,
|
showMenuFlagGet: state => state.showMenuFlag,
|
||||||
// 获取激活信息
|
|
||||||
activateInfoGet: state => state.activateInfo
|
activateInfoGet: state => state.activateInfo
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
// Get AuthButtonList
|
|
||||||
async getAuthButtonList() {
|
async getAuthButtonList() {
|
||||||
const { data } = await getAuthButtonListApi()
|
const { data } = await getAuthButtonListApi()
|
||||||
this.authButtonList = data
|
this.authButtonList = data
|
||||||
},
|
},
|
||||||
// Get AuthMenuList
|
|
||||||
async getAuthMenuList() {
|
async getAuthMenuList() {
|
||||||
const modeStore = useModeStore()
|
const modeStore = useModeStore()
|
||||||
|
|
||||||
const { data: menuData } = await getAuthMenuListApi()
|
const { data: menuData } = await getAuthMenuListApi()
|
||||||
// 根据不同模式过滤菜单
|
|
||||||
const filteredMenu =
|
const isContrastMode = modeStore.currentMode === CONTRAST_MODE_NAME
|
||||||
modeStore.currentMode === '比对式'
|
const filteredMenu = isContrastMode
|
||||||
? filterMenuByExcludedNames(menuData, ['testSource', 'testScript', 'controlSource'])
|
? filterMenuByExcludedNames(menuData, ['testSource', 'testScript', 'controlSource'])
|
||||||
: filterMenuByExcludedNames(menuData, ['standardDevice'])
|
: filterMenuByExcludedNames(menuData, ['standardDevice'])
|
||||||
|
|
||||||
this.authMenuList = filteredMenu
|
this.authMenuList = filterMenuByExcludedNames(filteredMenu, ['sntp'])
|
||||||
},
|
},
|
||||||
// Set RouteName
|
|
||||||
async setRouteName(name: string) {
|
async setRouteName(name: string) {
|
||||||
this.routeName = name
|
this.routeName = name
|
||||||
},
|
},
|
||||||
//重置权限
|
|
||||||
async resetAuthStore() {
|
async resetAuthStore() {
|
||||||
this.showMenuFlag = false
|
this.showMenuFlag = false
|
||||||
localStorage.removeItem('showMenuFlag')
|
localStorage.removeItem('showMenuFlag')
|
||||||
},
|
},
|
||||||
//修改判断菜单栏/导航栏显示条件
|
|
||||||
async setShowMenu() {
|
async setShowMenu() {
|
||||||
this.showMenuFlag = true
|
this.showMenuFlag = true
|
||||||
localStorage.setItem('showMenuFlag', 'true')
|
localStorage.setItem('showMenuFlag', 'true')
|
||||||
},
|
},
|
||||||
//更改模式
|
|
||||||
changeModel() {
|
changeModel() {
|
||||||
this.showMenuFlag = false
|
this.showMenuFlag = false
|
||||||
localStorage.removeItem('showMenuFlag')
|
localStorage.removeItem('showMenuFlag')
|
||||||
},
|
},
|
||||||
async setActivateInfo() {
|
async setActivateInfo() {
|
||||||
const license_result = await getLicense()
|
const licenseResult = await getLicense()
|
||||||
const licenseData = license_result.data as Activate.ActivationCodePlaintext
|
const licenseData = licenseResult.data as Activate.ActivationCodePlaintext
|
||||||
|
|
||||||
if (!licenseData.simulate) {
|
if (!licenseData.simulate) {
|
||||||
licenseData.simulate = {
|
licenseData.simulate = {
|
||||||
permanently: 0
|
permanently: 0
|
||||||
@@ -91,24 +76,18 @@ export const useAuthStore = defineStore(AUTH_STORE_KEY, {
|
|||||||
permanently: 0
|
permanently: 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.activateInfo = licenseData
|
this.activateInfo = licenseData
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
function filterMenuByExcludedNames(menuList: Menu.MenuOptions[], excludedNames: string[]): Menu.MenuOptions[] {
|
||||||
* 通用菜单过滤函数
|
|
||||||
* @param menuList 菜单列表
|
|
||||||
* @param excludedNames 需要排除的菜单名称数组
|
|
||||||
* @returns 过滤后的菜单列表
|
|
||||||
*/
|
|
||||||
function filterMenuByExcludedNames(menuList: any[], excludedNames: string[]): any[] {
|
|
||||||
return menuList.filter(menu => {
|
return menuList.filter(menu => {
|
||||||
// 如果当前项有 children,递归处理子项
|
|
||||||
if (menu.children && menu.children.length > 0) {
|
if (menu.children && menu.children.length > 0) {
|
||||||
menu.children = filterMenuByExcludedNames(menu.children, excludedNames)
|
menu.children = filterMenuByExcludedNames(menu.children, excludedNames)
|
||||||
}
|
}
|
||||||
// 过滤掉在排除列表中的菜单项
|
|
||||||
return !excludedNames.includes(menu.name)
|
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