first commit

This commit is contained in:
2026-06-26 16:34:27 +08:00
commit 45ae1670c6
135 changed files with 10502 additions and 0 deletions

65
src/App.vue Normal file
View File

@@ -0,0 +1,65 @@
<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;
const viewportWidth = ref(0);
const viewportHeight = ref(0);
const scale = ref(1);
function updateScreenAdapter() {
viewportWidth.value = window.innerWidth;
viewportHeight.value = window.innerHeight;
const widthScale = viewportWidth.value / DESIGN_WIDTH;
const heightScale = viewportHeight.value / DESIGN_HEIGHT;
scale.value = Math.min(widthScale, heightScale);
}
const adapterStyle = computed(() => {
const scaledWidth = DESIGN_WIDTH * scale.value;
const scaledHeight = DESIGN_HEIGHT * scale.value;
return {
width: `${DESIGN_WIDTH}px`,
height: `${DESIGN_HEIGHT}px`,
left: `${(viewportWidth.value - scaledWidth) / 2}px`,
top: `${(viewportHeight.value - scaledHeight) / 2}px`,
transform: `scale(${scale.value})`,
};
});
onMounted(() => {
updateScreenAdapter();
window.addEventListener("resize", updateScreenAdapter);
});
onBeforeUnmount(() => {
window.removeEventListener("resize", updateScreenAdapter);
});
</script>
<template>
<div class="screen-adapter">
<div class="screen-adapter__inner" :style="adapterStyle">
<Component :is="dashboard1" />
</div>
</div>
</template>
<style scoped>
.screen-adapter {
position: relative;
width: 100vw;
height: 100vh;
overflow: hidden;
background: #000;
}
.screen-adapter__inner {
position: absolute;
transform-origin: left top;
}
</style>

BIN
src/assets/map-material.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -0,0 +1,56 @@
<script lang="ts" setup>
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
import * as echarts from "echarts";
import { EChartsOption } from "echarts";
let chart: echarts.ECharts | null = null;
const chartRef = ref<HTMLElement | undefined>();
const props = defineProps({
option: {
type: Object as () => EChartsOption | {},
required: true,
},
});
function applyOption(option?: EChartsOption | Record<string, never>) {
if (!chart || !option) {
return;
}
chart.clear();
chart.setOption(option);
}
onMounted(() => {
chart = echarts.init(chartRef.value, "t-theme");
applyOption(props.option);
});
watch(
() => props.option,
(value) => {
applyOption(value);
},
{ deep: true },
);
onBeforeUnmount(() => {
if (chart) {
chart.dispose();
chart = null;
}
});
</script>
<template>
<div ref="chartRef" class="t-chart" />
</template>
<style scoped>
.t-chart {
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,104 @@
const color = [
{
type: "linear",
x: 0,
y: 0,
x2: 1,
y2: 1,
colorStops: [
{ offset: 0, color: "#94c7fd" },
{ offset: 0.5, color: "#3b99fb" },
{ offset: 1, color: "#004ca0" },
],
},
{
type: "linear",
x: 0,
y: 0,
x2: 1,
y2: 1,
colorStops: [
{ offset: 0, color: "#b070c0" },
{ offset: 0.5, color: "#b070c0" },
{ offset: 1, color: "#5d235d" },
],
},
{
type: "linear",
x: 0,
y: 0,
x2: 1,
y2: 1,
colorStops: [
{ offset: 0, color: "#7eedd6" },
{ offset: 0.5, color: "#3be8bb" },
{ offset: 1, color: "#00b38a" },
],
},
{
type: "linear",
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: "#fff1f0" },
{ offset: 0.5, color: "#ff9494" },
{ offset: 1, color: "#ff4d4f" },
],
},
{
type: "linear",
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: "#fffbe6" },
{ offset: 0.5, color: "#ffe58f" },
{ offset: 1, color: "#ffbb96" },
],
},
{
type: "linear",
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: "#ff3333" },
{ offset: 0.5, color: "#ff0000" },
{ offset: 1, color: "#cc0000" },
],
},
{
type: "linear",
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: "#ffe6f0" },
{ offset: 0.5, color: "#ffb3d9" },
{ offset: 1, color: "#ff85c0" },
],
},
];
export default {
color,
textStyle: {
fontSize: 12,
color: "#fff",
},
legend: {
textStyle: {
color: "#fff",
},
},
tooltip: {
textStyle: {
align: "left",
},
},
};

View File

@@ -0,0 +1,36 @@
import { Map } from "ol";
import VectorSource from "ol/source/Vector";
import { GeoJSON } from "ol/format";
import VectorLayer from "ol/layer/Vector";
import { Fill, Stroke, Style } from "ol/style";
import { FeatureLike } from "ol/Feature";
import { MultiPolygon } from "ol/geom";
export default function (mapInstance: Map) {
const vectorSource = new VectorSource({
url: "https://geo.datav.aliyun.com/areas_v3/bound/330000.json",
format: new GeoJSON(),
});
for (let i = 1; i <= 3; i++) {
const layer = new VectorLayer({
zIndex: -1,
source: vectorSource,
style: (feature: FeatureLike) => {
const geometry = (feature.getGeometry() as MultiPolygon).clone();
geometry.translate(0, -0.04 * i);
return new Style({
fill: new Fill({
color: "rgba(176, 166, 132,0.6)",
}),
stroke: new Stroke({
color: "rgba(176, 166, 132,0.6)",
width: 1,
}),
geometry,
});
},
});
mapInstance.addLayer(layer);
}
}

111
src/hooks/useMap.ts Normal file
View File

@@ -0,0 +1,111 @@
import { Map, View } from "ol";
import { onMounted } from "vue";
import { GeoJSON } from "ol/format";
import VectorSource from "ol/source/Vector";
import VectorLayer from "ol/layer/Vector";
import { Fill, Stroke, Style, Text } from "ol/style";
import { getBottomLeft, getCenter, getHeight, getWidth } from "ol/extent";
import materialJpg from "@/assets/map-material.jpg";
import { toContext } from "ol/render";
import { MultiPolygon } from "ol/geom";
import { defaults } from "ol/interaction";
export default function useMap(target: string) {
const mapInstance = new Map({
view: new View({
projection: "EPSG:4326",
zoom: 12,
center: [0, 0],
}),
controls: [],
interactions: defaults({
dragPan: false,
mouseWheelZoom: false,
}),
});
const vectorSource = new VectorSource({
url: "https://geo.datav.aliyun.com/areas_v3/bound/330000_full.json",
format: new GeoJSON(),
});
const customStyle = new Style({
renderer: function (pixelCoordinates, state) {
const context = state.context;
const multiPolygon = state.geometry.clone() as MultiPolygon;
// @ts-ignore
multiPolygon.setCoordinates(pixelCoordinates);
const extent = multiPolygon.getExtent();
const width = getWidth(extent);
const height = getHeight(extent);
const flag = state.feature.get("material");
if (!flag || height < 1 || width < 1) {
return;
}
context.save();
const renderContext = toContext(context, {
pixelRatio: 1,
});
renderContext.setFillStrokeStyle(
new Fill(),
new Stroke({
color: "rgba(255,255,255,0.8)",
width: 3,
}),
);
renderContext.drawMultiPolygon(multiPolygon);
context.clip();
const bottomLeft = getBottomLeft(extent);
const left = bottomLeft[0];
const bottom = bottomLeft[1];
context.drawImage(flag, left, bottom, width, height);
context.restore();
},
});
const layer = new VectorLayer({
source: vectorSource,
style: (feature) => {
return [
customStyle,
new Style({
text: new Text({
text: feature.get("name"),
fill: new Fill({
color: "#fff",
}),
scale: 1.5,
}),
zIndex: 1,
}),
];
},
});
vectorSource.on("addfeature", function (event) {
const feature = event.feature;
const img = new Image();
img.src = materialJpg;
img.onload = function () {
feature?.set("material", img);
};
});
vectorSource.on("featuresloadend", (event) => {
const view = mapInstance.getView();
const extent = event.target.getExtent();
view.fit(extent, { padding: [0, 30, 0, 0] });
view.setCenter(getCenter(extent));
});
mapInstance.addLayer(layer);
onMounted(() => {
mapInstance.setTarget(target);
});
return {
mapInstance,
};
}

