feat: 模拟采集集成——后端SimulatorController(22端点代理转发)+前端总览/详情页+路由+侧边栏菜单+Mock数据

feat/windows-service-status-auto
haoliang 4 hours ago
parent 4b70b8eacf
commit 72cb43c493

@ -0,0 +1,139 @@
import type { MockMethod, MockRequest } from './types'
// 模拟采集地址数据
const mockAddresses = [
{
dbId: 1,
name: 'FANUC-1号',
url: 'http://localhost:9001/',
machineCount: 32,
machines: Array.from({ length: 32 }, (_, i) => ({
id: i + 1,
deviceCode: `fanake_1.${i + 2}`,
name: `西-1.${i + 2}`
})),
isRunning: true,
runningPort: 9001
},
{
dbId: 2,
name: 'FANUC-2号',
url: 'http://localhost:9002/',
machineCount: 16,
machines: Array.from({ length: 16 }, (_, i) => ({
id: i + 33,
deviceCode: `fanake_2.${i + 1}`,
name: `东-2.${i + 1}`
})),
isRunning: false,
runningPort: 0
}
]
// 模拟状态汇总
const mockStatusList = [
{
dbAddressId: 1,
name: 'FANUC-1号模拟',
port: 9001,
isRunning: true,
totalDevices: 32,
onlineDevices: 28,
requestCount: 1560,
dataChangeInterval: 10,
totalParts: 128
}
]
// 模拟设备状态
const mockDevices = [
{ deviceCode: 'fanake_1.2', desc: '西-1.2', scenario: 'machining', isOnline: true, programName: 'O504', partCount: 14, runStatus: 3, operateMode: 10, spindleSpeedSet: 3000, spindleSpeedActual: 2980, feedSpeedSet: 500, feedSpeedActual: 490, spindleLoad: 65, machiningStatus: 'cutting', scenarioTick: 45, scenarioDuration: 120 },
{ deviceCode: 'fanake_1.3', desc: '西-1.3', scenario: 'idle', isOnline: true, programName: 'O1', partCount: 53, runStatus: 1, operateMode: 10, spindleSpeedSet: 0, spindleSpeedActual: 0, feedSpeedSet: 0, feedSpeedActual: 0, spindleLoad: 5, machiningStatus: 'idle', scenarioTick: 12, scenarioDuration: 60 },
{ deviceCode: 'fanake_1.4', desc: '西-1.4', scenario: 'offline', isOnline: false, programName: 'O200', partCount: 0, runStatus: 0, operateMode: 0, spindleSpeedSet: 0, spindleSpeedActual: 0, feedSpeedSet: 0, feedSpeedActual: 0, spindleLoad: 0, machiningStatus: 'offline', scenarioTick: 0, scenarioDuration: 0 }
]
// 模拟请求日志
const mockLogs = Array.from({ length: 10 }, (_, i) => ({
index: 10 - i,
timestamp: `${String(14 + Math.floor(i / 6)).padStart(2, '0')}:${String(30 - i * 2).padStart(2, '0')}:${String(15 + i).padStart(2, '0')}`,
deviceCount: 28 + Math.floor(Math.random() * 5),
keyData: `fanake_1.2(P=14,Prog=O504,Run=3) fanake_1.3(P=53,Prog=O1,Run=1)`,
duration: 12 + Math.floor(Math.random() * 20),
fullJson: `[{"device":"fanake_1.2","desc":"西-1.2","tags":[{"id":"Tag5","value":"O504"}]}]`
}))
const mocks: MockMethod[] = [
// 探测模拟器
{ url: '/api/admin/simulator/ping', method: 'get', response: () => ({ code: 0, message: 'success', data: { running: true } }) },
// 获取采集地址列表
{ url: '/api/admin/simulator/addresses', method: 'get', response: () => ({ code: 0, message: 'success', data: mockAddresses }) },
// 获取模拟状态汇总
{ url: '/api/admin/simulator/status', method: 'get', response: () => ({ code: 0, message: 'success', data: mockStatusList }) },
// 启动模拟
{ url: '/api/admin/simulator/start', method: 'post', response: () => ({ code: 0, message: 'success', data: { ok: true, port: 9001 } }) },
// 停止模拟
{ url: '/api/admin/simulator/stop', method: 'post', response: () => ({ code: 0, message: 'success', data: { ok: true } }) },
// 全部启动
{ url: '/api/admin/simulator/start-all', method: 'post', response: () => ({ code: 0, message: 'success', data: { ok: true } }) },
// 全部停止
{ url: '/api/admin/simulator/stop-all', method: 'post', response: () => ({ code: 0, message: 'success', data: { ok: true } }) },
// 重新加载
{ url: '/api/admin/simulator/reload', method: 'post', response: () => ({ code: 0, message: 'success', data: { ok: true, count: 2 } }) },
// 单地址状态(匹配 /address/{port}/status
{ url: /\/api\/admin\/simulator\/address\/\d+\/status$/, method: 'get', response: () => ({
code: 0, message: 'success', data: {
name: 'FANUC-1号模拟', port: 9001, isRunning: true,
requestCount: 1560, successCount: 1540, failCount: 20,
totalDevices: 32, onlineDevices: 28, dataChangeInterval: 10,
scenarioMode: 'auto', networkError: 'normal',
startTime: '2026-05-06 10:00:00', uptime: '04:32:15',
devices: mockDevices
}
})},
// 单地址启动/停止/事件/设置POST类统返回ok
{ url: /\/api\/admin\/simulator\/address\/\d+\/start$/, method: 'post', response: () => ({ code: 0, message: 'success', data: { ok: true } }) },
{ url: /\/api\/admin\/simulator\/address\/\d+\/stop$/, method: 'post', response: () => ({ code: 0, message: 'success', data: { ok: true } }) },
{ url: /\/api\/admin\/simulator\/address\/\d+\/event$/, method: 'post', response: () => ({ code: 0, message: 'success', data: { ok: true } }) },
{ url: /\/api\/admin\/simulator\/address\/\d+\/interval$/, method: 'post', response: () => ({ code: 0, message: 'success', data: { ok: true } }) },
{ url: /\/api\/admin\/simulator\/address\/\d+\/network$/, method: 'post', response: () => ({ code: 0, message: 'success', data: { ok: true } }) },
{ url: /\/api\/admin\/simulator\/address\/\d+\/mode$/, method: 'post', response: () => ({ code: 0, message: 'success', data: { ok: true } }) },
{ url: /\/api\/admin\/simulator\/address\/\d+\/add-device$/, method: 'post', response: () => ({ code: 0, message: 'success', data: { ok: true } }) },
{ url: /\/api\/admin\/simulator\/address\/\d+\/remove-device$/, method: 'post', response: () => ({ code: 0, message: 'success', data: { ok: true } }) },
// 日志
{ url: /\/api\/admin\/simulator\/address\/\d+\/logs$/, method: 'get', response: () => ({ code: 0, message: 'success', data: mockLogs }) },
// 统计
{ url: /\/api\/admin\/simulator\/address\/\d+\/stats$/, method: 'get', response: () => ({
code: 0, message: 'success', data: {
totalDevices: 32, onlineDevices: 28, totalParts: 128,
partsByDevice: {
'fanake_1.2': { desc: '西-1.2', totalParts: 14, currentProgram: 'O504', currentPartCount: 14, programs: { 'O504': 14 } },
'fanake_1.3': { desc: '西-1.3', totalParts: 53, currentProgram: 'O1', currentPartCount: 53, programs: { 'O1': 53 } }
}
}
})},
// 事件历史
{ url: /\/api\/admin\/simulator\/address\/\d+\/event-history$/, method: 'get', response: () => ({ code: 0, message: 'success', data: [
{ timestamp: '2026-05-06 14:30:00', deviceCode: 'fanake_1.2', eventType: 'change_program', oldProgram: 'O200', newProgram: 'O504', partCountBefore: 10, partCountAfter: 14, detail: '程序切换' },
{ timestamp: '2026-05-06 14:25:00', deviceCode: 'fanake_1.3', eventType: 'part_count_increase', oldProgram: 'O1', newProgram: 'O1', partCountBefore: 52, partCountAfter: 53, detail: '零件数+1' }
] })},
// 完整汇总
{ url: /\/api\/admin\/simulator\/address\/\d+\/full-summary$/, method: 'get', response: () => ({ code: 0, message: 'success', data: { exportTime: '2026-05-06 14:35:00', addressName: 'FANUC-1号模拟', port: 9001, totalDevices: 32, onlineDevices: 28, totalParts: 128 } }) },
// 异常日志
{ url: /\/api\/admin\/simulator\/address\/\d+\/error-log$/, method: 'get', response: () => ({ code: 0, message: 'success', data: [] }) },
]
export default mocks

