feat: 增加网页后台管理

This commit is contained in:
2026-07-21 17:46:00 +08:00
parent 1edb87b5f2
commit ef786b1529
42 changed files with 5222 additions and 2 deletions

View File

@@ -0,0 +1,3 @@
<template>
<router-view />
</template>

View File

@@ -0,0 +1,71 @@
import request from './request';
export type DeviceType = 'CONTROLLER' | 'CONTROLLED' | null;
export interface DeviceInfo {
deviceId: string;
deviceType: DeviceType;
online: boolean;
connectedAt: number | null;
}
export interface DashboardData {
serverStartTime: number;
currentTime: number;
uptimeMs: number;
totalDevices: number;
onlineDevices: number;
offlineDevices: number;
onlineControllers: number;
onlineControlled: number;
pendingConnections: number;
metrics: {
totalMessages: number;
totalOffers: number;
totalAnswers: number;
totalRejected: number;
totalRegistered: number;
peakSessions: number;
};
}
export interface PendingConnection {
fromDeviceId: string;
toDeviceId: string;
createdAt: number;
elapsedMs: number;
}
export interface ServerConfig {
dedupWindowMs: number;
requestTimeoutMs: number;
websocketEndpoint: string;
serverPort: number;
}
export function getDashboard(): Promise<DashboardData> {
return request.get('/api/admin/dashboard') as unknown as Promise<DashboardData>;
}
export function getDevices(type?: string): Promise<DeviceInfo[]> {
return request.get('/api/admin/devices', { params: type ? { type } : {} }) as unknown as Promise<
DeviceInfo[]
>;
}
export function getConnections(): Promise<PendingConnection[]> {
return request.get('/api/admin/connections') as unknown as Promise<PendingConnection[]>;
}
export function getConfig(): Promise<ServerConfig> {
return request.get('/api/admin/config') as unknown as Promise<ServerConfig>;
}
export function getHealth(): Promise<{ status: string }> {
return request.get('/api/admin/health') as unknown as Promise<{ status: string }>;
}
/** 校验令牌有效性(用于登录时验证)。 */
export function verifyToken(token: string): Promise<unknown> {
return request.get('/api/admin/health', { headers: { 'X-Admin-Token': token } });
}

View File

@@ -0,0 +1,33 @@
import axios from 'axios';
import { ElMessage } from 'element-plus';
import router from '@/router';
const request = axios.create({
baseURL: import.meta.env.VITE_API_BASE || 'http://localhost:8080',
timeout: 15000,
});
request.interceptors.request.use((config) => {
const token = localStorage.getItem('admin_token');
if (token) {
config.headers['X-Admin-Token'] = token;
}
return config;
});
request.interceptors.response.use(
(resp) => resp.data,
(error) => {
const status = error.response?.status;
if (status === 401) {
localStorage.removeItem('admin_token');
ElMessage.error('登录已失效,请重新登录');
router.replace({ name: 'Login' });
} else {
ElMessage.error(error.response?.data?.message || error.message || '请求失败');
}
return Promise.reject(error);
},
);
export default request;

View File

@@ -0,0 +1,55 @@
<template>
<el-card shadow="hover" class="stat-card">
<div class="stat">
<div class="stat-icon" :style="{ background: color || '#3b82f6' }">
<el-icon><component :is="icon" /></el-icon>
</div>
<div class="stat-body">
<div class="stat-value">{{ value }}</div>
<div class="stat-title">{{ title }}</div>
</div>
</div>
</el-card>
</template>
<script setup lang="ts">
defineProps<{
title: string;
value: string | number;
icon: string;
color?: string;
}>();
</script>
<style scoped>
.stat-card {
border: none;
}
.stat {
display: flex;
align-items: center;
gap: 16px;
}
.stat-icon {
width: 52px;
height: 52px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 24px;
flex-shrink: 0;
}
.stat-value {
font-size: 24px;
font-weight: 700;
line-height: 1.2;
color: #1f2937;
}
.stat-title {
font-size: 13px;
color: #909399;
margin-top: 2px;
}
</style>

