新增磁导航内部交管和信号交互界面
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import http from './http'
|
||||
|
||||
const MOCK = import.meta.env.VITE_USE_MOCK === 'true'
|
||||
|
||||
export interface SignalColumn {
|
||||
key: string
|
||||
label: string
|
||||
type: 'string' | 'int' | 'bool' | 'enum' | string
|
||||
options?: string[] | null
|
||||
group?: string | null
|
||||
}
|
||||
|
||||
export interface SignalTableSummary {
|
||||
id: string
|
||||
title: string
|
||||
category: string
|
||||
fileName: string
|
||||
}
|
||||
|
||||
export interface SignalTableDto extends SignalTableSummary {
|
||||
exists: boolean
|
||||
error?: string | null
|
||||
columns: SignalColumn[]
|
||||
rows: Record<string, unknown>[]
|
||||
}
|
||||
|
||||
export interface SignalDataListDto {
|
||||
signalEnabled: boolean
|
||||
workingDirectory?: string | null
|
||||
tables: SignalTableSummary[]
|
||||
}
|
||||
|
||||
export async function listSignalTables(): Promise<SignalDataListDto> {
|
||||
if (MOCK) {
|
||||
return {
|
||||
signalEnabled: true,
|
||||
workingDirectory: 'D:\\工作\\stand\\SimpleLite',
|
||||
tables: [
|
||||
{ id: 'stations', title: 'PLC机构', category: 'PLC数据管理', fileName: 'stations.json' },
|
||||
{ id: 'docks', title: '机构工位', category: 'PLC数据管理', fileName: 'station-docks.json' },
|
||||
{ id: 'handshake', title: '握手点', category: '握手点数据管理', fileName: 'handshake-points.json' },
|
||||
{ id: 'release', title: '放行点', category: '放行点数据管理', fileName: 'release-points.json' },
|
||||
{ id: 'mag-control', title: '磁条管控区', category: '磁条交管', fileName: 'mag-control-areas.json' }
|
||||
]
|
||||
}
|
||||
}
|
||||
const { data } = await http.get<SignalDataListDto>('/signal-data', { params: { summary: true } })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getSignalTable(id: string): Promise<SignalTableDto> {
|
||||
if (MOCK) {
|
||||
const list = await listSignalTables()
|
||||
const meta = list.tables.find((t) => t.id === id)
|
||||
if (!meta) throw new Error(`未知数据表:${id}`)
|
||||
return { ...meta, exists: true, columns: [], rows: [] }
|
||||
}
|
||||
const { data } = await http.get<SignalTableDto>(`/signal-data/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function saveSignalTable(id: string, rows: Record<string, unknown>[]): Promise<SignalTableDto> {
|
||||
if (MOCK) {
|
||||
const t = await getSignalTable(id)
|
||||
return { ...t, rows: JSON.parse(JSON.stringify(rows)) }
|
||||
}
|
||||
const { data } = await http.put<SignalTableDto>(`/signal-data/${id}`, { rows })
|
||||
return data
|
||||
}
|
||||
@@ -29,6 +29,25 @@ const MOCK_OPTIONS: WizardOptions = {
|
||||
}
|
||||
}
|
||||
|
||||
const NAV_SCENE: Record<string, string> = {
|
||||
magnetic: 'scene.mag',
|
||||
qrcode: 'scene.qrlidar',
|
||||
laser: 'scene.qrlidar'
|
||||
}
|
||||
|
||||
function toLauncherSceneIds(kinds: string[]): string[] {
|
||||
const result: string[] = []
|
||||
for (const k of kinds) {
|
||||
const id = NAV_SCENE[k] ?? `scene.${k}`
|
||||
if (!result.includes(id)) result.push(id)
|
||||
}
|
||||
if (kinds.some((k) => k.toLowerCase() === 'magnetic') && !result.includes('scene.signal')) {
|
||||
result.push('scene.signal')
|
||||
}
|
||||
if (result.length > 0 && !result.includes('scene.device')) result.push('scene.device')
|
||||
return result
|
||||
}
|
||||
|
||||
let mockProfile: DeploymentProfileDto = {
|
||||
configured: false,
|
||||
platformType: 'standard',
|
||||
@@ -61,7 +80,7 @@ export async function saveWizardProfile(req: SaveWizardRequest): Promise<Deploym
|
||||
navigationKinds: req.navigationKinds ?? [],
|
||||
scenarios: req.scenarios ?? [],
|
||||
configured: true,
|
||||
activeSceneIds: (req.navigationKinds ?? []).map((k) => `scene.${k}`)
|
||||
activeSceneIds: toLauncherSceneIds(req.navigationKinds ?? [])
|
||||
}
|
||||
return mockProfile
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
DocumentCopy,
|
||||
EditPen,
|
||||
Files,
|
||||
Grid,
|
||||
Histogram,
|
||||
Link,
|
||||
List,
|
||||
@@ -59,6 +60,16 @@ export const ADMIN_MENU: NavMenuItem[] = [
|
||||
{ path: '/admin/simple-fields', label: '字段管理', icon: Collection, key: 'admin-simple-fields', group: '设计与编辑' }
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/admin/data-center', label: '数据中心', icon: Grid, key: 'admin-data-center', group: '数据中心',
|
||||
children: [
|
||||
{ path: '/admin/data-center/stations', label: 'PLC机构', icon: Cpu, key: 'admin-data-center', group: '数据中心' },
|
||||
{ path: '/admin/data-center/docks', label: '机构工位', icon: Connection, key: 'admin-data-center', group: '数据中心' },
|
||||
{ path: '/admin/data-center/handshake', label: '握手点', icon: Connection, key: 'admin-data-center', group: '数据中心' },
|
||||
{ path: '/admin/data-center/release', label: '放行点', icon: Promotion, key: 'admin-data-center', group: '数据中心' },
|
||||
{ path: '/admin/data-center/mag-control', label: '磁条管控区', icon: Operation, key: 'admin-data-center', group: '数据中心' }
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/admin/config', label: '平台配置中心', icon: Setting, group: '平台配置中心',
|
||||
children: [
|
||||
@@ -84,8 +95,12 @@ export const MONITOR_MENU: NavMenuItem[] = [
|
||||
export function flattenNavMenu(items: NavMenuItem[]): NavMenuItem[] {
|
||||
const out: NavMenuItem[] = []
|
||||
for (const item of items) {
|
||||
if (item.children?.length) out.push(...flattenNavMenu(item.children))
|
||||
else if (item.key) out.push(item)
|
||||
if (item.children?.length) {
|
||||
if (item.key) out.push({ ...item, children: undefined })
|
||||
out.push(...flattenNavMenu(item.children))
|
||||
} else if (item.key) {
|
||||
out.push(item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ function buildCatalog(scope: Scope): Map<string, QuickEntryDef> {
|
||||
const menu = scope === 'RCSMonitor' ? MONITOR_MENU : ADMIN_MENU
|
||||
for (const item of flattenNavMenu(menu)) {
|
||||
const q = menuItemToQuick(item)
|
||||
if (q) map.set(q.key, q)
|
||||
if (q && !map.has(q.key)) map.set(q.key, q)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
@@ -41,6 +41,13 @@ const routes: RouteRecordRaw[] = [
|
||||
{ path: 'task-templates', name: 'admin-task-templates', component: () => import('@/views/admin/TaskTemplateView.vue'), meta: { title: '任务编排' } },
|
||||
{ path: 'simple-fields', name: 'admin-simple-fields', component: () => import('@/views/admin/SimpleFieldManagementView.vue'), meta: { title: '字段管理' } },
|
||||
{ path: 'project-properties', name: 'admin-project-properties', component: () => import('@/views/admin/ProjectPropertiesView.vue'), meta: { title: '项目属性' } },
|
||||
{ path: 'data-center', redirect: '/admin/data-center/stations' },
|
||||
{
|
||||
path: 'data-center/:tableId',
|
||||
name: 'admin-data-center',
|
||||
component: () => import('@/views/admin/DataCenterView.vue'),
|
||||
meta: { title: '数据中心' }
|
||||
},
|
||||
// ── 平台配置中心:聚合页 + 独立业务页(page key = route.name,对齐后端 PageCatalog)。 ──
|
||||
{ path: 'config/strategy', name: 'admin-config-strategy', component: () => import('@/views/admin/config/StrategyConfigView.vue'), meta: { title: '调度策略' } },
|
||||
{ path: 'config/vehicle-hub', name: 'admin-vehicle-hub', component: () => import('@/views/shared/VehicleHubView.vue'), meta: { title: '车辆运维' } },
|
||||
|
||||
@@ -185,6 +185,23 @@ export const useAuthStore = defineStore('auth', {
|
||||
/** 向导保存成功后调用:清掉 needsWizard,避免守卫再次把用户导回 /wizard。 */
|
||||
markWizardDone() {
|
||||
this.needsWizard = false
|
||||
},
|
||||
/** 向导保存后刷新 allowedPages(数据中心等按选型裁剪的页),失败不登出。 */
|
||||
async refreshPermissions() {
|
||||
try {
|
||||
const me = await apiGetMe()
|
||||
this.user = me.user
|
||||
this.scope = me.scope
|
||||
this.runMode = me.runMode
|
||||
this.effectivePermissions = me.effectivePermissions
|
||||
this.needsWizard = me.needsWizard ?? false
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(me.user))
|
||||
localStorage.setItem(SCOPE_KEY, me.scope)
|
||||
localStorage.setItem(RUN_MODE_KEY, me.runMode)
|
||||
localStorage.setItem(PERM_KEY, JSON.stringify(me.effectivePermissions))
|
||||
} catch {
|
||||
/* 保存已成功,权限下次进页 / 刷新再对齐 */
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -47,7 +47,7 @@ export interface DeploymentProfileDto {
|
||||
navigationKinds: string[]
|
||||
scenarios: string[]
|
||||
updatedBy: string
|
||||
/** 由导航选型推导的 SimpleLite 激活场景 id(如 scene.magnetic)。 */
|
||||
/** 由导航选型推导的 SimpleLite 激活场景 id(如 scene.mag / scene.qrlidar / scene.signal)。 */
|
||||
activeSceneIds: string[]
|
||||
/** 被部署画像裁剪隐藏的页面 Key。 */
|
||||
hiddenPages: string[]
|
||||
|
||||
@@ -117,11 +117,22 @@ const scenarioTemplates = computed<ScenarioTemplateLite[]>(() => options.value?.
|
||||
|
||||
// 导航方式 → 内核场景 id 预览(与后端 DeploymentProfile.NavKindToSceneId 对齐)。
|
||||
const NAV_SCENE: Record<string, string> = {
|
||||
magnetic: 'scene.magnetic',
|
||||
qrcode: 'scene.qrcode',
|
||||
laser: 'scene.laser'
|
||||
magnetic: 'scene.mag',
|
||||
qrcode: 'scene.qrlidar',
|
||||
laser: 'scene.qrlidar'
|
||||
}
|
||||
const activeScenes = computed(() => sel.navigationKinds.map((k) => NAV_SCENE[k] ?? `scene.${k}`))
|
||||
const activeScenes = computed(() => {
|
||||
const scenes: string[] = []
|
||||
for (const k of sel.navigationKinds) {
|
||||
const id = NAV_SCENE[k] ?? `scene.${k}`
|
||||
if (!scenes.includes(id)) scenes.push(id)
|
||||
}
|
||||
if (sel.navigationKinds.includes('magnetic') && !scenes.includes('scene.signal')) {
|
||||
scenes.push('scene.signal')
|
||||
}
|
||||
if (scenes.length > 0 && !scenes.includes('scene.device')) scenes.push('scene.device')
|
||||
return scenes
|
||||
})
|
||||
|
||||
const canSave = computed(() => sel.navigationKinds.length > 0)
|
||||
|
||||
@@ -161,6 +172,7 @@ async function save() {
|
||||
scenarios: sel.scenarios
|
||||
})
|
||||
auth.markWizardDone()
|
||||
await auth.refreshPermissions()
|
||||
ElMessage.success('部署配置已保存')
|
||||
router.push(auth.scope === 'RCSMonitor' ? '/monitor/map' : '/admin/dashboard')
|
||||
} catch (e) {
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
<template>
|
||||
<div class="data-center-page">
|
||||
<el-card v-loading="loading" shadow="never" class="page-card">
|
||||
<template #header>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>{{ table?.title ?? title }}</h2>
|
||||
<p>
|
||||
读写 SimpleLite 工作目录 <code>Config/Signal</code>;改完后到信号交互进程点重新加载配置。
|
||||
点位为 BOOL 时按「字节 + 位」直接读写。
|
||||
<span v-if="table">
|
||||
({{ table.fileName }}{{ table.exists ? '' : ' · 文件尚未创建,保存后写入' }})
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button type="primary" :disabled="loading" @click="openDialog()">新增</el-button>
|
||||
<el-button :icon="Refresh" :loading="loading" @click="load">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-alert
|
||||
v-if="loadError"
|
||||
type="warning"
|
||||
:title="loadError"
|
||||
show-icon
|
||||
:closable="false"
|
||||
style="margin-bottom: 12px"
|
||||
/>
|
||||
|
||||
<el-table :data="rows" border size="small" height="560" empty-text="暂无数据">
|
||||
<el-table-column
|
||||
v-for="col in columns"
|
||||
:key="col.key"
|
||||
:prop="col.key"
|
||||
:label="col.label"
|
||||
min-width="120"
|
||||
show-overflow-tooltip
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="col.type === 'bool'" :type="truthy(row[col.key]) ? 'success' : 'info'" size="small">
|
||||
{{ truthy(row[col.key]) ? '是' : '否' }}
|
||||
</el-tag>
|
||||
<span v-else>{{ formatCell(row[col.key]) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="{ row, $index }">
|
||||
<el-button size="small" link @click="openDialog(row, $index)">编辑</el-button>
|
||||
<el-button size="small" link type="danger" @click="removeRow($index)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="editingIndex >= 0 ? '编辑' : '新增'" width="640px" destroy-on-close>
|
||||
<el-form label-width="148px">
|
||||
<template v-for="group in formGroups" :key="group.name || '_default'">
|
||||
<div v-if="group.name" class="form-group-title">{{ group.name }}</div>
|
||||
<el-form-item v-for="col in group.columns" :key="col.key" :label="col.label">
|
||||
<el-switch v-if="col.type === 'bool'" v-model="form[col.key]" />
|
||||
<el-select
|
||||
v-else-if="col.type === 'enum'"
|
||||
v-model="form[col.key]"
|
||||
filterable
|
||||
allow-create
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option v-for="opt in col.options ?? []" :key="opt" :label="opt" :value="opt" />
|
||||
</el-select>
|
||||
<el-input-number
|
||||
v-else-if="col.type === 'int'"
|
||||
v-model="form[col.key]"
|
||||
:controls="false"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<el-input v-else v-model="form[col.key]" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="commitDialog">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import { getSignalTable, saveSignalTable, type SignalColumn, type SignalTableDto } from '@/api/signalData'
|
||||
|
||||
const route = useRoute()
|
||||
const title = computed(() => (route.meta.title as string | undefined) ?? '数据中心')
|
||||
const tableId = computed(() => String(route.params.tableId ?? route.meta.tableId ?? ''))
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const loadError = ref('')
|
||||
const table = ref<SignalTableDto | null>(null)
|
||||
const rows = ref<Record<string, unknown>[]>([])
|
||||
const columns = computed<SignalColumn[]>(() => table.value?.columns ?? [])
|
||||
|
||||
const formGroups = computed(() => {
|
||||
const map = new Map<string, SignalColumn[]>()
|
||||
for (const col of columns.value) {
|
||||
const name = col.group?.trim() || ''
|
||||
if (!map.has(name)) map.set(name, [])
|
||||
map.get(name)!.push(col)
|
||||
}
|
||||
return [...map.entries()].map(([name, cols]) => ({ name, columns: cols }))
|
||||
})
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const editingIndex = ref(-1)
|
||||
const form = reactive<Record<string, unknown>>({})
|
||||
|
||||
function truthy(v: unknown): boolean {
|
||||
return v === true || v === 'true' || v === 1 || v === '1'
|
||||
}
|
||||
|
||||
function formatCell(v: unknown): string {
|
||||
if (v == null) return ''
|
||||
return String(v)
|
||||
}
|
||||
|
||||
function emptyValue(col: SignalColumn): unknown {
|
||||
if (col.type === 'bool') return false
|
||||
if (col.type === 'int') return 0
|
||||
if (col.type === 'enum') return col.options?.[0] ?? ''
|
||||
return ''
|
||||
}
|
||||
|
||||
function fillForm(src?: Record<string, unknown>) {
|
||||
for (const key of Object.keys(form)) delete form[key]
|
||||
for (const col of columns.value) {
|
||||
const raw = src?.[col.key]
|
||||
if (raw !== undefined && raw !== null) {
|
||||
form[col.key] = col.type === 'int' ? Number(raw) : raw
|
||||
} else {
|
||||
form[col.key] = emptyValue(col)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!tableId.value) return
|
||||
loading.value = true
|
||||
loadError.value = ''
|
||||
try {
|
||||
const data = await getSignalTable(tableId.value)
|
||||
table.value = data
|
||||
rows.value = Array.isArray(data.rows) ? data.rows.map((r) => ({ ...r })) : []
|
||||
if (data.error) loadError.value = data.error
|
||||
} catch (e) {
|
||||
table.value = null
|
||||
rows.value = []
|
||||
loadError.value = e instanceof Error ? e.message : String(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openDialog(row?: Record<string, unknown>, index?: number) {
|
||||
editingIndex.value = index ?? -1
|
||||
fillForm(row)
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function persist(next: Record<string, unknown>[]) {
|
||||
saving.value = true
|
||||
try {
|
||||
const data = await saveSignalTable(tableId.value, next)
|
||||
table.value = data
|
||||
rows.value = Array.isArray(data.rows) ? data.rows.map((r) => ({ ...r })) : next
|
||||
ElMessage.success('已保存到信号配置 JSON')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function commitDialog() {
|
||||
const item: Record<string, unknown> = {}
|
||||
for (const col of columns.value) {
|
||||
let v = form[col.key]
|
||||
if (col.type === 'int') {
|
||||
const n = Number(v)
|
||||
v = Number.isFinite(n) ? Math.trunc(n) : 0
|
||||
} else if (col.type === 'bool') {
|
||||
v = truthy(v)
|
||||
} else {
|
||||
v = v == null ? '' : String(v)
|
||||
}
|
||||
item[col.key] = v
|
||||
}
|
||||
const next = rows.value.map((r) => ({ ...r }))
|
||||
if (editingIndex.value >= 0) next[editingIndex.value] = item
|
||||
else next.push(item)
|
||||
await persist(next)
|
||||
dialogVisible.value = false
|
||||
}
|
||||
|
||||
async function removeRow(index: number) {
|
||||
const row = rows.value[index]
|
||||
const label = columns.value[0] ? String(row?.[columns.value[0].key] ?? index + 1) : String(index + 1)
|
||||
try {
|
||||
await ElMessageBox.confirm(`删除「${label}」?`, '确认', { type: 'warning' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const next = rows.value.filter((_, i) => i !== index)
|
||||
await persist(next)
|
||||
}
|
||||
|
||||
watch(tableId, () => { void load() }, { immediate: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.data-center-page {
|
||||
padding: 16px;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.page-card {
|
||||
height: 100%;
|
||||
}
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
.page-header h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 18px;
|
||||
}
|
||||
.page-header p {
|
||||
margin: 0;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.page-header code {
|
||||
font-size: 12px;
|
||||
}
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.form-group-title {
|
||||
margin: 12px 0 8px;
|
||||
padding-bottom: 4px;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-primary);
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
.form-group-title:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user