14
src/main.ts Normal file
View File

@@ -0,0 +1,14 @@
import { createApp } from "vue";
import "./style.less";
import App from "./App.vue";
import Chart from "./components/chart/index.vue";
import * as echarts from "echarts";
import echartsConfig from "./config/echarts.config";
echarts.registerTheme("t-theme", echartsConfig);
const app = createApp(App);
app.component("TChart", Chart);
app.mount("#app");

45
src/style.less Normal file
View File

@@ -0,0 +1,45 @@
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
#app {
width: 100vw;
height: 100vh;
overflow: hidden;
}
body {
margin: 0;
overflow: hidden;
}
.dashboard {
width: 100%;
height: 100%;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

19
src/utils/index.ts Normal file
View File

@@ -0,0 +1,19 @@
function generateNumbers(min: number, max: number, count: number): number[];
function generateNumbers(min: number, max: number): number;
function generateNumbers(
min: number,
max: number,
count?: number,
): number | number[] {
if (!count) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
const numbers = [];
for (let i = 0; i < count; i++) {
const randomNum = Math.floor(Math.random() * (max - min + 1)) + min;
numbers.push(randomNum);
}
return numbers;
}
export { generateNumbers };

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 733 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@@ -0,0 +1,61 @@
.btn {
width: 130px;
height: 35px;
&:hover {
cursor: pointer;
}
&.normal {
background-image: url("../../assets/按钮未选中.png"), url("../../assets/按钮未选中1.png");
background-repeat: no-repeat, no-repeat;
background-position: 4px 0, 0 4px;
text-align: center;
line-height: 35px;
.text {
background-image: -webkit-linear-gradient(top, #fff, #fff, #69a2d8, #69a2d8, #69a2d8);
}
&.right {
.text {
transform: rotateY(-180deg);
}
transform: rotateY(180deg);
}
}
&.active {
background-image: url("../../assets/按钮选中.png"),
url("../../assets/按钮选中2.png"),
url("../../assets/按钮选中3.png"),
url("../../assets/按钮选中1.png");
background-repeat: no-repeat, no-repeat, no-repeat, no-repeat;
background-size: 100%, 100%, 100% 100%, 100% 100%;
background-position: 0 2.5px, -2px 0, center, top;
text-align: center;
line-height: 35px;
.text {
background-image: -webkit-linear-gradient(top, rgb(157, 195, 234), #fff, rgb(96, 162, 225));
}
&.left {
.text {
transform: rotateY(-180deg);
}
transform: rotateY(180deg);
}
}
.text {
font-weight: 600;
text-align: center;
user-select: none;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
}

View File

@@ -0,0 +1,29 @@
import { defineComponent } from "vue";
import style from "./index.module.less";
export default defineComponent({
name: "switchPageBtn",
props: {
direction: {
type: String as () => "left" | "right",
required: true,
},
text: {
required: true,
},
active: Boolean,
},
setup(props) {
return () => (
<div
class={[
style.btn,
props.active ? style.active : style.normal,
style[props.direction],
]}
>
<div class={[style.text]}>{props.text}</div>
</div>
);
},
});

View File

@@ -0,0 +1,51 @@
import { onBeforeUnmount, ref } from "vue";
import { EChartsOption } from "echarts";
import { generateNumbers } from "@/utils";
export default function () {
const option = ref<EChartsOption>({});
function refresh() {
option.value = {
xAxis: {
type: "category",
data: ["周一", "周二", "周三", "周四", "周五", "周六", "周日"],
},
yAxis: {
type: "value",
},
grid: {
left: "12%",
},
series: [
{
data: generateNumbers(1, 300, 7),
type: "line",
},
{
data: generateNumbers(1, 300, 7),
type: "line",
},
{
data: generateNumbers(1, 300, 7),
type: "line",
},
],
};
}
refresh();
const timer = setInterval(() => {
refresh();
}, 3000);
onBeforeUnmount(() => {
clearInterval(timer);
});
return {
option,
refresh,
};
}

View File

@@ -0,0 +1,66 @@
import { onBeforeUnmount, ref } from "vue";
import { EChartsOption } from "echarts";
import { generateNumbers } from "@/utils";
export default function () {
const option = ref<EChartsOption>({});
function refresh() {
option.value = {
tooltip: {
trigger: "item",
},
legend: {
top: "5%",
left: "center",
},
series: [
{
name: "Access From",
type: "pie",
radius: ["40%", "70%"],
avoidLabelOverlap: false,
itemStyle: {
borderColor: "#fff",
},
label: {
show: false,
position: "center",
},
emphasis: {
label: {
show: true,
fontSize: 30,
fontWeight: "bold",
},
},
labelLine: {
show: false,
},
data: [
{ value: generateNumbers(10, 100), name: "产品一" },
{ value: generateNumbers(10, 100), name: "产品二" },
{ value: generateNumbers(10, 100), name: "产品三" },
{ value: generateNumbers(10, 100), name: "产品四" },
{ value: generateNumbers(10, 100), name: "产品五" },
],
},
],
};
}
refresh();
const timer = setInterval(() => {
refresh();
}, 3000);
onBeforeUnmount(() => {
clearInterval(timer);
});
return {
option,
refresh,
};
}

View File

@@ -0,0 +1,74 @@
import { onBeforeUnmount, ref } from "vue";
import { EChartsOption } from "echarts";
import { generateNumbers } from "@/utils";
export default function () {
const option = ref<EChartsOption>({});
function refresh() {
option.value = {
tooltip: {
trigger: "item",
formatter: "{a} <br/>{b} : {c}%",
},
series: [
{
name: "Funnel",
type: "funnel",
left: "center",
width: "100%",
min: 0,
max: 1000,
minSize: "0%",
maxSize: "100%",
sort: "descending",
gap: 2,
label: {
show: true,
position: "inside",
},
labelLine: {
length: 10,
lineStyle: {
width: 1,
type: "solid",
},
},
itemStyle: {
borderColor: "#fff",
borderWidth: 1,
},
emphasis: {
label: {
fontSize: 20,
},
},
data: [
{ value: generateNumbers(500, 1000), name: "访问量" },
{ value: generateNumbers(500, 1000), name: "注册量" },
{ value: generateNumbers(500, 1000), name: "浏览量" },
{ value: generateNumbers(200, 500), name: "加入购物车" },
{ value: generateNumbers(100, 800), name: "购买" },
{ value: generateNumbers(1, 20), name: "退货" },
{ value: generateNumbers(1, 20), name: "取消订单" },
],
},
],
};
}
refresh();
const timer = setInterval(() => {
refresh();
}, 3000);
onBeforeUnmount(() => {
clearInterval(timer);
});
return {
option,
refresh,
};
}

View File

@@ -0,0 +1,49 @@
import { onBeforeUnmount, ref } from "vue";
import { EChartsOption } from "echarts";
import { generateNumbers } from "@/utils";
export default function () {
const option = ref<EChartsOption>({});
function refresh() {
option.value = {
radar: {
indicator: [
{ name: "用户满意度", max: 200 },
{ name: "价格", max: 200 },
{ name: "品质", max: 200 },
{ name: "客户支持", max: 200 },
{ name: "可持续性", max: 200 },
{ name: "效果", max: 200 },
],
radius: 100,
},
series: [
{
type: "radar",
data: [
{
value: generateNumbers(1, 200, 6),
areaStyle: {},
},
],
},
],
};
}
refresh();
const timer = setInterval(() => {
refresh();
}, 3000);
onBeforeUnmount(() => {
clearInterval(timer);
});
return {
option,
refresh,
};
}

View File

@@ -0,0 +1,97 @@
import { onBeforeUnmount, ref } from "vue";
import { EChartsOption } from "echarts";
import { generateNumbers } from "@/utils";
export default function () {
const option = ref<EChartsOption>({});
function refresh() {
option.value = {
tooltip: {
trigger: "axis",
axisPointer: {
type: "shadow",
},
},
legend: {
top: "5%",
itemStyle: {
borderColor: "#fff",
borderWidth: 2,
},
},
grid: {
left: "10%",
bottom: "8%",
containLabel: true,
},
xAxis: [
{
type: "value",
},
],
yAxis: [
{
type: "category",
axisTick: {
show: false,
},
data: ["一月", "二月", "三月", "四月", "五月", "六月", "七月"],
},
],
series: [
{
name: "预定",
type: "bar",
label: {
show: true,
position: "inside",
},
emphasis: {
focus: "series",
},
data: generateNumbers(1, 200, 7),
},
{
name: "购买",
type: "bar",
stack: "Total",
label: {
show: true,
},
emphasis: {
focus: "series",
},
data: generateNumbers(1, 300, 7),
},
{
name: "退货",
type: "bar",
stack: "Total",
label: {
show: true,
},
emphasis: {
focus: "series",
},
data: generateNumbers(-50, -10, 7),
},
],
};
}
refresh();
const timer = setInterval(() => {
refresh();
}, 3000);
onBeforeUnmount(() => {
clearInterval(timer);
});
return {
option,
refresh,
};
}

View File

@@ -0,0 +1,59 @@
import { onBeforeUnmount, ref } from "vue";
import { EChartsOption } from "echarts";
import { generateNumbers } from "@/utils";
export default function () {
const option = ref<EChartsOption>({});
function refresh() {
option.value = {
xAxis: {},
yAxis: {},
series: [
{
symbolSize: 20,
data: [
generateNumbers(1, 10, 2),
generateNumbers(1, 10, 2),
generateNumbers(1, 10, 2),
generateNumbers(1, 10, 2),
generateNumbers(1, 10, 2),
[8.07, 6.95],
[13.0, 7.58],
[14.0, 7.66],
[13.4, 6.81],
generateNumbers(1, 10, 2),
generateNumbers(1, 10, 2),
generateNumbers(1, 10, 2),
generateNumbers(1, 10, 2),
[11.5, 7.2],
[3.03, 4.23],
[1.05, 3.33],
[4.05, 4.96],
generateNumbers(1, 10, 2),
generateNumbers(1, 10, 2),
generateNumbers(1, 10, 2),
generateNumbers(1, 10, 2),
[5.02, 5.68],
],
type: "scatter",
},
],
};
}
refresh();
const timer = setInterval(() => {
refresh();
}, 3000);
onBeforeUnmount(() => {
clearInterval(timer);
});
return {
option,
refresh,
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,89 @@
<script lang="ts" setup>
import { computed, ref, type Component } from "vue";
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);
const tabs = {
left: [
{ id: 1, text: "监管中心" },
{ id: 2, text: "设备管理" },
{ id: 3, text: "视频中心" },
],
right: [
{ id: 4, text: "告警管理" },
{ id: 5, text: "巡检记录" },
{ id: 6, text: "统计分析" },
],
};
const pageComponents: Record<number, Component> = {
1: IntegratedCenterPage,
2: EquipmentManagementPage,
3: VideoCenterPage,
4: AlarmManagementPage,
5: PatrolPlanPage,
6: DataReportPage,
};
const currentViewComponent = computed<Component>(
() => pageComponents[currentTab.value] ?? IntegratedCenterPage,
);
function switchPage(tab: number) {
currentTab.value = tab;
}
</script>
<template>
<div class="dashboard">
<video
autoplay
class="background"
loop
muted
preload="auto"
:src="backgroundVideo"
></video>
<div class="main">
<div class="btn-group">
<div class="left">
<SwitchPageBtn
v-for="tab in tabs.left"
:key="tab.id"
:active="currentTab === tab.id"
:text="tab.text"
direction="left"
@click="switchPage(tab.id)"
/>
</div>
<div class="right">
<SwitchPageBtn
v-for="tab in tabs.right"
:key="tab.id"
:active="currentTab === tab.id"
:text="tab.text"
direction="right"
@click="switchPage(tab.id)"
/>
</div>
</div>
<div class="content-stage">
<KeepAlive>
<component :is="currentViewComponent" />
</KeepAlive>
</div>
</div>
</div>
</template>
<style lang="less">
@import "./index";
</style>

View File

@@ -0,0 +1,214 @@
<script lang="ts" setup>
import { computed, ref } from "vue";
interface AlarmRecord {
id: string;
type: string;
robot: string;
photo: string;
video: string;
status: "待研判" | "待处理" | "处置完成" | "误判";
time: string;
}
const alarms: AlarmRecord[] = [
{
id: "AL-001",
type: "线缆破损",
robot: "巡检机器人 R1",
photo: "破损抓拍_001.jpg",
video: "线缆破损_001.mp4",
status: "待研判",
time: "2026-06-23 10:24:18",
},
{
id: "AL-002",
type: "局部高温",
robot: "巡检机器人 Q1",
photo: "高温抓拍_017.jpg",
video: "局部高温_017.mp4",
status: "待处理",
time: "2026-06-23 10:18:42",
},
{
id: "AL-003",
type: "墙体裂缝",
robot: "巡检机器人 N3",
photo: "裂缝取证_006.jpg",
video: "墙体裂缝_006.mp4",
status: "处置完成",
time: "2026-06-23 09:56:11",
},
{
id: "AL-004",
type: "水渍渗漏",
robot: "巡检机器人 R2",
photo: "渗漏抓拍_012.jpg",
video: "水渍渗漏_012.mp4",
status: "误判",
time: "2026-06-23 09:20:36",
},
{
id: "AL-005",
type: "应急门开启异常",
robot: "巡检机器人 N1",
photo: "门禁取证_031.jpg",
video: "门禁异常_031.mp4",
status: "待处理",
time: "2026-06-23 08:42:57",
},
];
const keyword = ref("");
const pageSize = 4;
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)),
);
const pagedAlarms = computed(() => {
const start = (currentPage.value - 1) * pageSize;
return filteredAlarms.value.slice(start, start + pageSize);
});
const visiblePages = computed(() => {
const pages: number[] = [];
const start = Math.max(1, currentPage.value - 2);
const end = Math.min(totalPages.value, start + 4);
for (let page = start; page <= end; page += 1) {
pages.push(page);
}
return pages;
});
function getAlarmStatusClass(status: AlarmRecord["status"]) {
const classMap = {
待研判: "alarm-status-pending",
待处理: "alarm-status-handling",
处置完成: "alarm-status-finished",
误判: "alarm-status-false",
};
return classMap[status];
}
function setPage(page: number) {
currentPage.value = page;
}
function handleSearch() {
currentPage.value = 1;
}
</script>
<template>
<div class="page-shell alarm-page">
<div class="page-grid management-layout">
<section class="page-card management-table-card">
<div class="management-toolbar">
<div class="toolbar-left">
<button class="page-action-btn primary" type="button">批量通知</button>
<button class="page-action-btn ghost" type="button">导出告警</button>
<input
v-model="keyword"
class="toolbar-input"
placeholder="搜索告警编号 / 告警类型 / 报警机器人"
type="text"
@input="handleSearch"
/>
</div>
<div class="toolbar-right"> {{ filteredAlarms.length }} 条告警记录</div>
</div>
<div class="table-shell">
<table class="device-table alarm-device-table">
<thead>
<tr>
<th>告警编号</th>
<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">
<td>{{ alarm.id }}</td>
<td>{{ alarm.type }}</td>
<td>{{ alarm.robot }}</td>
<td>
<div class="evidence-chip photo">{{ alarm.photo }}</div>
</td>
<td>
<div class="evidence-chip video">{{ alarm.video }}</div>
</td>
<td>
<span :class="['table-status', getAlarmStatusClass(alarm.status)]">
{{ alarm.status }}
</span>
</td>
<td>{{ alarm.time }}</td>
<td>
<div class="table-actions">
<button type="button">研判</button>
<button type="button">通知</button>
<button class="danger" type="button">删除</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination-bar">
<button
class="pagination-btn"
:disabled="currentPage === 1"
type="button"
@click="setPage(currentPage - 1)"
>
上一页
</button>
<button
v-for="page in visiblePages"
:key="page"
:class="['pagination-btn', { active: currentPage === page }]"
type="button"
@click="setPage(page)"
>
{{ page }}
</button>
<button
class="pagination-btn"
:disabled="currentPage === totalPages"
type="button"
@click="setPage(currentPage + 1)"
>
下一页
</button>
</div>
</section>
</div>
</div>
</template>

View File

@@ -0,0 +1,492 @@
<script lang="ts" setup>
import { computed, ref } from "vue";
import type { EChartsOption } from "echarts";
import Chart from "@/components/chart/index.vue";
type AlarmStatus = "待研判" | "待处理" | "处置完成" | "误判";
type ProjectName = "西江隧道" | "青云隧道" | "南山隧道";
type DistributionRange = "近一周" | "近一个月" | "近三个月";
interface AlarmReportRecord {
id: string;
type: string;
robot: string;
project: ProjectName;
status: AlarmStatus;
time: string;
}
interface TrendRecord {
date: string;
project: ProjectName;
total: number;
closed: number;
}
const distributionRange = ref<DistributionRange>("近一周");
const reportData: AlarmReportRecord[] = [
{ id: "AL-001", type: "线缆破损", robot: "巡检机器人 R1", project: "西江隧道", status: "待研判", time: "2026-06-23 10:24:18" },
{ id: "AL-002", type: "局部高温", robot: "巡检机器人 Q1", project: "青云隧道", status: "待处理", time: "2026-06-23 10:18:42" },
{ id: "AL-003", type: "墙体裂缝", robot: "巡检机器人 N3", project: "南山隧道", status: "处置完成", time: "2026-06-23 09:56:11" },
{ id: "AL-004", type: "水渍渗漏", robot: "巡检机器人 R2", project: "西江隧道", status: "误判", time: "2026-06-23 09:20:36" },
{ id: "AL-005", type: "应急门开启异常", robot: "巡检机器人 N1", project: "南山隧道", status: "待处理", time: "2026-06-23 08:42:57" },
{ id: "AL-006", type: "局部高温", robot: "巡检机器人 N3", project: "南山隧道", status: "处置完成", time: "2026-06-22 19:12:06" },
{ id: "AL-007", type: "线缆破损", robot: "巡检机器人 R1", project: "西江隧道", status: "处置完成", time: "2026-06-22 17:46:28" },
{ id: "AL-008", type: "墙体裂缝", robot: "巡检机器人 Q2", project: "青云隧道", status: "待研判", time: "2026-06-22 16:08:40" },
{ id: "AL-009", type: "水渍渗漏", robot: "巡检机器人 R4", project: "西江隧道", status: "待处理", time: "2026-06-22 14:21:19" },
{ id: "AL-010", type: "局部高温", robot: "巡检机器人 N1", project: "南山隧道", status: "误判", time: "2026-06-22 11:03:50" },
{ id: "AL-011", type: "线缆破损", robot: "巡检机器人 R3", project: "西江隧道", status: "处置完成", time: "2026-06-16 13:14:09" },
{ id: "AL-012", type: "局部高温", robot: "巡检机器人 Q1", project: "青云隧道", status: "待处理", time: "2026-06-15 16:42:33" },
{ id: "AL-013", type: "墙体裂缝", robot: "巡检机器人 N2", project: "南山隧道", status: "处置完成", time: "2026-06-12 09:25:50" },
{ id: "AL-014", type: "水渍渗漏", robot: "巡检机器人 R2", project: "西江隧道", status: "误判", time: "2026-06-08 20:14:27" },
{ id: "AL-015", type: "应急门开启异常", robot: "巡检机器人 Q3", project: "青云隧道", status: "待研判", time: "2026-05-28 11:38:44" },
{ id: "AL-016", type: "局部高温", robot: "巡检机器人 N4", project: "南山隧道", status: "处置完成", time: "2026-05-19 15:07:18" },
{ id: "AL-017", type: "线缆破损", robot: "巡检机器人 R1", project: "西江隧道", status: "待处理", time: "2026-05-06 08:11:52" },
{ id: "AL-018", type: "墙体裂缝", robot: "巡检机器人 Q2", project: "青云隧道", status: "误判", time: "2026-04-23 14:54:06" },
{ id: "AL-019", type: "水渍渗漏", robot: "巡检机器人 N1", project: "南山隧道", status: "处置完成", time: "2026-04-12 10:03:16" },
];
const trendData: TrendRecord[] = [
{ date: "06-09", project: "西江隧道", total: 1, closed: 1 },
{ date: "06-09", project: "青云隧道", total: 1, closed: 0 },
{ date: "06-09", project: "南山隧道", total: 1, closed: 1 },
{ date: "06-10", project: "西江隧道", total: 2, closed: 1 },
{ date: "06-10", project: "青云隧道", total: 1, closed: 0 },
{ date: "06-10", project: "南山隧道", total: 1, closed: 1 },
{ date: "06-11", project: "西江隧道", total: 2, closed: 1 },
{ date: "06-11", project: "青云隧道", total: 1, closed: 1 },
{ date: "06-11", project: "南山隧道", total: 2, closed: 1 },
{ date: "06-12", project: "西江隧道", total: 1, closed: 1 },
{ date: "06-12", project: "青云隧道", total: 1, closed: 1 },
{ date: "06-12", project: "南山隧道", total: 2, closed: 1 },
{ date: "06-13", project: "西江隧道", total: 2, closed: 1 },
{ date: "06-13", project: "青云隧道", total: 2, closed: 1 },
{ date: "06-13", project: "南山隧道", total: 2, closed: 2 },
{ date: "06-14", project: "西江隧道", total: 2, closed: 1 },
{ date: "06-14", project: "青云隧道", total: 1, closed: 1 },
{ date: "06-14", project: "南山隧道", total: 2, closed: 2 },
{ date: "06-15", project: "西江隧道", total: 3, closed: 2 },
{ date: "06-15", project: "青云隧道", total: 2, closed: 1 },
{ date: "06-15", project: "南山隧道", total: 2, closed: 2 },
{ date: "06-16", project: "西江隧道", total: 2, closed: 1 },
{ date: "06-16", project: "青云隧道", total: 2, closed: 1 },
{ date: "06-16", project: "南山隧道", total: 2, closed: 2 },
{ date: "06-17", project: "西江隧道", total: 3, closed: 2 },
{ date: "06-17", project: "青云隧道", total: 2, closed: 1 },
{ date: "06-17", project: "南山隧道", total: 3, closed: 3 },
{ date: "06-18", project: "西江隧道", total: 2, closed: 2 },
{ date: "06-18", project: "青云隧道", total: 2, closed: 1 },
{ date: "06-18", project: "南山隧道", total: 3, closed: 2 },
{ date: "06-19", project: "西江隧道", total: 3, closed: 2 },
{ date: "06-19", project: "青云隧道", total: 2, closed: 2 },
{ date: "06-19", project: "南山隧道", total: 4, closed: 3 },
{ date: "06-20", project: "西江隧道", total: 3, closed: 2 },
{ date: "06-20", project: "青云隧道", total: 2, closed: 2 },
{ date: "06-20", project: "南山隧道", total: 3, closed: 2 },
{ date: "06-21", project: "西江隧道", total: 2, closed: 2 },
{ date: "06-21", project: "青云隧道", total: 2, closed: 1 },
{ date: "06-21", project: "南山隧道", total: 3, closed: 2 },
{ date: "06-22", project: "西江隧道", total: 4, closed: 3 },
{ date: "06-22", project: "青云隧道", total: 2, closed: 1 },
{ date: "06-22", project: "南山隧道", total: 4, closed: 3 },
{ date: "06-23", project: "西江隧道", total: 4, closed: 3 },
{ date: "06-23", project: "青云隧道", total: 3, closed: 2 },
{ date: "06-23", project: "南山隧道", total: 4, closed: 3 },
];
const trendSeries = computed(() => {
const grouped = trendData.reduce<Record<string, { total: number; closed: number }>>(
(accumulator, item) => {
if (!accumulator[item.date]) {
accumulator[item.date] = { total: 0, closed: 0 };
}
accumulator[item.date].total += item.total;
accumulator[item.date].closed += item.closed;
return accumulator;
},
{},
);
return Object.entries(grouped).map(([date, value]) => ({
date,
total: value.total,
closed: value.closed,
}));
});
const trendOption = computed<EChartsOption>(() => {
const dates = trendSeries.value.map((item) => item.date);
const totalValues = trendSeries.value.map((item) => item.total);
const closedValues = trendSeries.value.map((item) => item.closed);
return {
grid: {
left: 24,
right: 16,
top: 24,
bottom: 28,
containLabel: true,
},
tooltip: {
trigger: "axis",
backgroundColor: "rgba(6, 20, 42, 0.92)",
borderColor: "rgba(96, 208, 255, 0.24)",
textStyle: {
color: "#eefcff",
},
},
legend: {
show: false,
},
xAxis: {
type: "category",
boundaryGap: false,
data: dates,
axisLine: {
lineStyle: {
color: "rgba(96, 188, 242, 0.2)",
},
},
axisLabel: {
color: "rgba(181, 230, 249, 0.74)",
fontSize: 11,
},
axisTick: {
show: false,
},
},
yAxis: {
type: "value",
splitNumber: 5,
axisLine: {
show: false,
},
axisTick: {
show: false,
},
axisLabel: {
color: "rgba(181, 230, 249, 0.62)",
fontSize: 11,
},
splitLine: {
lineStyle: {
color: "rgba(97, 189, 242, 0.12)",
},
},
},
series: [
{
name: "新增告警",
type: "line",
smooth: true,
symbol: "circle",
symbolSize: 7,
data: totalValues,
lineStyle: {
width: 3,
color: "#74ecff",
},
itemStyle: {
color: "#74ecff",
borderColor: "#081f3e",
borderWidth: 2,
},
areaStyle: {
color: "rgba(116, 236, 255, 0.12)",
},
},
{
name: "闭环告警",
type: "line",
smooth: true,
symbol: "circle",
symbolSize: 7,
data: closedValues,
lineStyle: {
width: 3,
color: "#62e0a8",
},
itemStyle: {
color: "#62e0a8",
borderColor: "#081f3e",
borderWidth: 2,
},
areaStyle: {
color: "rgba(98, 224, 168, 0.08)",
},
},
],
};
});
const typeStats = computed(() => {
const statsMap = new Map<string, number>();
reportData.forEach((item) => {
statsMap.set(item.type, (statsMap.get(item.type) ?? 0) + 1);
});
return Array.from(statsMap.entries()).map(([label, value]) => ({
label,
value,
}));
});
const typeRadarOption = computed<EChartsOption>(() => {
const maxValue = Math.max(...typeStats.value.map((item) => item.value), 1);
const indicators = typeStats.value.map((item) => ({
name: item.label,
max: maxValue,
}));
return {
tooltip: {
trigger: "item",
backgroundColor: "rgba(6, 20, 42, 0.92)",
borderColor: "rgba(96, 208, 255, 0.24)",
textStyle: {
color: "#eefcff",
},
},
radar: {
radius: "68%",
center: ["50%", "54%"],
indicator: indicators,
splitNumber: 4,
axisName: {
color: "#dff7ff",
fontSize: 12,
},
axisLine: {
lineStyle: {
color: "rgba(97, 189, 242, 0.22)",
},
},
splitLine: {
lineStyle: {
color: "rgba(97, 189, 242, 0.16)",
},
},
splitArea: {
areaStyle: {
color: ["rgba(16, 52, 94, 0.10)", "rgba(16, 52, 94, 0.05)"],
},
},
},
series: [
{
type: "radar",
data: [
{
value: typeStats.value.map((item) => item.value),
name: "告警类型分布",
areaStyle: {
color: "rgba(73, 207, 255, 0.20)",
},
lineStyle: {
color: "#61dcff",
width: 2.5,
},
itemStyle: {
color: "#8ff3ff",
borderColor: "#07203e",
borderWidth: 2,
},
symbolSize: 8,
},
],
},
],
};
});
const statusStats = computed(() => {
const order: AlarmStatus[] = ["待研判", "待处理", "处置完成", "误判"];
const total = reportData.length || 1;
return order.map((status) => {
const value = reportData.filter((item) => item.status === status).length;
return {
label: status,
value,
percent: `${Math.round((value / total) * 100)}%`,
};
});
});
const statusPieOption = computed<EChartsOption>(() => ({
tooltip: {
trigger: "item",
backgroundColor: "rgba(6, 20, 42, 0.92)",
borderColor: "rgba(96, 208, 255, 0.24)",
textStyle: {
color: "#eefcff",
},
},
legend: {
show: false,
},
series: [
{
type: "pie",
radius: ["46%", "72%"],
center: ["50%", "50%"],
avoidLabelOverlap: false,
itemStyle: {
borderColor: "#081f3e",
borderWidth: 3,
},
label: {
show: true,
color: "#dff7ff",
formatter: "{b}\n{d}%",
fontSize: 12,
},
labelLine: {
lineStyle: {
color: "rgba(181, 230, 249, 0.52)",
},
},
data: statusStats.value.map((item) => ({
name: item.label,
value: item.value,
itemStyle: {
color:
item.label === "待研判"
? "#ffd66e"
: item.label === "待处理"
? "#ff8b78"
: item.label === "处置完成"
? "#62e0a8"
: "#8ea7ff",
},
})),
},
],
}));
const projectDistribution = computed(() => {
const now = new Date("2026-06-23T23:59:59");
const rangeDaysMap: Record<DistributionRange, number> = {
近一周: 7,
近一个月: 30,
近三个月: 90,
};
const threshold = new Date(
now.getTime() - rangeDaysMap[distributionRange.value] * 24 * 60 * 60 * 1000,
);
const data = reportData.filter((item) => new Date(item.time.replace(" ", "T")) >= threshold);
const statsMap = new Map<string, number>();
data.forEach((item) => {
statsMap.set(item.project, (statsMap.get(item.project) ?? 0) + 1);
});
const maxValue = Math.max(...statsMap.values(), 1);
return Array.from(statsMap.entries())
.map(([label, value]) => ({
label,
value,
width: `${(value / maxValue) * 100}%`,
}))
.sort((a, b) => b.value - a.value);
});
</script>
<template>
<div class="page-shell report-page report-dashboard-page">
<div class="report-dashboard">
<section class="report-main-grid">
<div class="page-card report-panel trend-panel">
<div class="report-panel-header">
<div class="page-card-title">告警趋势分析</div>
</div>
<div class="trend-chart">
<Chart :option="trendOption" />
</div>
<div class="trend-legend">
<span><i class="legend-dot total"></i>新增告警</span>
<span><i class="legend-dot closed"></i>闭环告警</span>
</div>
</div>
<div class="page-card report-panel type-panel">
<div class="report-panel-header">
<div class="page-card-title">告警类型分布</div>
</div>
<div class="type-radar-layout">
<div class="type-radar-chart">
<Chart :option="typeRadarOption" />
</div>
<div class="type-radar-legend">
<div v-for="item in typeStats" :key="item.label" class="type-legend-item">
<span class="type-legend-name">{{ item.label }}</span>
<span class="type-legend-value">{{ item.value }}</span>
</div>
</div>
</div>
</div>
<div class="page-card report-panel status-panel">
<div class="report-panel-header">
<div class="page-card-title">案件状态分布</div>
</div>
<div class="status-pie-layout">
<div class="status-pie-chart">
<Chart :option="statusPieOption" />
</div>
<div class="status-pie-legend">
<div v-for="item in statusStats" :key="item.label" class="status-legend-item">
<span
:class="[
'status-legend-dot',
item.label === '待研判'
? 'pending'
: item.label === '待处理'
? 'handling'
: item.label === '处置完成'
? 'finished'
: 'false',
]"
></span>
<span class="status-legend-name">{{ item.label }}</span>
<span class="status-legend-value">{{ item.value }}</span>
<span class="status-legend-percent">{{ item.percent }}</span>
</div>
</div>
</div>
</div>
<div class="page-card report-panel ranking-panel">
<div class="report-panel-header">
<div class="page-card-title">项目告警分布</div>
<div class="report-filter-tabs">
<button
v-for="range in ['近一周', '近一个月', '近三个月']"
:key="range"
:class="['report-filter-tab', { active: distributionRange === range }]"
type="button"
@click="distributionRange = range as DistributionRange"
>
{{ range }}
</button>
</div>
</div>
<div class="report-bar-list">
<div v-for="item in projectDistribution" :key="item.label" class="report-bar-row">
<div class="bar-row-head">
<span>{{ item.label }}</span>
<span>{{ item.value }} </span>
</div>
<div class="report-bar-track">
<span class="report-bar-fill blue" :style="{ width: item.width }"></span>
</div>
</div>
</div>
</div>
</section>
</div>
</div>
</template>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,484 @@
<script lang="ts" setup>
import { computed, ref } from "vue";
import monitorVideo from "../assets/循环背景动画.mp4";
interface DeviceItem {
id: string;
name: string;
status: "空闲" | "离线" | "充电中";
location: string;
battery: string;
task: string;
linearSpeed: string;
angularSpeed: string;
}
interface ProjectItem {
id: number;
name: string;
devices: DeviceItem[];
}
interface NavPoint {
x: number;
y: number;
}
interface NavScenario {
routePath: string;
cloudPoints: NavPoint[];
checkPoints: Array<{ x: number; y: number; label: string }>;
}
interface AlertItem {
id: string;
title: string;
level: "高" | "中" | "低";
location: string;
time: string;
}
const projectDevices: ProjectItem[] = [
{
id: 1,
name: "西江隧道",
devices: [
{
id: "ROBOT-001",
name: "巡检机器人 R1",
status: "空闲",
location: "东洞口至 K1+240",
battery: "82%",
task: "日常巡检",
linearSpeed: "0.42m/s",
angularSpeed: "0.08rad/s",
},
{
id: "ROBOT-002",
name: "巡检机器人 R2",
status: "空闲",
location: "K1+240 至 K2+120",
battery: "76%",
task: "设备复核",
linearSpeed: "0.38m/s",
angularSpeed: "0.06rad/s",
},
{
id: "ROBOT-003",
name: "巡检机器人 R3",
status: "充电中",
location: "中控区",
battery: "48%",
task: "回库维护",
linearSpeed: "0.00m/s",
angularSpeed: "0.00rad/s",
},
{
id: "ROBOT-004",
name: "巡检机器人 R4",
status: "空闲",
location: "K2+120 至西洞口",
battery: "91%",
task: "夜间待命",
linearSpeed: "0.31m/s",
angularSpeed: "0.04rad/s",
},
],
},
];
const recentAlerts: AlertItem[] = [
{
id: "AL-001",
title: "线缆破损",
level: "高",
location: "西江隧道 K1+240",
time: "10:24:18",
},
{
id: "AL-002",
title: "局部高温",
level: "中",
location: "西江隧道 泵房区",
time: "10:18:42",
},
{
id: "AL-003",
title: "墙体裂缝",
level: "低",
location: "西江隧道 K1+860",
time: "09:56:11",
},
];
const alertTabs = ["全部", "线缆破损", "局部高温", "墙体裂缝"] as const;
const activeAlertTab = ref<(typeof alertTabs)[number]>("全部");
const activeCameraMode = ref<"white" | "infrared">("white");
const controlMode = ref<"人工接管" | "自主巡检">("人工接管");
const targetLiftHeight = ref("1200");
const liftRealtimeStatus = ref("待命");
const liftRealtimeHeight = ref("1180");
const navScenario: NavScenario = {
routePath:
"M 72 310 C 118 282, 164 265, 212 248 S 312 205, 360 216 S 446 270, 504 248 S 612 188, 690 210",
cloudPoints: [
{ x: 52, y: 120 },
{ x: 86, y: 132 },
{ x: 128, y: 148 },
{ x: 164, y: 138 },
{ x: 204, y: 118 },
{ x: 252, y: 104 },
{ x: 304, y: 120 },
{ x: 346, y: 142 },
{ x: 392, y: 168 },
{ x: 442, y: 162 },
{ x: 494, y: 134 },
{ x: 548, y: 116 },
{ x: 602, y: 132 },
{ x: 660, y: 152 },
{ x: 94, y: 254 },
{ x: 150, y: 236 },
{ x: 214, y: 220 },
{ x: 278, y: 208 },
{ x: 338, y: 214 },
{ x: 400, y: 236 },
{ x: 468, y: 258 },
{ x: 530, y: 238 },
{ x: 592, y: 214 },
{ x: 656, y: 226 },
],
checkPoints: [
{ x: 104, y: 280, label: "起点" },
{ x: 268, y: 224, label: "高温检测点" },
{ x: 480, y: 256, label: "泵房区" },
{ x: 674, y: 214, label: "终点" },
],
};
const currentProject = computed(() => projectDevices[0]);
const currentDevices = computed(() => currentProject.value.devices);
const currentRobot = computed(() => currentDevices.value[0]);
const currentScenario = computed(() => navScenario);
const filteredRecentAlerts = computed(() => {
if (activeAlertTab.value === "全部") {
return recentAlerts;
}
return recentAlerts.filter((alert) => alert.title === activeAlertTab.value);
});
function switchAlertTab(tab: (typeof alertTabs)[number]) {
activeAlertTab.value = tab;
}
function switchCameraMode(mode: "white" | "infrared") {
activeCameraMode.value = mode;
}
function toggleControlMode() {
controlMode.value =
controlMode.value === "人工接管" ? "自主巡检" : "人工接管";
}
function runToTargetHeight() {
const normalizedHeight = targetLiftHeight.value.replace(/[^\d]/g, "");
targetLiftHeight.value = normalizedHeight || "0";
liftRealtimeStatus.value = "运行中";
liftRealtimeHeight.value = targetLiftHeight.value;
}
function getStatusClass(status: DeviceItem["status"]) {
const classMap: Record<DeviceItem["status"], string> = {
空闲: "status-idle",
离线: "status-offline",
充电中: "status-charging",
};
return classMap[status];
}
</script>
<template>
<div class="center">
<div class="left">
<div class="card-1 device-card">
<div class="title">设备列表</div>
<div class="device-panel">
<div class="project-name">{{ currentProject.name }}</div>
<div class="device-list">
<div v-for="device in currentDevices" :key="device.id" class="device-item">
<div class="device-main">
<div class="device-name">{{ device.name }}</div>
<div :class="['device-status', getStatusClass(device.status)]">
{{ device.status }}
</div>
</div>
<div class="device-meta">
<span>{{ device.id }}</span>
<span>{{ device.location }}</span>
<span>{{ device.battery }}</span>
</div>
</div>
</div>
</div>
</div>
<div class="card-2 alert-card">
<div class="title">近期告警</div>
<div class="alert-panel">
<div class="alert-tabs">
<button
v-for="tab in alertTabs"
:key="tab"
:class="['alert-tab', { active: activeAlertTab === tab }]"
type="button"
@click="switchAlertTab(tab)"
>
{{ tab }}
</button>
</div>
<template v-if="filteredRecentAlerts.length">
<div v-for="alert in filteredRecentAlerts" :key="alert.id" class="alert-item">
<div class="alert-main">
<div class="alert-title">{{ alert.title }}</div>
<div class="alert-level">{{ alert.level }}</div>
</div>
<div class="alert-meta">
<span>{{ alert.id }}</span>
<span>{{ alert.location }}</span>
<span>{{ alert.time }}</span>
</div>
</div>
</template>
<div v-else class="alert-empty">当前筛选下暂无告警</div>
</div>
</div>
</div>
<div class="middle">
<div class="nav-map-panel">
<div class="nav-map-stage">
<svg class="nav-map-svg" viewBox="0 0 760 420" preserveAspectRatio="xMidYMid meet">
<g class="grid-layer">
<line v-for="line in 7" :key="`h-${line}`" :x1="40" :y1="line * 52" :x2="720" :y2="line * 52" />
<line v-for="line in 12" :key="`v-${line}`" :x1="line * 56" :y1="40" :x2="line * 56" :y2="380" />
</g>
<g class="cloud-layer">
<circle
v-for="(point, index) in currentScenario.cloudPoints"
:key="`point-${index}`"
:cx="point.x"
:cy="point.y"
:r="index % 3 === 0 ? 3 : 2"
/>
</g>
<path class="route-glow" :d="currentScenario.routePath" />
<path class="route-line" :d="currentScenario.routePath" />
<g class="checkpoint-layer">
<g
v-for="point in currentScenario.checkPoints"
:key="point.label"
class="checkpoint"
:transform="`translate(${point.x}, ${point.y})`"
>
<circle r="7"></circle>
<text x="12" y="4">{{ point.label }}</text>
</g>
</g>
<g class="robot-layer">
<circle class="robot-shadow" r="18">
<animateMotion dur="18s" repeatCount="indefinite" :path="currentScenario.routePath" />
</circle>
<circle class="robot-body" r="10">
<animateMotion dur="18s" repeatCount="indefinite" rotate="auto" :path="currentScenario.routePath" />
</circle>
<path class="robot-head" d="M -3 -9 L 8 0 L -3 9 Z">
<animateMotion dur="18s" repeatCount="indefinite" rotate="auto" :path="currentScenario.routePath" />
</path>
</g>
</svg>
<div class="nav-map-overlay top-left">激光雷达点云</div>
<div class="nav-map-overlay top-right">路径跟踪中</div>
<div class="nav-map-overlay bottom-left">
机器人编号{{ currentRobot.id }} / {{ currentRobot.task }}
</div>
<div class="nav-map-overlay bottom-right">
线速度 {{ currentRobot.linearSpeed }} / 角速度 {{ currentRobot.angularSpeed }}
</div>
</div>
<div class="nav-map-footer">
<div class="footer-item">
<span class="footer-label">当前位置</span>
<span class="footer-value">{{ currentRobot.location }}</span>
</div>
<div class="footer-item">
<span class="footer-label">电量</span>
<span class="footer-value">{{ currentRobot.battery }}</span>
</div>
<div class="footer-item">
<span class="footer-label">任务状态</span>
<span class="footer-value">{{ currentRobot.task }}</span>
</div>
<div class="footer-item">
<span class="footer-label">实时线速度</span>
<span class="footer-value">{{ currentRobot.linearSpeed }}</span>
</div>
<div class="footer-item">
<span class="footer-label">实时角速度</span>
<span class="footer-value">{{ currentRobot.angularSpeed }}</span>
</div>
</div>
</div>
</div>
<div class="right">
<div class="card-1 video-card">
<div class="card-header">
<div class="title">视频监控</div>
<div class="camera-switch header-switch">
<button
:class="['camera-btn', { active: activeCameraMode === 'white' }]"
type="button"
@click="switchCameraMode('white')"
>
白光
</button>
<button
:class="['camera-btn', { active: activeCameraMode === 'infrared' }]"
type="button"
@click="switchCameraMode('infrared')"
>
红外
</button>
</div>
</div>
<div class="video-panel">
<div class="video-screen">
<video
:class="['monitor-video', { infrared: activeCameraMode === 'infrared' }]"
autoplay
loop
muted
playsinline
:src="monitorVideo"
></video>
<div class="scan-line"></div>
<div class="video-overlay top-left">实时视频流</div>
<div class="video-overlay top-right">
{{ activeCameraMode === "white" ? "白光镜头" : "红外镜头" }}
</div>
<div class="video-overlay bottom-left">{{ currentRobot.id }}</div>
<div class="video-overlay bottom-right">{{ currentRobot.location }}</div>
</div>
</div>
</div>
<div class="card-2 control-card">
<div class="card-header">
<div class="title">远程控制</div>
<button class="mode-toggle-btn" type="button" @click="toggleControlMode">
<span :class="['mode-toggle-chip', { active: controlMode === '人工接管' }]">
人工接管
</span>
<span :class="['mode-toggle-chip', { active: controlMode === '自主巡检' }]">
自主巡检
</span>
</button>
</div>
<div class="control-panel">
<div class="control-status">
<div class="status-item">
<span class="status-label">控制对象</span>
<span class="status-value">{{ currentRobot.id }}</span>
</div>
<div class="status-item">
<span class="status-label">当前模式</span>
<span class="status-value">{{ controlMode }}</span>
</div>
</div>
<div class="control-main">
<div class="control-top-row">
<div class="vehicle-section">
<div class="section-title control-block-title">底盘驱动</div>
<div class="vehicle-pad">
<button class="drive-btn drive-up" type="button">
<span class="drive-arrow"></span>
<span class="drive-label">前进</span>
</button>
<button class="drive-btn drive-left" type="button">
<span class="drive-arrow"></span>
<span class="drive-label">左转</span>
</button>
<div class="drive-core" aria-hidden="true"></div>
<button class="drive-btn drive-right" type="button">
<span class="drive-arrow"></span>
<span class="drive-label">右转</span>
</button>
<button class="drive-btn drive-down" type="button">
<span class="drive-arrow"></span>
<span class="drive-label">后退</span>
</button>
</div>
</div>
<div class="control-section gimbal-section">
<div class="section-title">云台控制</div>
<div class="vehicle-pad gimbal-wheel">
<button class="drive-btn drive-up" type="button">
<span class="drive-arrow"></span>
<span class="drive-label"></span>
</button>
<button class="drive-btn drive-left" type="button">
<span class="drive-arrow"></span>
<span class="drive-label"></span>
</button>
<div class="drive-core" aria-hidden="true"></div>
<button class="drive-btn drive-right" type="button">
<span class="drive-arrow"></span>
<span class="drive-label"></span>
</button>
<button class="drive-btn drive-down" type="button">
<span class="drive-arrow"></span>
<span class="drive-label"></span>
</button>
</div>
</div>
</div>
<div class="control-section lift-section">
<div class="section-title">升降柱控制</div>
<div class="lift-control-panel">
<label class="lift-input-group">
<span class="lift-input-label">目标高度</span>
<div class="lift-input-shell">
<input
v-model="targetLiftHeight"
class="lift-input"
type="text"
inputmode="numeric"
/>
<span class="lift-unit">mm</span>
</div>
</label>
<button class="lift-submit-btn" type="button" @click="runToTargetHeight">
运行到指定高度
</button>
<div class="lift-status-panel">
<div class="lift-status-item">
<span class="lift-status-label">实时状态</span>
<span class="lift-status-value">{{ liftRealtimeStatus }}</span>
</div>
<div class="lift-status-item">
<span class="lift-status-label">当前高度</span>
<span class="lift-status-value">{{ liftRealtimeHeight }} mm</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,161 @@
<script lang="ts" setup>
import { ref } from "vue";
interface PatrolAlarmRecord {
id: string;
type: string;
reportTime: string;
}
interface PatrolRecord {
id: string;
robot: string;
startTime: string;
endTime: string;
mileage: string;
alarms: PatrolAlarmRecord[];
}
const patrolRecords: PatrolRecord[] = [
{
id: "PR-20260623-001",
robot: "巡检机器人 R1",
startTime: "2026-06-23 08:10:22",
endTime: "2026-06-23 09:02:15",
mileage: "3.8 km",
alarms: [
{ id: "AL-001", type: "线缆破损", reportTime: "2026-06-23 08:36:12" },
{ id: "AL-002", type: "局部高温", reportTime: "2026-06-23 08:42:09" },
],
},
{
id: "PR-20260623-002",
robot: "巡检机器人 Q1",
startTime: "2026-06-23 09:20:08",
endTime: "2026-06-23 10:01:46",
mileage: "2.9 km",
alarms: [
{ id: "AL-005", type: "应急门开启异常", reportTime: "2026-06-23 09:48:57" },
],
},
{
id: "PR-20260623-003",
robot: "巡检机器人 N3",
startTime: "2026-06-23 10:08:34",
endTime: "2026-06-23 11:12:25",
mileage: "4.6 km",
alarms: [
{ id: "AL-003", type: "墙体裂缝", reportTime: "2026-06-23 10:36:41" },
{ id: "AL-006", type: "水渍渗漏", reportTime: "2026-06-23 10:54:18" },
{ id: "AL-007", type: "局部高温", reportTime: "2026-06-23 11:02:33" },
],
},
{
id: "PR-20260623-004",
robot: "巡检机器人 R2",
startTime: "2026-06-23 13:15:17",
endTime: "2026-06-23 14:05:52",
mileage: "3.2 km",
alarms: [],
},
];
const dialogVisible = ref(false);
const activePatrolRecord = ref<PatrolRecord | null>(null);
function openAlarmDialog(record: PatrolRecord) {
activePatrolRecord.value = record;
dialogVisible.value = true;
}
function closeDialog() {
dialogVisible.value = false;
activePatrolRecord.value = null;
}
</script>
<template>
<div class="page-shell plan-page">
<div class="page-grid management-layout">
<section class="page-card management-table-card">
<div class="management-toolbar">
<div class="toolbar-left">
<button class="page-action-btn primary" type="button">导出记录</button>
</div>
<div class="toolbar-right"> {{ patrolRecords.length }} 条巡检记录</div>
</div>
<div class="table-shell">
<table class="device-table patrol-record-table">
<thead>
<tr>
<th>巡检记录号</th>
<th>巡检机器人</th>
<th>开始巡检时间</th>
<th>结束巡检时间</th>
<th>巡检里程</th>
<th>告警触发次数</th>
</tr>
</thead>
<tbody>
<tr v-for="record in patrolRecords" :key="record.id">
<td>{{ record.id }}</td>
<td>{{ record.robot }}</td>
<td>{{ record.startTime }}</td>
<td>{{ record.endTime }}</td>
<td>{{ record.mileage }}</td>
<td>
<button
class="alarm-link-btn"
type="button"
@click="openAlarmDialog(record)"
>
{{ record.alarms.length }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
<div v-if="dialogVisible" class="crud-dialog-mask" @click.self="closeDialog">
<div class="crud-dialog patrol-alarm-dialog">
<div class="crud-dialog-header">
<div class="crud-dialog-title">关联告警列表</div>
<button class="dialog-close" type="button" @click="closeDialog">×</button>
</div>
<div class="patrol-dialog-meta">
{{ activePatrolRecord?.id }} / {{ activePatrolRecord?.robot }}
</div>
<div class="table-shell patrol-dialog-table-shell">
<table class="device-table">
<thead>
<tr>
<th>告警编号</th>
<th>告警类型</th>
<th>上报时间</th>
</tr>
</thead>
<tbody>
<tr
v-for="alarm in activePatrolRecord?.alarms ?? []"
:key="alarm.id"
>
<td>{{ alarm.id }}</td>
<td>{{ alarm.type }}</td>
<td>{{ alarm.reportTime }}</td>
</tr>
<tr v-if="!(activePatrolRecord?.alarms.length)">
<td class="table-empty" colspan="3">本次巡检未触发告警</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,211 @@
<script lang="ts" setup>
import { computed, ref } from "vue";
import monitorVideo from "../assets/循环背景动画.mp4";
type GridMode = 1 | 4 | 9;
interface CameraItem {
id: string;
name: string;
type: string;
status: string;
}
interface DeviceNode {
id: string;
name: string;
cameras: CameraItem[];
}
interface ProjectNode {
id: string;
name: string;
devices: DeviceNode[];
}
interface ProjectCameraItem extends CameraItem {
deviceId: string;
deviceName: 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: "在线" },
],
},
],
};
const gridMode = ref<GridMode>(4);
const playbackProgress = ref(42);
const playbackDate = ref("2026-06-23");
const playbackTime = ref("14:20:00");
const activeCameraId = ref(projectData.devices[0].cameras[0].id);
const currentProject = computed(() => projectData);
const projectCameras = computed<ProjectCameraItem[]>(() =>
currentProject.value.devices.flatMap((device) =>
device.cameras.map((camera) => ({
...camera,
deviceId: device.id,
deviceName: device.name,
})),
),
);
const activeCamera = computed(
() => projectCameras.value.find((camera) => camera.id === activeCameraId.value) ?? projectCameras.value[0],
);
const orderedCameras = computed(() => {
const selected = activeCamera.value;
return projectCameras.value
.filter((camera) => camera.id !== selected.id)
.reduce<ProjectCameraItem[]>((list, camera) => {
list.push(camera);
return list;
}, [selected]);
});
const visibleCameras = computed(() => {
const countMap: Record<GridMode, number> = {
1: 1,
4: 4,
9: 9,
};
const targetCount = countMap[gridMode.value];
const cameras = orderedCameras.value;
return Array.from({ length: targetCount }, (_, index) => cameras[index] ?? null);
});
function selectCamera(cameraId: string) {
activeCameraId.value = cameraId;
}
function setGridMode(mode: GridMode) {
gridMode.value = mode;
}
</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="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>
</div>
</aside>
<section class="page-card video-wall-card">
<div class="video-wall-header">
<div>
<div class="page-card-title">视频播放</div>
<div class="video-wall-meta">
{{ currentProject.name }} / {{ activeCamera.name }}
</div>
</div>
<div class="grid-switch">
<button
v-for="mode in [1, 4, 9]"
:key="mode"
:class="['grid-switch-btn', { active: gridMode === mode }]"
type="button"
@click="setGridMode(mode as GridMode)"
>
{{ mode }} 宫格
</button>
</div>
</div>
<div :class="['video-grid-board', `grid-${gridMode}`]">
<div
v-for="(camera, index) in visibleCameras"
:key="camera?.id ?? `empty-${index}`"
class="video-grid-cell"
>
<template v-if="camera">
<video autoplay loop muted playsinline :src="monitorVideo"></video>
<div class="video-cell-top">
<span class="cell-badge">LIVE</span>
<span class="cell-type">{{ camera.type }}</span>
</div>
<div class="video-cell-bottom">
<div class="cell-name">{{ camera.name }}</div>
<div class="cell-meta">{{ camera.deviceName }} / {{ camera.status }}</div>
</div>
</template>
<template v-else>
<div class="video-empty">
<span>未分配画面</span>
</div>
</template>
</div>
</div>
<div class="playback-panel">
<div class="playback-header">
<div class="playback-title">录像回放</div>
<div class="playback-filter">
<input v-model="playbackDate" type="date" />
<input v-model="playbackTime" type="time" step="1" />
</div>
</div>
<div class="playback-controls">
<button class="playback-btn" type="button">回放</button>
<button class="playback-btn" type="button">暂停</button>
<button class="playback-btn" type="button">快退</button>
<button class="playback-btn" type="button">快进</button>
<div class="playback-track">
<div class="playback-track-line">
<span :style="{ width: `${playbackProgress}%` }"></span>
</div>
<input
v-model="playbackProgress"
class="playback-range"
max="100"
min="0"
type="range"
/>
</div>
<div class="playback-time">{{ playbackDate }} {{ playbackTime }}</div>
</div>
</div>
</section>
</div>
</div>
</template>

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 725 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 427 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 725 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 494 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 629 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 376 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

