You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

728 lines
26 KiB
Vue

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<template>
<main class="config-root config-page">
<div class="config-top-status row-between">
<span class="socket-status" :class="socketRunning ? 'running' : 'stopped'">
<i class="socket-dot" />
Socket {{ socketRunning ? "运行中" : "未启动" }} (9501)
</span>
<div class="top-actions">
<el-button type="primary" @click="saveConfig"></el-button>
<el-button type="success" plain :disabled="socketRunning" @click="startSocketService">
Socket
</el-button>
<el-button type="danger" plain :disabled="!socketRunning" @click="stopSocketService">
Socket
</el-button>
</div>
</div>
<el-collapse v-model="activePanels" class="config-collapse">
<el-collapse-item name="base">
<template #title>
<div class="card-header">基础配置</div>
</template>
<div class="panel-scroll">
<el-form label-width="140px">
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="主div总长度(px)">
<el-input-number v-model="draft.totalWidth" :min="1" :max="screenWidth" :step="1" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="主div高度(px)">
<el-input-number v-model="draft.segmentHeight" :min="1" :step="1" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="标尺显示">
<el-switch v-model="draft.showRuler" />
</el-form-item>
</el-col>
</el-row>
</el-form>
</div>
</el-collapse-item>
<el-collapse-item name="segments">
<template #title>
<div class="card-header row-between">
<span>分段列表(主屏宽度上限 {{ screenWidth }}px</span>
<el-button type="primary" plain size="small" @click.stop="addSegment">添加分段</el-button>
</div>
</template>
<div class="panel-scroll">
<el-table :data="draft.segments" border size="small">
<el-table-column type="index" label="#" width="56" />
<el-table-column label="段长度(px)" width="140">
<template #default="{ row }">
<el-input-number v-model="row.length" :min="1" :max="screenWidth" :step="1" />
</template>
</el-table-column>
<el-table-column label="X" width="120">
<template #default="{ row }">
<el-input-number v-model="row.x" :min="0" :step="1" />
</template>
</el-table-column>
<el-table-column label="Y" width="120">
<template #default="{ row }">
<el-input-number v-model="row.y" :min="0" :step="1" />
</template>
</el-table-column>
<el-table-column label="操作" width="90">
<template #default="{ $index }">
<el-button type="danger" link @click="removeSegment($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-divider content-position="left">应用后预览</el-divider>
<el-table :data="appliedSegments" border size="small">
<el-table-column type="index" label="#" width="56" />
<el-table-column prop="sliceWidth" label="段长度(px)" width="140" />
<el-table-column label="左顶点坐标">
<template #default="{ row }">({{ row.left }}, {{ row.top }})</template>
</el-table-column>
</el-table>
</div>
</el-collapse-item>
<el-collapse-item name="areas">
<template #title>
<div class="card-header row-between">
<span>子div窗口区域</span>
<el-button type="primary" plain size="small" @click.stop="addWindowArea">添加子div</el-button>
</div>
</template>
<div class="panel-scroll panel-scroll--tall area-list">
<el-card v-for="(area, index) in draft.windowAreas" :key="area.id" class="area-item" shadow="never">
<template #header>
<div class="row-between">
<strong>子div {{ index + 1 }}</strong>
<el-button type="danger" link @click="removeWindowArea(index)">删除</el-button>
</div>
</template>
<el-form label-width="120px" size="small">
<el-row :gutter="12">
<el-col :span="6">
<el-form-item label="窗口区域编号">
<el-input-number v-model="area.windowId" :min="1" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="时钟窗口">
<el-switch v-model="area.isClockWindow" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="宽度">
<el-input-number v-model="area.width" :min="1" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="高度">
<el-input-number v-model="area.height" :min="1" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="X">
<el-input-number v-model="area.x" :min="0" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="Y">
<el-input-number v-model="area.y" :min="0" />
</el-form-item>
</el-col>
</el-row>
<el-divider content-position="left">窗口号区域(占比 1</el-divider>
<el-row :gutter="12">
<el-col :span="6">
<el-form-item label="窗口编号">
<el-input v-model="area.windowNumber" />
</el-form-item>
</el-col>
<el-col :span="4">
<el-form-item label="圆圈边框">
<el-switch v-model="area.windowNumberCircle" />
</el-form-item>
</el-col>
<el-col :span="4">
<el-form-item label="字号">
<el-input-number v-model="area.windowNumberStyle.fontSize" :min="1" />
</el-form-item>
</el-col>
<el-col :span="5">
<el-form-item label="颜色">
<el-input v-model="area.windowNumberStyle.color" />
</el-form-item>
</el-col>
<el-col :span="5">
<el-form-item label="粗细">
<el-input-number v-model="area.windowNumberStyle.fontWeight" :min="100" :step="100" />
</el-form-item>
</el-col>
<el-col :span="4">
<el-form-item label="圆圈大小">
<el-input-number v-model="area.windowNumberCircleStyle.size" :min="1" />
</el-form-item>
</el-col>
<el-col :span="4">
<el-form-item label="边框粗细">
<el-input-number v-model="area.windowNumberCircleStyle.borderWidth" :min="1" />
</el-form-item>
</el-col>
<el-col :span="4">
<el-form-item label="圆角半径">
<el-input-number v-model="area.windowNumberCircleStyle.borderRadius" :min="0" />
</el-form-item>
</el-col>
</el-row>
<el-divider content-position="left">文本区域(占比 2.5,静态/动态=1:1</el-divider>
<el-row :gutter="12">
<el-col :span="6">
<el-form-item label="静态文本">
<el-input v-model="area.staticText" />
</el-form-item>
</el-col>
<el-col :span="4">
<el-form-item label="静态字号">
<el-input-number v-model="area.staticTextStyle.fontSize" :min="1" />
</el-form-item>
</el-col>
<el-col :span="5">
<el-form-item label="静态颜色">
<el-input v-model="area.staticTextStyle.color" />
</el-form-item>
</el-col>
<el-col :span="5">
<el-form-item label="静态粗细">
<el-input-number v-model="area.staticTextStyle.fontWeight" :min="100" :step="100" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="6">
<el-form-item label="动态文本">
<el-input v-model="area.dynamicText" />
</el-form-item>
</el-col>
<el-col :span="4">
<el-form-item label="动态字号">
<el-input-number v-model="area.dynamicTextStyle.fontSize" :min="1" />
</el-form-item>
</el-col>
<el-col :span="5">
<el-form-item label="动态颜色">
<el-input v-model="area.dynamicTextStyle.color" />
</el-form-item>
</el-col>
<el-col :span="5">
<el-form-item label="动态粗细">
<el-input-number v-model="area.dynamicTextStyle.fontWeight" :min="100" :step="100" />
</el-form-item>
</el-col>
</el-row>
</el-form>
</el-card>
</div>
</el-collapse-item>
<el-collapse-item name="subtitles">
<template #title>
<div class="card-header row-between">
<span>滚动字幕区域</span>
<el-button type="primary" plain size="small" @click.stop="addSubtitleArea">
添加滚动字幕
</el-button>
</div>
</template>
<div class="panel-scroll area-list">
<el-card
v-for="(subtitle, index) in draft.subtitleAreas"
:key="subtitle.id"
class="area-item"
shadow="never"
>
<template #header>
<div class="row-between">
<strong>字幕区域 {{ index + 1 }}</strong>
<el-button type="danger" link @click="removeSubtitleArea(index)">删除</el-button>
</div>
</template>
<el-form label-width="120px" size="small">
<el-row :gutter="12">
<el-col :span="6">
<el-form-item label="宽度">
<el-input-number v-model="subtitle.width" :min="1" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="高度">
<el-input-number v-model="subtitle.height" :min="1" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="X">
<el-input-number v-model="subtitle.x" :min="0" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="Y">
<el-input-number v-model="subtitle.y" :min="0" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="8">
<el-form-item label="字幕文本">
<el-input v-model="subtitle.text" />
</el-form-item>
</el-col>
<el-col :span="4">
<el-form-item label="字号">
<el-input-number v-model="subtitle.textStyle.fontSize" :min="1" />
</el-form-item>
</el-col>
<el-col :span="4">
<el-form-item label="颜色">
<el-input v-model="subtitle.textStyle.color" />
</el-form-item>
</el-col>
<el-col :span="4">
<el-form-item label="粗细">
<el-input-number v-model="subtitle.textStyle.fontWeight" :min="100" :step="100" />
</el-form-item>
</el-col>
<el-col :span="4">
<el-form-item label="滚动速度">
<el-input-number v-model="subtitle.speed" :min="1" />
</el-form-item>
</el-col>
</el-row>
</el-form>
</el-card>
</div>
</el-collapse-item>
</el-collapse>
<div class="actions-row">
<span class="version-text">版本号V{{ appVersion }}</span>
<el-button type="primary" plain :loading="checkingUpdate" @click="handleCheckUpdate">
检查更新
</el-button>
<el-button @click="closeConfigWindow">关闭配置窗口</el-button>
<el-button type="danger" @click="quitApplication">退出程序</el-button>
<span class="save-hint">{{ saveMessage }}</span>
</div>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref, watch } from "vue";
import { appWindow, currentMonitor } from "@tauri-apps/api/window";
import { invoke } from "@tauri-apps/api/tauri";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { ElMessage } from "element-plus";
import type {
BroadcastConfig,
ChildWindowAreaConfig,
SegmentConfigItem,
SubtitleAreaConfig,
} from "../models/config";
import { DEFAULT_BROADCAST_CONFIG } from "../models/config";
import { useBroadcastConfig } from "../composables/useBroadcastConfig";
import {
buildSegmentsFromConfig,
normalizeScreenWidth,
normalizeSegmentConfigItem,
normalizeSegmentHeight,
} from "../services/segmentService";
interface SocketStatusPayload {
running: boolean;
port: number;
}
interface AptUpdateCheckResult {
packageName: string;
currentVersion: string;
installedVersion: string;
candidateVersion: string;
hasUpdate: boolean;
sourceAvailable: boolean;
updateCommand: string;
}
//
const screenWidth = ref(normalizeScreenWidth(window.screen.width || 1920));
const { config, patchConfig } = useBroadcastConfig();
const saveMessage = ref("");
const activePanels = ref(["base", "segments", "areas", "subtitles"]);
const socketRunning = ref(false);
const checkingUpdate = ref(false);
const appVersion = ref("0.1.0");
const APT_SOURCE_ENTRY =
"deb [arch=amd64 signed-by=/usr/share/keyrings/zyyun-archive-keyring.gpg] http://80.12.140.29:80/apt v10 main";
const APT_SOURCE_SETUP_COMMAND = `echo "${APT_SOURCE_ENTRY}" | sudo tee /etc/apt/sources.list.d/zyyun.list && sudo apt update`;
// store -> draft 回填触发未保存提示
const syncingFromStore = ref(false);
let socketStatusUnlisten: UnlistenFn | null = null;
const draft = reactive<BroadcastConfig>({ ...DEFAULT_BROADCAST_CONFIG });
// 按当前草稿配置预演最终会应用到同步屏的分段数据。
const appliedSegments = computed(() =>
buildSegmentsFromConfig(
draft.segments,
screenWidth.value,
Math.min(draft.totalWidth, screenWidth.value),
draft.segmentHeight,
),
);
/**
* 深拷贝分段数组,避免引用同一对象导致联动副作用。
*/
function cloneSegments(segments: SegmentConfigItem[]) {
return segments.map((item) => ({ ...item }));
}
function cloneWindowAreas(areas: ChildWindowAreaConfig[]) {
return areas.map((item) => ({
...item,
windowNumberCircleStyle: { ...item.windowNumberCircleStyle },
windowNumberStyle: { ...item.windowNumberStyle },
staticTextStyle: { ...item.staticTextStyle },
dynamicTextStyle: { ...item.dynamicTextStyle },
}));
}
function cloneSubtitleAreas(areas: SubtitleAreaConfig[]) {
return areas.map((item) => ({
...item,
textStyle: { ...item.textStyle },
}));
}
function normalizeStyle(style: { fontSize: number; color: string; fontWeight: number }) {
return {
fontSize: Number.isFinite(style.fontSize) && style.fontSize > 0 ? Math.floor(style.fontSize) : 14,
color: typeof style.color === "string" && style.color.trim() ? style.color.trim() : "#ffffff",
fontWeight:
Number.isFinite(style.fontWeight) && style.fontWeight >= 100 ? Math.floor(style.fontWeight) : 500,
};
}
function createDefaultWindowArea(index: number): ChildWindowAreaConfig {
return {
id: `area-${Date.now()}-${index}`,
windowId: index + 1,
isClockWindow: false,
width: 220,
height: 48,
x: 0,
y: index * 50,
windowNumber: String(index + 1),
windowNumberCircle: false,
windowNumberCircleStyle: {
size: 36,
borderWidth: 1,
borderRadius: 18,
},
windowNumberStyle: {
fontSize: 16,
color: "#ffffff",
fontWeight: 700,
},
staticText: "静态文本",
staticTextStyle: {
fontSize: 14,
color: "#ffffff",
fontWeight: 500,
},
dynamicText: "动态文本",
dynamicTextStyle: {
fontSize: 14,
color: "#ffffff",
fontWeight: 500,
},
};
}
function createDefaultSubtitleArea(index: number): SubtitleAreaConfig {
return {
id: `subtitle-${Date.now()}-${index}`,
width: 420,
height: 28,
x: 0,
y: 0,
text: "欢迎使用同步屏系统",
textStyle: {
fontSize: 16,
color: "#ffffff",
fontWeight: 500,
},
speed: 80,
};
}
/**
* 添加一个新分段,默认放在当前末段之后。
*/
function addSegment() {
const nextIndex = draft.segments.length;
draft.segments.push(
normalizeSegmentConfigItem(
{
length: Math.min(screenWidth.value, draft.totalWidth),
x: 0,
y: nextIndex * normalizeSegmentHeight(draft.segmentHeight),
},
nextIndex,
screenWidth.value,
draft.segmentHeight,
),
);
saveMessage.value = "已修改,点击“保存配置”后生效。";
}
/**
* 删除指定下标的分段配置。
*/
function removeSegment(index: number) {
draft.segments.splice(index, 1);
saveMessage.value = "已修改,点击“保存配置”后生效。";
}
function addWindowArea() {
draft.windowAreas.push(createDefaultWindowArea(draft.windowAreas.length));
saveMessage.value = "已修改,点击“保存配置”后生效。";
}
function removeWindowArea(index: number) {
draft.windowAreas.splice(index, 1);
saveMessage.value = "已修改,点击“保存配置”后生效。";
}
function addSubtitleArea() {
draft.subtitleAreas.push(createDefaultSubtitleArea(draft.subtitleAreas.length));
saveMessage.value = "已修改,点击“保存配置”后生效。";
}
function removeSubtitleArea(index: number) {
draft.subtitleAreas.splice(index, 1);
saveMessage.value = "已修改,点击“保存配置”后生效。";
}
async function startSocketService() {
try {
const result = await invoke<SocketStatusPayload>("start_socket_service");
socketRunning.value = result.running;
saveMessage.value = "Socket 服务已启动,正在监听 9501 端口。";
} catch (error) {
saveMessage.value = `Socket 启动失败: ${String(error)}`;
}
}
async function stopSocketService() {
try {
const result = await invoke<SocketStatusPayload>("stop_socket_service");
socketRunning.value = result.running;
saveMessage.value = "Socket 服务已停止。";
} catch (error) {
saveMessage.value = `Socket 停止失败: ${String(error)}`;
}
}
async function quitApplication() {
try {
await invoke("quit_app");
} catch (error) {
saveMessage.value = `退出失败: ${String(error)}`;
}
}
async function closeConfigWindow() {
try {
await appWindow.close();
} catch (error) {
saveMessage.value = `关闭配置窗口失败: ${String(error)}`;
}
}
async function handleCheckUpdate() {
if (checkingUpdate.value) {
return;
}
checkingUpdate.value = true;
try {
const result = await invoke<AptUpdateCheckResult>("check_apt_update", {
packageName: "broadcast-client",
currentVersion: "0.1.0",
});
if (!result.sourceAvailable) {
ElMessage.warning(
`未检测到可用更新源,请先执行:${APT_SOURCE_SETUP_COMMAND}`,
);
return;
}
if (!result.hasUpdate) {
ElMessage.success(`当前已是最新版本(${result.installedVersion}`);
return;
}
ElMessage.warning(
`发现新版本 ${result.candidateVersion},请在终端执行:${result.updateCommand}`,
);
} catch (error) {
ElMessage.error(`检查更新失败:${String(error)}`);
} finally {
checkingUpdate.value = false;
}
}
/**
* 手动保存配置到持久化存储,并广播给同步屏窗口实时生效。
*/
async function saveConfig() {
const totalWidth = Math.min(Math.max(1, Math.floor(draft.totalWidth)), screenWidth.value);
const segmentHeight = normalizeSegmentHeight(draft.segmentHeight);
const segments = draft.segments.map((item, index) =>
normalizeSegmentConfigItem(item, index, screenWidth.value, segmentHeight),
);
const windowAreas = draft.windowAreas.map((area, index) => {
const circleStyle = area.windowNumberCircleStyle ?? { size: 36, borderWidth: 1, borderRadius: 18 };
return {
id: area.id || `area-${index + 1}`,
windowId:
Number.isFinite(area.windowId) && area.windowId > 0 ? Math.floor(area.windowId) : index + 1,
width: Number.isFinite(area.width) && area.width > 0 ? Math.floor(area.width) : 220,
height: Number.isFinite(area.height) && area.height > 0 ? Math.floor(area.height) : 48,
x: Number.isFinite(area.x) ? Math.max(0, Math.floor(area.x)) : 0,
y: Number.isFinite(area.y) ? Math.max(0, Math.floor(area.y)) : 0,
windowNumber: area.windowNumber || String(index + 1),
isClockWindow: area.isClockWindow === true,
windowNumberCircle: area.windowNumberCircle === true,
windowNumberCircleStyle: {
size: Number.isFinite(circleStyle.size) && circleStyle.size > 0 ? Math.floor(circleStyle.size) : 36,
borderWidth:
Number.isFinite(circleStyle.borderWidth) && circleStyle.borderWidth > 0
? Math.floor(circleStyle.borderWidth)
: 1,
borderRadius:
Number.isFinite(circleStyle.borderRadius) && circleStyle.borderRadius >= 0
? Math.floor(circleStyle.borderRadius)
: 18,
},
windowNumberStyle: normalizeStyle(area.windowNumberStyle),
staticText: area.staticText || "静态文本",
staticTextStyle: normalizeStyle(area.staticTextStyle),
dynamicText: area.dynamicText || "动态文本",
dynamicTextStyle: normalizeStyle(area.dynamicTextStyle),
};
});
const subtitleAreas = draft.subtitleAreas.map((area, index) => ({
id: area.id || `subtitle-${index + 1}`,
width: Number.isFinite(area.width) && area.width > 0 ? Math.floor(area.width) : 420,
height: Number.isFinite(area.height) && area.height > 0 ? Math.floor(area.height) : 28,
x: Number.isFinite(area.x) ? Math.max(0, Math.floor(area.x)) : 0,
y: Number.isFinite(area.y) ? Math.max(0, Math.floor(area.y)) : 0,
text: area.text || "欢迎使用同步屏系统",
textStyle: normalizeStyle(area.textStyle),
speed: Number.isFinite(area.speed) && area.speed > 0 ? area.speed : 80,
}));
await patchConfig({
totalWidth,
segmentHeight,
showRuler: draft.showRuler,
segments,
windowAreas,
subtitleAreas,
});
saveMessage.value = "保存成功,已写入配置文件并实时应用。";
}
watch(
config,
(next) => {
syncingFromStore.value = true;
draft.totalWidth = Math.min(next.totalWidth, screenWidth.value);
draft.segmentHeight = next.segmentHeight;
draft.showRuler = next.showRuler;
draft.segments = cloneSegments(next.segments);
draft.windowAreas = cloneWindowAreas(next.windowAreas);
draft.subtitleAreas = cloneSubtitleAreas(next.subtitleAreas);
syncingFromStore.value = false;
},
{ immediate: true, deep: true },
);
watch(
() => ({
totalWidth: draft.totalWidth,
segmentHeight: draft.segmentHeight,
showRuler: draft.showRuler,
segments: draft.segments.map((item) => ({ ...item })),
windowAreas: draft.windowAreas.map((item) => ({
...item,
windowNumberCircleStyle: { ...item.windowNumberCircleStyle },
windowNumberStyle: { ...item.windowNumberStyle },
staticTextStyle: { ...item.staticTextStyle },
dynamicTextStyle: { ...item.dynamicTextStyle },
})),
subtitleAreas: draft.subtitleAreas.map((item) => ({
...item,
textStyle: { ...item.textStyle },
})),
}),
() => {
if (!syncingFromStore.value) {
saveMessage.value = "已修改,点击“保存配置”后生效。";
}
},
{ deep: true },
);
onMounted(async () => {
try {
const monitor = await currentMonitor();
const width = monitor?.size?.width;
if (typeof width === "number" && Number.isFinite(width) && width > 0) {
screenWidth.value = normalizeScreenWidth(width);
if (draft.totalWidth > screenWidth.value) {
draft.totalWidth = screenWidth.value;
}
}
} catch {
// 浏览器模式下忽略主屏信息读取失败。
}
try {
const status = await invoke<SocketStatusPayload>("get_socket_service_status");
socketRunning.value = status.running;
} catch {
socketRunning.value = false;
}
try {
socketStatusUnlisten = await listen<SocketStatusPayload>("socket-status", (event) => {
socketRunning.value = event.payload.running;
});
} catch {
socketStatusUnlisten = null;
}
});
onUnmounted(() => {
socketStatusUnlisten?.();
socketStatusUnlisten = null;
});
</script>