16
WebRTCSignalServerWeb/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,16 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue';
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>;
export default component;
}
interface ImportMetaEnv {
readonly VITE_API_BASE: string;
readonly VITE_ADMIN_TOKEN: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -0,0 +1,49 @@
<template>
<div class="layout">
<SideBar :collapsed="collapsed" />
<div class="layout-main">
<HeaderBar :collapsed="collapsed" @toggle="collapsed = !collapsed" />
<div class="layout-content">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import SideBar from './components/SideBar.vue';
import HeaderBar from './components/HeaderBar.vue';
const collapsed = ref(false);
</script>
<style scoped>
.layout {
display: flex;
height: 100%;
}
.layout-main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.layout-content {
flex: 1;
overflow: auto;
background: var(--admin-bg);
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,116 @@
<template>
<header class="header">
<div class="left">
<el-icon class="icon-btn" @click="emit('toggle')">
<component :is="collapsed ? 'Expand' : 'Fold'" />
</el-icon>
<el-breadcrumb separator="/">
<el-breadcrumb-item>首页</el-breadcrumb-item>
<el-breadcrumb-item v-for="b in breadcrumb" :key="b">{{ b }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="right">
<el-tooltip content="刷新" placement="bottom">
<el-icon class="icon-btn" @click="refresh"><Refresh /></el-icon>
</el-tooltip>
<el-tooltip content="全屏" placement="bottom">
<el-icon class="icon-btn" @click="toggleFullscreen"><FullScreen /></el-icon>
</el-tooltip>
<el-dropdown trigger="click">
<span class="user">
<el-avatar :size="30" style="background: var(--admin-primary)">
{{ user.username.charAt(0).toUpperCase() }}
</el-avatar>
<span class="username">{{ user.username }}</span>
<el-icon><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item disabled>{{ user.username }}</el-dropdown-item>
<el-dropdown-item divided @click="logout">
<el-icon><SwitchButton /></el-icon> 退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</header>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useUserStore } from '@/stores/user';
import { ElMessage } from 'element-plus';
defineProps<{ collapsed: boolean }>();
const emit = defineEmits<{ toggle: [] }>();
const route = useRoute();
const router = useRouter();
const user = useUserStore();
const breadcrumb = computed(() =>
route.meta.title ? [route.meta.title as string] : [],
);
function toggleFullscreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
} else {
document.exitFullscreen();
}
}
function refresh() {
router.go(0);
}
function logout() {
user.logout();
ElMessage.success('已退出登录');
router.replace({ name: 'Login' });
}
</script>
<style scoped>
.header {
height: 60px;
background: var(--admin-header-bg);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
flex-shrink: 0;
}
.left {
display: flex;
align-items: center;
gap: 16px;
}
.right {
display: flex;
align-items: center;
gap: 14px;
}
.icon-btn {
font-size: 20px;
cursor: pointer;
color: #5b6573;
}
.icon-btn:hover {
color: var(--admin-primary);
}
.user {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
outline: none;
}
.username {
font-size: 14px;
color: #303133;
}
</style>

View File