@ -0,0 +1,223 @@
import request from '@/utils/request'
import type { ApiResponse } from '@/types'
// --- 模拟器数据模型 ---
/** 模拟器连接状态 */
export interface SimulatorPing {
running: boolean
}
/** 数据库采集地址(模拟器返回) */
export interface SimulatorAddress {
dbId: number
name: string
url: string
machineCount: number
machines: { id: number; deviceCode: string; name: string }[]
isRunning: boolean
runningPort: number
}
/** 模拟状态汇总 */
export interface SimulatorStatus {
dbAddressId: number
name: string
port: number
isRunning: boolean
totalDevices: number
onlineDevices: number
requestCount: number
dataChangeInterval: number
totalParts: number
}
/** 设备状态 */
export interface DeviceStatus {
deviceCode: string
desc: string
scenario: string
isOnline: boolean
programName: string
partCount: number
runStatus: number
operateMode: number
spindleSpeedSet: number
spindleSpeedActual: number
feedSpeedSet: number
feedSpeedActual: number
spindleLoad: number
machiningStatus: string
scenarioTick: number
scenarioDuration: number
}
/** 单地址详情状态 */
export interface AddressStatus {
name: string
port: number
isRunning: boolean
requestCount: number
successCount: number
failCount: number
totalDevices: number
onlineDevices: number
dataChangeInterval: number
scenarioMode: string
networkError: string
startTime: string
uptime: string
devices: DeviceStatus[]
}
/** 零件统计 */
export interface AddressStats {
totalDevices: number
onlineDevices: number
totalParts: number
partsByDevice: Record<string, {
desc: string
totalParts: number
currentProgram: string
currentPartCount: number
programs: Record<string, number>
}>
}
/** 请求日志 */
export interface SimulatorLog {
index: number
timestamp: string
deviceCount: number
keyData: string
duration: number
fullJson: string
}
/** 事件历史 */
export interface EventHistory {
timestamp: string
deviceCode: string
eventType: string
oldProgram: string
newProgram: string
partCountBefore: number
partCountAfter: number
detail: string
}
// --- 网关API ---
/** 探测模拟器是否运行 */
export function pingSimulator() {
return request.get<SimulatorPing>('/admin/simulator/ping')
}
/** 获取数据库采集地址列表 */
export function fetchSimulatorAddresses() {
return request.get<SimulatorAddress[]>('/admin/simulator/addresses')
}
/** 获取所有模拟状态汇总 */
export function fetchSimulatorStatus() {
return request.get<SimulatorStatus[]>('/admin/simulator/status')
}
/** 启动指定地址的模拟 */
export function startSimulator(data: { dbAddressId: number; deviceCodes?: string[] }) {
return request.post('/admin/simulator/start', data)
}
/** 停止指定地址的模拟 */
export function stopSimulator(data: { dbAddressId: number }) {
return request.post('/admin/simulator/stop', data)
}
/** 启动所有地址的模拟 */
export function startAllSimulators() {
return request.post('/admin/simulator/start-all')
}
/** 停止所有地址的模拟 */
export function stopAllSimulators() {
return request.post('/admin/simulator/stop-all')
}
/** 重新加载数据库配置 */
export function reloadSimulator() {
return request.post('/admin/simulator/reload')
}
// --- 单地址API ---
/** 获取单地址状态 */
export function fetchAddressStatus(port: number) {
return request.get<AddressStatus>(`/admin/simulator/address/${port}/status`)
}
/** 启动单地址数据模拟 */
export function startAddressSimulation(port: number) {
return request.post(`/admin/simulator/address/${port}/start`)
}
/** 停止单地址数据模拟 */
export function stopAddressSimulation(port: number) {
return request.post(`/admin/simulator/address/${port}/stop`)
}
/** 触发设备事件 */
export function triggerDeviceEvent(port: number, data: { deviceId: string; eventType: string }) {
return request.post(`/admin/simulator/address/${port}/event`, data)
}
/** 修改数据变化频率 */
export function setAddressInterval(port: number, data: { value: number }) {
return request.post(`/admin/simulator/address/${port}/interval`, data)
}
/** 设置网络异常类型 */
export function setNetworkError(port: number, data: { type: string }) {
return request.post(`/admin/simulator/address/${port}/network`, data)
}
/** 切换剧本模式 */
export function setScenarioMode(port: number, data: { mode: string }) {
return request.post(`/admin/simulator/address/${port}/mode`, data)
}
/** 获取请求日志 */
export function fetchAddressLogs(port: number) {
return request.get<SimulatorLog[]>(`/admin/simulator/address/${port}/logs`)
}
/** 获取零件统计 */
export function fetchAddressStats(port: number) {
return request.get<AddressStats>(`/admin/simulator/address/${port}/stats`)
}
/** 添加设备 */
export function addDevice(port: number, data: { deviceCode: string; desc: string }) {
return request.post(`/admin/simulator/address/${port}/add-device`, data)
}
/** 移除设备 */
export function removeDevice(port: number, data: { deviceCode: string }) {
return request.post(`/admin/simulator/address/${port}/remove-device`, data)
}
/** 获取事件历史 */
export function fetchEventHistory(port: number) {
return request.get<EventHistory[]>(`/admin/simulator/address/${port}/event-history`)
}
/** 获取完整汇总 */
export function fetchFullSummary(port: number) {
return request.get(`/admin/simulator/address/${port}/full-summary`)
}
/** 获取异常日志 */
export function fetchErrorLog(port: number) {
return request.get(`/admin/simulator/address/${port}/error-log`)
}
export default {}

