新增小工具页面、SNTP对时功能集成到小工具页面

This commit is contained in:
caozehui
2026-06-08 08:46:29 +08:00
parent 27b593ba01
commit 0423de2683
6 changed files with 793 additions and 403 deletions

View File

@@ -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', {})
}

View File

@@ -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 === '比对式'
? filterMenuByExcludedNames(menuData, ['testSource', 'testScript', 'controlSource'])
: filterMenuByExcludedNames(menuData, ['standardDevice'])
this.authMenuList = filteredMenu
const isContrastMode = modeStore.currentMode === CONTRAST_MODE_NAME
const filteredMenu = isContrastMode
? filterMenuByExcludedNames(menuData, ['testSource', 'testScript', 'controlSource'])
: filterMenuByExcludedNames(menuData, ['standardDevice'])
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)
})
}

View File

@@ -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>

View 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>

View 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'
}
]

View 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>