@@ -0,0 +1,81 @@
<template>
<aside class="sidebar" :class="{ 'is-collapsed': collapsed }">
<div class="logo">
<span class="logo-icon">📡</span>
<span v-show="!collapsed" class="logo-text">信令管理后台</span>
</div>
<el-menu
router
:default-active="activeMenu"
:collapse="collapsed"
:collapse-transition="false"
background-color="#1b1f2d"
text-color="#cbd5e1"
active-text-color="#ffffff"
class="menu"
>
<el-menu-item v-for="m in menus" :key="m.path" :index="m.path">
<el-icon><component :is="m.icon" /></el-icon>
<template #title>{{ m.title }}</template>
</el-menu-item>
</el-menu>
</aside>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute } from 'vue-router';
defineProps<{ collapsed: boolean }>();
const route = useRoute();
const activeMenu = computed(() => route.path);
const menus = [
{ path: '/dashboard', title: '仪表盘', icon: 'Odometer' },
{ path: '/devices', title: '设备管理', icon: 'Monitor' },
{ path: '/connections', title: '连接请求', icon: 'Connection' },
{ path: '/system/info', title: '服务器信息', icon: 'Cpu' },
{ path: '/system/config', title: '系统配置', icon: 'Setting' },
];
</script>
<style scoped>
.sidebar {
width: 220px;
background: var(--admin-sidebar-bg);
display: flex;
flex-direction: column;
transition: width 0.2s;
flex-shrink: 0;
}
.sidebar.is-collapsed {
width: 64px;
}
.logo {
height: 60px;
display: flex;
align-items: center;
gap: 10px;
padding: 0 18px;
color: #fff;
font-weight: 600;
font-size: 16px;
white-space: nowrap;
overflow: hidden;
}
.logo-icon {
font-size: 22px;
}
.menu {
border-right: none;
flex: 1;
width: 100%;
}
:deep(.el-menu-item.is-active) {
background: var(--admin-sidebar-active) !important;
}
:deep(.el-menu-item:hover) {
background: #2a3142 !important;
}
</style>

View File

@@ -0,0 +1,20 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import ElementPlus from 'element-plus';
import 'element-plus/dist/index.css';
import * as ElementPlusIconsVue from '@element-plus/icons-vue';
import App from './App.vue';
import router from './router';
import './styles/index.css';
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.use(ElementPlus);
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component);
}
app.mount('#app');

View File

@@ -0,0 +1,70 @@
import { createRouter, createWebHistory } from 'vue-router';
import type { RouteRecordRaw } from 'vue-router';
import BasicLayout from '@/layout/BasicLayout.vue';
import { useUserStore } from '@/stores/user';
const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/login/Login.vue'),
meta: { public: true, title: '登录' },
},
{
path: '/',
component: BasicLayout,
redirect: '/dashboard',
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/dashboard/Dashboard.vue'),
meta: { title: '仪表盘', icon: 'Odometer' },
},
{
path: 'devices',
name: 'Devices',
component: () => import('@/views/devices/DeviceList.vue'),
meta: { title: '设备管理', icon: 'Monitor' },
},
{
path: 'connections',
name: 'Connections',
component: () => import('@/views/connections/ConnectionList.vue'),
meta: { title: '连接请求', icon: 'Connection' },
},
{
path: 'system/info',
name: 'SystemInfo',
component: () => import('@/views/system/ServerInfo.vue'),
meta: { title: '服务器信息', icon: 'Cpu' },
},
{
path: 'system/config',
name: 'SystemConfig',
component: () => import('@/views/system/Settings.vue'),
meta: { title: '系统配置', icon: 'Setting' },
},
],
},
{ path: '/:pathMatch(.*)*', redirect: '/dashboard' },
];
const router = createRouter({
history: createWebHistory(),
routes,
});
router.beforeEach((to) => {
const user = useUserStore();
const token = user.token || localStorage.getItem('admin_token');
if (!to.meta.public && !token) {
return { name: 'Login', query: { redirect: to.fullPath } };
}
if (to.name === 'Login' && token) {
return { name: 'Dashboard' };
}
return true;
});
export default router;

View File

@@ -0,0 +1,34 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { verifyToken } from '@/api/admin';
const TOKEN_KEY = 'admin_token';
const USER_KEY = 'admin_user';
export const useUserStore = defineStore('user', () => {
const token = ref<string>(localStorage.getItem(TOKEN_KEY) || '');
const username = ref<string>(localStorage.getItem(USER_KEY) || 'admin');
function setToken(value: string) {
token.value = value;
localStorage.setItem(TOKEN_KEY, value);
}
function setUser(value: string) {
username.value = value;
localStorage.setItem(USER_KEY, value);
}
/** 登录:先用令牌请求 /health 校验有效性,成功后再保存。 */
async function login(value: string) {
await verifyToken(value);
setToken(value);
}
function logout() {
token.value = '';
localStorage.removeItem(TOKEN_KEY);
}
return { token, username, setToken, setUser, login, logout };
});