@ -71,6 +71,10 @@
<el-icon><Document /></el-icon>
<template #title>操作日志</template>
</el-menu-item>
<el-menu-item :index="menuPath('/simulator')">
<el-icon><VideoPlay /></el-icon>
<template #title>模拟采集</template>
</el-menu-item>
<el-menu-item :index="menuPath('/screen-config')">
<el-icon><FullScreen /></el-icon>
<template #title>大屏配置</template>

@ -27,6 +27,8 @@ const LogPage = () => import('@/views/log/LogPage.vue')
const ScreenConfigPage = () => import('@/views/screen-config/ScreenConfigPage.vue')
const ScreenPage = () => import('@/views/screen/ScreenPage.vue')
const CollectLogPage = () => import('@/views/collect-log/CollectLogPage.vue')
const SimulatorPage = () => import('@/views/simulator/SimulatorPage.vue')
const SimulatorDetailPage = () => import('@/views/simulator/SimulatorDetailPage.vue')
// 正常路由
const normalRoutes: RouteRecordRaw[] = [
@ -52,6 +54,8 @@ const normalRoutes: RouteRecordRaw[] = [
{ path: 'settings', name: 'Settings', component: SettingsPage, meta: { title: '系统设置' } },
{ path: 'log', name: 'Log', component: LogPage, meta: { title: '操作日志' } },
{ path: 'screen-config', name: 'ScreenConfig', component: ScreenConfigPage, meta: { title: '大屏配置' } },
{ path: 'simulator', name: 'Simulator', component: SimulatorPage, meta: { title: '模拟采集' } },
{ path: 'simulator/:port', name: 'SimulatorDetail', component: SimulatorDetailPage, meta: { title: '模拟详情' } },
],
},
{

@ -0,0 +1,284 @@
<template>
<div>
<!-- 顶部栏 -->
<div class="mb-16" style="display:flex;justify-content:space-between;align-items:center">
<div style="display:flex;align-items:center;gap:12px">
<el-button :icon="ArrowLeft" @click="goBack"></el-button>
<span style="font-size:16px;font-weight:600">{{ status?.name ?? '加载中...' }}</span>
<el-tag v-if="status" :type="status.isRunning ? 'success' : 'danger'" size="small">
{{ status.isRunning ? '运行中' : '已停止' }}
</el-tag>
</div>
<div>
<el-button
v-if="status && !status.isRunning"
type="success"
@click="handleStartStop('start')"
>启动</el-button>
<el-popconfirm
v-if="status && status.isRunning"
title="确定停止数据模拟?"
@confirm="handleStartStop('stop')"
>
<template #reference>
<el-button type="danger">停止</el-button>
</template>
</el-popconfirm>
<el-button :icon="Refresh" circle @click="loadStatus" style="margin-left:8px" />
</div>
</div>
<div v-if="!status" v-loading="true" style="height:200px"></div>
<template v-else>
<!-- 统计卡片 -->
<el-row :gutter="16" class="mb-16">
<el-col :span="6">
<el-card shadow="hover" body-style="padding:16px">
<div style="color:#909399;font-size:13px;margin-bottom:4px">设备总数</div>
<div style="font-size:24px;font-weight:700;color:#303133">{{ status.totalDevices }}</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" body-style="padding:16px">
<div style="color:#909399;font-size:13px;margin-bottom:4px">在线设备</div>
<div style="font-size:24px;font-weight:700;color:#67c23a">{{ status.onlineDevices }}</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" body-style="padding:16px">
<div style="color:#909399;font-size:13px;margin-bottom:4px">总零件数</div>
<div style="font-size:24px;font-weight:700;color:#409eff">{{ stats?.totalParts ?? '-' }}</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" body-style="padding:16px">
<div style="color:#909399;font-size:13px;margin-bottom:4px">请求次数</div>
<div style="font-size:24px;font-weight:700;color:#e6a23c">{{ status.requestCount }}</div>
</el-card>
</el-col>
</el-row>
<!-- 设备状态表格 -->
<div class="mb-16">
<h3 style="margin:0 0 12px;font-size:15px">设备状态</h3>
<el-table :data="status.devices" border stripe style="width:100%" max-height="400">
<el-table-column prop="deviceCode" label="编码" width="130" />
<el-table-column prop="desc" label="描述" min-width="100" />
<el-table-column prop="scenario" label="场景" width="90" align="center" />
<el-table-column prop="programName" label="程序" width="90" align="center" />
<el-table-column prop="partCount" label="零件" width="70" align="center" />
<el-table-column label="状态" width="70" align="center">
<template #default="{ row }">
<el-tag :type="row.isOnline ? 'success' : 'danger'" size="small">
{{ row.isOnline ? '在线' : '离线' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" align="center">
<template #default="{ row }">
<el-button link size="small" @click="handleEvent(row.deviceCode, 'change_program')">换程序</el-button>
<el-button link size="small" @click="handleEvent(row.deviceCode, 'reset_parts')">清零</el-button>
<el-button link size="small" @click="handleEvent(row.deviceCode, 'pause')">暂停</el-button>
<el-button link size="small" @click="handleEvent(row.deviceCode, 'resume')">恢复</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- 设置面板 -->
<el-collapse class="mb-16">
<el-collapse-item title="模拟设置" name="settings">
<el-row :gutter="24">
<el-col :span="8">
<div style="margin-bottom:8px;font-size:13px;color:#606266">数据频率</div>
<el-slider v-model="intervalValue" :min="1" :max="60" show-input @change="handleIntervalChange" />
</el-col>
<el-col :span="8">
<div style="margin-bottom:8px;font-size:13px;color:#606266">场景模式</div>
<el-radio-group :model-value="status.scenarioMode" @change="handleModeChange">
<el-radio-button value="auto">自动</el-radio-button>
<el-radio-button value="manual">手动</el-radio-button>
</el-radio-group>
</el-col>
<el-col :span="8">
<div style="margin-bottom:8px;font-size:13px;color:#606266">网络模拟</div>
<el-select :model-value="status.networkError" @change="handleNetworkChange" style="width:100%">
<el-option label="正常" value="normal" />
<el-option label="HTTP 500" value="http500" />
<el-option label="超时" value="timeout" />
<el-option label="空数据" value="empty" />
<el-option label="畸形JSON" value="malformed" />
<el-option label="拒绝连接" value="refuse" />
</el-select>
</el-col>
</el-row>
</el-collapse-item>
</el-collapse>
<!-- 请求日志 -->
<div>
<h3 style="margin:0 0 12px;font-size:15px">最近请求日志</h3>
<el-table :data="logs" border stripe style="width:100%" max-height="300">
<el-table-column prop="index" label="#" width="50" align="center" />
<el-table-column prop="timestamp" label="时间" width="100" />
<el-table-column prop="deviceCount" label="设备数" width="70" align="center" />
<el-table-column prop="keyData" label="关键数据" min-width="200" show-overflow-tooltip />
<el-table-column prop="duration" label="耗时(ms)" width="80" align="center" />
<el-table-column type="expand">
<template #default="{ row }">
<pre style="padding:12px;font-size:12px;max-height:300px;overflow:auto;white-space:pre-wrap">{{ row.fullJson }}</pre>
</template>
</el-table-column>
</el-table>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ArrowLeft, Refresh } from '@element-plus/icons-vue'
import {
fetchAddressStatus, startAddressSimulation, stopAddressSimulation,
triggerDeviceEvent, setAddressInterval, setNetworkError, setScenarioMode,
fetchAddressLogs, fetchAddressStats
} from '@/api/simulator'
import type { AddressStatus, AddressStats, SimulatorLog } from '@/api/simulator'
import { useMockMode } from '@/composables/useMockMode'
const route = useRoute()
const router = useRouter()
const { isMock } = useMockMode()
const port = Number(route.params.port)
const status = ref<AddressStatus | null>(null)
const stats = ref<AddressStats | null>(null)
const logs = ref<SimulatorLog[]>([])
const intervalValue = ref(10)
let pollTimer: ReturnType<typeof setInterval> | null = null
/** 加载地址状态 */
async function loadStatus() {
try {
const res = await fetchAddressStatus(port)
status.value = res.data ?? null
if (status.value) {
intervalValue.value = status.value.dataChangeInterval
}
} catch {
ElMessage.error('获取地址状态失败')
}
}
/** 加载统计 */
async function loadStats() {
try {
const res = await fetchAddressStats(port)
stats.value = res.data ?? null
} catch { /* 静默 */ }
}
/** 加载日志 */
async function loadLogs() {
try {
const res = await fetchAddressLogs(port)
logs.value = res.data ?? []
} catch { /* 静默 */ }
}
/** 返回总览 */
function goBack() {
const base = isMock.value ? '/mock' : ''
router.push(`${base}/simulator`)
}
/** 启动/停止 */
async function handleStartStop(action: string) {
try {
if (action === 'start') {
await startAddressSimulation(port)
ElMessage.success('已启动')
} else {
await stopAddressSimulation(port)
ElMessage.success('已停止')
}
await loadStatus()
} catch (e: any) {
ElMessage.error(e?.message ?? '操作失败')
}
}
/** 触发事件 */
async function handleEvent(deviceId: string, eventType: string) {
try {
await triggerDeviceEvent(port, { deviceId, eventType })
ElMessage.success('事件已触发')
await loadStatus()
} catch (e: any) {
ElMessage.error(e?.message ?? '触发失败')
}
}
/** 修改频率 */
async function handleIntervalChange(val: number) {
try {
await setAddressInterval(port, { value: val })
ElMessage.success('频率已修改')
} catch (e: any) {
ElMessage.error(e?.message ?? '修改失败')
}
}
/** 修改模式 */
async function handleModeChange(mode: string) {
try {
await setScenarioMode(port, { mode })
ElMessage.success('模式已切换')
await loadStatus()
} catch (e: any) {
ElMessage.error(e?.message ?? '切换失败')
}
}
/** 修改网络模拟 */
async function handleNetworkChange(type: string) {
try {
await setNetworkError(port, { type })
ElMessage.success('网络模拟已设置')
await loadStatus()
} catch (e: any) {
ElMessage.error(e?.message ?? '设置失败')
}
}
function startPolling() {
stopPolling()
pollTimer = setInterval(() => {
if (document.visibilityState === 'visible') {
loadStatus()
loadStats()
}
}, 5000)
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
onMounted(async () => {
await loadStatus()
await loadStats()
await loadLogs()
startPolling()
})
onUnmounted(() => {
stopPolling()
})
</script>

@ -0,0 +1,253 @@
<template>
<div>
<!-- 顶部操作栏 -->
<div class="mb-16" style="display:flex;justify-content:space-between;align-items:center">
<div style="display:flex;align-items:center;gap:8px">
<span
:style="{
display: 'inline-block', width: '10px', height: '10px', borderRadius: '50%',
backgroundColor: connected ? '#67c23a' : '#f56c6c'
}"
></span>
<span style="font-size:14px;color:#606266">{{ connected ? '模拟器已连接' : '模拟器未连接' }}</span>
<el-button size="small" :icon="Refresh" circle @click="loadAll" />
</div>
<div>
<el-button type="success" :disabled="!connected" @click="handleStartAll"></el-button>
<el-button type="danger" :disabled="!connected" @click="handleStopAll"></el-button>
<el-button :disabled="!connected" @click="handleReload"></el-button>
</div>
</div>
<!-- 未连接提示 -->
<el-empty v-if="!connected" description="模拟器未启动,请在服务器上运行 CncSimulator.exe" />
<!-- 地址列表 -->
<el-table
v-else
:data="addresses"
border
stripe
v-loading="loading"
style="width:100%"
>
<el-table-column prop="name" label="名称" min-width="140">
<template #default="{ row }">
<el-link
v-if="row.isRunning && row.runningPort > 0"
type="primary"
@click="goDetail(row.runningPort)"
>{{ row.name }}</el-link>
<span v-else>{{ row.name }}</span>
</template>
</el-table-column>
<el-table-column prop="url" label="URL" min-width="200" show-overflow-tooltip />
<el-table-column prop="machineCount" label="机床数" align="center" width="80" />
<el-table-column label="状态" align="center" width="100">
<template #default="{ row }">
<el-tag :type="row.isRunning ? 'success' : 'info'" size="small">
{{ row.isRunning ? '运行中' : '未启动' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="端口" align="center" width="80">
<template #default="{ row }">{{ row.isRunning ? row.runningPort : '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="160" align="center">
<template #default="{ row }">
<div style="white-space:nowrap">
<el-button
v-if="row.isRunning"
link type="primary"
@click="goDetail(row.runningPort)"
>详情</el-button>
<el-button
v-if="!row.isRunning"
link type="success"
@click="handleStart(row)"
>启动</el-button>
<el-popconfirm
v-if="row.isRunning"
title="确定停止该地址的模拟?"
@confirm="handleStop(row)"
>
<template #reference>
<el-button link type="danger">停止</el-button>
</template>
</el-popconfirm>
</div>
</template>
</el-table-column>
</el-table>
<!-- 启动弹窗选择机床 -->
<el-dialog v-model="startDialogVisible" title="选择模拟机床" width="500px" destroy-on-close>
<el-checkbox-group v-model="selectedDevices">
<el-checkbox
v-for="m in startTarget?.machines ?? []"
:key="m.deviceCode"
:label="m.deviceCode"
:value="m.deviceCode"
>{{ m.name }} ({{ m.deviceCode }})</el-checkbox>
</el-checkbox-group>
<p style="margin-top:12px;color:#909399;font-size:13px">
不选择则模拟该地址下全部机床{{ startTarget?.machineCount }}
</p>
<template #footer>
<el-button @click="startDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="startLoading" @click="confirmStart"></el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Refresh } from '@element-plus/icons-vue'
import { pingSimulator, fetchSimulatorAddresses, startSimulator, stopSimulator, startAllSimulators, stopAllSimulators, reloadSimulator } from '@/api/simulator'
import type { SimulatorAddress } from '@/api/simulator'
import { useMockMode } from '@/composables/useMockMode'
const router = useRouter()
const { isMock } = useMockMode()
const loading = ref(false)
const connected = ref(false)
const addresses = ref<SimulatorAddress[]>([])
//
const startDialogVisible = ref(false)
const startLoading = ref(false)
const startTarget = ref<SimulatorAddress | null>(null)
const selectedDevices = ref<string[]>([])
//
let pollTimer: ReturnType<typeof setInterval> | null = null
/** 检测连接状态并加载数据 */
async function loadAll() {
loading.value = true
try {
const pingRes = await pingSimulator()
connected.value = pingRes.data?.running ?? false
if (connected.value) {
const addrRes = await fetchSimulatorAddresses()
addresses.value = addrRes.data ?? []
} else {
addresses.value = []
}
} catch {
connected.value = false
addresses.value = []
} finally {
loading.value = false
}
}
/** 跳转到详情页 */
function goDetail(port: number) {
const base = isMock.value ? '/mock' : ''
router.push(`${base}/simulator/${port}`)
}
/** 打开启动弹窗 */
function handleStart(row: SimulatorAddress) {
startTarget.value = row
selectedDevices.value = []
startDialogVisible.value = true
}
/** 确认启动 */
async function confirmStart() {
if (!startTarget.value) return
startLoading.value = true
try {
const payload: { dbAddressId: number; deviceCodes?: string[] } = {
dbAddressId: startTarget.value.dbId
}
if (selectedDevices.value.length > 0) {
payload.deviceCodes = selectedDevices.value
}
await startSimulator(payload)
ElMessage.success('启动成功')
startDialogVisible.value = false
await loadAll()
} catch (e: any) {
ElMessage.error(e?.message ?? '启动失败')
} finally {
startLoading.value = false
}
}
/** 停止 */
async function handleStop(row: SimulatorAddress) {
try {
await stopSimulator({ dbAddressId: row.dbId })
ElMessage.success('已停止')
await loadAll()
} catch (e: any) {
ElMessage.error(e?.message ?? '停止失败')
}
}
/** 全部启动 */
async function handleStartAll() {
try {
await startAllSimulators()
ElMessage.success('全部启动成功')
await loadAll()
} catch (e: any) {
ElMessage.error(e?.message ?? '操作失败')
}
}
/** 全部停止 */
async function handleStopAll() {
try {
await stopAllSimulators()
ElMessage.success('全部停止')
await loadAll()
} catch (e: any) {
ElMessage.error(e?.message ?? '操作失败')
}
}
/** 刷新配置 */
async function handleReload() {
try {
await reloadSimulator()
ElMessage.success('配置已刷新')
await loadAll()
} catch (e: any) {
ElMessage.error(e?.message ?? '刷新失败')
}
}
//
function startPolling() {
stopPolling()
pollTimer = setInterval(() => {
if (document.visibilityState === 'visible') {
loadAll()
}
}, 5000)
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
onMounted(() => {
loadAll()
startPolling()
})
onUnmounted(() => {
stopPolling()
})
</script>

@ -0,0 +1,367 @@
using System;
using System.Configuration;
using System.Net.Http;
using System.Text;
using System.Web.Http;
using CncModels.Dto;
using CncWebApi.Infrastructure;
using Newtonsoft.Json;
namespace CncWebApi.Controllers
{
/// <summary>
/// 模拟采集服务控制器。
/// 将所有请求转发到 CncSimulatorlocalhost:9000网关 + 动态端口单地址)。
/// </summary>
[RoutePrefix("api/admin/simulator")]
[JwtAuthFilter]
public class SimulatorController : ApiController
{
private static readonly HttpClient _httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
private static readonly string _gatewayUrl = ConfigurationManager.AppSettings["SimulatorGatewayUrl"] ?? "http://localhost:9000";
#region 网关API→ localhost:9000
/// <summary>
/// 探测模拟器是否运行
/// GET /api/admin/simulator/ping
/// </summary>
[HttpGet]
[Route("ping")]
public IHttpActionResult Ping()
{
try
{
var response = _httpClient.GetAsync($"{_gatewayUrl}/admin/api/status").Result;
return Ok(ApiResponse<object>.Success(new { running = response.IsSuccessStatusCode }));
}
catch
{
return Ok(ApiResponse<object>.Success(new { running = false }));
}
}
/// <summary>
/// 获取数据库采集地址列表
/// GET /api/admin/simulator/addresses
/// </summary>
[HttpGet]
[Route("addresses")]
public IHttpActionResult GetAddresses()
{
return ForwardToGateway("/admin/api/db-addresses");
}
/// <summary>
/// 获取所有模拟状态汇总
/// GET /api/admin/simulator/status
/// </summary>
[HttpGet]
[Route("status")]
public IHttpActionResult GetStatus()
{
return ForwardToGateway("/admin/api/status");
}
/// <summary>
/// 启动指定地址的模拟
/// POST /api/admin/simulator/start
/// </summary>
[HttpPost]
[Route("start")]
public IHttpActionResult Start()
{
return ForwardToGatewayPost("/admin/api/start-address");
}
/// <summary>
/// 停止指定地址的模拟
/// POST /api/admin/simulator/stop
/// </summary>
[HttpPost]
[Route("stop")]
public IHttpActionResult Stop()
{
return ForwardToGatewayPost("/admin/api/stop-address");
}
/// <summary>
/// 启动所有地址的模拟
/// POST /api/admin/simulator/start-all
/// </summary>
[HttpPost]
[Route("start-all")]
public IHttpActionResult StartAll()
{
return ForwardToGatewayPost("/admin/api/start-all");
}
/// <summary>
/// 停止所有地址的模拟
/// POST /api/admin/simulator/stop-all
/// </summary>
[HttpPost]
[Route("stop-all")]
public IHttpActionResult StopAll()
{
return ForwardToGatewayPost("/admin/api/stop-all");
}
/// <summary>
/// 重新加载数据库配置
/// POST /api/admin/simulator/reload
/// </summary>
[HttpPost]
[Route("reload")]
public IHttpActionResult Reload()
{
return ForwardToGatewayPost("/admin/api/reload-db");
}
#endregion
#region 单地址API→ localhost:{port}
/// <summary>
/// 获取单地址状态
/// GET /api/admin/simulator/address/{port}/status
/// </summary>
[HttpGet]
[Route("address/{port}/status")]
public IHttpActionResult GetAddressStatus(int port)
{
return ForwardToAddress(port, "/admin/api/status");
}
/// <summary>
/// 启动单地址数据模拟
/// POST /api/admin/simulator/address/{port}/start
/// </summary>
[HttpPost]
[Route("address/{port}/start")]
public IHttpActionResult StartAddress(int port)
{
return ForwardToAddressPost(port, "/admin/api/start");
}
/// <summary>
/// 停止单地址数据模拟
/// POST /api/admin/simulator/address/{port}/stop
/// </summary>
[HttpPost]
[Route("address/{port}/stop")]
public IHttpActionResult StopAddress(int port)
{
return ForwardToAddressPost(port, "/admin/api/stop");
}
/// <summary>
/// 触发设备事件
/// POST /api/admin/simulator/address/{port}/event
/// </summary>
[HttpPost]
[Route("address/{port}/event")]
public IHttpActionResult TriggerEvent(int port)
{
return ForwardToAddressPost(port, "/admin/api/event");
}
/// <summary>
/// 修改数据变化频率
/// POST /api/admin/simulator/address/{port}/interval
/// </summary>
[HttpPost]
[Route("address/{port}/interval")]
public IHttpActionResult SetInterval(int port)
{
return ForwardToAddressPost(port, "/admin/api/interval");
}
/// <summary>
/// 设置网络异常类型
/// POST /api/admin/simulator/address/{port}/network
/// </summary>
[HttpPost]
[Route("address/{port}/network")]
public IHttpActionResult SetNetwork(int port)
{
return ForwardToAddressPost(port, "/admin/api/network");
}
/// <summary>
/// 切换剧本模式
/// POST /api/admin/simulator/address/{port}/mode
/// </summary>
[HttpPost]
[Route("address/{port}/mode")]
public IHttpActionResult SetMode(int port)
{
return ForwardToAddressPost(port, "/admin/api/mode");
}
/// <summary>
/// 获取请求日志
/// GET /api/admin/simulator/address/{port}/logs
/// </summary>
[HttpGet]
[Route("address/{port}/logs")]
public IHttpActionResult GetLogs(int port)
{
return ForwardToAddress(port, "/admin/api/logs");
}
/// <summary>
/// 获取零件统计
/// GET /api/admin/simulator/address/{port}/stats
/// </summary>
[HttpGet]
[Route("address/{port}/stats")]
public IHttpActionResult GetStats(int port)
{
return ForwardToAddress(port, "/admin/api/stats");
}
/// <summary>
/// 添加设备
/// POST /api/admin/simulator/address/{port}/add-device
/// </summary>
[HttpPost]
[Route("address/{port}/add-device")]
public IHttpActionResult AddDevice(int port)
{
return ForwardToAddressPost(port, "/admin/api/add-device");
}
/// <summary>
/// 移除设备
/// POST /api/admin/simulator/address/{port}/remove-device
/// </summary>
[HttpPost]
[Route("address/{port}/remove-device")]
public IHttpActionResult RemoveDevice(int port)
{
return ForwardToAddressPost(port, "/admin/api/remove-device");
}
/// <summary>
/// 获取事件历史
/// GET /api/admin/simulator/address/{port}/event-history
/// </summary>
[HttpGet]
[Route("address/{port}/event-history")]
public IHttpActionResult GetEventHistory(int port)
{
return ForwardToAddress(port, "/admin/api/event-history");
}
/// <summary>
/// 获取完整汇总
/// GET /api/admin/simulator/address/{port}/full-summary
/// </summary>
[HttpGet]
[Route("address/{port}/full-summary")]
public IHttpActionResult GetFullSummary(int port)
{
return ForwardToAddress(port, "/admin/api/full-summary");
}
/// <summary>
/// 获取异常日志
/// GET /api/admin/simulator/address/{port}/error-log
/// </summary>
[HttpGet]
[Route("address/{port}/error-log")]
public IHttpActionResult GetErrorLog(int port)
{
return ForwardToAddress(port, "/admin/api/error-log");
}
#endregion
#region 转发辅助方法
/// <summary>
/// GET转发到网关9000端口
/// </summary>
private IHttpActionResult ForwardToGateway(string path)
{
try
{
var response = _httpClient.GetAsync($"{_gatewayUrl}{path}").Result;
var body = response.Content.ReadAsStringAsync().Result;
var data = JsonConvert.DeserializeObject(body);
return Ok(ApiResponse<object>.Success(data));
}
catch (Exception ex)
{
return Ok(ApiResponse<object>.Fail(50001, $"模拟器连接失败: {ex.Message}"));
}
}
/// <summary>
/// POST转发到网关9000端口透传请求体
/// </summary>
private IHttpActionResult ForwardToGatewayPost(string path)
{
try
{
var body = Request.Content.ReadAsStringAsync().Result;
var request = new HttpRequestMessage(HttpMethod.Post, $"{_gatewayUrl}{path}")
{
Content = new StringContent(body, Encoding.UTF8, "application/json")
};
var response = _httpClient.SendAsync(request).Result;
var responseBody = response.Content.ReadAsStringAsync().Result;
var data = JsonConvert.DeserializeObject(responseBody);
return Ok(ApiResponse<object>.Success(data));
}
catch (Exception ex)
{
return Ok(ApiResponse<object>.Fail(50001, $"模拟器连接失败: {ex.Message}"));
}
}
/// <summary>
/// GET转发到单地址动态端口
/// </summary>
private IHttpActionResult ForwardToAddress(int port, string path)
{
try
{
var response = _httpClient.GetAsync($"http://localhost:{port}{path}").Result;
var body = response.Content.ReadAsStringAsync().Result;
var data = JsonConvert.DeserializeObject(body);
return Ok(ApiResponse<object>.Success(data));
}
catch (Exception ex)
{
return Ok(ApiResponse<object>.Fail(50001, $"模拟地址(端口{port})连接失败: {ex.Message}"));
}
}
/// <summary>
/// POST转发到单地址动态端口透传请求体
/// </summary>
private IHttpActionResult ForwardToAddressPost(int port, string path)
{
try
{
var body = Request.Content.ReadAsStringAsync().Result;
var request = new HttpRequestMessage(HttpMethod.Post, $"http://localhost:{port}{path}")
{
Content = new StringContent(body, Encoding.UTF8, "application/json")
};
var response = _httpClient.SendAsync(request).Result;
var responseBody = response.Content.ReadAsStringAsync().Result;
var data = JsonConvert.DeserializeObject(responseBody);
return Ok(ApiResponse<object>.Success(data));
}
catch (Exception ex)
{
return Ok(ApiResponse<object>.Fail(50001, $"模拟地址(端口{port})连接失败: {ex.Message}"));
}
}
#endregion
}
}

@ -85,6 +85,8 @@ namespace CncWebApi.Infrastructure
return new Controllers.CollectLogController(
ResolveCollectLogService(),
new CncRepository.Impl.Log.CollectRawRepository(_logConn));
if (serviceType == typeof(Controllers.SimulatorController))
return new Controllers.SimulatorController();
return null;
}

@ -20,6 +20,8 @@
<add key="JwtSecret" value="CncDataSystem_2026_SecretKey_For_Jwt_Token_Generation_Min32Chars" />
<!-- Token过期时间小时 -->
<add key="TokenExpirationHours" value="24" />
<!-- 模拟器网关地址(默认 http://localhost:9000 -->
<add key="SimulatorGatewayUrl" value="http://localhost:9000" />
</appSettings>
<system.web>

Loading…
Cancel
Save