同步屏客户端
@ -0,0 +1,25 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
src-tauri/target/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Broadcast Client</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "broadcast-client",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@tauri-apps/plugin-store": "^2",
|
||||
"element-plus": "^2.11.4",
|
||||
"vue": "^3.5.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.3",
|
||||
"vitest": "^3.2.4",
|
||||
"vue-tsc": "^2.1.10"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "broadcast-client"
|
||||
version = "0.1.0"
|
||||
description = "Broadcast Ruler Client"
|
||||
authors = ["team"]
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "broadcast_client_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-store = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
@ -0,0 +1,4 @@
|
||||
fn main() {
|
||||
println!("cargo:rustc-check-cfg=cfg(mobile)");
|
||||
tauri_build::build()
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Default capability for broadcast window",
|
||||
"windows": ["main", "sync-config"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-set-position",
|
||||
"core:window:allow-set-size",
|
||||
"core:window:allow-start-dragging",
|
||||
"opener:default",
|
||||
"store:default"
|
||||
]
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default capability for broadcast window","local":true,"windows":["main","sync-config"],"permissions":["core:default","core:window:allow-set-position","core:window:allow-set-size","core:window:allow-start-dragging","opener:default","store:default"]}}
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 504 B |
|
After Width: | Height: | Size: 763 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 480 B |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 617 B |
|
After Width: | Height: | Size: 860 B |
|
After Width: | Height: | Size: 1002 B |
|
After Width: | Height: | Size: 669 B |
@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 711 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 841 B |
|
After Width: | Height: | Size: 758 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 774 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 389 B |
|
After Width: | Height: | Size: 638 B |
|
After Width: | Height: | Size: 638 B |
|
After Width: | Height: | Size: 736 B |
|
After Width: | Height: | Size: 534 B |
|
After Width: | Height: | Size: 669 B |
|
After Width: | Height: | Size: 669 B |
|
After Width: | Height: | Size: 971 B |
|
After Width: | Height: | Size: 638 B |
|
After Width: | Height: | Size: 882 B |
|
After Width: | Height: | Size: 882 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 875 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
@ -0,0 +1,6 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
// 原生启动入口,转交到库内 run()。
|
||||
fn main() {
|
||||
broadcast_client_lib::run()
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "broadcast-client",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.ziyun.broadcastclient",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "Broadcast Client",
|
||||
"width": 1280,
|
||||
"height": 256,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"decorations": false,
|
||||
"transparent": false,
|
||||
"shadow": false,
|
||||
"alwaysOnTop": true,
|
||||
"resizable": false,
|
||||
"maximizable": false,
|
||||
"fullscreen": false,
|
||||
"visible": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": false,
|
||||
"targets": "all"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<section
|
||||
class="segment"
|
||||
:style="{
|
||||
left: `${segment.left}px`,
|
||||
top: `${segment.top}px`,
|
||||
width: `${segment.sliceWidth}px`,
|
||||
height: `${segment.height}px`,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="segment-track"
|
||||
:style="{
|
||||
width: `${totalWidth}px`,
|
||||
height: `${segment.height}px`,
|
||||
transform: `translateX(${-segment.sourceX}px)`,
|
||||
}"
|
||||
>
|
||||
<RulerTicks
|
||||
v-if="showRuler"
|
||||
:ticks="ticks"
|
||||
:total-width="totalWidth"
|
||||
:segment-height="segment.height"
|
||||
/>
|
||||
</div>
|
||||
<WindowAreasLayer :slices="segmentAreaSlices" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import type { Segment, Tick } from "../models/ruler";
|
||||
import type { WindowAreaSlice } from "../services/windowAreaSliceService";
|
||||
import RulerTicks from "./RulerTicks.vue";
|
||||
import WindowAreasLayer from "./WindowAreasLayer.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
segment: Segment;
|
||||
ticks: Tick[];
|
||||
totalWidth: number;
|
||||
showRuler: boolean;
|
||||
windowAreaSlices: WindowAreaSlice[];
|
||||
}>();
|
||||
|
||||
const segmentAreaSlices = computed(() =>
|
||||
props.windowAreaSlices.filter((item) => item.segmentIndex === props.segment.index),
|
||||
);
|
||||
</script>
|
||||
@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<div class="ruler-track" :style="{ width: `${totalWidth}px`, height: `${segmentHeight}px` }">
|
||||
<div
|
||||
v-for="tick in ticks"
|
||||
:key="`${tick.type}-${tick.x}`"
|
||||
class="tick"
|
||||
:class="tick.type"
|
||||
:style="{ left: `${tick.x}px` }"
|
||||
>
|
||||
<span v-if="tick.type === 'major'" class="tick-label">{{ tick.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Tick } from "../models/ruler";
|
||||
|
||||
// 当前分段内可见的刻度数据与尺寸入参。
|
||||
defineProps<{
|
||||
ticks: Tick[];
|
||||
totalWidth: number;
|
||||
segmentHeight: number;
|
||||
}>();
|
||||
</script>
|
||||
@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<div class="children-layer">
|
||||
<div
|
||||
v-for="slice in slices"
|
||||
:key="`${slice.childId}-${slice.segmentIndex}-${slice.renderLeft}`"
|
||||
class="child-slice"
|
||||
:class="slice.className"
|
||||
:style="{
|
||||
left: `${slice.renderLeft}px`,
|
||||
top: `${slice.renderTop}px`,
|
||||
width: `${slice.renderWidth}px`,
|
||||
height: `${slice.renderHeight}px`,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ChildSlice } from "../models/ruler";
|
||||
|
||||
// 分段内要绘制的子元素切片列表。
|
||||
defineProps<{
|
||||
slices: ChildSlice[];
|
||||
}>();
|
||||
</script>
|
||||
@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<div class="window-areas-layer">
|
||||
<section
|
||||
v-for="slice in slices"
|
||||
:key="`${slice.area.id}-${slice.segmentIndex}-${slice.renderLeft}`"
|
||||
class="window-area"
|
||||
:style="{
|
||||
left: `${slice.renderLeft}px`,
|
||||
top: `${slice.renderTop}px`,
|
||||
width: `${slice.renderWidth}px`,
|
||||
height: `${slice.renderHeight}px`,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="window-area-inner"
|
||||
:style="{
|
||||
width: `${slice.area.width}px`,
|
||||
height: `${slice.area.height}px`,
|
||||
transform: `translateX(${-slice.clipOffset}px)`,
|
||||
}"
|
||||
>
|
||||
<div class="window-no-region">
|
||||
<span
|
||||
class="window-no-text"
|
||||
:class="{ circle: slice.area.windowNumberCircle }"
|
||||
:style="{
|
||||
fontSize: `${slice.area.windowNumberStyle.fontSize}px`,
|
||||
color: slice.area.windowNumberStyle.color,
|
||||
fontWeight: slice.area.windowNumberStyle.fontWeight,
|
||||
}"
|
||||
>
|
||||
{{ slice.area.windowNumber }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="window-text-region">
|
||||
<div
|
||||
class="window-text-line"
|
||||
:style="{
|
||||
fontSize: `${slice.area.staticTextStyle.fontSize}px`,
|
||||
color: slice.area.staticTextStyle.color,
|
||||
fontWeight: slice.area.staticTextStyle.fontWeight,
|
||||
}"
|
||||
>
|
||||
{{ slice.area.staticText }}
|
||||
</div>
|
||||
<div
|
||||
class="window-text-line"
|
||||
:style="{
|
||||
fontSize: `${slice.area.dynamicTextStyle.fontSize}px`,
|
||||
color: slice.area.dynamicTextStyle.color,
|
||||
fontWeight: slice.area.dynamicTextStyle.fontWeight,
|
||||
}"
|
||||
>
|
||||
{{ slice.area.dynamicText }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { WindowAreaSlice } from "../services/windowAreaSliceService";
|
||||
|
||||
defineProps<{
|
||||
slices: WindowAreaSlice[];
|
||||
}>();
|
||||
</script>
|
||||
@ -0,0 +1,17 @@
|
||||
import { computed, type Ref } from "vue";
|
||||
import { buildSegments } from "../services/segmentService";
|
||||
|
||||
/**
|
||||
* 根据屏宽和配置生成分段布局及容器高度。
|
||||
*/
|
||||
export function useSegmentLayout(
|
||||
screenWidth: Ref<number>,
|
||||
totalWidth: Ref<number>,
|
||||
segmentHeight: Ref<number>,
|
||||
) {
|
||||
const segments = computed(() =>
|
||||
buildSegments(screenWidth.value, totalWidth.value, segmentHeight.value),
|
||||
);
|
||||
const containerHeight = computed(() => segments.value.length * segmentHeight.value);
|
||||
return { segments, containerHeight };
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@ -0,0 +1,8 @@
|
||||
import { createApp } from "vue";
|
||||
import ElementPlus from "element-plus/es";
|
||||
import "element-plus/theme-chalk/index.css";
|
||||
import App from "./App.vue";
|
||||
import "./styles.css";
|
||||
|
||||
// 前端入口:挂载 Vue 根组件。
|
||||
createApp(App).use(ElementPlus).mount("#app");
|
||||
@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_SEGMENT_HEIGHT } from "../models/ruler";
|
||||
import { buildSegments, normalizeScreenWidth } from "./segmentService";
|
||||
|
||||
const TOTAL_WIDTH = 5000;
|
||||
|
||||
// 分段算法核心用例。
|
||||
describe("segmentService", () => {
|
||||
// 非法屏宽应回退默认值。
|
||||
it("normalizes invalid screen width", () => {
|
||||
expect(normalizeScreenWidth(0)).toBe(1920);
|
||||
expect(normalizeScreenWidth(-1)).toBe(1920);
|
||||
expect(normalizeScreenWidth(Number.NaN)).toBe(1920);
|
||||
});
|
||||
|
||||
// 分段数量与前两段布局应符合规则。
|
||||
it("builds segments with correct count and size", () => {
|
||||
const width = 1080;
|
||||
const segments = buildSegments(width, TOTAL_WIDTH, DEFAULT_SEGMENT_HEIGHT);
|
||||
|
||||
expect(segments.length).toBe(Math.ceil(TOTAL_WIDTH / width));
|
||||
expect(segments[0]).toMatchObject({
|
||||
index: 0,
|
||||
sourceX: 0,
|
||||
sliceWidth: 1080,
|
||||
top: 0,
|
||||
height: DEFAULT_SEGMENT_HEIGHT,
|
||||
});
|
||||
expect(segments[1]).toMatchObject({
|
||||
index: 1,
|
||||
sourceX: 1080,
|
||||
sliceWidth: 1080,
|
||||
top: DEFAULT_SEGMENT_HEIGHT,
|
||||
height: DEFAULT_SEGMENT_HEIGHT,
|
||||
});
|
||||
});
|
||||
|
||||
// 最后一段应被裁剪到剩余宽度。
|
||||
it("clips the last segment width correctly", () => {
|
||||
const width = 1080;
|
||||
const segments = buildSegments(width, TOTAL_WIDTH, DEFAULT_SEGMENT_HEIGHT);
|
||||
const last = segments[segments.length - 1];
|
||||
|
||||
expect(last.sliceWidth).toBe(TOTAL_WIDTH - 1080 * 4);
|
||||
expect(last.sourceX + last.sliceWidth).toBe(TOTAL_WIDTH);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MAJOR_TICK_GAP, MINOR_TICK_GAP } from "../models/ruler";
|
||||
import { buildTicks } from "./tickService";
|
||||
|
||||
const TOTAL_WIDTH = 800;
|
||||
|
||||
// 刻度生成规则验证。
|
||||
describe("tickService", () => {
|
||||
// 刻度应覆盖整个主容器宽度。
|
||||
it("builds ticks covering full ruler width", () => {
|
||||
const ticks = buildTicks(TOTAL_WIDTH);
|
||||
|
||||
expect(ticks[0].x).toBe(0);
|
||||
expect(ticks[ticks.length - 1].x).toBe(TOTAL_WIDTH);
|
||||
expect(ticks.length).toBe(TOTAL_WIDTH / MINOR_TICK_GAP + 1);
|
||||
});
|
||||
|
||||
// 每 100px 应生成一个大刻度并附带标签。
|
||||
it("marks major ticks every 100px", () => {
|
||||
const ticks = buildTicks(TOTAL_WIDTH);
|
||||
const majors = ticks.filter((tick) => tick.type === "major");
|
||||
|
||||
expect(majors[0]).toMatchObject({ x: 0, label: "0" });
|
||||
expect(majors[1]).toMatchObject({ x: MAJOR_TICK_GAP, label: "100" });
|
||||
expect(majors.every((tick) => tick.x % MAJOR_TICK_GAP === 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,393 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 0 !important;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.broadcast-root {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.segment {
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.segment-track {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.ruler-track {
|
||||
position: relative;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.tick {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: #7f8da8;
|
||||
}
|
||||
|
||||
.tick.minor {
|
||||
height: 16px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.tick.major {
|
||||
height: 34px;
|
||||
background: #c4d2ea;
|
||||
}
|
||||
|
||||
.tick-label {
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
left: 4px;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
color: #d9e4ff;
|
||||
opacity: 0.9;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.children-layer {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.child-slice {
|
||||
position: absolute;
|
||||
border-radius: 0;
|
||||
background: linear-gradient(90deg, #27ae60 0%, #2ecc71 100%);
|
||||
}
|
||||
|
||||
.child-banner.alt {
|
||||
background: linear-gradient(90deg, #8e44ad 0%, #9b59b6 100%);
|
||||
}
|
||||
|
||||
.child-banner.warn {
|
||||
background: linear-gradient(90deg, #d35400 0%, #f39c12 100%);
|
||||
}
|
||||
|
||||
.config-root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #eef1f6;
|
||||
color: #111;
|
||||
padding: 0 16px 16px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.config-page {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.config-card {
|
||||
border: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
.config-collapse {
|
||||
border: 1px solid #b8c3d9;
|
||||
border-radius: 6px;
|
||||
background: #d9e1f0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.config-collapse .el-collapse-item__header {
|
||||
padding: 0 14px;
|
||||
background: #d0dbef;
|
||||
color: #1f2d3d;
|
||||
border-bottom: 1px solid #b8c3d9;
|
||||
}
|
||||
|
||||
.config-collapse .el-collapse-item__wrap {
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.config-collapse .el-collapse-item__content {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.panel-scroll {
|
||||
max-height: 42vh;
|
||||
overflow: auto;
|
||||
padding-right: 6px;
|
||||
}
|
||||
|
||||
.panel-scroll--tall {
|
||||
max-height: 56vh;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.row-between {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.area-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.area-item {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.el-input-number {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.config-title {
|
||||
margin: 12px 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.config-desc {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.config-form {
|
||||
margin-top: 16px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.field-row input[type="number"] {
|
||||
width: 160px;
|
||||
padding: 6px 8px;
|
||||
background: #fff;
|
||||
border: 1px solid #c8c8c8;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.checkbox-row {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.segments-panel {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.segments-panel h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.segment-grid {
|
||||
margin-top: 8px;
|
||||
display: grid;
|
||||
grid-template-columns: 90px 120px 160px;
|
||||
gap: 6px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.segment-grid-header {
|
||||
color: #95a5a6;
|
||||
}
|
||||
|
||||
.actions-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: 1px solid #c8c8c8;
|
||||
background: #fff;
|
||||
color: #222;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
background: #1677ff;
|
||||
color: #fff;
|
||||
border-color: #1677ff;
|
||||
}
|
||||
|
||||
.segment-list {
|
||||
margin-top: 10px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.segment-row {
|
||||
display: grid;
|
||||
grid-template-columns: 44px 110px 90px 90px 70px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.segment-row input[type="number"] {
|
||||
width: 100%;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid #c8c8c8;
|
||||
}
|
||||
|
||||
.save-hint {
|
||||
margin-top: 8px;
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.socket-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.socket-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.socket-status.running .socket-dot {
|
||||
background: #2ecc71;
|
||||
box-shadow: 0 0 6px rgba(46, 204, 113, 0.85);
|
||||
}
|
||||
|
||||
.socket-status.stopped .socket-dot {
|
||||
background: #e74c3c;
|
||||
box-shadow: 0 0 6px rgba(231, 76, 60, 0.85);
|
||||
}
|
||||
|
||||
.areas-panel {
|
||||
margin-top: 18px;
|
||||
padding-bottom: 18px;
|
||||
}
|
||||
|
||||
.window-area-list {
|
||||
margin-top: 10px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.window-area-card {
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.window-area-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.window-area-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.window-area-grid .field-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.window-area-grid input[type="text"],
|
||||
.window-area-grid input[type="number"] {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #c8c8c8;
|
||||
}
|
||||
|
||||
.window-areas-layer {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.window-area {
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.window-area-inner {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.window-no-region {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-right: none;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.window-no-text.circle {
|
||||
width: 2.2em;
|
||||
height: 2.2em;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.window-text-region {
|
||||
flex: 2.5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.window-text-line {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
|
||||
// @ts-expect-error process is a Node.js global
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
|
||||
export default defineConfig(() => ({
|
||||
plugins: [vue()],
|
||||
clearScreen: false,
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
host: host || false,
|
||||
hmr: host
|
||||
? {
|
||||
protocol: "ws",
|
||||
host,
|
||||
port: 1421,
|
||||
}
|
||||
: undefined,
|
||||
watch: {
|
||||
ignored: ["**/src-tauri/**"],
|
||||
},
|
||||
},
|
||||
}));
|
||||