View File

@@ -0,0 +1,55 @@
:root {
--admin-bg: #f0f2f5;
--admin-sidebar-bg: #1b1f2d;
--admin-sidebar-active: #3b82f6;
--admin-header-bg: #ffffff;
--admin-primary: #3b82f6;
--admin-text: #1f2937;
}
* {
box-sizing: border-box;
}
html,
body,
#app {
height: 100%;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
'Microsoft YaHei', sans-serif;
background: var(--admin-bg);
color: var(--admin-text);
}
a {
text-decoration: none;
color: inherit;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.18);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.3);
}
.page-container {
padding: 16px;
}
.text-muted {
color: #909399;
font-size: 13px;
}

View File

@@ -0,0 +1,95 @@
<template>
<div class="page-container">
<el-card shadow="hover">
<div class="toolbar">
<div>
<h2 class="page-title">连接请求</h2>
<span class="text-muted">
主控端发起等待被控端确认的连接请求 {{ connections.length }}
</span>
</div>
<div class="toolbar-actions">
<el-switch v-model="autoRefresh" active-text="自动刷新" />
<el-button :icon="'Refresh'" :loading="loading" @click="load">刷新</el-button>
</div>
</div>
<el-empty v-if="!connections.length" description="当前没有待确认的连接请求" />
<el-table v-else :data="connections" stripe>
<el-table-column label="主控端 (From)" prop="fromDeviceId" min-width="200" />
<el-table-column label="被控端 (To)" prop="toDeviceId" min-width="200" />
<el-table-column label="发起时间" min-width="180">
<template #default="{ row }">{{ fmtTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="已等待" width="140">
<template #default="{ row }">{{ (row.elapsedMs / 1000).toFixed(1) }} </template>
</el-table-column>
<el-table-column label="状态" width="120">
<template #default>
<el-tag type="warning" effect="dark">待确认</el-tag>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch } from 'vue';
import { getConnections } from '@/api/admin';
import type { PendingConnection } from '@/api/admin';
const connections = ref<PendingConnection[]>([]);
const loading = ref(false);
const autoRefresh = ref(true);
let timer: number | undefined;
async function load() {
loading.value = true;
try {
connections.value = await getConnections();
} finally {
loading.value = false;
}
}
function startTimer() {
if (timer) clearInterval(timer);
if (autoRefresh.value) timer = window.setInterval(load, 3000);
}
watch(autoRefresh, startTimer);
function fmtTime(ts: number) {
return new Date(ts).toLocaleString('zh-CN');
}
onMounted(() => {
load();
startTimer();
});
onBeforeUnmount(() => {
if (timer) clearInterval(timer);
});
</script>
<style scoped>
.page-title {
margin: 0 0 4px;
font-size: 20px;
}
.toolbar {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 16px;
flex-wrap: wrap;
gap: 8px;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 12px;
}
</style>

View File

