Merge branch 'main' of http://it.fairylandtech.com:8666/Third_Dev_Dept/Migu2.0
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
import { listCars, listSites, listTracks } from '@/api/projection'
|
||||
import { reflectionApi } from '@/api/reflection'
|
||||
import { getWizardProfile } from '@/api/wizard'
|
||||
import type { SetupCarParamRow, SetupStatus } from '@/types/setup'
|
||||
|
||||
const IP_KEYS = ['address', 'ip']
|
||||
const PORT_KEYS = ['port', 'magport']
|
||||
|
||||
function pick(rows: Array<{ key: string; value: string }>, keys: string[]): string {
|
||||
const set = new Set(keys)
|
||||
const hit = rows.find((r) => set.has(r.key.toLowerCase()))
|
||||
return (hit?.value ?? '').trim()
|
||||
}
|
||||
|
||||
function ipOk(v: string): boolean {
|
||||
return v.length > 0 && v !== '0.0.0.0'
|
||||
}
|
||||
|
||||
function portOk(v: string): boolean {
|
||||
const n = Number(v)
|
||||
return Number.isFinite(n) && n > 0 && n <= 65535
|
||||
}
|
||||
|
||||
async function inspectCarParams(rawId: number, name: string): Promise<SetupCarParamRow> {
|
||||
try {
|
||||
const fields = await reflectionApi.getFields('car', rawId)
|
||||
const address = pick(fields, IP_KEYS)
|
||||
const port = pick(fields, PORT_KEYS)
|
||||
return { id: rawId, name, address, port, paramsReady: ipOk(address) && portOk(port) }
|
||||
} catch {
|
||||
return { id: rawId, name, address: '', port: '', paramsReady: false }
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadSetupStatus(): Promise<SetupStatus> {
|
||||
const empty: SetupStatus = {
|
||||
carCount: 0,
|
||||
carsWithParams: 0,
|
||||
siteCount: 0,
|
||||
trackCount: 0,
|
||||
carsReady: false,
|
||||
mapsReady: false,
|
||||
incomplete: true,
|
||||
cars: [],
|
||||
navigationKinds: [],
|
||||
scenarios: [],
|
||||
modules: []
|
||||
}
|
||||
|
||||
try {
|
||||
const [cars, sites, tracks, profile] = await Promise.all([
|
||||
listCars().catch(() => []),
|
||||
listSites().catch(() => []),
|
||||
listTracks().catch(() => []),
|
||||
getWizardProfile().catch(() => null)
|
||||
])
|
||||
|
||||
const inspected = await Promise.all(
|
||||
cars.slice(0, 40).map((c) => {
|
||||
const id = c.rawId ?? Number(String(c.id).replace(/^C/i, ''))
|
||||
if (!Number.isFinite(id) || id <= 0) {
|
||||
return Promise.resolve({
|
||||
id: 0,
|
||||
name: c.name,
|
||||
address: c.address ?? c.ip ?? '',
|
||||
port: '',
|
||||
paramsReady: ipOk(c.address ?? c.ip ?? '')
|
||||
} satisfies SetupCarParamRow)
|
||||
}
|
||||
return inspectCarParams(id, c.name)
|
||||
})
|
||||
)
|
||||
|
||||
const carsWithParams = inspected.filter((c) => c.paramsReady).length
|
||||
const couldReadParams = inspected.some((c) => c.address || c.port || c.paramsReady)
|
||||
const carsReady = cars.length >= 1 && (!couldReadParams || carsWithParams >= 1)
|
||||
const mapsReady = sites.length >= 1 && tracks.length >= 1
|
||||
|
||||
return {
|
||||
carCount: cars.length,
|
||||
carsWithParams,
|
||||
siteCount: sites.length,
|
||||
trackCount: tracks.length,
|
||||
carsReady,
|
||||
mapsReady,
|
||||
incomplete: !(carsReady && mapsReady),
|
||||
cars: inspected,
|
||||
navigationKinds: profile?.navigationKinds ?? [],
|
||||
scenarios: profile?.scenarios ?? [],
|
||||
modules: profile?.modules ?? []
|
||||
}
|
||||
} catch (e) {
|
||||
return {
|
||||
...empty,
|
||||
error: e instanceof Error ? e.message : String(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,24 @@ const MOCK_OPTIONS: WizardOptions = {
|
||||
}
|
||||
}
|
||||
|
||||
const NAV_SCENE: Record<string, string> = {
|
||||
magnetic: 'scene.mag',
|
||||
qrcode: 'scene.qrlidar',
|
||||
laser: 'scene.qrlidar'
|
||||
}
|
||||
|
||||
function toLauncherSceneIds(kinds: string[], scenarios: string[] = []): string[] {
|
||||
const result: string[] = []
|
||||
for (const k of kinds) {
|
||||
const id = NAV_SCENE[k] ?? `scene.${k}`
|
||||
if (!result.includes(id)) result.push(id)
|
||||
}
|
||||
const wantSignal = scenarios.some((s) => s === 'tpl-sps' || s === 'tpl-pack')
|
||||
if (wantSignal && !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 +79,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 ?? [], req.scenarios ?? [])
|
||||
}
|
||||
return mockProfile
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<el-alert v-if="fromSetup" class="setup-guide" type="warning" show-icon :closable="false">
|
||||
<template #title>
|
||||
<div class="setup-guide-row">
|
||||
<div>
|
||||
<div class="setup-guide-title">{{ title }}</div>
|
||||
<div v-if="desc" class="setup-guide-desc">{{ desc }}</div>
|
||||
</div>
|
||||
<el-button size="small" type="primary" plain @click="back">返回初始配置</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-alert>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
defineProps<{
|
||||
title: string
|
||||
desc?: string
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const fromSetup = computed(() => route.query.setup === '1')
|
||||
|
||||
function back() {
|
||||
router.push('/admin/setup')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.setup-guide { margin-bottom: 10px; flex-shrink: 0; }
|
||||
.setup-guide-row {
|
||||
display: flex; align-items: flex-start; justify-content: space-between; gap: 16px;
|
||||
}
|
||||
.setup-guide-title { font-weight: 600; }
|
||||
.setup-guide-desc { margin-top: 4px; font-size: 12.5px; line-height: 1.55; font-weight: 400; opacity: 0.9; }
|
||||
</style>
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
DocumentCopy,
|
||||
EditPen,
|
||||
Files,
|
||||
Grid,
|
||||
Histogram,
|
||||
Link,
|
||||
List,
|
||||
@@ -37,6 +38,7 @@ export interface NavMenuItem {
|
||||
|
||||
export const ADMIN_MENU: NavMenuItem[] = [
|
||||
{ path: '/admin/dashboard', label: '总览', icon: Histogram, key: 'admin-dashboard', group: '概览' },
|
||||
{ path: '/admin/setup', label: '初始配置', icon: SetUp, key: 'admin-setup', group: '概览' },
|
||||
{
|
||||
path: '/admin/operations', label: '运营管理', icon: Monitor, group: '概览',
|
||||
children: [
|
||||
@@ -60,6 +62,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: [
|
||||
@@ -85,8 +97,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
|
||||
}
|
||||
|
||||
@@ -80,6 +80,7 @@
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item v-if="auth.scope === 'Platform'" command="setup">初始配置</el-dropdown-item>
|
||||
<el-dropdown-item v-if="auth.scope === 'Platform'" command="wizard">配置向导</el-dropdown-item>
|
||||
<el-dropdown-item command="status">服务状态</el-dropdown-item>
|
||||
<el-dropdown-item divided disabled class="legacy-theme-label">高级 · 兼容主题</el-dropdown-item>
|
||||
@@ -193,6 +194,8 @@ function onUserCommand(cmd: string) {
|
||||
router.push('/login')
|
||||
} else if (cmd === 'status') {
|
||||
router.push('/status')
|
||||
} else if (cmd === 'setup') {
|
||||
router.push('/admin/setup')
|
||||
} else if (cmd === 'wizard') {
|
||||
router.push('/wizard')
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
|
||||
const PAGES: PageDef[] = [
|
||||
{ key: 'admin-dashboard', label: '总览', group: '概览', scope: 'Platform' },
|
||||
{ key: 'admin-setup', label: '初始配置', group: '概览', scope: 'Platform' },
|
||||
{ key: 'admin-map-monitor', label: '地图监控', group: '概览', scope: 'Platform' },
|
||||
{ key: 'admin-tasks', label: '任务管理', group: '概览', scope: 'Platform' },
|
||||
{ key: 'admin-alarms', label: '报警管理', group: '概览', scope: 'Platform' },
|
||||
|
||||
@@ -29,6 +29,7 @@ const routes: RouteRecordRaw[] = [
|
||||
redirect: '/admin/dashboard',
|
||||
children: [
|
||||
{ path: 'dashboard', name: 'admin-dashboard', component: () => import('@/views/admin/DashboardView.vue'), meta: { title: '总览' } },
|
||||
{ path: 'setup', name: 'admin-setup', component: () => import('@/views/admin/SetupChecklistView.vue'), meta: { title: '初始配置' } },
|
||||
{ path: 'map-monitor', name: 'admin-map-monitor', component: () => import('@/views/admin/MapMonitorView.vue'), meta: { title: '地图监控' } },
|
||||
{ path: 'tasks', name: 'admin-tasks', component: () => import('@/views/admin/TaskManagementView.vue'), meta: { title: '任务管理' } },
|
||||
{ path: 'alarms', name: 'admin-alarms', component: () => import('@/views/admin/AlarmManagementView.vue'), meta: { title: '报警管理' } },
|
||||
@@ -42,6 +43,13 @@ const routes: RouteRecordRaw[] = [
|
||||
{ path: 'wcs-template-proto', name: 'admin-wcs-template-proto', component: () => import('@/views/admin/WcsTemplateProtoView.vue'), meta: { title: 'WCS模板引擎原型' } },
|
||||
{ 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: '车辆运维' } },
|
||||
@@ -108,9 +116,33 @@ const router = createRouter({
|
||||
routes
|
||||
})
|
||||
|
||||
function safeRedirect(raw: unknown, fallback: string): string {
|
||||
const redirect = Array.isArray(raw) ? raw[0] : raw
|
||||
if (
|
||||
typeof redirect === 'string' &&
|
||||
redirect.startsWith('/') &&
|
||||
!redirect.startsWith('//') &&
|
||||
!redirect.startsWith('/login')
|
||||
) {
|
||||
return redirect
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
const auth = useAuthStore()
|
||||
if (to.meta.public) return true
|
||||
if (to.meta.public) {
|
||||
// 已登录再进登录页:直接送去向导 / 业务页,避免「登录成功仍停在 /login」。
|
||||
if (to.name === 'login' && auth.isAuthed) {
|
||||
if (!auth.validated) {
|
||||
try { await auth.validate() } catch { return true }
|
||||
}
|
||||
if (auth.needsWizard) return { name: 'wizard' }
|
||||
const fallback = auth.scope === 'RCSMonitor' ? '/monitor/map' : '/admin/dashboard'
|
||||
return { path: safeRedirect(to.query.redirect, fallback) }
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (!auth.isAuthed) {
|
||||
return { path: '/login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
/* 保存已成功,权限下次进页 / 刷新再对齐 */
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
export interface SetupCarParamRow {
|
||||
id: number
|
||||
name: string
|
||||
address: string
|
||||
port: string
|
||||
paramsReady: boolean
|
||||
}
|
||||
|
||||
export interface SetupStatus {
|
||||
carCount: number
|
||||
carsWithParams: number
|
||||
siteCount: number
|
||||
trackCount: number
|
||||
carsReady: boolean
|
||||
mapsReady: boolean
|
||||
incomplete: boolean
|
||||
error?: string
|
||||
cars: SetupCarParamRow[]
|
||||
navigationKinds: string[]
|
||||
scenarios: string[]
|
||||
modules: string[]
|
||||
}
|
||||
@@ -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 仅 SPS / Pack)。 */
|
||||
activeSceneIds: string[]
|
||||
/** 被部署画像裁剪隐藏的页面 Key。 */
|
||||
hiddenPages: string[]
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
<div class="panel-sub">登录以进入智能调度平台</div>
|
||||
</div>
|
||||
|
||||
<el-form ref="formRef" :model="form" :rules="rules" class="glass-form" hide-required-asterisk>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" class="glass-form" hide-required-asterisk @submit.prevent="submit">
|
||||
<el-form-item prop="username">
|
||||
<el-input v-model="form.username" size="large" placeholder="用户名" autocomplete="username" clearable>
|
||||
<template #prefix><el-icon><User /></el-icon></template>
|
||||
@@ -136,7 +136,7 @@
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
|
||||
<el-button type="primary" :loading="loading" class="btn-login" size="large" @click="submit">
|
||||
<el-button type="primary" native-type="submit" :loading="loading" class="btn-login" size="large">
|
||||
登 录
|
||||
</el-button>
|
||||
|
||||
@@ -205,40 +205,56 @@ const rules: FormRules = {
|
||||
|
||||
const year = computed(() => new Date().getFullYear())
|
||||
|
||||
function postLoginTarget(needsWizard?: boolean): string {
|
||||
if (needsWizard) return '/wizard'
|
||||
const raw = route.query.redirect
|
||||
const redirect = Array.isArray(raw) ? raw[0] : raw
|
||||
if (
|
||||
typeof redirect === 'string' &&
|
||||
redirect.startsWith('/') &&
|
||||
!redirect.startsWith('//') &&
|
||||
!redirect.startsWith('/login')
|
||||
) {
|
||||
return redirect
|
||||
}
|
||||
return form.scope === 'Platform' ? '/admin/dashboard' : '/monitor/map'
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!formRef.value) return
|
||||
await formRef.value.validate(async (ok) => {
|
||||
if (!ok) return
|
||||
loading.value = true
|
||||
try {
|
||||
const resp = await auth.login({
|
||||
username: form.username,
|
||||
password: form.password,
|
||||
scope: form.scope,
|
||||
launchMode: form.launchMode
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const resp = await auth.login({
|
||||
username: form.username,
|
||||
password: form.password,
|
||||
scope: form.scope,
|
||||
launchMode: form.launchMode
|
||||
})
|
||||
ElMessage.success(`欢迎,${auth.user?.displayName ?? form.username}`)
|
||||
// 会话 N+1:如果后端返回了 launchWarning(如「检测到既有 SimpleLite 在跑、本次启动模式未生效」),
|
||||
// 在登录成功的 toast 之后再追加一条警告条,确保用户感知到「实际行为」与「期望」之间的偏差。
|
||||
if (resp.launchWarning) {
|
||||
ElMessage({ message: resp.launchWarning, type: 'warning', duration: 6000, showClose: true })
|
||||
} else if (resp.runMode === 'Detached') {
|
||||
ElMessage({
|
||||
message: 'SimpleLite 未启动(Platform.Server 单独运行),/api/sl/* 相关功能将不可用。',
|
||||
type: 'warning',
|
||||
duration: 6000,
|
||||
showClose: true
|
||||
})
|
||||
ElMessage.success(`欢迎,${auth.user?.displayName ?? form.username}`)
|
||||
// 会话 N+1:如果后端返回了 launchWarning(如「检测到既有 SimpleLite 在跑、本次启动模式未生效」),
|
||||
// 在登录成功的 toast 之后再追加一条警告条,确保用户感知到「实际行为」与「期望」之间的偏差。
|
||||
if (resp.launchWarning) {
|
||||
ElMessage({ message: resp.launchWarning, type: 'warning', duration: 6000, showClose: true })
|
||||
} else if (resp.runMode === 'Detached') {
|
||||
ElMessage({
|
||||
message: 'SimpleLite 未启动(Platform.Server 单独运行),/api/sl/* 相关功能将不可用。',
|
||||
type: 'warning',
|
||||
duration: 6000,
|
||||
showClose: true
|
||||
})
|
||||
}
|
||||
const target = (route.query.redirect as string | undefined) ?? (form.scope === 'Platform' ? '/admin/dashboard' : '/monitor/map')
|
||||
router.push(target)
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
ElMessage.error(`登录失败:${msg}`)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
await router.push(postLoginTarget(resp.needsWizard))
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
ElMessage.error(`登录失败:${msg}`)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</div>
|
||||
<div class="wz-titles">
|
||||
<div class="wz-title">平台配置向导</div>
|
||||
<div class="wz-sub">按需选择导航方式与功能模块,系统据此裁剪界面并按需加载内核能力</div>
|
||||
<div class="wz-sub">先选导航方式,再选业务场景与功能模块;保存后进入车辆与地图配置</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wz-user">{{ auth.user?.displayName ?? auth.user?.username ?? '' }}</div>
|
||||
@@ -23,6 +23,7 @@
|
||||
<div class="wz-main">
|
||||
<section class="wz-section">
|
||||
<div class="wz-section-head">
|
||||
<span class="step-no">1</span>
|
||||
<el-icon><Compass /></el-icon><h3>导航方式</h3><span class="req">至少选 1 项</span>
|
||||
</div>
|
||||
<div class="chip-grid">
|
||||
@@ -37,7 +38,28 @@
|
||||
</section>
|
||||
|
||||
<section class="wz-section">
|
||||
<div class="wz-section-head"><el-icon><Box /></el-icon><h3>功能模块</h3></div>
|
||||
<div class="wz-section-head">
|
||||
<span class="step-no">2</span>
|
||||
<el-icon><Histogram /></el-icon><h3>业务场景</h3><span class="opt">可多选,可暂不选</span>
|
||||
</div>
|
||||
<div class="section-hint">选「SPS 物料配送」或「电池 Pack 自动化产线」才会加载 signal 插件</div>
|
||||
<div v-if="scenarioTemplates.length" class="chip-grid">
|
||||
<button
|
||||
v-for="t in scenarioTemplates" :key="t.id" type="button"
|
||||
class="chip" :class="{ on: sel.scenarios.includes(t.id) }"
|
||||
@click="toggle(sel.scenarios, t.id)">
|
||||
<div class="chip-name">{{ t.name }}</div>
|
||||
<div class="chip-desc">{{ t.category }}</div>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="chip-empty">暂无场景模板,可跳过这一步</div>
|
||||
</section>
|
||||
|
||||
<section class="wz-section">
|
||||
<div class="wz-section-head">
|
||||
<span class="step-no">3</span>
|
||||
<el-icon><Box /></el-icon><h3>功能模块</h3><span class="opt">可多选,可暂不选</span>
|
||||
</div>
|
||||
<div class="chip-grid">
|
||||
<button
|
||||
v-for="o in options?.modules ?? []" :key="o.id" type="button"
|
||||
@@ -48,19 +70,6 @@
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="scenarioTemplates.length" class="wz-section">
|
||||
<div class="wz-section-head"><el-icon><Histogram /></el-icon><h3>业务场景</h3></div>
|
||||
<div class="chip-grid">
|
||||
<button
|
||||
v-for="t in scenarioTemplates" :key="t.id" type="button"
|
||||
class="chip" :class="{ on: sel.scenarios.includes(t.id) }"
|
||||
@click="toggle(sel.scenarios, t.id)">
|
||||
<div class="chip-name">{{ t.name }}</div>
|
||||
<div class="chip-desc">{{ t.category }}</div>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside class="wz-summary">
|
||||
@@ -80,9 +89,9 @@
|
||||
<footer class="wz-foot">
|
||||
<el-button text class="logout-btn" @click="onLogout">退出登录</el-button>
|
||||
<div class="foot-right">
|
||||
<span class="foot-hint">保存后写入部署画像并联动 SimpleLite 选择性加载导航场景</span>
|
||||
<span class="foot-hint">保存后进入车辆与地图配置,不选导航方式无法继续</span>
|
||||
<el-button type="primary" :loading="saving" :disabled="!canSave" @click="save">
|
||||
<el-icon v-if="!saving" class="btn-ic"><Check /></el-icon>完成并进入平台
|
||||
<el-icon v-if="!saving" class="btn-ic"><Check /></el-icon>下一步:进入配置
|
||||
</el-button>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -117,11 +126,23 @@ 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 SIGNAL_SCENARIOS = ['tpl-sps', 'tpl-pack']
|
||||
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.scenarios.some((s) => SIGNAL_SCENARIOS.includes(s)) && !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)
|
||||
|
||||
@@ -137,8 +158,7 @@ onMounted(async () => {
|
||||
options.value = opt
|
||||
sel.platformType = profile.platformType || 'standard'
|
||||
sel.navigationKinds = [...(profile.navigationKinds ?? [])]
|
||||
// WMS 为暂定保留的核心仓储模块,首次进入默认勾选,避免用户误漏。
|
||||
sel.modules = profile.modules?.length ? [...profile.modules] : ['wms']
|
||||
sel.modules = [...(profile.modules ?? [])]
|
||||
sel.scenarios = [...(profile.scenarios ?? [])]
|
||||
} catch (e) {
|
||||
ElMessage.error(`加载向导失败:${e instanceof Error ? e.message : String(e)}`)
|
||||
@@ -161,8 +181,9 @@ async function save() {
|
||||
scenarios: sel.scenarios
|
||||
})
|
||||
auth.markWizardDone()
|
||||
ElMessage.success('部署配置已保存')
|
||||
router.push(auth.scope === 'RCSMonitor' ? '/monitor/map' : '/admin/dashboard')
|
||||
await auth.refreshPermissions()
|
||||
ElMessage.success('选型已保存,请继续配置车辆与地图')
|
||||
router.push(auth.scope === 'RCSMonitor' ? '/monitor/map' : '/admin/setup')
|
||||
} catch (e) {
|
||||
ElMessage.error(`保存失败:${e instanceof Error ? e.message : String(e)}`)
|
||||
} finally {
|
||||
@@ -280,11 +301,28 @@ function onLogout() {
|
||||
}
|
||||
.wz-section-head .el-icon { font-size: 18px; color: var(--lg-accent); }
|
||||
.wz-section-head h3 { margin: 0; font-size: 15px; font-weight: 600; }
|
||||
.wz-section-head .step-no {
|
||||
width: 20px; height: 20px; border-radius: 50%;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: 11px; font-weight: 700; color: #fff;
|
||||
background: var(--lg-primary);
|
||||
}
|
||||
.wz-section-head .req {
|
||||
font-size: 11px; color: var(--lg-accent);
|
||||
padding: 1px 8px; border-radius: 8px;
|
||||
border: 1px solid rgba(var(--lg-accent-rgb), 0.4);
|
||||
}
|
||||
.wz-section-head .opt {
|
||||
font-size: 11px; color: rgba(232, 215, 245, 0.7);
|
||||
padding: 1px 8px; border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
.chip-empty {
|
||||
font-size: 12.5px; color: rgba(255, 255, 255, 0.45); padding: 8px 2px;
|
||||
}
|
||||
.section-hint {
|
||||
font-size: 12px; color: rgba(232, 215, 245, 0.6); margin: -4px 0 10px; line-height: 1.5;
|
||||
}
|
||||
|
||||
.chip-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 10px;
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<template>
|
||||
<div class="car-page">
|
||||
<SetupGuideAlert
|
||||
title="必须添加车辆并补齐参数"
|
||||
desc="至少添加 1 辆车,并在车辆属性中填写 IP(字段 address)和端口(字段 Port,默认 5000)。配完后返回初始配置查看进度。"
|
||||
/>
|
||||
<el-tabs v-model="tab" class="car-tabs admin-tabs admin-tabs--ops" @tab-change="onTabChange">
|
||||
<el-tab-pane label="车辆列表" name="list">
|
||||
<ReflectionManagerPanel
|
||||
@@ -22,6 +26,7 @@
|
||||
import { onMounted, ref } from 'vue'
|
||||
import ReflectionManagerPanel from '@/components/reflection/ReflectionManagerPanel.vue'
|
||||
import CarStyleEditor from './CarStyleEditor.vue'
|
||||
import SetupGuideAlert from '@/components/setup/SetupGuideAlert.vue'
|
||||
|
||||
const tab = ref<'list' | 'style'>('list')
|
||||
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<el-alert
|
||||
v-if="setupBanner"
|
||||
class="setup-banner"
|
||||
type="warning"
|
||||
show-icon
|
||||
closable
|
||||
@close="dismissSetupBanner"
|
||||
>
|
||||
<template #title>
|
||||
<div class="setup-banner-row">
|
||||
<span>请完成初始配置:必须添加车辆并补齐参数,同时配置地图站点、路径与功能参数。</span>
|
||||
<el-button size="small" type="primary" @click="router.push('/admin/setup')">继续配置</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-alert>
|
||||
|
||||
<!-- ===== 系统状态栏:实时连接 / 关键指标 / 时钟 ===== -->
|
||||
<section class="status-bar">
|
||||
<div class="status-left">
|
||||
@@ -353,11 +369,19 @@ import { useDashboardQuickEntries } from '@/composables/useDashboardQuickEntries
|
||||
import { useQuickEntryDragSwap } from '@/composables/useQuickEntryDragSwap'
|
||||
import { fetchAlarmFeed } from '@/api/alarm'
|
||||
import { listCars, listMissions } from '@/api/projection'
|
||||
import { loadSetupStatus } from '@/api/setup'
|
||||
import type { VehicleAlarm } from '@/types/alarm'
|
||||
import type { Car } from '@/types/car'
|
||||
import type { Mission } from '@/types/mission'
|
||||
|
||||
const router = useRouter()
|
||||
const SETUP_BANNER_KEY = 'simple.setup.bannerDismissed'
|
||||
const setupBanner = ref(false)
|
||||
|
||||
function dismissSetupBanner() {
|
||||
setupBanner.value = false
|
||||
try { sessionStorage.setItem(SETUP_BANNER_KEY, '1') } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const {
|
||||
resolvedEntries,
|
||||
@@ -726,6 +750,13 @@ onMounted(async () => {
|
||||
cars.value = carList
|
||||
missions.value = missionList
|
||||
alarms.value = alarmFeed.alarms
|
||||
try {
|
||||
const dismissed = sessionStorage.getItem(SETUP_BANNER_KEY) === '1'
|
||||
if (!dismissed) {
|
||||
const st = await loadSetupStatus()
|
||||
setupBanner.value = st.incomplete
|
||||
}
|
||||
} catch { /* 清单失败不挡总览 */ }
|
||||
await nextTick()
|
||||
renderTrendChart()
|
||||
renderAgvChart()
|
||||
@@ -754,6 +785,10 @@ onUnmounted(() => {
|
||||
.dashboard > * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.setup-banner { margin: 12px 16px 0; }
|
||||
.setup-banner-row {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ───────────── Hero Banner(深色沉浸背景跨主题响应) ─────────────
|
||||
* 设计策略:Hero 区始终是深色(参考 SaaS Dashboard 标杆),但色调跟随主题切换。
|
||||
|
||||
@@ -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>
|
||||
@@ -13,6 +13,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SetupGuideAlert
|
||||
title="请先准备地图"
|
||||
desc="新增或选用一张地图后,再到场景管理添加站点与路径。"
|
||||
/>
|
||||
|
||||
<el-alert
|
||||
v-if="directory"
|
||||
class="dir-tip"
|
||||
@@ -114,6 +119,7 @@ import { mapsApi, type MapListItem } from '@/api/mapEdit'
|
||||
import JsonFoldViewer from '@/components/common/JsonFoldViewer.vue'
|
||||
import MapConnectionPanel from '@/components/map-manage/MapConnectionPanel.vue'
|
||||
import MapMergePanel from '@/components/map-manage/MapMergePanel.vue'
|
||||
import SetupGuideAlert from '@/components/setup/SetupGuideAlert.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const tableRef = ref<TableInstance>()
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<template>
|
||||
<div class="scene-mgr-page ops-console-page">
|
||||
<SetupGuideAlert
|
||||
title="请配置站点与路径"
|
||||
desc="在「站点」页添加站点,在「路径」页连接站点。至少各有 1 条后,初始配置中的地图步骤才会完成。"
|
||||
/>
|
||||
<el-tabs v-model="activeTab" class="scene-tabs admin-tabs admin-tabs--ops" @tab-change="onTabChange">
|
||||
<el-tab-pane label="站点" name="site">
|
||||
<ReflectionManagerPanel
|
||||
@@ -42,9 +46,15 @@
|
||||
*/
|
||||
|
||||
import { ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import ReflectionManagerPanel from '@/components/reflection/ReflectionManagerPanel.vue'
|
||||
import SetupGuideAlert from '@/components/setup/SetupGuideAlert.vue'
|
||||
|
||||
const activeTab = ref<'site' | 'track' | 'special'>('site')
|
||||
const route = useRoute()
|
||||
const rawTab = Array.isArray(route.query.tab) ? route.query.tab[0] : route.query.tab
|
||||
const activeTab = ref<'site' | 'track' | 'special'>(
|
||||
rawTab === 'track' || rawTab === 'special' ? rawTab : 'site'
|
||||
)
|
||||
|
||||
const sitePanelRef = ref<InstanceType<typeof ReflectionManagerPanel> | null>(null)
|
||||
const trackPanelRef = ref<InstanceType<typeof ReflectionManagerPanel> | null>(null)
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
<template>
|
||||
<div class="setup-page" v-loading="loading">
|
||||
<header class="setup-head">
|
||||
<div>
|
||||
<h1>初始配置</h1>
|
||||
<p>向导选型已保存。请先完成车辆与地图,调度才能落地。</p>
|
||||
</div>
|
||||
<div class="setup-progress">
|
||||
<span class="pg-num">{{ doneCount }}/{{ steps.length }}</span>
|
||||
<span class="pg-label">必做步骤</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<el-alert
|
||||
v-if="status?.error"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
:title="`无法读取现场数据:${status.error}`"
|
||||
/>
|
||||
|
||||
<div v-if="profileLine" class="setup-profile">{{ profileLine }}</div>
|
||||
|
||||
<article class="setup-card" :class="{ done: status?.carsReady }">
|
||||
<div class="card-head">
|
||||
<div class="card-index">1</div>
|
||||
<div class="card-titles">
|
||||
<h2>车辆配置</h2>
|
||||
<el-tag size="small" type="danger" effect="plain">必做</el-tag>
|
||||
</div>
|
||||
<el-tag :type="status?.carsReady ? 'success' : 'warning'" size="small">
|
||||
{{ status?.carsReady ? '已完成' : '未完成' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<p class="card-lead">
|
||||
必须至少添加 <b>1 辆车</b>,并补齐通讯参数:<b>IP</b>(字段 address)和 <b>端口</b>(字段 Port,默认 5000)。
|
||||
</p>
|
||||
<ul class="card-facts">
|
||||
<li>当前车辆:{{ status?.carCount ?? '—' }} 辆</li>
|
||||
<li>已填 IP + 端口:{{ status?.carsWithParams ?? '—' }} 辆</li>
|
||||
</ul>
|
||||
<div class="card-actions">
|
||||
<el-button type="primary" @click="go('/admin/cars?setup=1')">去添加车辆</el-button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="setup-card" :class="{ done: status?.mapsReady }">
|
||||
<div class="card-head">
|
||||
<div class="card-index">2</div>
|
||||
<div class="card-titles">
|
||||
<h2>地图配置</h2>
|
||||
<el-tag size="small" type="danger" effect="plain">必做</el-tag>
|
||||
</div>
|
||||
<el-tag :type="status?.mapsReady ? 'success' : 'warning'" size="small">
|
||||
{{ status?.mapsReady ? '已完成' : '未完成' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<p class="card-lead">按顺序配置站点、路径和功能参数。至少要有 1 个站点和 1 条路径。</p>
|
||||
<ul class="card-facts">
|
||||
<li>站点:{{ status?.siteCount ?? '—' }}</li>
|
||||
<li>路径:{{ status?.trackCount ?? '—' }}</li>
|
||||
</ul>
|
||||
<div class="map-grid">
|
||||
<button type="button" class="map-link" @click="go('/admin/maps?setup=1')">
|
||||
<span class="map-link-name">地图管理</span>
|
||||
<span class="map-link-desc">新增或选用地图文件</span>
|
||||
</button>
|
||||
<button type="button" class="map-link" @click="go('/admin/tracks?setup=1&tab=site')">
|
||||
<span class="map-link-name">站点</span>
|
||||
<span class="map-link-desc">在场景里添加站点</span>
|
||||
</button>
|
||||
<button type="button" class="map-link" @click="go('/admin/tracks?setup=1&tab=track')">
|
||||
<span class="map-link-name">路径</span>
|
||||
<span class="map-link-desc">连接站点形成路径</span>
|
||||
</button>
|
||||
<button type="button" class="map-link" @click="go('/admin/simple-fields?setup=1')">
|
||||
<span class="map-link-name">功能参数</span>
|
||||
<span class="map-link-desc">维护站点 / 车辆字段</span>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<footer class="setup-foot">
|
||||
<el-button @click="refresh" :loading="loading">刷新进度</el-button>
|
||||
<el-button type="primary" @click="go('/admin/dashboard')">
|
||||
{{ status?.incomplete === false ? '进入总览' : '稍后去总览' }}
|
||||
</el-button>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { loadSetupStatus } from '@/api/setup'
|
||||
import type { SetupStatus } from '@/types/setup'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const status = ref<SetupStatus | null>(null)
|
||||
const steps = [{ id: 'cars' }, { id: 'maps' }]
|
||||
|
||||
const doneCount = computed(() => {
|
||||
if (!status.value) return 0
|
||||
return Number(status.value.carsReady) + Number(status.value.mapsReady)
|
||||
})
|
||||
|
||||
const NAV_LABEL: Record<string, string> = {
|
||||
magnetic: '磁导航',
|
||||
qrcode: '二维码导航',
|
||||
laser: '激光导航'
|
||||
}
|
||||
|
||||
const profileLine = computed(() => {
|
||||
const s = status.value
|
||||
if (!s) return ''
|
||||
const nav = s.navigationKinds.map((k) => NAV_LABEL[k] ?? k).join('、') || '未选'
|
||||
const scene = s.scenarios.length ? s.scenarios.join('、') : '暂不选'
|
||||
const mods = s.modules.length ? s.modules.join('、') : '暂不选'
|
||||
return `本次选型:导航 ${nav} · 场景 ${scene} · 模块 ${mods}`
|
||||
})
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
try {
|
||||
status.value = await loadSetupStatus()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function go(path: string) {
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
onMounted(() => { void refresh() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.setup-page {
|
||||
max-width: 920px;
|
||||
margin: 0 auto;
|
||||
padding: 8px 4px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.setup-head {
|
||||
display: flex; align-items: flex-end; justify-content: space-between; gap: 16px;
|
||||
}
|
||||
.setup-head h1 { margin: 0; font-size: 22px; color: var(--mg-text, #28213a); }
|
||||
.setup-head p { margin: 6px 0 0; font-size: 13px; color: var(--mg-text-muted, #756d85); }
|
||||
.setup-progress {
|
||||
display: flex; flex-direction: column; align-items: flex-end; line-height: 1.2;
|
||||
}
|
||||
.pg-num { font-size: 28px; font-weight: 700; color: #7543e8; font-variant-numeric: tabular-nums; }
|
||||
.pg-label { font-size: 12px; color: #756d85; }
|
||||
.setup-profile {
|
||||
font-size: 12.5px; color: #756d85;
|
||||
padding: 8px 12px; border-radius: 10px;
|
||||
background: #f6f3fb; border: 1px solid rgba(40, 33, 58, 0.08);
|
||||
}
|
||||
.setup-card {
|
||||
background: #fff;
|
||||
border: 1px solid rgba(40, 33, 58, 0.08);
|
||||
border-radius: 14px;
|
||||
padding: 18px 20px 16px;
|
||||
}
|
||||
.setup-card.done { border-color: rgba(82, 196, 26, 0.35); }
|
||||
.card-head { display: flex; align-items: center; gap: 12px; }
|
||||
.card-index {
|
||||
width: 28px; height: 28px; border-radius: 50%;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
background: #7543e8; color: #fff; font-weight: 700; font-size: 13px;
|
||||
}
|
||||
.card-titles { flex: 1; display: flex; align-items: center; gap: 8px; }
|
||||
.card-titles h2 { margin: 0; font-size: 16px; }
|
||||
.card-lead { margin: 12px 0 8px; font-size: 13.5px; line-height: 1.65; color: #4a4458; }
|
||||
.card-facts {
|
||||
margin: 0 0 14px; padding: 0 0 0 18px;
|
||||
font-size: 13px; color: #756d85; line-height: 1.7;
|
||||
}
|
||||
.card-actions { display: flex; gap: 8px; }
|
||||
.map-grid {
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 10px;
|
||||
}
|
||||
.map-link {
|
||||
appearance: none; cursor: pointer; text-align: left;
|
||||
border: 1px solid rgba(40, 33, 58, 0.1);
|
||||
background: #f6f3fb;
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
color: #28213a;
|
||||
transition: border-color .15s, transform .15s;
|
||||
}
|
||||
.map-link:hover { border-color: #7543e8; transform: translateY(-1px); }
|
||||
.map-link-name { display: block; font-weight: 600; font-size: 14px; }
|
||||
.map-link-desc { display: block; margin-top: 4px; font-size: 12px; color: #756d85; }
|
||||
.setup-foot {
|
||||
display: flex; justify-content: flex-end; gap: 10px; padding-top: 4px;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.map-grid { grid-template-columns: 1fr; }
|
||||
.setup-head { flex-direction: column; align-items: flex-start; }
|
||||
}
|
||||
</style>
|
||||
@@ -15,6 +15,11 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<SetupGuideAlert
|
||||
title="请配置功能参数"
|
||||
desc="在此维护站点 / 车辆字段(速度、功能点等)。配完后返回初始配置。"
|
||||
/>
|
||||
|
||||
<div class="filters">
|
||||
<div class="car-type-filter">
|
||||
<span class="filter-label">车辆类型:</span>
|
||||
@@ -172,6 +177,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import { reflectionApi, type CarTypeCoderFieldsRow, type ReflectionCreatableType } from '@/api/reflection'
|
||||
import * as simpleFieldApi from '@/api/simpleField'
|
||||
import SetupGuideAlert from '@/components/setup/SetupGuideAlert.vue'
|
||||
import {
|
||||
SIMPLE_FIELD_CATEGORIES,
|
||||
buildCarType,
|
||||
|
||||
Reference in New Issue
Block a user