feat: initialize managed portal

This commit is contained in:
Yoilun
2026-04-27 10:04:36 +08:00
commit d4e351df71
145 changed files with 13425 additions and 0 deletions

29
web/Dockerfile Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

200
web/src/App.vue Normal file
View 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
View 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
View 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
View 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
View 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;

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

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

View 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
View 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,
},
},
},
});