@@ -0,0 +1,188 @@
<template>
<div class="page-container">
<div class="page-head">
<div>
<h2 class="page-title">仪表盘</h2>
<span class="text-muted">实时展示信令服务器运行态 5 秒自动刷新</span>
</div>
<div class="page-actions">
<span class="text-muted">最后更新{{ lastUpdate }}</span>
<el-button :icon="'Refresh'" :loading="loading" @click="load">刷新</el-button>
</div>
</div>
<el-row :gutter="16">
<el-col :xs="12" :sm="12" :md="6">
<StatCard title="在线设备" :value="data?.onlineDevices ?? 0" icon="Monitor" color="#3b82f6" />
</el-col>
<el-col :xs="12" :sm="12" :md="6">
<StatCard title="在线主控端" :value="data?.onlineControllers ?? 0" icon="VideoCamera" color="#67c23a" />
</el-col>
<el-col :xs="12" :sm="12" :md="6">
<StatCard title="在线被控端" :value="data?.onlineControlled ?? 0" icon="Cellphone" color="#e6a23c" />
</el-col>
<el-col :xs="12" :sm="12" :md="6">
<StatCard title="待确认连接" :value="data?.pendingConnections ?? 0" icon="Connection" color="#f56c6c" />
</el-col>
</el-row>
<el-row :gutter="16" class="charts-row">
<el-col :xs="24" :md="10">
<el-card shadow="hover" header="设备类型分布">
<div ref="pieRef" class="chart"></div>
</el-card>
</el-col>
<el-col :xs="24" :md="14">
<el-card shadow="hover" header="信令流量统计">
<div ref="barRef" class="chart"></div>
</el-card>
</el-col>
</el-row>
<el-card shadow="hover" header="最近在线设备">
<el-table :data="devices.slice(0, 8)" stripe size="default">
<el-table-column prop="deviceId" label="设备 ID" min-width="180" />
<el-table-column label="类型" width="120">
<template #default="{ row }">
<el-tag v-if="row.deviceType === 'CONTROLLER'" type="success">主控端</el-tag>
<el-tag v-else-if="row.deviceType === 'CONTROLLED'" type="warning">被控端</el-tag>
<el-tag v-else type="info">未知</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.online ? 'success' : 'danger'" effect="dark">
{{ row.online ? '在线' : '离线' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="上线时间" min-width="180">
<template #default="{ row }">{{ fmtTime(row.connectedAt) }}</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue';
import * as echarts from 'echarts';
import { getDashboard, getDevices } from '@/api/admin';
import type { DashboardData, DeviceInfo } from '@/api/admin';
import StatCard from '@/components/StatCard.vue';
const data = ref<DashboardData | null>(null);
const devices = ref<DeviceInfo[]>([]);
const loading = ref(false);
const lastUpdate = ref('—');
const pieRef = ref<HTMLDivElement>();
const barRef = ref<HTMLDivElement>();
let pieChart: echarts.ECharts | null = null;
let barChart: echarts.ECharts | null = null;
let timer: number | undefined;
async function load() {
loading.value = true;
try {
const [d, devs] = await Promise.all([getDashboard(), getDevices()]);
data.value = d;
devices.value = devs;
lastUpdate.value = new Date().toLocaleTimeString('zh-CN');
renderCharts();
} finally {
loading.value = false;
}
}
function renderCharts() {
if (!data.value) return;
const d = data.value;
pieChart?.setOption({
tooltip: { trigger: 'item' },
legend: { bottom: 0 },
series: [
{
name: '设备分布',
type: 'pie',
radius: ['42%', '70%'],
avoidLabelOverlap: true,
label: { show: true, formatter: '{b}: {c}' },
data: [
{ value: d.onlineControllers, name: '在线主控端', itemStyle: { color: '#67c23a' } },
{ value: d.onlineControlled, name: '在线被控端', itemStyle: { color: '#e6a23c' } },
{ value: d.offlineDevices, name: '离线设备', itemStyle: { color: '#c0c4cc' } },
],
},
],
});
const m = d.metrics;
barChart?.setOption({
tooltip: { trigger: 'axis' },
grid: { left: 40, right: 20, top: 30, bottom: 30 },
xAxis: { type: 'category', data: ['注册', 'OFFER', 'ANSWER', '拒绝', '消息总量'] },
yAxis: { type: 'value' },
series: [
{
type: 'bar',
barWidth: '48%',
data: [m.totalRegistered, m.totalOffers, m.totalAnswers, m.totalRejected, m.totalMessages],
itemStyle: { borderRadius: [4, 4, 0, 0], color: '#3b82f6' },
},
],
});
}
function initCharts() {
if (pieRef.value) pieChart = echarts.init(pieRef.value);
if (barRef.value) barChart = echarts.init(barRef.value);
}
function onResize() {
pieChart?.resize();
barChart?.resize();
}
function fmtTime(ts: number | null) {
return ts ? new Date(ts).toLocaleString('zh-CN') : '—';
}
onMounted(() => {
initCharts();
load();
timer = window.setInterval(load, 5000);
window.addEventListener('resize', onResize);
});
onBeforeUnmount(() => {
if (timer) clearInterval(timer);
window.removeEventListener('resize', onResize);
pieChart?.dispose();
barChart?.dispose();
});
</script>
<style scoped>
.page-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 16px;
flex-wrap: wrap;
gap: 8px;
}
.page-title {
margin: 0 0 4px;
font-size: 20px;
}
.page-actions {
display: flex;
align-items: center;
gap: 12px;
}
.charts-row {
margin-top: 16px;
}
.chart {
height: 300px;
}
</style>

View File

@@ -0,0 +1,131 @@
<template>
<div class="page-container">
<el-card shadow="hover">
<div class="toolbar">
<div>
<h2 class="page-title">设备管理</h2>
<span class="text-muted"> {{ devices.length }} 台已注册设备</span>
</div>
<div class="toolbar-actions">
<el-select v-model="filterType" placeholder="全部类型" clearable style="width: 150px">
<el-option label="主控端" value="CONTROLLER" />
<el-option label="被控端" value="CONTROLLED" />
</el-select>
<el-switch v-model="autoRefresh" active-text="自动刷新" />
<el-button :icon="'Refresh'" :loading="loading" @click="load">刷新</el-button>
</div>
</div>
<el-table :data="devices" stripe>
<el-table-column prop="deviceId" label="设备 ID" min-width="200" />
<el-table-column label="类型" width="120">
<template #default="{ row }">
<el-tag v-if="row.deviceType === 'CONTROLLER'" type="success">主控端</el-tag>
<el-tag v-else-if="row.deviceType === 'CONTROLLED'" type="warning">被控端</el-tag>
<el-tag v-else type="info">未知</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<span class="status-dot" :class="row.online ? 'online' : 'offline'"></span>
<el-tag :type="row.online ? 'success' : 'danger'" effect="dark" size="small">
{{ row.online ? '在线' : '离线' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="上线时间" min-width="180">
<template #default="{ row }">{{ fmtTime(row.connectedAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button link type="primary" :icon="'CopyDocument'" @click="copyId(row.deviceId)">
复制 ID
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch } from 'vue';
import { getDevices } from '@/api/admin';
import type { DeviceInfo } from '@/api/admin';
import { ElMessage } from 'element-plus';
const devices = ref<DeviceInfo[]>([]);
const loading = ref(false);
const filterType = ref<string>('');
const autoRefresh = ref(true);
let timer: number | undefined;
async function load() {
loading.value = true;
try {
devices.value = await getDevices(filterType.value || undefined);
} finally {
loading.value = false;
}
}
function startTimer() {
if (timer) clearInterval(timer);
if (autoRefresh.value) timer = window.setInterval(load, 5000);
}
watch(autoRefresh, startTimer);
watch(filterType, load);
function copyId(id: string) {
navigator.clipboard
?.writeText(id)
.then(() => ElMessage.success('已复制设备 ID'))
.catch(() => ElMessage.error('复制失败'));
}
function fmtTime(ts: number | null) {
return ts ? new Date(ts).toLocaleString('zh-CN') : '—';
}
onMounted(() => {
load();
startTimer();
});
onBeforeUnmount(() => {
if (timer) clearInterval(timer);
});
</script>
<style scoped>
.page-title {
margin: 0 0 4px;
font-size: 20px;
}
.toolbar {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 16px;
flex-wrap: wrap;
gap: 8px;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 12px;
}
.status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
}
.status-dot.online {
background: #67c23a;
}
.status-dot.offline {
background: #f56c6c;
}
</style>

View File

@@ -0,0 +1,116 @@
<template>
<div class="login-wrap">
<div class="login-card">
<div class="brand">
<div class="brand-logo">📡</div>
<h1>WebRTC 信令服务器</h1>
<p>后台管理系统</p>
</div>
<el-form @submit.prevent="onSubmit">
<el-form-item>
<el-input
v-model="token"
size="large"
placeholder="请输入管理令牌 (X-Admin-Token)"
:prefix-icon="'Key'"
@keyup.enter="onSubmit"
/>
</el-form-item>
<el-button
type="primary"
size="large"
class="login-btn"
:loading="loading"
@click="onSubmit"
>
</el-button>
<p class="hint">
默认令牌与服务器 <code>application.yml</code> 中的
<code>admin.token</code> 一致当前默认<code>webrtc-admin-token</code>
</p>
</el-form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useUserStore } from '@/stores/user';
import { ElMessage } from 'element-plus';
const route = useRoute();
const router = useRouter();
const user = useUserStore();
const token = ref(import.meta.env.VITE_ADMIN_TOKEN || 'webrtc-admin-token');
const loading = ref(false);
async function onSubmit() {
if (!token.value.trim()) {
ElMessage.warning('请输入管理令牌');
return;
}
loading.value = true;
try {
await user.login(token.value.trim());
ElMessage.success('登录成功');
const redirect = (route.query.redirect as string) || '/dashboard';
router.replace(redirect);
} catch (e: unknown) {
const err = e as { response?: { data?: { message?: string } } };
ElMessage.error(err?.response?.data?.message || '令牌校验失败');
} finally {
loading.value = false;
}
}
</script>
<style scoped>
.login-wrap {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #1b1f2d 0%, #2b3a67 100%);
}
.login-card {
width: 380px;
background: #fff;
border-radius: 16px;
padding: 36px 32px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25);
}
.brand {
text-align: center;
margin-bottom: 24px;
}
.brand-logo {
font-size: 40px;
}
.brand h1 {
font-size: 20px;
margin: 10px 0 4px;
color: #1f2937;
}
.brand p {
margin: 0;
color: #909399;
font-size: 13px;
}
.login-btn {
width: 100%;
}
.hint {
margin-top: 16px;
font-size: 12px;
color: #909399;
line-height: 1.6;
}
.hint code {
background: #f0f2f5;
padding: 1px 5px;
border-radius: 4px;
color: #3b82f6;
}
</style>

