初始化
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import dashboard1 from "@/views/dashboard1/index.vue";
|
||||
|
||||
const DESIGN_WIDTH = 1920;
|
||||
const DESIGN_HEIGHT = 1080;
|
||||
@@ -44,7 +43,7 @@ onBeforeUnmount(() => {
|
||||
<template>
|
||||
<div class="screen-adapter">
|
||||
<div class="screen-adapter__inner" :style="adapterStyle">
|
||||
<Component :is="dashboard1" />
|
||||
<RouterView />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
75
src/api/alarm.ts
Normal file
75
src/api/alarm.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import request from "@/utils/request";
|
||||
|
||||
export interface RosInspectionRecordLineImageQueryDTO {
|
||||
recognizeType?: number | string;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
keyword?: string;
|
||||
}
|
||||
|
||||
export interface OrderItem {
|
||||
property: string;
|
||||
tableIndex: number;
|
||||
asc?: boolean;
|
||||
}
|
||||
|
||||
export interface PageParam<T> {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
orderList?: OrderItem[];
|
||||
query?: T;
|
||||
}
|
||||
|
||||
export type RosInspectionRecordLineImagePageQuery =
|
||||
Partial<RosInspectionRecordLineImage> & {
|
||||
keyword?: string;
|
||||
};
|
||||
|
||||
export interface RosInspectionRecordLineImageCar {
|
||||
id?: string;
|
||||
deviceKey?: string;
|
||||
deviceNo?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface RosInspectionRecordLineImage {
|
||||
recordId: string;
|
||||
carId?: string;
|
||||
car?: RosInspectionRecordLineImageCar;
|
||||
recordActionId: string;
|
||||
taskId?: string;
|
||||
recognizeType?: number;
|
||||
imageUrl?: string;
|
||||
captureTime?: string;
|
||||
poseX?: number;
|
||||
poseY?: number;
|
||||
poseYaw?: number;
|
||||
recognitionStatus?: number;
|
||||
prompt?: string;
|
||||
recognitionResult?: string;
|
||||
warning?: boolean;
|
||||
warningValue?: string;
|
||||
failReason?: string;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
createTime?: string;
|
||||
updateTime?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
// 查询近期告警列表
|
||||
export function getRecentAlarmList(data: RosInspectionRecordLineImageQueryDTO) {
|
||||
return request({
|
||||
url: "/admin/rosInspectionRecordLineImage/warnList",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
// 分页查询告警列表
|
||||
export function getAlarmPage(data: PageParam<RosInspectionRecordLineImagePageQuery>) {
|
||||
return request({
|
||||
url: "/admin/rosInspectionRecordLineImage/list",
|
||||
method: "POST",
|
||||
data,
|
||||
});
|
||||
}
|
||||
73
src/api/rosCar.ts
Normal file
73
src/api/rosCar.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import request from "@/utils/request";
|
||||
|
||||
// 小车分页列表
|
||||
export function getRosCarList(data: unknown) {
|
||||
return request({
|
||||
url: "/admin/rosCar/list",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
// 保存巡检任务模板
|
||||
export function saveRosInspectionTask(data: unknown) {
|
||||
return request({
|
||||
url: "/admin/rosInspectionTask/save",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
// 巡检任务模板详情
|
||||
export function getRosInspectionTaskDetail(cardId: string) {
|
||||
return request({
|
||||
url: "/admin/rosInspectionTask/detail",
|
||||
params: { cardId },
|
||||
});
|
||||
}
|
||||
|
||||
// 查询小车当前巡检状态
|
||||
export function getCurrentRosInspection(carId: string) {
|
||||
return request({
|
||||
url: "/admin/rosCar/currentInspection",
|
||||
params: { carId },
|
||||
});
|
||||
}
|
||||
|
||||
// 下发巡检任务
|
||||
export function dispatchRosInspectionTask(taskId: string) {
|
||||
return request({
|
||||
url: "/admin/rosInspectionTask/dispatch",
|
||||
params: { taskId },
|
||||
});
|
||||
}
|
||||
|
||||
// 停止已下发的巡检任务
|
||||
export function stopDispatchRosInspectionTask(taskId: string) {
|
||||
return request({
|
||||
url: "/admin/rosInspectionTask/stopDispatch",
|
||||
params: { taskId },
|
||||
});
|
||||
}
|
||||
|
||||
// 上传文件并返回后端保存后的访问地址
|
||||
export function uploadFile(file: File) {
|
||||
const data = new FormData();
|
||||
|
||||
data.append("file", file);
|
||||
|
||||
return request({
|
||||
url: "/admin/file/upload",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
// 更新小车地图文件地址
|
||||
export function updateRosCarMapUrls(data: {
|
||||
id: string;
|
||||
mapPgmUrl?: string;
|
||||
mapYamlUrl?: string;
|
||||
}) {
|
||||
return request({
|
||||
url: "/admin/rosCar/updateMapUrls",
|
||||
data,
|
||||
});
|
||||
}
|
||||
52
src/api/user.js
Normal file
52
src/api/user.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import request, { BASE_URL } from '@/utils/request'
|
||||
export function getPublicKey() {
|
||||
return request({
|
||||
url: 'admin/account/publicKey'
|
||||
})
|
||||
}
|
||||
export function login(data) {
|
||||
return request({
|
||||
url: 'admin/account/login',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function captcha() {
|
||||
return BASE_URL + '/admin/account/captcha'
|
||||
}
|
||||
export function getInfo() {
|
||||
return request({
|
||||
url: 'admin/account/info',
|
||||
})
|
||||
}
|
||||
|
||||
export function updateInfo(data) {
|
||||
return request({
|
||||
url: 'admin/account/update',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function updatePwd(data) {
|
||||
return request({
|
||||
url: 'admin/account/updatePwd',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 发送重置密码验证码
|
||||
export function getVerifyCode(data) {
|
||||
return request({
|
||||
url: '/admin/sysUser/getVerifyCode',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
//忘记密码
|
||||
export function resetUserPassword(data) {
|
||||
return request({
|
||||
url: '/admin/sysUser/resetUserPassword',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,11 +4,20 @@ import App from "./App.vue";
|
||||
import Chart from "./components/chart/index.vue";
|
||||
import * as echarts from "echarts";
|
||||
import echartsConfig from "./config/echarts.config";
|
||||
import { createDiscreteApi } from "naive-ui";
|
||||
import { createPinia } from "pinia";
|
||||
import router from "./router";
|
||||
|
||||
echarts.registerTheme("t-theme", echartsConfig);
|
||||
|
||||
const { message, dialog } = createDiscreteApi(["message", "dialog"]);
|
||||
window.$message = message;
|
||||
window.$modal = dialog;
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
app.use(createPinia());
|
||||
app.use(router);
|
||||
app.component("TChart", Chart);
|
||||
|
||||
app.mount("#app");
|
||||
|
||||
97
src/router/index.ts
Normal file
97
src/router/index.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { getToken } from "@/utils/auth";
|
||||
import {
|
||||
createRouter,
|
||||
createWebHashHistory,
|
||||
type RouteRecordRaw,
|
||||
} from "vue-router";
|
||||
|
||||
const dashboardDefaultPath = "/dashboard/integrated-center";
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: "/",
|
||||
redirect: () => (getToken() ? dashboardDefaultPath : "/login"),
|
||||
},
|
||||
{
|
||||
path: "/dashboard",
|
||||
component: () => import("@/views/dashboard1/index.vue"),
|
||||
redirect: dashboardDefaultPath,
|
||||
children: [
|
||||
{
|
||||
path: "integrated-center",
|
||||
name: "IntegratedCenter",
|
||||
component: () =>
|
||||
import("@/views/dashboard1/pages/IntegratedCenterPage.vue"),
|
||||
meta: { title: "监管中心", tabId: 1, },
|
||||
},
|
||||
{
|
||||
path: "equipment-management",
|
||||
name: "EquipmentManagement",
|
||||
component: () =>
|
||||
import("@/views/dashboard1/pages/EquipmentManagementPage.vue"),
|
||||
meta: { title: "设备管理", tabId: 2, },
|
||||
},
|
||||
{
|
||||
path: "video-center",
|
||||
name: "VideoCenter",
|
||||
component: () => import("@/views/dashboard1/pages/VideoCenterPage.vue"),
|
||||
meta: { title: "视频中心", tabId: 3, },
|
||||
},
|
||||
{
|
||||
path: "alarm-management",
|
||||
name: "AlarmManagement",
|
||||
component: () =>
|
||||
import("@/views/dashboard1/pages/AlarmManagementPage.vue"),
|
||||
meta: { title: "告警管理", tabId: 4, },
|
||||
},
|
||||
{
|
||||
path: "patrol-plan",
|
||||
name: "PatrolPlan",
|
||||
component: () => import("@/views/dashboard1/pages/PatrolPlanPage.vue"),
|
||||
meta: { title: "巡检记录", tabId: 5, },
|
||||
},
|
||||
{
|
||||
path: "data-report",
|
||||
name: "DataReport",
|
||||
component: () => import("@/views/dashboard1/pages/DataReportPage.vue"),
|
||||
meta: { title: "统计分析", tabId: 6, },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/login",
|
||||
name: "Login",
|
||||
component: () => import("@/views/login/index.vue"),
|
||||
meta: { title: "登录" },
|
||||
},
|
||||
{
|
||||
path: "/:pathMatch(.*)*",
|
||||
redirect: dashboardDefaultPath,
|
||||
},
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes,
|
||||
});
|
||||
|
||||
const whiteList = ["/login"];
|
||||
|
||||
router.beforeEach((to) => {
|
||||
const hasToken = Boolean(getToken());
|
||||
|
||||
if (!hasToken && !whiteList.includes(to.path)) {
|
||||
return {
|
||||
path: "/login",
|
||||
query: { redirect: to.fullPath },
|
||||
};
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
export function resetRouter() {
|
||||
// 当前项目暂无动态 addRoute 路由,保留该方法兼容登出流程。
|
||||
}
|
||||
|
||||
export default router;
|
||||
5
src/store/index.js
Normal file
5
src/store/index.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
export function setupStore(app) {
|
||||
app.use(createPinia())
|
||||
}
|
||||
47
src/store/modules/app.js
Normal file
47
src/store/modules/app.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
import { useDark } from '@vueuse/core'
|
||||
|
||||
const sidebarStatusKey = 'sidebar_status'
|
||||
|
||||
export const useAppStore = defineStore('app', {
|
||||
state() {
|
||||
return {
|
||||
sidebarStatus: localStorage.getItem(sidebarStatusKey) ? localStorage.getItem(sidebarStatusKey) === '1' : true,
|
||||
device: 'desktop',
|
||||
config: {},
|
||||
reloadFlag: true,
|
||||
isDark: !useDark(),
|
||||
}
|
||||
},
|
||||
getters: {
|
||||
sidebar() {
|
||||
return this.sidebarStatus
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
async toggleSideBar() {
|
||||
this.sidebarStatus = !this.sidebarStatus
|
||||
if (this.sidebarStatus) {
|
||||
localStorage.setItem(sidebarStatusKey, '1')
|
||||
} else {
|
||||
localStorage.setItem(sidebarStatusKey, '0')
|
||||
}
|
||||
},
|
||||
closeSideBar(withoutAnimation) {
|
||||
localStorage.setItem(sidebarStatusKey, '0')
|
||||
this.sidebarStatus = false
|
||||
},
|
||||
toggleDevice(device) {
|
||||
this.device = device
|
||||
},
|
||||
async reloadPage() {
|
||||
this.reloadFlag = false
|
||||
await nextTick()
|
||||
this.reloadFlag = true
|
||||
},
|
||||
toggleDark() {
|
||||
this.isDark = !this.isDark
|
||||
},
|
||||
}
|
||||
})
|
||||
112
src/store/modules/user.js
Normal file
112
src/store/modules/user.js
Normal file
@@ -0,0 +1,112 @@
|
||||
import { getToken, removeToken, setToken } from "@/utils/auth.js";
|
||||
import { getInfo, login as doLogin } from "@/api/user.js";
|
||||
import { defineStore } from "pinia";
|
||||
import { resetRouter } from "@/router";
|
||||
|
||||
export const useUserStore = defineStore("user", {
|
||||
state() {
|
||||
return {
|
||||
userInfo: {
|
||||
token: getToken() || "",
|
||||
id: "",
|
||||
avatar: "",
|
||||
name: "",
|
||||
profile: {},
|
||||
permissions: [],
|
||||
dictMap: {},
|
||||
dictTreeMap: {},
|
||||
dictIdMap: {},
|
||||
dynamicAttrs: [],
|
||||
companyLogStatus: null,
|
||||
attrIds: [],
|
||||
regionIds: [],
|
||||
},
|
||||
};
|
||||
},
|
||||
getters: {
|
||||
userId() {
|
||||
return this.userInfo?.id || "";
|
||||
},
|
||||
name() {
|
||||
return this.userInfo?.name || "";
|
||||
},
|
||||
avatar() {
|
||||
return this.userInfo?.profile?.avatar || "";
|
||||
},
|
||||
permissions() {
|
||||
return this.userInfo?.permissions || [];
|
||||
},
|
||||
dictMap() {
|
||||
return this.userInfo?.dictMap || {};
|
||||
},
|
||||
dictTreeMap() {
|
||||
return this.userInfo?.dictTreeMap || {};
|
||||
},
|
||||
dictIdMap() {
|
||||
return this.userInfo?.dictIdMap || {};
|
||||
},
|
||||
dynamicAttrs() {
|
||||
return this.userInfo?.dynamicAttrs || [];
|
||||
},
|
||||
companyLogStatus() {
|
||||
return this.userInfo?.companyLogStatus || 0;
|
||||
},
|
||||
attrIds() {
|
||||
return this.userInfo?.attrIds || [];
|
||||
},
|
||||
regionIds() {
|
||||
return this.userInfo?.regionIds || [];
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
async login(userInfo) {
|
||||
const res = await doLogin(userInfo);
|
||||
const { token, tokenName } = res.data;
|
||||
this.userInfo.token = token;
|
||||
setToken(token, tokenName);
|
||||
return true;
|
||||
},
|
||||
async getInfo() {
|
||||
const res = await getInfo();
|
||||
const data = res.data;
|
||||
const {
|
||||
name,
|
||||
profile,
|
||||
permissions,
|
||||
dynamicAttrs,
|
||||
attrIds,
|
||||
companyLogStatus,
|
||||
regionIds,
|
||||
} = data;
|
||||
|
||||
// 保留当前的 token,避免被覆盖
|
||||
const currentToken = this.userInfo.token || getToken();
|
||||
|
||||
this.userInfo = {
|
||||
token: currentToken,
|
||||
id: profile.id,
|
||||
name,
|
||||
profile,
|
||||
permissions,
|
||||
dynamicAttrs,
|
||||
attrIds,
|
||||
companyLogStatus,
|
||||
regionIds,
|
||||
};
|
||||
return data;
|
||||
},
|
||||
// user logout
|
||||
async logout() {
|
||||
await this.resetToken();
|
||||
await resetRouter();
|
||||
return true;
|
||||
},
|
||||
async resetToken() {
|
||||
this.userInfo = {
|
||||
profile: {},
|
||||
permissions: [],
|
||||
};
|
||||
removeToken();
|
||||
},
|
||||
},
|
||||
});
|
||||
63
src/types/js-modules.d.ts
vendored
Normal file
63
src/types/js-modules.d.ts
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
interface Window {
|
||||
$message: {
|
||||
error(message: string, options?: Record<string, unknown>): void;
|
||||
success(message: string, options?: Record<string, unknown>): void;
|
||||
warning(message: string, options?: Record<string, unknown>): void;
|
||||
info(message: string, options?: Record<string, unknown>): void;
|
||||
};
|
||||
$modal: {
|
||||
create(options: Record<string, unknown>): unknown;
|
||||
warning(options: Record<string, unknown>): unknown;
|
||||
};
|
||||
}
|
||||
|
||||
declare module "@/utils/auth" {
|
||||
export function getToken(): string | null;
|
||||
export function getTokenName(): string | null;
|
||||
export function setToken(tokenValue: string, tokenNameValue?: string): void;
|
||||
export function removeToken(): void;
|
||||
}
|
||||
|
||||
declare module "@/utils/auth.js" {
|
||||
export function getToken(): string | null;
|
||||
export function getTokenName(): string | null;
|
||||
export function setToken(tokenValue: string, tokenNameValue?: string): void;
|
||||
export function removeToken(): void;
|
||||
}
|
||||
|
||||
declare module "@/store/modules/user" {
|
||||
export function useUserStore(): {
|
||||
login(userInfo: Record<string, unknown>): Promise<boolean>;
|
||||
logout(): Promise<boolean>;
|
||||
};
|
||||
}
|
||||
|
||||
declare module "@/store/modules/user.js" {
|
||||
export function useUserStore(): {
|
||||
login(userInfo: Record<string, unknown>): Promise<boolean>;
|
||||
logout(): Promise<boolean>;
|
||||
};
|
||||
}
|
||||
|
||||
declare module "@/utils/passwordEncrypt.js" {
|
||||
export function encryptPasswordPayload<T extends Record<string, unknown>>(
|
||||
payload: T,
|
||||
fields: Array<keyof T>,
|
||||
): Promise<T>;
|
||||
}
|
||||
|
||||
declare module "@/utils/request" {
|
||||
import type { AxiosRequestConfig } from "axios";
|
||||
|
||||
export const BASE_URL: string;
|
||||
const request: <T = unknown>(config: AxiosRequestConfig) => Promise<T>;
|
||||
export default request;
|
||||
}
|
||||
|
||||
declare module "@/utils/request.js" {
|
||||
import type { AxiosRequestConfig } from "axios";
|
||||
|
||||
export const BASE_URL: string;
|
||||
const request: <T = unknown>(config: AxiosRequestConfig) => Promise<T>;
|
||||
export default request;
|
||||
}
|
||||
35
src/utils/auth.js
Normal file
35
src/utils/auth.js
Normal file
@@ -0,0 +1,35 @@
|
||||
const TokenKey = "token";
|
||||
const TOKEN_NAME_KEY = "tokenName";
|
||||
|
||||
let token = null;
|
||||
let tokenName = null;
|
||||
|
||||
export function getToken() {
|
||||
const storedToken = token || window.localStorage.getItem(TokenKey);
|
||||
if (!storedToken || storedToken === "null" || storedToken === "undefined") {
|
||||
token = null;
|
||||
window.localStorage.removeItem(TokenKey);
|
||||
return null;
|
||||
}
|
||||
token = storedToken;
|
||||
return token;
|
||||
}
|
||||
|
||||
export function setToken(tokenValue, tokenNameValue) {
|
||||
token = tokenValue;
|
||||
tokenName = tokenNameValue || "Authorization";
|
||||
window.localStorage.setItem(TokenKey, token);
|
||||
window.localStorage.setItem(TOKEN_NAME_KEY, tokenName);
|
||||
}
|
||||
|
||||
export function removeToken() {
|
||||
token = null;
|
||||
tokenName = null;
|
||||
window.localStorage.removeItem(TokenKey);
|
||||
window.localStorage.removeItem(TOKEN_NAME_KEY);
|
||||
}
|
||||
|
||||
export function getTokenName() {
|
||||
tokenName = tokenName || window.localStorage.getItem(TOKEN_NAME_KEY);
|
||||
return tokenName;
|
||||
}
|
||||
38
src/utils/passwordEncrypt.js
Normal file
38
src/utils/passwordEncrypt.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import { JSEncrypt } from 'jsencrypt'
|
||||
import { getPublicKey } from '@/api/user'
|
||||
|
||||
export async function getPasswordPublicKey() {
|
||||
const res = await getPublicKey()
|
||||
return res.data
|
||||
}
|
||||
|
||||
export function encryptPassword(plainText, publicKey) {
|
||||
const encryptor = new JSEncrypt()
|
||||
encryptor.setPublicKey(formatPublicKey(publicKey))
|
||||
const encrypted = encryptor.encrypt(plainText)
|
||||
if (!encrypted) {
|
||||
throw new Error('密码加密失败,请刷新页面后重试')
|
||||
}
|
||||
return encrypted
|
||||
}
|
||||
|
||||
export async function encryptPasswordPayload(payload, fields) {
|
||||
const publicKey = await getPasswordPublicKey()
|
||||
const nextPayload = { ...payload }
|
||||
for (const field of fields) {
|
||||
nextPayload[field] = encryptPassword(payload[field] || '', publicKey)
|
||||
}
|
||||
return nextPayload
|
||||
}
|
||||
|
||||
function formatPublicKey(publicKey) {
|
||||
if (!publicKey) {
|
||||
return ''
|
||||
}
|
||||
if (publicKey.includes('BEGIN PUBLIC KEY')) {
|
||||
return publicKey
|
||||
}
|
||||
const normalized = publicKey.replace(/\s+/g, '')
|
||||
const lines = normalized.match(/.{1,64}/g) || []
|
||||
return ['-----BEGIN PUBLIC KEY-----', ...lines, '-----END PUBLIC KEY-----'].join('\n')
|
||||
}
|
||||
108
src/utils/request.js
Normal file
108
src/utils/request.js
Normal file
@@ -0,0 +1,108 @@
|
||||
import axios from "axios";
|
||||
import { useUserStore } from "@/store/modules/user";
|
||||
import { getToken, getTokenName } from "@/utils/auth";
|
||||
import router from "@/router";
|
||||
|
||||
export const BASE_URL = import.meta.env.VITE_APP_BASE_API || "/api";
|
||||
|
||||
// create an axios instance
|
||||
const service = axios.create({
|
||||
method: "POST",
|
||||
baseURL: BASE_URL, // url = base url + request url
|
||||
withCredentials: false, // dont send cookies when cross-domain requests
|
||||
timeout: 20000, // request timeout
|
||||
});
|
||||
|
||||
// request interceptor
|
||||
service.interceptors.request.use(
|
||||
(config) => {
|
||||
// do something before request is sent
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
config.headers[getTokenName() || "Authorization"] = token; // 让每个请求携带自定义token 请根据实际情况自行修改
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
// do something with request error
|
||||
console.log(error); // for debug
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
// response interceptor
|
||||
service.interceptors.response.use(
|
||||
(response) => {
|
||||
if (response.data instanceof Blob) {
|
||||
return response.data;
|
||||
}
|
||||
const res = response.data;
|
||||
// 状态码不是0, 说明出错了
|
||||
if (res.code !== 200) {
|
||||
showApiMessage(res);
|
||||
|
||||
if (res.code === 401) {
|
||||
// to re-login
|
||||
handleNoLogin();
|
||||
}
|
||||
return Promise.reject(res);
|
||||
} else {
|
||||
return res;
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
console.log(error);
|
||||
if (error.response && error.response.status === 401) {
|
||||
if (error.response.data.msg) {
|
||||
if (error.response.data.msg.includes("权限")) {
|
||||
window.$message.error(error.response.data.msg, {
|
||||
duration: 5 * 1000,
|
||||
});
|
||||
} else {
|
||||
handleNoLogin();
|
||||
}
|
||||
} else {
|
||||
handleNoLogin();
|
||||
}
|
||||
} else {
|
||||
window.$message.error(error.message, { duration: 5 * 1000 });
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
export default service;
|
||||
|
||||
function showApiMessage(res) {
|
||||
window.$message.error(res.msg || "error", {
|
||||
duration: 5 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
// 弹窗确认防抖
|
||||
let noLoginAlert = null;
|
||||
|
||||
function handleNoLogin() {
|
||||
if (noLoginAlert == null) {
|
||||
noLoginAlert = window.$modal.create({
|
||||
type: "warning",
|
||||
preset: "dialog",
|
||||
title: "登录失效",
|
||||
content: "您的登录已过期,请重新登录",
|
||||
positiveText: "确认退出",
|
||||
maskClosable: false,
|
||||
closable: false,
|
||||
onPositiveClick: async () => {
|
||||
await useUserStore().logout();
|
||||
router.replace({
|
||||
path: "/login",
|
||||
query: { redirect: router.currentRoute.value.path },
|
||||
});
|
||||
noLoginAlert = null;
|
||||
},
|
||||
onClose: () => (noLoginAlert = null),
|
||||
});
|
||||
} else {
|
||||
console.log("跳过确认退出弹窗");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,44 +1,37 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, type Component } from "vue";
|
||||
import { computed } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import SwitchPageBtn from "./components/btn";
|
||||
import backgroundVideo from "./assets/循环背景动画.mp4";
|
||||
import AlarmManagementPage from "./pages/AlarmManagementPage.vue";
|
||||
import DataReportPage from "./pages/DataReportPage.vue";
|
||||
import EquipmentManagementPage from "./pages/EquipmentManagementPage.vue";
|
||||
import IntegratedCenterPage from "./pages/IntegratedCenterPage.vue";
|
||||
import PatrolPlanPage from "./pages/PatrolPlanPage.vue";
|
||||
import VideoCenterPage from "./pages/VideoCenterPage.vue";
|
||||
|
||||
const currentTab = ref<number>(1);
|
||||
interface DashboardTab {
|
||||
id: number;
|
||||
text: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
const tabs = {
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const tabs: Record<"left" | "right", DashboardTab[]> = {
|
||||
left: [
|
||||
{ id: 1, text: "监管中心" },
|
||||
{ id: 2, text: "设备管理" },
|
||||
{ id: 3, text: "视频中心" },
|
||||
{ id: 1, text: "监管中心", path: "/dashboard/integrated-center" },
|
||||
{ id: 2, text: "设备管理", path: "/dashboard/equipment-management" },
|
||||
{ id: 3, text: "视频中心", path: "/dashboard/video-center" },
|
||||
],
|
||||
right: [
|
||||
{ id: 4, text: "告警管理" },
|
||||
{ id: 5, text: "巡检记录" },
|
||||
{ id: 6, text: "统计分析" },
|
||||
{ id: 4, text: "告警管理", path: "/dashboard/alarm-management" },
|
||||
{ id: 5, text: "巡检记录", path: "/dashboard/patrol-plan" },
|
||||
{ id: 6, text: "统计分析", path: "/dashboard/data-report" },
|
||||
],
|
||||
};
|
||||
|
||||
const pageComponents: Record<number, Component> = {
|
||||
1: IntegratedCenterPage,
|
||||
2: EquipmentManagementPage,
|
||||
3: VideoCenterPage,
|
||||
4: AlarmManagementPage,
|
||||
5: PatrolPlanPage,
|
||||
6: DataReportPage,
|
||||
};
|
||||
const activeTabId = computed(() => Number(route.meta.tabId) || 1);
|
||||
|
||||
const currentViewComponent = computed<Component>(
|
||||
() => pageComponents[currentTab.value] ?? IntegratedCenterPage,
|
||||
);
|
||||
|
||||
function switchPage(tab: number) {
|
||||
currentTab.value = tab;
|
||||
function switchPage(tab: DashboardTab) {
|
||||
if (route.path !== tab.path) {
|
||||
router.push(tab.path);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -58,27 +51,27 @@ function switchPage(tab: number) {
|
||||
<SwitchPageBtn
|
||||
v-for="tab in tabs.left"
|
||||
:key="tab.id"
|
||||
:active="currentTab === tab.id"
|
||||
:active="activeTabId === tab.id"
|
||||
:text="tab.text"
|
||||
direction="left"
|
||||
@click="switchPage(tab.id)"
|
||||
@click="switchPage(tab)"
|
||||
/>
|
||||
</div>
|
||||
<div class="right">
|
||||
<SwitchPageBtn
|
||||
v-for="tab in tabs.right"
|
||||
:key="tab.id"
|
||||
:active="currentTab === tab.id"
|
||||
:active="activeTabId === tab.id"
|
||||
:text="tab.text"
|
||||
direction="right"
|
||||
@click="switchPage(tab.id)"
|
||||
@click="switchPage(tab)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content-stage">
|
||||
<KeepAlive>
|
||||
<component :is="currentViewComponent" />
|
||||
</KeepAlive>
|
||||
<RouterView v-slot="{ Component, route }">
|
||||
<component :is="Component" :key="route.fullPath" />
|
||||
</RouterView>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { NImage } from "naive-ui";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import {
|
||||
getAlarmPage,
|
||||
type RosInspectionRecordLineImage,
|
||||
} from "@/api/alarm";
|
||||
import { BASE_URL } from "@/utils/request";
|
||||
import { getRecentAlertPhoto } from "../shared/alertPhotos";
|
||||
|
||||
interface AlarmRecord {
|
||||
@@ -7,80 +13,55 @@ interface AlarmRecord {
|
||||
type: string;
|
||||
robot: string;
|
||||
photo: string;
|
||||
status: "待处理";
|
||||
status: RecognitionStatusText;
|
||||
time: string;
|
||||
position: string;
|
||||
result: string;
|
||||
warning: boolean;
|
||||
}
|
||||
|
||||
const alarms: AlarmRecord[] = [
|
||||
{
|
||||
id: "AL-001",
|
||||
type: "线缆破损",
|
||||
robot: "巡检机器人 R1",
|
||||
photo: getRecentAlertPhoto("线缆破损"),
|
||||
status: "待处理",
|
||||
time: "2026-06-23 10:24:18",
|
||||
},
|
||||
{
|
||||
id: "AL-002",
|
||||
type: "局部高温",
|
||||
robot: "巡检机器人 Q1",
|
||||
photo: getRecentAlertPhoto("局部高温"),
|
||||
status: "待处理",
|
||||
time: "2026-06-23 10:18:42",
|
||||
},
|
||||
{
|
||||
id: "AL-003",
|
||||
type: "墙体裂缝",
|
||||
robot: "巡检机器人 N3",
|
||||
photo: getRecentAlertPhoto("墙体裂缝"),
|
||||
status: "待处理",
|
||||
time: "2026-06-23 09:56:11",
|
||||
},
|
||||
{
|
||||
id: "AL-004",
|
||||
type: "水渍渗漏",
|
||||
robot: "巡检机器人 R2",
|
||||
photo: getRecentAlertPhoto("局部高温"),
|
||||
status: "待处理",
|
||||
time: "2026-06-23 09:20:36",
|
||||
},
|
||||
{
|
||||
id: "AL-005",
|
||||
type: "应急门开启异常",
|
||||
robot: "巡检机器人 N1",
|
||||
photo: getRecentAlertPhoto("线缆破损"),
|
||||
status: "待处理",
|
||||
time: "2026-06-23 08:42:57",
|
||||
},
|
||||
];
|
||||
type RecognitionStatusText = "待执行" | "执行中" | "执行成功" | "执行失败" | "未知状态";
|
||||
|
||||
const keyword = ref("");
|
||||
const pageSize = 4;
|
||||
interface AlarmPageResult {
|
||||
total?: number;
|
||||
data?: RosInspectionRecordLineImage[];
|
||||
}
|
||||
|
||||
interface AlarmPageResponse {
|
||||
data?: AlarmPageResult;
|
||||
}
|
||||
|
||||
const recognizeTypeMap: Record<number, string> = {
|
||||
100: "局部高温",
|
||||
1: "电缆表面破损",
|
||||
2: "桥架断裂",
|
||||
3: "隧道积水",
|
||||
4: "墙体裂缝",
|
||||
};
|
||||
|
||||
const recognitionStatusMap: Record<number, RecognitionStatusText> = {
|
||||
0: "待执行",
|
||||
1: "执行中",
|
||||
2: "执行成功",
|
||||
3: "执行失败",
|
||||
};
|
||||
|
||||
const alarmNo = ref("");
|
||||
const recognizeType = ref("");
|
||||
const startTime = ref("");
|
||||
const endTime = ref("");
|
||||
const alarms = ref<AlarmRecord[]>([]);
|
||||
const totalAlarms = ref(0);
|
||||
const listLoading = ref(false);
|
||||
const listError = ref("");
|
||||
const pageSize = 10;
|
||||
const currentPage = ref(1);
|
||||
|
||||
const filteredAlarms = computed(() => {
|
||||
const search = keyword.value.trim().toLowerCase();
|
||||
|
||||
if (!search) {
|
||||
return alarms;
|
||||
}
|
||||
|
||||
return alarms.filter((alarm) =>
|
||||
[alarm.id, alarm.type, alarm.robot, alarm.status]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(search),
|
||||
);
|
||||
});
|
||||
|
||||
const totalPages = computed(() =>
|
||||
Math.max(1, Math.ceil(filteredAlarms.value.length / pageSize)),
|
||||
Math.max(1, Math.ceil(totalAlarms.value / pageSize)),
|
||||
);
|
||||
|
||||
const pagedAlarms = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize;
|
||||
return filteredAlarms.value.slice(start, start + pageSize);
|
||||
});
|
||||
const pagedAlarms = computed(() => alarms.value);
|
||||
|
||||
const visiblePages = computed(() => {
|
||||
const pages: number[] = [];
|
||||
@@ -95,20 +76,124 @@ const visiblePages = computed(() => {
|
||||
});
|
||||
|
||||
function getAlarmStatusClass(status: AlarmRecord["status"]) {
|
||||
const classMap = {
|
||||
待处理: "alarm-status-handling",
|
||||
const classMap: Record<AlarmRecord["status"], string> = {
|
||||
待执行: "alarm-status-pending",
|
||||
执行中: "alarm-status-handling",
|
||||
执行成功: "alarm-status-finished",
|
||||
执行失败: "alarm-status-false",
|
||||
未知状态: "alarm-status-false",
|
||||
};
|
||||
|
||||
return classMap[status];
|
||||
}
|
||||
|
||||
function setPage(page: number) {
|
||||
currentPage.value = page;
|
||||
currentPage.value = Math.min(Math.max(page, 1), totalPages.value);
|
||||
fetchAlarmList();
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
currentPage.value = 1;
|
||||
fetchAlarmList();
|
||||
}
|
||||
|
||||
// 请求告警分页列表并更新表格数据
|
||||
async function fetchAlarmList() {
|
||||
listLoading.value = true;
|
||||
listError.value = "";
|
||||
|
||||
try {
|
||||
const response = (await getAlarmPage({
|
||||
page: currentPage.value,
|
||||
limit: pageSize,
|
||||
query: {
|
||||
recognizeType: normalizeRecognizeType(recognizeType.value),
|
||||
startTime: normalizeDateTime(startTime.value),
|
||||
endTime: normalizeDateTime(endTime.value),
|
||||
id: alarmNo.value.trim() || undefined,
|
||||
},
|
||||
})) as AlarmPageResponse;
|
||||
const result = response.data;
|
||||
const rows = result?.data ?? [];
|
||||
|
||||
alarms.value = rows.map((record, index) => normalizeAlarmRecord(record, index));
|
||||
totalAlarms.value = result?.total ?? rows.length;
|
||||
} catch (error) {
|
||||
listError.value = "近期告警加载失败";
|
||||
alarms.value = [];
|
||||
totalAlarms.value = 0;
|
||||
console.error(error);
|
||||
} finally {
|
||||
listLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 将后端告警记录转换为页面表格展示结构
|
||||
function normalizeAlarmRecord(record: RosInspectionRecordLineImage, index: number): AlarmRecord {
|
||||
const type = getRecognizeTypeText(record.recognizeType);
|
||||
|
||||
return {
|
||||
id: record.id || record.recordActionId || record.recordId || `AL-${index + 1}`,
|
||||
type,
|
||||
robot: getCarName(record),
|
||||
photo: buildFileAccessUrl(record.imageUrl || "") || getRecentAlertPhoto(type),
|
||||
status: getRecognitionStatusText(record.recognitionStatus),
|
||||
time: record.captureTime || record.createTime || "--",
|
||||
position: formatPosition(record),
|
||||
result: record.warningValue || record.recognitionResult || record.failReason || "--",
|
||||
warning: Boolean(record.warning),
|
||||
};
|
||||
}
|
||||
|
||||
function getCarName(record: RosInspectionRecordLineImage) {
|
||||
return record.car?.name || record.car?.deviceNo || record.car?.deviceKey || record.carId || "未知设备";
|
||||
}
|
||||
|
||||
function getRecognizeTypeText(type?: number) {
|
||||
return type ? recognizeTypeMap[type] ?? "未知告警" : "未知告警";
|
||||
}
|
||||
|
||||
function getRecognitionStatusText(status?: number) {
|
||||
return typeof status === "number" ? recognitionStatusMap[status] ?? "未知状态" : "未知状态";
|
||||
}
|
||||
|
||||
// 相对文件地址通过接口代理访问,避免开发环境直接请求后端静态路径失败
|
||||
function buildFileAccessUrl(fileUrl: string) {
|
||||
if (
|
||||
!fileUrl ||
|
||||
/^(https?:)?\/\//i.test(fileUrl) ||
|
||||
fileUrl.startsWith("blob:") ||
|
||||
fileUrl.startsWith("data:")
|
||||
) {
|
||||
return fileUrl;
|
||||
}
|
||||
|
||||
if (fileUrl === BASE_URL || fileUrl.startsWith(`${BASE_URL}/`)) {
|
||||
return fileUrl;
|
||||
}
|
||||
|
||||
return `${BASE_URL}${fileUrl.startsWith("/") ? fileUrl : `/${fileUrl}`}`;
|
||||
}
|
||||
|
||||
function normalizeDateTime(value: string) {
|
||||
return value ? value.replace("T", " ") : undefined;
|
||||
}
|
||||
|
||||
function normalizeRecognizeType(value: string) {
|
||||
return value ? Number(value) : undefined;
|
||||
}
|
||||
|
||||
function formatPosition(record: RosInspectionRecordLineImage) {
|
||||
if (typeof record.poseX !== "number" || typeof record.poseY !== "number") {
|
||||
return "--";
|
||||
}
|
||||
|
||||
return `X:${record.poseX.toFixed(2)} Y:${record.poseY.toFixed(2)}`;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchAlarmList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -117,16 +202,35 @@ function handleSearch() {
|
||||
<section class="page-card management-table-card">
|
||||
<div class="management-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<button class="page-action-btn ghost" type="button">导出告警</button>
|
||||
<select v-model="recognizeType" class="toolbar-select">
|
||||
<option value="">全部告警类型</option>
|
||||
<option value="100">局部高温</option>
|
||||
<option value="1">电缆表面破损</option>
|
||||
<option value="2">桥架断裂</option>
|
||||
<option value="3">隧道积水</option>
|
||||
<option value="4">墙体裂缝</option>
|
||||
</select>
|
||||
<input
|
||||
v-model="keyword"
|
||||
class="toolbar-input"
|
||||
placeholder="搜索告警编号 / 告警类型 / 报警机器人"
|
||||
type="text"
|
||||
@input="handleSearch"
|
||||
v-model="startTime"
|
||||
class="toolbar-input alarm-time-input"
|
||||
type="datetime-local"
|
||||
/>
|
||||
<input
|
||||
v-model="endTime"
|
||||
class="toolbar-input alarm-time-input"
|
||||
type="datetime-local"
|
||||
/>
|
||||
<input
|
||||
v-model="alarmNo"
|
||||
class="toolbar-input"
|
||||
placeholder="搜索告警编号"
|
||||
type="text"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<button class="page-action-btn primary" type="button" @click="handleSearch">搜索</button>
|
||||
<button class="page-action-btn ghost" type="button" @click="fetchAlarmList">刷新</button>
|
||||
</div>
|
||||
<div class="toolbar-right">共 {{ filteredAlarms.length }} 条告警记录</div>
|
||||
<div class="toolbar-right">共 {{ totalAlarms }} 条告警记录</div>
|
||||
</div>
|
||||
|
||||
<div class="table-shell">
|
||||
@@ -139,16 +243,33 @@ function handleSearch() {
|
||||
<th>取证照片</th>
|
||||
<th>案件状态</th>
|
||||
<th>告警时间</th>
|
||||
<th>操作</th>
|
||||
<th>位置</th>
|
||||
<th>识别结果</th>
|
||||
<th>是否预警</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="alarm in pagedAlarms" :key="alarm.id">
|
||||
<tr v-if="listLoading">
|
||||
<td class="table-empty" colspan="9">近期告警加载中...</td>
|
||||
</tr>
|
||||
<tr v-else-if="listError">
|
||||
<td class="table-empty" colspan="9">{{ listError }}</td>
|
||||
</tr>
|
||||
<tr v-else-if="!pagedAlarms.length">
|
||||
<td class="table-empty" colspan="9">暂无告警记录</td>
|
||||
</tr>
|
||||
<tr v-for="alarm in pagedAlarms" v-else :key="alarm.id">
|
||||
<td>{{ alarm.id }}</td>
|
||||
<td>{{ alarm.type }}</td>
|
||||
<td>{{ alarm.robot }}</td>
|
||||
<td>
|
||||
<img class="evidence-photo" :src="alarm.photo" :alt="`${alarm.type}取证照片`" />
|
||||
<NImage
|
||||
class="evidence-photo"
|
||||
:src="alarm.photo"
|
||||
:alt="`${alarm.type}取证照片`"
|
||||
object-fit="contain"
|
||||
:previewed-img-props="{ style: { maxWidth: '82vw', maxHeight: '82vh', objectFit: 'contain' } }"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<span :class="['table-status', getAlarmStatusClass(alarm.status)]">
|
||||
@@ -156,11 +277,9 @@ function handleSearch() {
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ alarm.time }}</td>
|
||||
<td>
|
||||
<div class="table-actions">
|
||||
<button class="danger" type="button">删除</button>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ alarm.position }}</td>
|
||||
<td>{{ alarm.result }}</td>
|
||||
<td>{{ alarm.warning ? "是" : "否" }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -169,7 +288,7 @@ function handleSearch() {
|
||||
<div class="pagination-bar">
|
||||
<button
|
||||
class="pagination-btn"
|
||||
:disabled="currentPage === 1"
|
||||
:disabled="currentPage === 1 || listLoading"
|
||||
type="button"
|
||||
@click="setPage(currentPage - 1)"
|
||||
>
|
||||
@@ -179,6 +298,7 @@ function handleSearch() {
|
||||
v-for="page in visiblePages"
|
||||
:key="page"
|
||||
:class="['pagination-btn', { active: currentPage === page }]"
|
||||
:disabled="listLoading"
|
||||
type="button"
|
||||
@click="setPage(page)"
|
||||
>
|
||||
@@ -186,7 +306,7 @@ function handleSearch() {
|
||||
</button>
|
||||
<button
|
||||
class="pagination-btn"
|
||||
:disabled="currentPage === totalPages"
|
||||
:disabled="currentPage === totalPages || listLoading"
|
||||
type="button"
|
||||
@click="setPage(currentPage + 1)"
|
||||
>
|
||||
@@ -197,3 +317,9 @@ function handleSearch() {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.alarm-time-input {
|
||||
width: 190px;
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,59 +1,77 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, nextTick, ref } from "vue";
|
||||
import monitorVideo from "../assets/循环背景动画.mp4";
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { getRosCarList } from "@/api/rosCar";
|
||||
|
||||
type GridMode = 1 | 4 | 9;
|
||||
type CameraType = "可见光" | "红外";
|
||||
type DeviceStatus = "空闲" | "巡检" | "离线" | "充电中";
|
||||
|
||||
interface RosCarRecord {
|
||||
id?: string;
|
||||
deviceNo?: string;
|
||||
name?: string;
|
||||
batteryLevel?: number;
|
||||
status?: number;
|
||||
projectName?: string;
|
||||
cameraWebrtcUrl?: string;
|
||||
cameraNo?: string;
|
||||
cameraWebrtcUrl2?: string;
|
||||
cameraNo2?: string;
|
||||
}
|
||||
|
||||
interface RosCarListResult {
|
||||
total?: number;
|
||||
data?: RosCarRecord[];
|
||||
}
|
||||
|
||||
interface RosCarListResponse {
|
||||
data?: RosCarListResult;
|
||||
}
|
||||
|
||||
interface CameraItem {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
status: string;
|
||||
type: CameraType;
|
||||
status: DeviceStatus;
|
||||
url: string;
|
||||
cameraNo?: string;
|
||||
}
|
||||
|
||||
interface DeviceNode {
|
||||
id: string;
|
||||
deviceNo?: string;
|
||||
name: string;
|
||||
project: string;
|
||||
status: DeviceStatus;
|
||||
battery: string;
|
||||
cameras: CameraItem[];
|
||||
}
|
||||
|
||||
interface ProjectNode {
|
||||
id: string;
|
||||
name: string;
|
||||
devices: DeviceNode[];
|
||||
}
|
||||
|
||||
interface ProjectCameraItem extends CameraItem {
|
||||
deviceId: string;
|
||||
deviceName: string;
|
||||
project: string;
|
||||
}
|
||||
|
||||
const projectData: ProjectNode = {
|
||||
id: "project-1",
|
||||
name: "西江隧道",
|
||||
devices: [
|
||||
{
|
||||
id: "ROBOT-001",
|
||||
name: "巡检机器人 R1",
|
||||
cameras: [
|
||||
{ id: "CAM-001", name: "白光镜头", type: "白光", status: "在线" },
|
||||
{ id: "CAM-002", name: "红外镜头", type: "红外", status: "在线" },
|
||||
{ id: "CAM-003", name: "云台辅助镜头", type: "辅助", status: "在线" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "ROBOT-002",
|
||||
name: "巡检机器人 R2",
|
||||
cameras: [
|
||||
{ id: "CAM-004", name: "白光镜头", type: "白光", status: "在线" },
|
||||
{ id: "CAM-005", name: "红外镜头", type: "红外", status: "在线" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
interface SlotStreamState {
|
||||
loading: boolean;
|
||||
error: string;
|
||||
}
|
||||
|
||||
const defaultProject = "视频中心";
|
||||
const gridMode = ref<GridMode>(4);
|
||||
const activeCameraId = ref(projectData.devices[0].cameras[0].id);
|
||||
const activeSlotIndex = ref(0);
|
||||
const videoSlots = ref<Array<ProjectCameraItem | null>>([]);
|
||||
const slotVideoRefs = ref<Array<HTMLVideoElement | null>>([]);
|
||||
const slotStreamStates = ref<SlotStreamState[]>([]);
|
||||
const videoGridBoardRef = ref<HTMLElement | null>(null);
|
||||
const videoGridBoardStyle = ref<Record<string, string>>({});
|
||||
const slotPeerConnections = new Map<number, RTCPeerConnection>();
|
||||
const slotRequestIds = new Map<number, number>();
|
||||
let videoGridResizeObserver: ResizeObserver | null = null;
|
||||
const devices = ref<DeviceNode[]>([]);
|
||||
const listLoading = ref(false);
|
||||
const listError = ref("");
|
||||
const playbackDialogVisible = ref(false);
|
||||
const playbackStartAt = ref("2026-06-23T14:20");
|
||||
const playbackEndAt = ref("2026-06-23T14:50");
|
||||
@@ -61,46 +79,39 @@ const playbackProgress = ref(0);
|
||||
const playbackPaused = ref(true);
|
||||
const playbackPlayerRef = ref<HTMLVideoElement | null>(null);
|
||||
|
||||
const currentProject = computed(() => projectData);
|
||||
const slotCountMap: Record<GridMode, number> = {
|
||||
1: 1,
|
||||
4: 4,
|
||||
9: 9,
|
||||
};
|
||||
|
||||
const projectCameras = computed<ProjectCameraItem[]>(() =>
|
||||
currentProject.value.devices.flatMap((device) =>
|
||||
const currentProjectName = computed(() => devices.value[0]?.project || defaultProject);
|
||||
|
||||
const playableCameras = computed<ProjectCameraItem[]>(() =>
|
||||
devices.value.flatMap((device) =>
|
||||
device.cameras.map((camera) => ({
|
||||
...camera,
|
||||
deviceId: device.id,
|
||||
deviceName: device.name,
|
||||
project: device.project,
|
||||
})),
|
||||
),
|
||||
);
|
||||
|
||||
const activeCamera = computed(
|
||||
() =>
|
||||
projectCameras.value.find((camera) => camera.id === activeCameraId.value) ??
|
||||
projectCameras.value[0],
|
||||
);
|
||||
const cameraTotal = computed(() => playableCameras.value.length);
|
||||
|
||||
const orderedCameras = computed(() => {
|
||||
const selected = activeCamera.value;
|
||||
const visibleSlots = computed(() => {
|
||||
const targetCount = slotCountMap[gridMode.value];
|
||||
|
||||
return projectCameras.value
|
||||
.filter((camera) => camera.id !== selected.id)
|
||||
.reduce<ProjectCameraItem[]>((list, camera) => {
|
||||
list.push(camera);
|
||||
return list;
|
||||
}, [selected]);
|
||||
return Array.from(
|
||||
{ length: targetCount },
|
||||
(_, index) => videoSlots.value[index] ?? null,
|
||||
);
|
||||
});
|
||||
|
||||
const visibleCameras = computed(() => {
|
||||
const countMap: Record<GridMode, number> = {
|
||||
1: 1,
|
||||
4: 4,
|
||||
9: 9,
|
||||
};
|
||||
const targetCount = countMap[gridMode.value];
|
||||
const cameras = orderedCameras.value;
|
||||
const activeCamera = computed(() => visibleSlots.value[activeSlotIndex.value] ?? null);
|
||||
|
||||
return Array.from({ length: targetCount }, (_, index) => cameras[index] ?? null);
|
||||
});
|
||||
const activeSlotLabel = computed(() => `画面 ${activeSlotIndex.value + 1}`);
|
||||
|
||||
const playbackRangeLabel = computed(() => {
|
||||
const startLabel = playbackStartAt.value.replace("T", " ");
|
||||
@@ -108,14 +119,311 @@ const playbackRangeLabel = computed(() => {
|
||||
return `${startLabel} - ${endLabel}`;
|
||||
});
|
||||
|
||||
function selectCamera(cameraId: string) {
|
||||
activeCameraId.value = cameraId;
|
||||
// 根据视频墙实际可用宽高计算宫格尺寸,保证每个画面都是 16:9 且不会溢出卡片
|
||||
function updateVideoGridSize() {
|
||||
const board = videoGridBoardRef.value;
|
||||
|
||||
if (!board) {
|
||||
return;
|
||||
}
|
||||
|
||||
const columns = Math.sqrt(gridMode.value);
|
||||
const rows = columns;
|
||||
const gap = 8;
|
||||
const availableWidth = board.clientWidth - gap * (columns - 1);
|
||||
const availableHeight = board.clientHeight - gap * (rows - 1);
|
||||
const cellWidthByContainerWidth = availableWidth / columns;
|
||||
const cellWidthByContainerHeight = (availableHeight / rows) * (16 / 9);
|
||||
const cellWidth = Math.max(0, Math.min(cellWidthByContainerWidth, cellWidthByContainerHeight));
|
||||
const cellHeight = cellWidth * (9 / 16);
|
||||
|
||||
videoGridBoardStyle.value = {
|
||||
gridTemplateColumns: `repeat(${columns}, ${cellWidth}px)`,
|
||||
gridAutoRows: `${cellHeight}px`,
|
||||
};
|
||||
}
|
||||
|
||||
function setGridMode(mode: GridMode) {
|
||||
// 根据当前宫格数量补齐画面槽位,切换布局时保留已播放的视频
|
||||
function ensureVideoSlotCount() {
|
||||
const targetCount = slotCountMap[gridMode.value];
|
||||
const previousCount = videoSlots.value.length;
|
||||
|
||||
for (let index = targetCount; index < previousCount; index += 1) {
|
||||
stopSlotStream(index);
|
||||
}
|
||||
|
||||
videoSlots.value = Array.from(
|
||||
{ length: targetCount },
|
||||
(_, index) => videoSlots.value[index] ?? null,
|
||||
);
|
||||
slotVideoRefs.value = Array.from(
|
||||
{ length: targetCount },
|
||||
(_, index) => slotVideoRefs.value[index] ?? null,
|
||||
);
|
||||
slotStreamStates.value = Array.from(
|
||||
{ length: targetCount },
|
||||
(_, index) => slotStreamStates.value[index] ?? { loading: false, error: "" },
|
||||
);
|
||||
|
||||
if (activeSlotIndex.value >= targetCount) {
|
||||
activeSlotIndex.value = targetCount - 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 将接口的小车状态码转换为页面展示状态
|
||||
function normalizeDeviceStatus(status?: number): DeviceStatus {
|
||||
const statusMap: Record<number, DeviceStatus> = {
|
||||
0: "空闲",
|
||||
1: "巡检",
|
||||
2: "离线",
|
||||
3: "充电中",
|
||||
};
|
||||
|
||||
return statusMap[status ?? 2] ?? "离线";
|
||||
}
|
||||
|
||||
// 根据小车视频地址生成可播放的相机节点
|
||||
function buildCameraItem(
|
||||
car: RosCarRecord,
|
||||
deviceId: string,
|
||||
type: CameraType,
|
||||
url?: string,
|
||||
cameraNo?: string,
|
||||
): CameraItem | null {
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: `${deviceId}-${type}`,
|
||||
name: `${type}相机`,
|
||||
type,
|
||||
status: normalizeDeviceStatus(car.status),
|
||||
url,
|
||||
cameraNo,
|
||||
};
|
||||
}
|
||||
|
||||
// 将设备列表接口的小车记录转换为左侧机器人树节点
|
||||
function normalizeDeviceNode(car: RosCarRecord, index: number): DeviceNode {
|
||||
const deviceId = car.id ?? `ROBOT-${index + 1}`;
|
||||
const cameras = [
|
||||
buildCameraItem(car, deviceId, "可见光", car.cameraWebrtcUrl, car.cameraNo),
|
||||
buildCameraItem(car, deviceId, "红外", car.cameraWebrtcUrl2, car.cameraNo2),
|
||||
].filter((camera): camera is CameraItem => Boolean(camera));
|
||||
|
||||
return {
|
||||
id: deviceId,
|
||||
deviceNo: car.deviceNo,
|
||||
name: car.name || car.deviceNo || `机器人 ${index + 1}`,
|
||||
project: car.projectName || defaultProject,
|
||||
status: normalizeDeviceStatus(car.status),
|
||||
battery: typeof car.batteryLevel === "number" ? `${car.batteryLevel}%` : "--",
|
||||
cameras,
|
||||
};
|
||||
}
|
||||
|
||||
// 请求设备列表接口,加载左侧机器人及其可见光/红外相机
|
||||
async function fetchDeviceList() {
|
||||
listLoading.value = true;
|
||||
listError.value = "";
|
||||
|
||||
try {
|
||||
const response = (await getRosCarList({
|
||||
page: 1,
|
||||
limit: 999,
|
||||
query: {},
|
||||
})) as RosCarListResponse;
|
||||
const rows = response.data?.data ?? [];
|
||||
|
||||
devices.value = rows.map((car, index) => normalizeDeviceNode(car, index));
|
||||
} catch (error) {
|
||||
listError.value = "设备列表加载失败";
|
||||
console.error(error);
|
||||
} finally {
|
||||
listLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 选中右侧视频墙中的目标画面
|
||||
function selectVideoSlot(index: number) {
|
||||
activeSlotIndex.value = index;
|
||||
}
|
||||
|
||||
// 停止全部画面的 WebRTC 拉流,用于页面卸载时释放资源
|
||||
function stopAllSlotStreams() {
|
||||
videoSlots.value.forEach((_, index) => stopSlotStream(index));
|
||||
}
|
||||
|
||||
// 停止指定画面的 WebRTC 拉流并释放播放器资源
|
||||
function stopSlotStream(index: number, clearState = true) {
|
||||
slotRequestIds.set(index, (slotRequestIds.get(index) ?? 0) + 1);
|
||||
|
||||
if (clearState) {
|
||||
slotStreamStates.value[index] = { loading: false, error: "" };
|
||||
}
|
||||
|
||||
const peerConnection = slotPeerConnections.get(index);
|
||||
if (peerConnection) {
|
||||
peerConnection.close();
|
||||
slotPeerConnections.delete(index);
|
||||
}
|
||||
|
||||
const video = slotVideoRefs.value[index];
|
||||
if (video) {
|
||||
video.srcObject = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 将设备返回的 webrtc 播放地址转换为 ZLMediaKit WebRTC 播放接口地址
|
||||
function buildWebrtcApiUrl(streamUrl: string) {
|
||||
const normalizedUrl = streamUrl.replace(/^webrtc:\/\//, "https://");
|
||||
const url = new URL(normalizedUrl);
|
||||
|
||||
return `${url.origin}${url.pathname}${url.search}`;
|
||||
}
|
||||
|
||||
// 等待本地 ICE 候选收集完成,确保发给流媒体服务的 offer 信息完整
|
||||
function waitForIceGatheringComplete(peerConnection: RTCPeerConnection) {
|
||||
if (peerConnection.iceGatheringState === "complete") {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
const handleIceGatheringStateChange = () => {
|
||||
if (peerConnection.iceGatheringState === "complete") {
|
||||
peerConnection.removeEventListener("icegatheringstatechange", handleIceGatheringStateChange);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
peerConnection.addEventListener("icegatheringstatechange", handleIceGatheringStateChange);
|
||||
});
|
||||
}
|
||||
|
||||
// 收集视频墙每个画面对应的 video 元素,供 WebRTC ontrack 写入媒体流
|
||||
function setSlotVideoRef(element: Element | { $el?: Element } | null, index: number) {
|
||||
const target = element instanceof Element ? element : element?.$el;
|
||||
slotVideoRefs.value[index] = target instanceof HTMLVideoElement ? target : null;
|
||||
}
|
||||
|
||||
// 通过 WebRTC offer/answer 将指定相机拉流到选中的画面槽位
|
||||
async function playSlotStream(index: number, camera: ProjectCameraItem) {
|
||||
stopSlotStream(index);
|
||||
|
||||
if (!window.RTCPeerConnection) {
|
||||
slotStreamStates.value[index] = { loading: false, error: "当前浏览器不支持 WebRTC 播放" };
|
||||
return;
|
||||
}
|
||||
|
||||
const video = slotVideoRefs.value[index];
|
||||
if (!video) {
|
||||
slotStreamStates.value[index] = { loading: false, error: "播放器未准备好" };
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = slotRequestIds.get(index) ?? 0;
|
||||
const peerConnection = new RTCPeerConnection();
|
||||
slotPeerConnections.set(index, peerConnection);
|
||||
slotStreamStates.value[index] = { loading: true, error: "" };
|
||||
|
||||
peerConnection.addTransceiver("video", { direction: "recvonly" });
|
||||
peerConnection.addTransceiver("audio", { direction: "recvonly" });
|
||||
peerConnection.ontrack = (event) => {
|
||||
if (requestId !== slotRequestIds.get(index)) {
|
||||
return;
|
||||
}
|
||||
|
||||
video.srcObject = event.streams[0];
|
||||
};
|
||||
|
||||
try {
|
||||
const offer = await peerConnection.createOffer();
|
||||
await peerConnection.setLocalDescription(offer);
|
||||
await waitForIceGatheringComplete(peerConnection);
|
||||
|
||||
const response = await fetch(buildWebrtcApiUrl(camera.url), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/sdp",
|
||||
},
|
||||
body: peerConnection.localDescription?.sdp ?? offer.sdp,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`拉流失败:${response.status}`);
|
||||
}
|
||||
|
||||
const responseText = await response.text();
|
||||
let answer = responseText;
|
||||
|
||||
try {
|
||||
const responseData = JSON.parse(responseText) as { code?: number; sdp?: string; msg?: string };
|
||||
|
||||
if (responseData.code && responseData.code !== 0) {
|
||||
throw new Error(responseData.msg || "视频流拉取失败");
|
||||
}
|
||||
|
||||
answer = responseData.sdp || responseText;
|
||||
} catch (error) {
|
||||
if (responseText.trim().startsWith("{")) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (requestId !== slotRequestIds.get(index)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await peerConnection.setRemoteDescription({
|
||||
type: "answer",
|
||||
sdp: answer,
|
||||
});
|
||||
} catch (error) {
|
||||
if (requestId === slotRequestIds.get(index)) {
|
||||
slotStreamStates.value[index] = {
|
||||
loading: false,
|
||||
error: error instanceof Error ? error.message : "视频流拉取失败",
|
||||
};
|
||||
stopSlotStream(index, false);
|
||||
}
|
||||
} finally {
|
||||
if (requestId === slotRequestIds.get(index)) {
|
||||
slotStreamStates.value[index] = {
|
||||
...slotStreamStates.value[index],
|
||||
loading: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭指定画面并释放对应 WebRTC 拉流资源
|
||||
function closeVideoSlot(index: number) {
|
||||
stopSlotStream(index);
|
||||
videoSlots.value[index] = null;
|
||||
|
||||
if (activeSlotIndex.value === index) {
|
||||
activeSlotIndex.value = index;
|
||||
}
|
||||
}
|
||||
|
||||
// 将左侧相机播放到当前选中的右侧画面
|
||||
async function playCamera(camera: ProjectCameraItem) {
|
||||
const targetIndex = activeSlotIndex.value;
|
||||
videoSlots.value[targetIndex] = camera;
|
||||
|
||||
await nextTick();
|
||||
await playSlotStream(targetIndex, camera);
|
||||
}
|
||||
|
||||
// 切换视频墙宫格布局
|
||||
async function setGridMode(mode: GridMode) {
|
||||
gridMode.value = mode;
|
||||
await nextTick();
|
||||
updateVideoGridSize();
|
||||
}
|
||||
|
||||
// 同步录像回放播放器进度和暂停状态
|
||||
function syncPlaybackProgress() {
|
||||
const video = playbackPlayerRef.value;
|
||||
|
||||
@@ -130,6 +438,7 @@ function syncPlaybackProgress() {
|
||||
playbackPaused.value = video.paused;
|
||||
}
|
||||
|
||||
// 从头开始播放当前回放视频
|
||||
function startPlayback() {
|
||||
const video = playbackPlayerRef.value;
|
||||
|
||||
@@ -143,7 +452,12 @@ function startPlayback() {
|
||||
syncPlaybackProgress();
|
||||
}
|
||||
|
||||
// 打开当前画面的录像回放弹窗
|
||||
async function openPlaybackDialog() {
|
||||
if (!activeCamera.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
playbackDialogVisible.value = true;
|
||||
playbackProgress.value = 0;
|
||||
playbackPaused.value = true;
|
||||
@@ -152,12 +466,14 @@ async function openPlaybackDialog() {
|
||||
startPlayback();
|
||||
}
|
||||
|
||||
// 关闭录像回放弹窗并暂停播放器
|
||||
function closePlaybackDialog() {
|
||||
playbackPlayerRef.value?.pause();
|
||||
playbackDialogVisible.value = false;
|
||||
playbackPaused.value = true;
|
||||
}
|
||||
|
||||
// 切换录像回放的播放和暂停状态
|
||||
function togglePlaybackPause() {
|
||||
const video = playbackPlayerRef.value;
|
||||
|
||||
@@ -174,6 +490,7 @@ function togglePlaybackPause() {
|
||||
syncPlaybackProgress();
|
||||
}
|
||||
|
||||
// 按秒数快进或快退录像回放
|
||||
function seekPlayback(offsetSeconds: number) {
|
||||
const video = playbackPlayerRef.value;
|
||||
|
||||
@@ -187,6 +504,7 @@ function seekPlayback(offsetSeconds: number) {
|
||||
syncPlaybackProgress();
|
||||
}
|
||||
|
||||
// 根据拖动条位置更新录像回放播放进度
|
||||
function updatePlaybackProgress() {
|
||||
const video = playbackPlayerRef.value;
|
||||
|
||||
@@ -201,32 +519,83 @@ function updatePlaybackProgress() {
|
||||
|
||||
video.currentTime = (playbackProgress.value / 100) * duration;
|
||||
}
|
||||
|
||||
watch(gridMode, async () => {
|
||||
ensureVideoSlotCount();
|
||||
await nextTick();
|
||||
updateVideoGridSize();
|
||||
}, { immediate: true });
|
||||
onMounted(() => {
|
||||
void fetchDeviceList();
|
||||
updateVideoGridSize();
|
||||
|
||||
if (videoGridBoardRef.value) {
|
||||
videoGridResizeObserver = new ResizeObserver(updateVideoGridSize);
|
||||
videoGridResizeObserver.observe(videoGridBoardRef.value);
|
||||
}
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
videoGridResizeObserver?.disconnect();
|
||||
stopAllSlotStreams();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-shell video-center-page video-center-shell">
|
||||
<div class="video-center-layout">
|
||||
<aside class="page-card video-tree-card">
|
||||
<div class="page-card-title">摄像头列表</div>
|
||||
<div class="page-card-title">机器人列表</div>
|
||||
<div class="tree-panel">
|
||||
<div class="tree-panel-project">
|
||||
<div class="tree-panel-project-name">{{ currentProject.name }}</div>
|
||||
<div class="tree-panel-project-meta">共 {{ projectCameras.length }} 路摄像头</div>
|
||||
</div>
|
||||
<div class="tree-device-list">
|
||||
<button
|
||||
v-for="camera in projectCameras"
|
||||
:key="camera.id"
|
||||
:class="['tree-device-btn', { active: activeCameraId === camera.id }]"
|
||||
type="button"
|
||||
@click="selectCamera(camera.id)"
|
||||
>
|
||||
<span class="tree-device-name">{{ camera.name }}</span>
|
||||
<span class="tree-device-id">{{ camera.deviceName }} / {{ camera.id }}</span>
|
||||
</button>
|
||||
<!-- <div class="tree-panel-project">
|
||||
<div class="tree-panel-project-name">{{ currentProjectName }}</div>
|
||||
<div class="tree-panel-project-meta">
|
||||
共 {{ devices.length }} 台机器人 / {{ cameraTotal }} 路视频
|
||||
</div>
|
||||
<div class="tree-panel-project-meta">当前目标:{{ activeSlotLabel }}</div>
|
||||
</div> -->
|
||||
|
||||
<div v-if="listLoading" class="tree-loading">设备列表加载中...</div>
|
||||
<div v-else-if="listError" class="tree-error">{{ listError }}</div>
|
||||
<div v-else class="tree-device-list">
|
||||
<div v-if="!devices.length" class="tree-list-empty">暂无机器人</div>
|
||||
<div v-for="device in devices" :key="device.id" class="tree-robot-node">
|
||||
<div class="tree-robot-header">
|
||||
<span class="tree-device-name">{{ device.name }}</span>
|
||||
<span class="tree-status-line">{{ device.status }} / 电量 {{ device.battery }}</span>
|
||||
</div>
|
||||
<div class="tree-device-id">
|
||||
{{ device.deviceNo || device.id }} / {{ device.project }}
|
||||
</div>
|
||||
|
||||
<div v-if="device.cameras.length" class="tree-camera-list">
|
||||
<button
|
||||
v-for="camera in device.cameras"
|
||||
:key="camera.id"
|
||||
:class="[
|
||||
'tree-device-btn',
|
||||
{ active: activeCamera?.id === camera.id },
|
||||
]"
|
||||
type="button"
|
||||
@click="playCamera({ ...camera, deviceId: device.id, deviceName: device.name, project: device.project })"
|
||||
>
|
||||
<span class="tree-device-name">{{ camera.name }}</span>
|
||||
<span class="tree-device-id">
|
||||
{{ camera.cameraNo || camera.type }} / {{ camera.status }}
|
||||
</span>
|
||||
<span class="tree-device-action">播放到 {{ activeSlotLabel }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="tree-list-empty">暂无视频地址</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tree-panel-footer">
|
||||
<button class="tree-playback-btn" type="button" @click="openPlaybackDialog">
|
||||
<button
|
||||
class="tree-playback-btn"
|
||||
type="button"
|
||||
:disabled="!activeCamera"
|
||||
@click="openPlaybackDialog"
|
||||
>
|
||||
录像回放
|
||||
</button>
|
||||
</div>
|
||||
@@ -238,8 +607,10 @@ function updatePlaybackProgress() {
|
||||
<div>
|
||||
<div class="page-card-title">视频播放</div>
|
||||
<div class="video-wall-meta">
|
||||
{{ currentProject.name }} / {{ activeCamera.name }}
|
||||
{{ activeSlotLabel }} /
|
||||
{{ activeCamera ? `${activeCamera.deviceName} - ${activeCamera.name}` : "未播放" }}
|
||||
</div>
|
||||
<div class="video-wall-meta">先选中右侧画面,再从左侧点击相机播放</div>
|
||||
</div>
|
||||
<div class="grid-switch">
|
||||
<button
|
||||
@@ -254,26 +625,54 @@ function updatePlaybackProgress() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="['video-grid-board', `grid-${gridMode}`]">
|
||||
<div
|
||||
ref="videoGridBoardRef"
|
||||
:class="['video-grid-board', `grid-${gridMode}`]"
|
||||
:style="videoGridBoardStyle"
|
||||
>
|
||||
<div
|
||||
v-for="(camera, index) in visibleCameras"
|
||||
:key="camera?.id ?? `empty-${index}`"
|
||||
class="video-grid-cell"
|
||||
v-for="(camera, index) in visibleSlots"
|
||||
:key="camera?.id ? `${camera.id}-${index}` : `empty-${index}`"
|
||||
:class="[
|
||||
'video-grid-cell',
|
||||
{ active: activeSlotIndex === index, empty: !camera },
|
||||
]"
|
||||
@click="selectVideoSlot(index)"
|
||||
>
|
||||
<template v-if="camera">
|
||||
<video autoplay loop muted playsinline :src="monitorVideo"></video>
|
||||
<video
|
||||
:ref="(element) => setSlotVideoRef(element, index)"
|
||||
autoplay
|
||||
muted
|
||||
playsinline
|
||||
></video>
|
||||
<div class="video-cell-top">
|
||||
<span class="cell-badge">LIVE</span>
|
||||
<span class="cell-type">{{ camera.type }}</span>
|
||||
<button
|
||||
class="video-close-btn"
|
||||
type="button"
|
||||
title="关闭画面"
|
||||
@click.stop="closeVideoSlot(index)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div class="video-cell-bottom">
|
||||
<div class="cell-name">{{ camera.name }}</div>
|
||||
<div class="cell-meta">{{ camera.deviceName }} / {{ camera.status }}</div>
|
||||
<div>
|
||||
<div class="cell-name">{{ camera.name }}</div>
|
||||
<div class="cell-meta">{{ camera.deviceName }} / {{ camera.status }}</div>
|
||||
</div>
|
||||
<div class="cell-meta">{{ activeSlotIndex === index ? "已选中" : `画面 ${index + 1}` }}</div>
|
||||
</div>
|
||||
<div v-if="slotStreamStates[index]?.error" class="video-empty stream-message">
|
||||
{{ slotStreamStates[index]?.error }}
|
||||
</div>
|
||||
<div v-else-if="slotStreamStates[index]?.loading" class="video-empty stream-message">
|
||||
视频流连接中...
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="video-empty">
|
||||
<span>未分配画面</span>
|
||||
<span>{{ activeSlotIndex === index ? "已选中,点击左侧相机播放" : "点击选中画面" }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -314,7 +713,6 @@ function updatePlaybackProgress() {
|
||||
<div class="playback-preview">
|
||||
<video
|
||||
ref="playbackPlayerRef"
|
||||
:src="monitorVideo"
|
||||
playsinline
|
||||
@ended="syncPlaybackProgress"
|
||||
@loadedmetadata="syncPlaybackProgress"
|
||||
@@ -322,6 +720,7 @@ function updatePlaybackProgress() {
|
||||
@play="syncPlaybackProgress"
|
||||
@timeupdate="syncPlaybackProgress"
|
||||
></video>
|
||||
<div class="video-empty stream-message">录像回放需接入回放拉流地址</div>
|
||||
</div>
|
||||
|
||||
<div class="playback-dialog-controls">
|
||||
|
||||
162
src/views/dashboard1/pages/小车状态WS推送协议.md
Normal file
162
src/views/dashboard1/pages/小车状态WS推送协议.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# 小车状态 WS 推送协议
|
||||
|
||||
## 适用范围
|
||||
|
||||
本文档说明 ROS 小车状态通过 WebSocket 上报到后端,以及后端向浏览器推送 `car_status` 消息的对接方式。
|
||||
|
||||
- 浏览器订阅地址:`ws://{host}:{port}/ws/ros?token={token}`
|
||||
- 小车设备连接地址:`ws://{host}:{port}/ws/ros?deviceKey={deviceKey}`
|
||||
|
||||
浏览器连接成功后无需额外发送订阅指令,后端会把收到的小车状态广播给当前所有在线浏览器会话。
|
||||
|
||||
消息统一使用 JSON,心跳兼容字符串消息:收到 `ping` 时回复 `pong`;主动发送 `ping` 时对端回复 `pong`。
|
||||
|
||||
## 小车上报消息
|
||||
|
||||
消息类型:`telemetry`
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "telemetry",
|
||||
"data": {
|
||||
"batteryLevel": 86,
|
||||
"voltage": 24.6,
|
||||
"poseX": 12.345,
|
||||
"poseY": 6.789,
|
||||
"poseYaw": 1.57,
|
||||
"status": 1,
|
||||
"signal": "-58dBm"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
| ------------------- | ---- | -------------------------------------------------- |
|
||||
| `type` | 是 | 固定为 `telemetry` |
|
||||
| `data.batteryLevel` | 否 | 电量百分比 |
|
||||
| `data.voltage` | 否 | 电压 |
|
||||
| `data.poseX` | 否 | 当前 X 坐标 |
|
||||
| `data.poseY` | 否 | 当前 Y 坐标 |
|
||||
| `data.poseYaw` | 否 | 当前朝向 |
|
||||
| `data.status` | 否 | 小车状态:`0` 空闲,`1` 巡检,`2` 离线,`3` 充电中 |
|
||||
| `data.signal` | 否 | 小车信号 |
|
||||
|
||||
说明:
|
||||
|
||||
- 支持增量上报,`data` 中只传有变化的字段即可。
|
||||
- 后端只会更新本次上报里有值的字段,未传字段保持上一次状态不变。
|
||||
- `deviceKey` 不需要放在消息体里,后端以连接参数 `deviceKey` 识别当前小车。
|
||||
|
||||
仅上报电量和状态示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "telemetry",
|
||||
"data": {
|
||||
"batteryLevel": 85,
|
||||
"status": 3
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 浏览器收到的推送
|
||||
|
||||
后端在处理完小车上报后,会向所有在线浏览器广播 `car_status` 消息。
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "car_status",
|
||||
"data": {
|
||||
"deviceKey": "ROBOT-001",
|
||||
"liftHeight": 1200,
|
||||
"batteryLevel": 86,
|
||||
"voltage": 24.6,
|
||||
"poseX": 12.345,
|
||||
"poseY": 6.789,
|
||||
"poseYaw": 1.57,
|
||||
"status": 1,
|
||||
"signal": "-58dBm"
|
||||
},
|
||||
"timestamp": 1782702000010
|
||||
}
|
||||
```
|
||||
|
||||
顶层字段说明:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| ---------------- | ---------------------------------------- |
|
||||
| `type` | 固定为 `car_status` |
|
||||
| `data.deviceKey` | 小车设备标识 |
|
||||
| `data.liftHeight` | 升降柱高度,单位 mm |
|
||||
| `timestamp` | 后端推送时间戳,毫秒 |
|
||||
|
||||
|
||||
## 小车速度上报消息
|
||||
|
||||
如果 ROS 端还需要把当前线速度、角速度实时推给浏览器,可额外发送 `velocity` 消息。该消息只做转发,不落库。
|
||||
|
||||
消息类型:`velocity`
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "velocity",
|
||||
"data": {
|
||||
"linearX": 0.12,
|
||||
"angularZ": -0.03
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
| --------------- | ---- | ---------------------------------------------------- |
|
||||
| `type` | 是 | 固定为 `velocity` |
|
||||
| `data.linearX` | 否 | 当前线速度,对应 ROS `geometry_msgs/Twist.linear.x` |
|
||||
| `data.angularZ` | 否 | 当前角速度,对应 ROS `geometry_msgs/Twist.angular.z` |
|
||||
|
||||
说明:
|
||||
|
||||
- `velocity` 与 `telemetry` 分开处理,不写入 `ros_car`。
|
||||
- 支持高频上报,后端收到后直接转发给浏览器。
|
||||
- `deviceKey` 仍然从连接参数获取,不需要放在消息体里。
|
||||
|
||||
## 浏览器收到的速度推送
|
||||
|
||||
后端收到 ROS 端 `velocity` 后,会向所有在线浏览器广播 `car_velocity` 消息。
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "car_velocity",
|
||||
"data": {
|
||||
"deviceKey": "ROBOT-001",
|
||||
"velocity": {
|
||||
"linearX": 0.12,
|
||||
"angularZ": -0.03
|
||||
}
|
||||
},
|
||||
"timestamp": 1782702001010
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| ------------------------ | --------------------- |
|
||||
| `type` | 固定为 `car_velocity` |
|
||||
| `data.deviceKey` | 小车设备标识 |
|
||||
| `data.velocity.linearX` | 当前线速度 |
|
||||
| `data.velocity.angularZ` | 当前角速度 |
|
||||
| `timestamp` | 后端推送时间戳,毫秒 |
|
||||
|
||||
## 对接约束
|
||||
|
||||
- 浏览器侧只需要建立连接并监听 `car_status`,不需要发送订阅命令。
|
||||
- 如果需要展示实时速度,浏览器额外监听 `car_velocity`。
|
||||
- 同一条 `car_status` 中,`telemetry` 表示本次上报内容,`car` 表示合并后的完整结果;前端展示建议优先使用 `car`。
|
||||
- 后端当前仅在收到小车 `telemetry` 上报后广播 `car_status`。
|
||||
- 后端当前在收到小车 `velocity` 上报后广播 `car_velocity`,不做缓存和存储。
|
||||
- 小车连接建立时,后端会将小车状态置为空闲;小车连接断开时,后端会将小车状态置为离线。
|
||||
- 仅连接断开导致的离线变化,当前不会单独触发一条 `car_status` 广播;如前端需要强一致离线提示,需要额外结合列表查询或后续补充离线广播事件。
|
||||
190
src/views/dashboard1/pages/浏览器WS控制协议.md
Normal file
190
src/views/dashboard1/pages/浏览器WS控制协议.md
Normal file
@@ -0,0 +1,190 @@
|
||||
# 浏览器 WS 控制协议
|
||||
|
||||
## 连接
|
||||
|
||||
浏览器端使用登录 token 建立连接:
|
||||
|
||||
```text
|
||||
ws://{host}:{port}/ws/ros?token={token}
|
||||
```
|
||||
|
||||
消息统一使用 JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "cmd_vel",
|
||||
"requestId": "browser-generated-id",
|
||||
"data": {
|
||||
"deviceKey": "ROBOT-001",
|
||||
"linearX": 0.1,
|
||||
"angularZ": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
通用字段:
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `type` | 是 | 消息类型 |
|
||||
| `requestId` | 否 | 浏览器生成的请求 id,后端回执会原样带回 |
|
||||
| `data.deviceKey` | 是 | 控制目标机器人设备标识 |
|
||||
|
||||
心跳兼容字符串消息:浏览器收到 `ping` 后回复 `pong`;浏览器主动发送 `ping` 时后端回复 `pong`。
|
||||
|
||||
## 后端回执
|
||||
|
||||
后端只确认消息是否成功转发到在线机器人,不等待机器人执行完成。
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "control_result",
|
||||
"requestId": "browser-generated-id",
|
||||
"data": {
|
||||
"sourceType": "cmd_vel",
|
||||
"deviceKey": "ROBOT-001",
|
||||
"accepted": true,
|
||||
"message": "已下发"
|
||||
},
|
||||
"timestamp": 1782702000020
|
||||
}
|
||||
```
|
||||
|
||||
`accepted=false` 常见原因:`data` 缺失、`deviceKey` 缺失、机器人不在线。
|
||||
|
||||
## 底盘控制
|
||||
|
||||
消息类型只使用 `cmd_vel`。
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "cmd_vel",
|
||||
"requestId": "cmd-1",
|
||||
"data": {
|
||||
"deviceKey": "ROBOT-001",
|
||||
"linearX": 0.1,
|
||||
"angularZ": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `linearX` | 对应 ROS `geometry_msgs/Twist.linear.x` |
|
||||
| `angularZ` | 对应 ROS `geometry_msgs/Twist.angular.z` |
|
||||
|
||||
按钮建议映射:
|
||||
|
||||
| 操作 | `linearX` | `angularZ` |
|
||||
| --- | --- | --- |
|
||||
| 前进 | `0.1` | `0` |
|
||||
| 后退 | `-0.1` | `0` |
|
||||
| 左转 | `0` | `0.1` |
|
||||
| 右转 | `0` | `-0.1` |
|
||||
| 停止 | `0` | `0` |
|
||||
|
||||
按钮按下时发送对应数值;按钮松开、指针移出、页面失焦时发送停止值。ROS 端 WebSocket 适配器负责把 `linearX/angularZ` 转成 `/cmd_vel` 的 `geometry_msgs/Twist`。
|
||||
|
||||
## 云台控制
|
||||
|
||||
ROS 包参考:`hikvision_camera_controller/srv/Ptz`
|
||||
|
||||
```text
|
||||
string action
|
||||
int32 speed
|
||||
int32 duration_ms
|
||||
```
|
||||
|
||||
浏览器协议只下发控制动作,`speed` 和 `duration_ms` 由 ROS 端适配器使用默认值。
|
||||
|
||||
消息类型:`ptz_control`
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "ptz_control",
|
||||
"requestId": "ptz-1",
|
||||
"data": {
|
||||
"deviceKey": "ROBOT-001",
|
||||
"action": "up"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`action` 可选值:
|
||||
|
||||
| 值 | 含义 |
|
||||
| --- | --- |
|
||||
| `up` | 上 |
|
||||
| `down` | 下 |
|
||||
| `left` | 左 |
|
||||
| `right` | 右 |
|
||||
| `stop` | 停止 |
|
||||
|
||||
ROS 端调用 `/hikvision_camera/ptz` 时建议映射:
|
||||
|
||||
| 浏览器 `action` | ROS `action` | ROS `speed` | ROS `duration_ms` |
|
||||
| --- | --- | --- | --- |
|
||||
| `up` | `up` | 默认值,如 20 | 默认值,如 500 |
|
||||
| `down` | `down` | 默认值,如 20 | 默认值,如 500 |
|
||||
| `left` | `left` | 默认值,如 20 | 默认值,如 500 |
|
||||
| `right` | `right` | 默认值,如 20 | 默认值,如 500 |
|
||||
| `stop` | `stop` | 0 | 0 |
|
||||
|
||||
## 升降柱控制
|
||||
|
||||
ROS 包参考:`rs485_lift_controller/srv/ControlLift`
|
||||
|
||||
```text
|
||||
int32 control_type
|
||||
uint16 height
|
||||
```
|
||||
|
||||
消息类型:`lift_control`
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "lift_control",
|
||||
"requestId": "lift-1",
|
||||
"data": {
|
||||
"deviceKey": "ROBOT-001",
|
||||
"control_type": 4,
|
||||
"height": 1200
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`control_type` 可选值:
|
||||
|
||||
| 值 | 含义 | `height` |
|
||||
| --- | --- | --- |
|
||||
| `0` | 复位 | `0` |
|
||||
| `1` | 上升 | `0` |
|
||||
| `2` | 下降 | `0` |
|
||||
| `3` | 停止 | `0` |
|
||||
| `4` | 运行到指定高度 | 目标高度,单位 mm |
|
||||
|
||||
## 机器人端收到的消息
|
||||
|
||||
设备端连接:
|
||||
|
||||
```text
|
||||
ws://{host}:{port}/ws/ros?deviceKey={deviceKey}
|
||||
```
|
||||
|
||||
后端按相同 `type` 透传 `data` 给设备。例如浏览器发送升降柱控制,设备端收到:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "lift_control",
|
||||
"data": {
|
||||
"deviceKey": "ROBOT-001",
|
||||
"control_type": 4,
|
||||
"height": 1200
|
||||
},
|
||||
"timestamp": 1782702000010
|
||||
}
|
||||
```
|
||||
|
||||
设备端应自行做安全保护:连接断开、长时间未收到控制消息、收到停止命令时立即停止对应动作。
|
||||
@@ -67,6 +67,9 @@ export function createAlertPhoto(scene: AlertPhotoScene, accent: string) {
|
||||
|
||||
export const recentAlertPhotoSources = {
|
||||
"线缆破损": createAlertPhoto("cable", "#ff8f79"),
|
||||
"电缆表面破损": createAlertPhoto("cable", "#ff8f79"),
|
||||
"桥架断裂": createAlertPhoto("crack", "#89d8ff"),
|
||||
"隧道积水": createAlertPhoto("heat", "#ffc86c"),
|
||||
"局部高温": createAlertPhoto("heat", "#ffc86c"),
|
||||
"墙体裂缝": createAlertPhoto("crack", "#89d8ff"),
|
||||
} as const;
|
||||
|
||||
203
src/views/login/index.vue
Normal file
203
src/views/login/index.vue
Normal file
@@ -0,0 +1,203 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useUserStore } from "@/store/modules/user.js";
|
||||
import { encryptPasswordPayload } from '@/utils/passwordEncrypt.js'
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
|
||||
const form = reactive({
|
||||
username: "",
|
||||
password: "",
|
||||
});
|
||||
|
||||
const loading = ref(false);
|
||||
const errorMessage = ref("");
|
||||
|
||||
const redirectPath = computed(() => {
|
||||
const redirect = route.query.redirect;
|
||||
if (
|
||||
typeof redirect === "string" &&
|
||||
redirect.startsWith("/") &&
|
||||
!redirect.startsWith("//")
|
||||
) {
|
||||
return redirect;
|
||||
}
|
||||
return "/dashboard/integrated-center";
|
||||
});
|
||||
|
||||
// 校验登录表单,避免空账号或空密码提交到后端。
|
||||
function validateLoginForm() {
|
||||
if (!form.username.trim()) {
|
||||
errorMessage.value = "请输入账号";
|
||||
return false;
|
||||
}
|
||||
if (!form.password) {
|
||||
errorMessage.value = "请输入密码";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 提交登录信息,成功后回到登录前访问的页面。
|
||||
async function handleLogin() {
|
||||
if (!validateLoginForm() || loading.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
errorMessage.value = "";
|
||||
|
||||
try {
|
||||
const encryptedForm = await encryptPasswordPayload(form, ['password'])
|
||||
await userStore.login(encryptedForm)
|
||||
router.replace(redirectPath.value);
|
||||
} catch (error) {
|
||||
errorMessage.value =
|
||||
error instanceof Error ? error.message : "登录失败,请检查账号或密码";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<form class="login-card" @submit.prevent="handleLogin">
|
||||
<div class="login-card__eyebrow">Robot Manager</div>
|
||||
<h1>系统登录</h1>
|
||||
<p>请输入账号和密码进入巡检管理大屏</p>
|
||||
|
||||
<label class="login-field">
|
||||
<span>账号</span>
|
||||
<input v-model="form.username" autocomplete="username" placeholder="请输入账号" type="text" />
|
||||
</label>
|
||||
|
||||
<label class="login-field">
|
||||
<span>密码</span>
|
||||
<input v-model="form.password" autocomplete="current-password" placeholder="请输入密码" type="password" />
|
||||
</label>
|
||||
|
||||
<div v-if="errorMessage" class="login-error">{{ errorMessage }}</div>
|
||||
|
||||
<button class="login-button" :disabled="loading" type="submit">
|
||||
{{ loading ? "登录中..." : "登录" }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: #dff7ff;
|
||||
background:
|
||||
radial-gradient(circle at 50% 42%,
|
||||
rgba(31, 126, 202, 0.32),
|
||||
transparent 34%),
|
||||
radial-gradient(circle at center, #15385d 0%, #061426 60%, #02070f 100%);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
box-sizing: border-box;
|
||||
width: 440px;
|
||||
padding: 44px 48px 48px;
|
||||
text-align: left;
|
||||
border: 1px solid rgba(116, 236, 255, 0.28);
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(180deg,
|
||||
rgba(7, 31, 61, 0.9),
|
||||
rgba(4, 15, 31, 0.86));
|
||||
box-shadow:
|
||||
0 0 40px rgba(69, 188, 255, 0.18),
|
||||
inset 0 0 26px rgba(116, 236, 255, 0.08);
|
||||
}
|
||||
|
||||
.login-card__eyebrow {
|
||||
margin-bottom: 12px;
|
||||
color: #74ecff;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.login-card h1 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 34px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.login-card p {
|
||||
margin: 0 0 34px;
|
||||
color: rgba(223, 247, 255, 0.72);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.login-field {
|
||||
display: block;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.login-field span {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: rgba(223, 247, 255, 0.78);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.login-field input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 46px;
|
||||
padding: 0 16px;
|
||||
color: #e8fbff;
|
||||
border: 1px solid rgba(116, 236, 255, 0.26);
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
background: rgba(3, 15, 32, 0.74);
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.login-field input:focus {
|
||||
border-color: rgba(116, 236, 255, 0.78);
|
||||
box-shadow: 0 0 18px rgba(116, 236, 255, 0.16);
|
||||
}
|
||||
|
||||
.login-field input::placeholder {
|
||||
color: rgba(223, 247, 255, 0.34);
|
||||
}
|
||||
|
||||
.login-error {
|
||||
min-height: 20px;
|
||||
margin: -4px 0 16px;
|
||||
color: #ff9c9c;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
color: #062239;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(90deg, #74ecff, #46a7ff);
|
||||
box-shadow: 0 0 24px rgba(70, 167, 255, 0.32);
|
||||
}
|
||||
|
||||
.login-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.68;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user