feat: initialize managed portal
This commit is contained in:
29
web/Dockerfile
Normal file
29
web/Dockerfile
Normal file
@@ -0,0 +1,29 @@
|
||||
FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/node:20-alpine AS builder
|
||||
|
||||
ENV TZ=Asia/Shanghai
|
||||
|
||||
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
|
||||
|
||||
WORKDIR /source
|
||||
|
||||
RUN npm install -g pnpm@10.30.3
|
||||
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
RUN pnpm build
|
||||
|
||||
FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/nginx:1.29.4-alpine
|
||||
|
||||
ENV TZ=Asia/Shanghai
|
||||
|
||||
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories && \
|
||||
apk add --no-cache tzdata
|
||||
|
||||
COPY --from=builder /source/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
12
web/index.html
Normal file
12
web/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Managed Portal</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
54
web/nginx.conf
Normal file
54
web/nginx.conf
Normal file
@@ -0,0 +1,54 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://managed-portal:8080/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
|
||||
location ^~ /proxy/ {
|
||||
proxy_pass http://managed-portal:8080/proxy/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
}
|
||||
}
|
||||
22
web/package.json
Normal file
22
web/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "managed-portal",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.30.3",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"axios": "^1.6.0",
|
||||
"element-plus": "^2.5.0",
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
1132
web/pnpm-lock.yaml
generated
Normal file
1132
web/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
200
web/src/App.vue
Normal file
200
web/src/App.vue
Normal file
@@ -0,0 +1,200 @@
|
||||
<template>
|
||||
<div class="app-shell">
|
||||
<header class="app-header">
|
||||
<div class="brand">
|
||||
<div class="brand-mark">MP</div>
|
||||
<div class="brand-copy">
|
||||
<div class="brand-title">Managed Portal</div>
|
||||
<div class="brand-subtitle">Service and device operations</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-menu
|
||||
:default-active="activeMenu"
|
||||
mode="horizontal"
|
||||
router
|
||||
class="top-nav"
|
||||
>
|
||||
<el-menu-item index="/managed-services">Managed Services</el-menu-item>
|
||||
<el-menu-item index="/web-devices">Web Devices</el-menu-item>
|
||||
</el-menu>
|
||||
</header>
|
||||
|
||||
<main class="app-main">
|
||||
<section class="page-header">
|
||||
<div>
|
||||
<p class="eyebrow">Standalone Frontend</p>
|
||||
<h1>{{ currentTitle }}</h1>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const activeMenu = computed(() => {
|
||||
if (route.path.startsWith("/managed-services")) {
|
||||
return "/managed-services";
|
||||
}
|
||||
return route.path;
|
||||
});
|
||||
|
||||
const currentTitle = computed(() => route.meta.title || "Managed Portal");
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:global(body) {
|
||||
margin: 0;
|
||||
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: #f4f6fb;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
:global(*) {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#app,
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(37, 99, 235, 0.08), transparent 28%),
|
||||
radial-gradient(circle at top right, rgba(16, 185, 129, 0.08), transparent 24%),
|
||||
#f4f6fb;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 16px 32px;
|
||||
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, #2563eb, #0f766e);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.brand-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
margin-top: 2px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.top-nav {
|
||||
min-width: 320px;
|
||||
justify-content: flex-end;
|
||||
border-bottom: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.top-nav :deep(.el-menu-item) {
|
||||
height: 48px;
|
||||
line-height: 48px;
|
||||
border-bottom-width: 2px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 32px 40px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
font-size: 30px;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 8px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.app-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 14px 20px;
|
||||
}
|
||||
|
||||
.top-nav {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
40
web/src/api/index.js
Normal file
40
web/src/api/index.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import request from "./request";
|
||||
|
||||
export function scanWebDevices() {
|
||||
return request.get("/web-devices/scan", { timeout: 120000 });
|
||||
}
|
||||
|
||||
export function getManagedServices() {
|
||||
return request.get("/managed-services");
|
||||
}
|
||||
|
||||
export function getManagedService(id) {
|
||||
return request.get(`/managed-services/${id}`);
|
||||
}
|
||||
|
||||
export function updateManagedServiceConfig(id, data) {
|
||||
return request.put(`/managed-services/${id}/config`, data);
|
||||
}
|
||||
|
||||
export function restartManagedService(id) {
|
||||
return request.post(`/managed-services/${id}/restart`);
|
||||
}
|
||||
|
||||
export function getManagedServiceSummary(id) {
|
||||
return request.get(`/managed-services/${id}/results/summary`);
|
||||
}
|
||||
|
||||
export function getManagedServiceFiles(id) {
|
||||
return request.get(`/managed-services/${id}/results/files`);
|
||||
}
|
||||
|
||||
export function getManagedServicePreview(id, path, lines = 2000) {
|
||||
return request.get(`/managed-services/${id}/results/preview`, {
|
||||
params: { path, lines },
|
||||
});
|
||||
}
|
||||
|
||||
export function getManagedServiceDownloadUrl(id, path) {
|
||||
const params = new URLSearchParams({ path });
|
||||
return `/api/managed-services/${encodeURIComponent(id)}/results/download?${params.toString()}`;
|
||||
}
|
||||
20
web/src/api/request.js
Normal file
20
web/src/api/request.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import axios from "axios";
|
||||
|
||||
const request = axios.create({
|
||||
baseURL: "/api",
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
request.interceptors.response.use(
|
||||
(response) => response.data,
|
||||
(error) => {
|
||||
const message =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
error.message ||
|
||||
"Request failed";
|
||||
return Promise.reject(new Error(message));
|
||||
},
|
||||
);
|
||||
|
||||
export default request;
|
||||
8
web/src/main.js
Normal file
8
web/src/main.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import { createApp } from "vue";
|
||||
import ElementPlus from "element-plus";
|
||||
import "element-plus/dist/index.css";
|
||||
|
||||
import App from "./App.vue";
|
||||
import router from "./router";
|
||||
|
||||
createApp(App).use(ElementPlus).use(router).mount("#app");
|
||||
37
web/src/router/index.js
Normal file
37
web/src/router/index.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: "/",
|
||||
redirect: "/managed-services",
|
||||
},
|
||||
{
|
||||
path: "/managed-services",
|
||||
name: "ManagedServices",
|
||||
component: () => import("@/views/ManagedServices.vue"),
|
||||
meta: { title: "Managed Services" },
|
||||
},
|
||||
{
|
||||
path: "/managed-services/:id",
|
||||
name: "ManagedServiceDetail",
|
||||
component: () => import("@/views/ManagedServiceDetail.vue"),
|
||||
meta: { title: "Managed Service Detail" },
|
||||
},
|
||||
{
|
||||
path: "/web-devices",
|
||||
name: "WebDevices",
|
||||
component: () => import("@/views/WebDevices.vue"),
|
||||
meta: { title: "Web Devices" },
|
||||
},
|
||||
{
|
||||
path: "/:pathMatch(.*)*",
|
||||
redirect: "/managed-services",
|
||||
},
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
});
|
||||
|
||||
export default router;
|
||||
620
web/src/views/ManagedServiceDetail.vue
Normal file
620
web/src/views/ManagedServiceDetail.vue
Normal file
@@ -0,0 +1,620 @@
|
||||
<template>
|
||||
<div class="managed-service-detail">
|
||||
<div class="page-toolbar">
|
||||
<div>
|
||||
<el-button link @click="router.push('/managed-services')">
|
||||
返回列表
|
||||
</el-button>
|
||||
<h3 class="page-title">{{ service?.display_name || route.params.id }}</h3>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<el-button @click="loadDetail" :loading="loading">刷新</el-button>
|
||||
<el-button
|
||||
type="warning"
|
||||
@click="handleRestart"
|
||||
:loading="restarting"
|
||||
>
|
||||
重启容器
|
||||
</el-button>
|
||||
<el-button type="primary" @click="openEdit">编辑 RTSP</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-row :gutter="20" v-loading="loading">
|
||||
<el-col :xs="24" :xl="10">
|
||||
<el-card shadow="never" class="detail-card">
|
||||
<template #header>
|
||||
<span>服务状态</span>
|
||||
</template>
|
||||
|
||||
<el-descriptions :column="1" border v-if="service">
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="statusType(service.status)">
|
||||
{{ statusText(service.status) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="容器">
|
||||
{{ service.container_name || service.service_name || "-" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="子服务 API">
|
||||
<span class="break-all">{{ service.api_base_url || "-" }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="配置文件">
|
||||
<span class="break-all">{{ service.config_path }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="RTSP">
|
||||
<span class="break-all">
|
||||
{{ service.rtsp || service.config_error || "-" }}
|
||||
</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="最近结果时间">
|
||||
{{ formatTime(service.summary?.last_result_time) }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
service?.config_error ||
|
||||
service?.service_error ||
|
||||
service?.result_error
|
||||
"
|
||||
class="error-tags"
|
||||
>
|
||||
<el-alert
|
||||
v-if="service?.config_error"
|
||||
title="配置读取异常"
|
||||
:description="service.config_error"
|
||||
type="warning"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
<el-alert
|
||||
v-if="service?.service_error"
|
||||
title="容器状态读取异常"
|
||||
:description="service.service_error"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
<el-alert
|
||||
v-if="service?.result_error"
|
||||
title="结果摘要读取异常"
|
||||
:description="service.result_error"
|
||||
type="info"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :xs="24" :xl="14">
|
||||
<el-card shadow="never" class="detail-card">
|
||||
<template #header>
|
||||
<span>结构化摘要</span>
|
||||
</template>
|
||||
|
||||
<div v-if="service?.summary">
|
||||
<p class="summary-headline">{{ service.summary.headline }}</p>
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item
|
||||
v-for="item in summaryEntries"
|
||||
:key="item.key"
|
||||
:label="item.key"
|
||||
>
|
||||
<span class="break-all">{{ item.value }}</span>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
<el-empty v-else description="暂无结构化摘要" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-card shadow="never" class="detail-card">
|
||||
<template #header>
|
||||
<div class="section-header">
|
||||
<span>半小时统计</span>
|
||||
<div v-if="allWindowStats.length > 0" class="section-actions">
|
||||
<el-button
|
||||
v-if="
|
||||
!showAllWindowStats &&
|
||||
allWindowStats.length > recentWindowStats.length
|
||||
"
|
||||
link
|
||||
type="primary"
|
||||
@click="openAllWindowStats"
|
||||
>
|
||||
查看全部
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="showAllWindowStats"
|
||||
link
|
||||
type="primary"
|
||||
@click="closeAllWindowStats"
|
||||
>
|
||||
返回最近24条
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="windowStatsToRender.length > 0">
|
||||
<el-table :data="windowStatsToRender" stripe>
|
||||
<el-table-column label="时间段" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<div>{{ formatTime(row.window_start) }}</div>
|
||||
<div class="secondary-text">
|
||||
至 {{ formatTime(row.window_end) }}
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<template v-if="isStoreDwell">
|
||||
<el-table-column
|
||||
prop="active_customer_count"
|
||||
label="活跃顾客人数"
|
||||
width="130"
|
||||
/>
|
||||
<el-table-column label="活跃顾客等待秒数列表" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<span class="break-all">
|
||||
{{ formatSecondList(row.active_wait_seconds) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="已结束顾客等待秒数列表" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<span class="break-all">
|
||||
{{ formatSecondList(row.closed_wait_seconds) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="max_wait_seconds"
|
||||
label="最大等待秒数"
|
||||
width="130"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else-if="isPeopleFlow">
|
||||
<el-table-column prop="total_people" label="人流总数" width="110" />
|
||||
<el-table-column label="年龄分布" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<span class="break-all">{{ formatCounts(row.age_counts) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="男女人数" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<span class="break-all">
|
||||
{{ formatCounts(row.gender_counts) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="unknown_attributes"
|
||||
label="未识别属性人数"
|
||||
width="140"
|
||||
/>
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
<div v-if="showAllWindowStats" class="pagination-wrap">
|
||||
<el-pagination
|
||||
v-model:current-page="windowStatsPage"
|
||||
v-model:page-size="windowStatsPageSize"
|
||||
background
|
||||
layout="total, sizes, prev, pager, next"
|
||||
:total="allWindowStats.length"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="暂无半小时统计" />
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="detail-card">
|
||||
<template #header>
|
||||
<span>原始结果文件</span>
|
||||
</template>
|
||||
|
||||
<el-table :data="files" stripe>
|
||||
<el-table-column prop="label" label="类型" width="160" />
|
||||
<el-table-column prop="path" label="路径" min-width="260" />
|
||||
<el-table-column prop="name" label="文件名" min-width="180" />
|
||||
<el-table-column label="更新时间" min-width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(row.modified_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="大小" width="120">
|
||||
<template #default="{ row }">
|
||||
{{ formatSize(row.size) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="previewFile(row)">
|
||||
预览
|
||||
</el-button>
|
||||
<el-button link type="primary" @click="downloadFile(row)">
|
||||
下载
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-empty v-if="files.length === 0" description="暂无可预览文件" />
|
||||
</el-card>
|
||||
|
||||
<el-dialog
|
||||
v-model="previewVisible"
|
||||
width="80%"
|
||||
top="6vh"
|
||||
title="原始文件预览"
|
||||
>
|
||||
<div class="preview-meta">
|
||||
<el-tag type="info">{{ previewPath || "-" }}</el-tag>
|
||||
</div>
|
||||
<el-skeleton :loading="previewLoading" :rows="8" animated>
|
||||
<pre class="preview-content">{{ previewLines.join("\n") }}</pre>
|
||||
</el-skeleton>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="editVisible" width="680px" title="编辑 RTSP">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="RTSP">
|
||||
<el-input
|
||||
v-model="editRTSP"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="rtsp://user:password@host:554/stream"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveConfig(false)" :loading="saving">
|
||||
保存
|
||||
</el-button>
|
||||
<el-button type="warning" @click="saveConfig(true)" :loading="saving">
|
||||
保存并重启
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import {
|
||||
getManagedService,
|
||||
getManagedServiceDownloadUrl,
|
||||
getManagedServiceFiles,
|
||||
getManagedServicePreview,
|
||||
restartManagedService,
|
||||
updateManagedServiceConfig,
|
||||
} from "@/api";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const loading = ref(false);
|
||||
const restarting = ref(false);
|
||||
const saving = ref(false);
|
||||
const service = ref(null);
|
||||
const files = ref([]);
|
||||
const previewVisible = ref(false);
|
||||
const previewLoading = ref(false);
|
||||
const previewPath = ref("");
|
||||
const previewLines = ref([]);
|
||||
const editVisible = ref(false);
|
||||
const editRTSP = ref("");
|
||||
const showAllWindowStats = ref(false);
|
||||
const windowStatsPage = ref(1);
|
||||
const windowStatsPageSize = ref(20);
|
||||
|
||||
const serviceId = computed(() => route.params.id);
|
||||
|
||||
const loadDetail = async () => {
|
||||
if (!serviceId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const [serviceRes, filesRes] = await Promise.all([
|
||||
getManagedService(serviceId.value),
|
||||
getManagedServiceFiles(serviceId.value),
|
||||
]);
|
||||
service.value = serviceRes.service || null;
|
||||
files.value = filesRes.files || [];
|
||||
showAllWindowStats.value = false;
|
||||
windowStatsPage.value = 1;
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || "加载服务详情失败");
|
||||
service.value = null;
|
||||
files.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openEdit = () => {
|
||||
editRTSP.value = service.value?.rtsp || "";
|
||||
editVisible.value = true;
|
||||
};
|
||||
|
||||
const saveConfig = async (restartAfter) => {
|
||||
if (!serviceId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
await updateManagedServiceConfig(serviceId.value, {
|
||||
rtsp_url: editRTSP.value,
|
||||
});
|
||||
if (restartAfter) {
|
||||
await restartManagedService(serviceId.value);
|
||||
}
|
||||
ElMessage.success(restartAfter ? "配置已保存并重启容器" : "配置已保存");
|
||||
editVisible.value = false;
|
||||
await loadDetail();
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || "保存配置失败");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestart = async () => {
|
||||
if (!serviceId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
restarting.value = true;
|
||||
try {
|
||||
await restartManagedService(serviceId.value);
|
||||
ElMessage.success("容器已重启");
|
||||
await loadDetail();
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || "重启服务失败");
|
||||
} finally {
|
||||
restarting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const previewFile = async (file) => {
|
||||
previewVisible.value = true;
|
||||
previewLoading.value = true;
|
||||
previewPath.value = file.path;
|
||||
previewLines.value = [];
|
||||
|
||||
try {
|
||||
const res = await getManagedServicePreview(serviceId.value, file.path, 2000);
|
||||
previewLines.value = res.lines || [];
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || "加载文件预览失败");
|
||||
} finally {
|
||||
previewLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const downloadFile = (file) => {
|
||||
window.open(getManagedServiceDownloadUrl(serviceId.value, file.path), "_blank");
|
||||
};
|
||||
|
||||
const summaryEntries = computed(() => {
|
||||
const metrics = service.value?.summary?.metrics || {};
|
||||
return Object.entries(metrics)
|
||||
.filter(
|
||||
([key]) => key !== "recent_window_stats" && key !== "all_window_stats",
|
||||
)
|
||||
.map(([key, value]) => ({
|
||||
key,
|
||||
value: formatMetric(value),
|
||||
}));
|
||||
});
|
||||
|
||||
const recentWindowStats = computed(() => {
|
||||
const stats = service.value?.summary?.metrics?.recent_window_stats;
|
||||
return Array.isArray(stats) ? stats : [];
|
||||
});
|
||||
|
||||
const allWindowStats = computed(() => {
|
||||
const stats = service.value?.summary?.metrics?.all_window_stats;
|
||||
return Array.isArray(stats) ? stats : [];
|
||||
});
|
||||
|
||||
const isStoreDwell = computed(
|
||||
() => service.value?.project_type === "store_dwell_alert",
|
||||
);
|
||||
const isPeopleFlow = computed(
|
||||
() => service.value?.project_type === "people_flow_project",
|
||||
);
|
||||
|
||||
const windowStatsToRender = computed(() => {
|
||||
if (!showAllWindowStats.value) {
|
||||
return recentWindowStats.value;
|
||||
}
|
||||
|
||||
const start = (windowStatsPage.value - 1) * windowStatsPageSize.value;
|
||||
return allWindowStats.value.slice(start, start + windowStatsPageSize.value);
|
||||
});
|
||||
|
||||
const openAllWindowStats = () => {
|
||||
showAllWindowStats.value = true;
|
||||
windowStatsPage.value = 1;
|
||||
};
|
||||
|
||||
const closeAllWindowStats = () => {
|
||||
showAllWindowStats.value = false;
|
||||
windowStatsPage.value = 1;
|
||||
};
|
||||
|
||||
const formatMetric = (value) => {
|
||||
if (value == null || value === "") {
|
||||
return "-";
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const formatSecondList = (value) => {
|
||||
if (!Array.isArray(value) || value.length === 0) {
|
||||
return "-";
|
||||
}
|
||||
return value.join(", ");
|
||||
};
|
||||
|
||||
const formatCounts = (value) => {
|
||||
if (!value || typeof value !== "object") {
|
||||
return "-";
|
||||
}
|
||||
const entries = Object.entries(value);
|
||||
if (entries.length === 0) {
|
||||
return "-";
|
||||
}
|
||||
return entries.map(([key, count]) => `${key}: ${count}`).join(", ");
|
||||
};
|
||||
|
||||
const formatTime = (value) => {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
const formatSize = (size) => {
|
||||
if (!size || size <= 0) {
|
||||
return "0 B";
|
||||
}
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let value = size;
|
||||
let unitIndex = 0;
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}`;
|
||||
};
|
||||
|
||||
const statusType = (status) => {
|
||||
const map = {
|
||||
running: "success",
|
||||
stopped: "info",
|
||||
failed: "danger",
|
||||
unknown: "warning",
|
||||
};
|
||||
return map[status] || "warning";
|
||||
};
|
||||
|
||||
const statusText = (status) => {
|
||||
const map = {
|
||||
running: "运行中",
|
||||
stopped: "已停止",
|
||||
failed: "失败",
|
||||
unknown: "未知",
|
||||
};
|
||||
return map[status] || status || "未知";
|
||||
};
|
||||
|
||||
watch(serviceId, loadDetail, { immediate: true });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.managed-service-detail {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.page-toolbar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.section-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.summary-headline {
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.error-tags {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.preview-meta {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.secondary-text {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pagination-wrap {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
max-height: 62vh;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 4px;
|
||||
background: var(--el-fill-color-lighter);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.break-all {
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
312
web/src/views/ManagedServices.vue
Normal file
312
web/src/views/ManagedServices.vue
Normal file
@@ -0,0 +1,312 @@
|
||||
<template>
|
||||
<div class="managed-services-page">
|
||||
<div class="page-toolbar">
|
||||
<p class="page-desc">
|
||||
统一管理两个 Docker 子服务:RTSP、容器状态与子项目结构化结果摘要。
|
||||
</p>
|
||||
<el-button type="primary" @click="loadServices" :loading="loading">
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-row v-loading="loading" :gutter="20">
|
||||
<el-col
|
||||
v-for="service in services"
|
||||
:key="service.id"
|
||||
:xs="24"
|
||||
:md="12"
|
||||
:xl="8"
|
||||
>
|
||||
<el-card class="service-card" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<div class="service-name">{{ service.display_name }}</div>
|
||||
<div class="service-type">{{ service.project_type }}</div>
|
||||
</div>
|
||||
<el-tag :type="statusType(service.status)">
|
||||
{{ statusText(service.status) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="service-section">
|
||||
<span class="section-label">RTSP</span>
|
||||
<div class="rtsp-value">
|
||||
{{ service.rtsp || service.config_error || "-" }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="service-section">
|
||||
<span class="section-label">最近结果</span>
|
||||
<div>{{ formatTime(service.summary?.last_result_time) }}</div>
|
||||
</div>
|
||||
|
||||
<div class="service-section">
|
||||
<span class="section-label">摘要</span>
|
||||
<div class="summary-text">
|
||||
{{
|
||||
service.summary?.headline ||
|
||||
service.result_error ||
|
||||
"暂无摘要"
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
service.config_error ||
|
||||
service.service_error ||
|
||||
service.result_error
|
||||
"
|
||||
class="service-errors"
|
||||
>
|
||||
<el-tag v-if="service.config_error" type="warning">
|
||||
配置异常
|
||||
</el-tag>
|
||||
<el-tag v-if="service.service_error" type="danger">
|
||||
服务状态异常
|
||||
</el-tag>
|
||||
<el-tag v-if="service.result_error" type="info">
|
||||
结果读取异常
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<div class="service-actions">
|
||||
<el-button @click="openEdit(service)">编辑 RTSP</el-button>
|
||||
<el-button
|
||||
type="warning"
|
||||
@click="handleRestart(service)"
|
||||
:loading="restartTarget === service.id"
|
||||
>
|
||||
重启容器
|
||||
</el-button>
|
||||
<el-button type="primary" @click="goDetail(service)">
|
||||
查看详情
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-empty
|
||||
v-if="!loading && services.length === 0"
|
||||
description="暂无被管理服务"
|
||||
/>
|
||||
|
||||
<el-dialog v-model="editVisible" width="680px" title="编辑 RTSP">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="服务">
|
||||
<span>{{ editForm.display_name }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="RTSP">
|
||||
<el-input
|
||||
v-model="editForm.rtsp"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="rtsp://user:password@host:554/stream"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveConfig(false)" :loading="saving">
|
||||
保存
|
||||
</el-button>
|
||||
<el-button type="warning" @click="saveConfig(true)" :loading="saving">
|
||||
保存并重启
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import {
|
||||
getManagedServices,
|
||||
restartManagedService,
|
||||
updateManagedServiceConfig,
|
||||
} from "@/api";
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const services = ref([]);
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const restartTarget = ref("");
|
||||
const editVisible = ref(false);
|
||||
const editForm = ref({
|
||||
id: "",
|
||||
display_name: "",
|
||||
rtsp: "",
|
||||
});
|
||||
|
||||
const loadServices = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getManagedServices();
|
||||
services.value = res.services || [];
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || "加载被管理服务失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openEdit = (service) => {
|
||||
editForm.value = {
|
||||
id: service.id,
|
||||
display_name: service.display_name,
|
||||
rtsp: service.rtsp || "",
|
||||
};
|
||||
editVisible.value = true;
|
||||
};
|
||||
|
||||
const saveConfig = async (restartAfter) => {
|
||||
if (!editForm.value.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
await updateManagedServiceConfig(editForm.value.id, {
|
||||
rtsp_url: editForm.value.rtsp,
|
||||
});
|
||||
if (restartAfter) {
|
||||
await restartManagedService(editForm.value.id);
|
||||
}
|
||||
ElMessage.success(restartAfter ? "配置已保存并重启容器" : "配置已保存");
|
||||
editVisible.value = false;
|
||||
await loadServices();
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || "保存配置失败");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestart = async (service) => {
|
||||
restartTarget.value = service.id;
|
||||
try {
|
||||
await restartManagedService(service.id);
|
||||
ElMessage.success("容器已重启");
|
||||
await loadServices();
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || "重启服务失败");
|
||||
} finally {
|
||||
restartTarget.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const goDetail = (service) => {
|
||||
router.push(`/managed-services/${service.id}`);
|
||||
};
|
||||
|
||||
const statusType = (status) => {
|
||||
const map = {
|
||||
running: "success",
|
||||
stopped: "info",
|
||||
failed: "danger",
|
||||
unknown: "warning",
|
||||
};
|
||||
return map[status] || "warning";
|
||||
};
|
||||
|
||||
const statusText = (status) => {
|
||||
const map = {
|
||||
running: "运行中",
|
||||
stopped: "已停止",
|
||||
failed: "失败",
|
||||
unknown: "未知",
|
||||
};
|
||||
return map[status] || status || "未知";
|
||||
};
|
||||
|
||||
const formatTime = (value) => {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
onMounted(loadServices);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.managed-services-page {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.page-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
margin: 0;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.service-card {
|
||||
margin-bottom: 20px;
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.service-name {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.service-type {
|
||||
margin-top: 4px;
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.service-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.rtsp-value,
|
||||
.summary-text {
|
||||
line-height: 1.6;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.service-errors {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.service-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
133
web/src/views/WebDevices.vue
Normal file
133
web/src/views/WebDevices.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<div class="web-devices-page">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>网页设备列表</span>
|
||||
<el-button type="primary" :loading="scanning" @click="handleScan">
|
||||
<el-icon><Search /></el-icon>
|
||||
扫描80端口
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="summary-row">
|
||||
<el-tag type="info">发现 {{ devices.length }} 个设备</el-tag>
|
||||
<el-tag v-if="interfaces.length > 0" type="success">
|
||||
网卡 {{ interfaces.length }} 个
|
||||
</el-tag>
|
||||
<el-tag v-if="scanErrors.length > 0" type="warning">
|
||||
{{ scanErrors.length }} 个网段扫描失败
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
:data="devices"
|
||||
border
|
||||
v-loading="scanning"
|
||||
empty-text="暂无网页设备"
|
||||
>
|
||||
<el-table-column prop="ip" label="IP地址" width="160" />
|
||||
<el-table-column prop="port" label="端口" width="90" />
|
||||
<el-table-column prop="interface" label="网卡" width="140" />
|
||||
<el-table-column
|
||||
prop="target_url"
|
||||
label="设备地址"
|
||||
min-width="180"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
prop="direct_url"
|
||||
label="打开地址"
|
||||
min-width="220"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column label="操作" width="130" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" size="small" @click="openDevice(row)">
|
||||
<el-icon><Monitor /></el-icon>
|
||||
打开
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-alert
|
||||
v-if="scanErrors.length > 0"
|
||||
class="scan-errors"
|
||||
title="部分网段扫描失败"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
>
|
||||
<div v-for="item in scanErrors" :key="item">{{ item }}</div>
|
||||
</el-alert>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { Monitor, Search } from "@element-plus/icons-vue";
|
||||
import { scanWebDevices } from "@/api";
|
||||
|
||||
const scanning = ref(false);
|
||||
const devices = ref([]);
|
||||
const interfaces = ref([]);
|
||||
const scanErrors = ref([]);
|
||||
|
||||
async function handleScan() {
|
||||
try {
|
||||
scanning.value = true;
|
||||
scanErrors.value = [];
|
||||
const response = await scanWebDevices();
|
||||
devices.value = response.devices || [];
|
||||
interfaces.value = response.interfaces || [];
|
||||
scanErrors.value = response.errors || [];
|
||||
|
||||
if (devices.value.length > 0) {
|
||||
ElMessage.success(`发现 ${devices.value.length} 个网页设备`);
|
||||
} else {
|
||||
ElMessage.info(response.message || "未发现网页设备");
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(`扫描失败: ${error.message}`);
|
||||
devices.value = [];
|
||||
} finally {
|
||||
scanning.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openDevice(row) {
|
||||
const url = row?.direct_url || row?.proxy_url;
|
||||
if (!url) {
|
||||
ElMessage.error("设备打开地址无效");
|
||||
return;
|
||||
}
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.web-devices-page {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.summary-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.scan-errors {
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
28
web/vite.config.js
Normal file
28
web/vite.config.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { resolve } from "path";
|
||||
|
||||
const devPort = Number(process.env.VITE_DEV_PORT || 13000);
|
||||
const apiTarget = process.env.VITE_API_PROXY_TARGET || "http://localhost:8080";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve(__dirname, "src"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: devPort,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: apiTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
"/proxy": {
|
||||
target: apiTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user