View File

@@ -0,0 +1,65 @@
.dashboard {
width: 2560px;
height: 1080px;
position: relative;
background-image: url("./assets/整体边框_静态图片.png"),
url("./assets/底部_动画.png"),
url("./assets/底部_动画.webp"),
url("./assets/左边_动画.png"),
url("./assets/左边_动画.webp"),
url("./assets/右侧_动画.png"),
url("./assets/右侧_动画.webp"),
url("./assets/头部动画.png"),
url("./assets/头部动画.webp"),
url("./assets/头部动画2.png"),
url("./assets/头部动画2.webp"),
url("./assets/头部动画_上升粒子-.png"),
url("./assets/头部动画_上升粒子.webp"),
url("./assets/背景_静态图片.jpg");
background-repeat: no-repeat;
background-size: auto;
background-position: center,
bottom, bottom, left,
left, right, right,
top, top, top,
top, top, top;
.header {
position: absolute;
display: flex;
}
.main {
width: 100%;
height: 100%;
& > div {
height: 100%;
display: inline-flex;
}
& > .left {
width: 20%;
display: flex;
flex-direction: column;
& > :nth-child(1) {
flex-basis: 20%;
}
}
& > .center {
width: 60%;
background-image: url("./assets/center.png");
background-size: auto;
background-repeat: no-repeat;
background-position: center;
}
& > .right {
width: 20%;
}
}
}

View File

@@ -0,0 +1,35 @@
<script lang="ts" setup>
import SwitchPageBtn from "@/views/dashboard1/components/btn";
</script>
<template>
<div class="dashboard">
<div class="header">
<div class="left">
<switch-page-btn active direction="left" text="页面一" />
<switch-page-btn active direction="left" text="页面一" />
<switch-page-btn active direction="left" text="页面一" />
<switch-page-btn active direction="left" text="页面一" />
</div>
<div class="right">
<switch-page-btn active direction="left" text="页面一" />
<switch-page-btn active direction="left" text="页面一" />
<switch-page-btn active direction="left" text="页面一" />
<switch-page-btn active direction="left" text="页面一" />
</div>
</div>
<div class="main">
<div class="left">
<div class="card"></div>
<div class="card"></div>
<div class="card"></div>
</div>
<div class="center"></div>
<div class="right"></div>
</div>
</div>
</template>
<style lang="less" scoped>
@import "index";
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 649 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 775 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Some files were not shown because too many files have changed in this diff Show More