View File

@@ -0,0 +1,95 @@
<template>
<div class="page-container">
<el-row :gutter="16">
<el-col :xs="12" :sm="8" :md="6">
<StatCard title="当前在线" :value="data?.onlineDevices ?? 0" icon="Monitor" color="#3b82f6" />
</el-col>
<el-col :xs="12" :sm="8" :md="6">
<StatCard title="峰值会话" :value="data?.metrics.peakSessions ?? 0" icon="TrendCharts" color="#67c23a" />
</el-col>
<el-col :xs="12" :sm="8" :md="6">
<StatCard title="累计注册" :value="data?.metrics.totalRegistered ?? 0" icon="User" color="#e6a23c" />
</el-col>
<el-col :xs="12" :sm="8" :md="6">
<StatCard title="消息总量" :value="data?.metrics.totalMessages ?? 0" icon="ChatDotRound" color="#f56c6c" />
</el-col>
</el-row>
<el-card shadow="hover" header="运行信息" class="info-card">
<el-descriptions :column="2" border>
<el-descriptions-item label="运行状态">
<el-tag type="success" effect="dark">运行中</el-tag>
</el-descriptions-item>
<el-descriptions-item label="服务端口">{{ config?.serverPort ?? '—' }}</el-descriptions-item>
<el-descriptions-item label="启动时间">
{{ data ? fmtTime(data.serverStartTime) : '—' }}
</el-descriptions-item>
<el-descriptions-item label="已运行时长">{{ uptime }}</el-descriptions-item>
<el-descriptions-item label="在线 / 总数">
{{ data?.onlineDevices ?? 0 }} / {{ data?.totalDevices ?? 0 }}
</el-descriptions-item>
<el-descriptions-item label="WebSocket 端点">
{{ config?.websocketEndpoint ?? '—' }}
</el-descriptions-item>
</el-descriptions>
</el-card>
<el-card shadow="hover" header="信令流量明细" class="info-card">
<el-descriptions :column="3" border>
<el-descriptions-item label="注册总数">{{ data?.metrics.totalRegistered ?? 0 }}</el-descriptions-item>
<el-descriptions-item label="OFFER 总数">{{ data?.metrics.totalOffers ?? 0 }}</el-descriptions-item>
<el-descriptions-item label="ANSWER 总数">{{ data?.metrics.totalAnswers ?? 0 }}</el-descriptions-item>
<el-descriptions-item label="拒绝总数">{{ data?.metrics.totalRejected ?? 0 }}</el-descriptions-item>
<el-descriptions-item label="消息总量">{{ data?.metrics.totalMessages ?? 0 }}</el-descriptions-item>
<el-descriptions-item label="峰值会话">{{ data?.metrics.peakSessions ?? 0 }}</el-descriptions-item>
</el-descriptions>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, computed } from 'vue';
import { getDashboard, getConfig } from '@/api/admin';
import type { DashboardData, ServerConfig } from '@/api/admin';
import StatCard from '@/components/StatCard.vue';
const data = ref<DashboardData | null>(null);
const config = ref<ServerConfig | null>(null);
const now = ref(Date.now());
let timer: number | undefined;
const uptime = computed(() => {
if (!data.value) return '—';
return fmtDuration(now.value - data.value.serverStartTime);
});
function fmtDuration(ms: number) {
const s = Math.floor(ms / 1000);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
return `${h} 小时 ${m}${sec}`;
}
function fmtTime(ts: number) {
return new Date(ts).toLocaleString('zh-CN');
}
onMounted(async () => {
const [d, c] = await Promise.all([getDashboard(), getConfig()]);
data.value = d;
config.value = c;
timer = window.setInterval(() => {
now.value = Date.now();
}, 1000);
});
onBeforeUnmount(() => {
if (timer) clearInterval(timer);
});
</script>
<style scoped>
.info-card {
margin-top: 16px;
}
</style>

View File

@@ -0,0 +1,60 @@
<template>
<div class="page-container">
<el-card shadow="hover" header="连接治理配置" class="info-card">
<el-descriptions :column="1" border>
<el-descriptions-item label="WebSocket 端点">
{{ config?.websocketEndpoint ?? '—' }}
</el-descriptions-item>
<el-descriptions-item label="服务端口">{{ config?.serverPort ?? '—' }}</el-descriptions-item>
<el-descriptions-item label="OFFER 去重窗口">
{{ config ? (config.dedupWindowMs / 1000).toFixed(0) + ' 秒' : '—' }}
<span class="text-muted">同一连接在此窗口内的重复 OFFER 将被忽略</span>
</el-descriptions-item>
<el-descriptions-item label="连接确认超时">
{{ config ? (config.requestTimeoutMs / 1000).toFixed(0) + ' 秒' : '—' }}
<span class="text-muted">超时未确认则通知主控端请求失败</span>
</el-descriptions-item>
</el-descriptions>
</el-card>
<el-card shadow="hover" header="管理令牌 (admin.token)" class="info-card">
<el-alert type="info" :closable="false" show-icon>
<template #title>如何修改后台登录令牌</template>
<p>
管理接口受 <code>X-Admin-Token</code> 头保护令牌由服务器
<code>application.yml</code> 中的 <code>admin.token</code> 配置
也可通过环境变量 <code>ADMIN_TOKEN</code> 覆盖
</p>
<p>当前前端默认令牌取自 <code>.env</code> <code>VITE_ADMIN_TOKEN</code>需与服务器保持一致</p>
<p>示例</p>
<pre>admin:
token: ${ADMIN_TOKEN:webrtc-admin-token}</pre>
<p class="text-muted">修改后重启信令服务器即可生效</p>
</el-alert>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { getConfig } from '@/api/admin';
import type { ServerConfig } from '@/api/admin';
const config = ref<ServerConfig | null>(null);
onMounted(async () => {
config.value = await getConfig();
});
</script>
<style scoped>
.info-card {
margin-bottom: 16px;
}
pre {
background: #f0f2f5;
padding: 10px 12px;
border-radius: 6px;
overflow: auto;
font-size: 13px;
}
</style>