完成开发,后续进行优化

This commit is contained in:
guanyuankai 2025-11-05 15:08:42 +08:00
parent 24bbadd931
commit 32c3d30844
6 changed files with 417 additions and 93 deletions

76
src/api/config.ts Normal file
View File

@ -0,0 +1,76 @@
// src/api/config.ts
import axios from "axios";
import { apiClient, handleApiError } from "./client";
// [!! 新增 !!] 为 config.json 定义一个接口
export interface MainConfig {
device_id: string;
config_base_path: string;
mqtt_broker: string;
mqtt_client_id_prefix: string;
data_storage_db_path: string;
data_cache_db_path: string;
tcp_server_ports: number[]; // 这是数字数组
web_server_port: number; // 这是数字
log_level: string;
alarm_rules_path: string;
piper_executable_path: string;
piper_model_path: string;
video_config_path: string;
}
/**
* @brief [!! !!] C++ config.json **
* (仿 video.ts getVideoConfig)
*/
export const getMainConfig = async (): Promise<MainConfig> => {
try {
const response = await apiClient.get<string | MainConfig>("/api/config");
if (typeof response.data === "string") {
try {
// C++ 后端按计划返回字符串,我们在此解析
return JSON.parse(response.data) as MainConfig;
} catch (e: any) {
throw new Error(`Failed to parse config JSON: ${e.message}`);
}
}
console.warn(
"getMainConfig: /api/config 返回了对象,这与预期不符,但仍会处理。"
);
return response.data as MainConfig;
} catch (error) {
throw handleApiError(error, "getMainConfig");
}
};
/**
* @brief [!! !!] ** POST C++
* (仿 video.ts postVideoConfig)
* @param configObject **
*/
export const postMainConfig = async (
configObject: MainConfig
): Promise<any> => {
try {
// 关键:我们发送的是序列化后的字符串
// 这与 postVideoConfig 的实现完全一致
const jsonString = JSON.stringify(configObject, null, 2);
const response = await apiClient.post("/api/config", jsonString, {
headers: { "Content-Type": "application/json" },
});
return response.data;
} catch (error: any) {
// 捕获来自 C++ 后端的特定验证错误
if (axios.isAxiosError(error) && error.response?.status === 400) {
const errorMsg =
error.response.data?.message || "JSON 格式或 schema 无效";
throw new Error(`保存失败 (400): ${errorMsg}`);
}
// 其他 API 错误
throw handleApiError(error, "postMainConfig");
}
};

View File

@ -1,38 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
defineProps<{ msg: string }>()
const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p>
Check out
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
>create-vue</a
>, the official Vue + Vite starter
</p>
<p>
Install
<a href="https://github.com/vuejs/language-tools" target="_blank">Volar</a>
in your IDE for a better DX
</p>
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>

View File

@ -64,11 +64,12 @@ import {
<span>视频参数配置</span>
</RouterLink>
</li>
<li class="disabled">
<a href="#">
<li>
<RouterLink to="/configManager">
<Setting class="nav-icon" />
<span>其他配置</span>
</a>
<span>主参数配置</span>
</RouterLink>
</li>
</ul>
</nav>

View File

@ -8,6 +8,7 @@ import DevicesView from "../views/DevicesView.vue";
import DeviceManager from "../views/DeviceManager.vue";
import VideoStreams from "../views/VideoStreams.vue";
import VideoConfigManager from "../views/VideoConfigManager.vue";
import MainConfigManager from "../views/MainConfigManager.vue";
const routes = [
{
@ -45,6 +46,11 @@ const routes = [
name: "VideoConfigManager",
component: VideoConfigManager,
},
{
path: "/configManager",
name: "MainConfigManager",
component: MainConfigManager,
},
];
const router = createRouter({

View File

@ -0,0 +1,303 @@
<script setup lang="ts">
import { ref, onMounted, computed } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { getMainConfig, postMainConfig, type MainConfig } from "../api/config"; // [!! !!]
import { postServiceReload } from "../api/video";
import { RefreshLeft } from "@element-plus/icons-vue";
// --- [!! !!] ---
// 仿 VideoConfigManager originalStreams editableStreams
const originalConfig = ref<MainConfig | null>(null);
const editableConfig = ref<MainConfig | null>(null);
const isLoading = ref(false);
const isSaving = ref(false);
/**
* @brief [!! 新增 !!] 处理 TCP 端口数组的辅助计算属性
* 因为 el-input 只能绑定字符串
*/
const editableTcpPortsString = computed({
get: () => {
return editableConfig.value?.tcp_server_ports.join(", ") || "";
},
set: (newValue: string) => {
if (editableConfig.value) {
// "123, 456" [123, 456]
editableConfig.value.tcp_server_ports = newValue
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0)
.map((s) => parseInt(s, 10))
.filter((n) => !isNaN(n)); //
}
},
});
// --- [!! !!] ---
// 仿 VideoConfigManager handleRefresh
const handleRefresh = async (confirm = false) => {
if (isDirty.value && confirm) {
try {
await ElMessageBox.confirm(
"您有未保存的更改。刷新将从服务器重新加载配置,并丢弃您当前的所有更改。",
"确认刷新?",
{ type: "warning" }
);
} catch (error) {
return; //
}
}
isLoading.value = true;
try {
const configData = await getMainConfig();
// ()
originalConfig.value = JSON.parse(JSON.stringify(configData));
// ()
editableConfig.value = JSON.parse(JSON.stringify(configData));
ElMessage.success("主配置已刷新");
} catch (error: any) {
ElMessage.error(`刷新失败: ${error.message}`);
} finally {
isLoading.value = false;
}
};
onMounted(() => {
handleRefresh(false);
});
// --- [!! !!] ---
// 仿 VideoConfigManager isDirty
const isDirty = computed(() => {
if (!originalConfig.value || !editableConfig.value) {
return false;
}
try {
//
return (
JSON.stringify(originalConfig.value) !==
JSON.stringify(editableConfig.value)
);
} catch {
return true;
}
});
// --- [!! !!] ---
// 仿 VideoConfigManager handleSaveAndApply
const handleSaveAndApply = async () => {
if (!editableConfig.value) {
ElMessage.error("配置数据未加载,无法保存。");
return;
}
isSaving.value = true;
try {
// 1.
await postMainConfig(editableConfig.value);
ElMessage.success("主配置保存成功!");
// 2.
await postServiceReload();
ElMessage.info("服务重启命令已发送,请稍候...");
// 3. ""
// 仿 VideoConfigManager originalStreams
originalConfig.value = JSON.parse(JSON.stringify(editableConfig.value));
} catch (error: any) {
ElMessage.error(`操作失败: ${error.message}`);
} finally {
isSaving.value = false;
}
};
</script>
<template>
<div class="config-manager-page">
<header class="page-header">
<h1>
主参数配置 (config.json)
<el-tag
v-if="isDirty"
type="warning"
effect="dark"
size="small"
style="margin-left: 10px"
>
* 未保存
</el-tag>
</h1>
<div class="button-group">
<el-button
@click="handleRefresh(true)"
:loading="isLoading"
:icon="RefreshLeft"
>
刷新 (撤销更改)
</el-button>
<el-button
type="primary"
@click="handleSaveAndApply"
:loading="isSaving"
:disabled="!isDirty"
>
保存并应用 (重启服务)
</el-button>
</div>
</header>
<el-card
class="config-editor-card"
v-loading="isLoading || !editableConfig"
>
<template #header>
<div class="card-header">
<span>编辑 config.json</span>
</div>
</template>
<el-alert
title="警告:修改主配置文件"
type="warning"
show-icon
:closable="false"
style="margin-bottom: 20px"
>
<p>
更改此文件将影响服务核心功能保存后必须
<strong>"保存并应用"</strong>
以触发服务重启新配置才能生效
</p>
</el-alert>
<el-form
v-if="editableConfig"
:model="editableConfig"
label-position="right"
label-width="200px"
>
<el-form-item label="设备 ID (device_id)" prop="device_id">
<el-input v-model="editableConfig.device_id" />
</el-form-item>
<el-form-item
label="配置根路径 (config_base_path)"
prop="config_base_path"
>
<el-input v-model="editableConfig.config_base_path" />
</el-form-item>
<el-form-item label="日志级别 (log_level)" prop="log_level">
<el-select v-model="editableConfig.log_level" placeholder="Select">
<el-option label="Trace" value="trace" />
<el-option label="Debug" value="debug" />
<el-option label="Info" value="info" />
<el-option label="Warning" value="warn" />
<el-option label="Error" value="error" />
<el-option label="Critical" value="critical" />
</el-select>
</el-form-item>
<el-divider content-position="left">网络与服务</el-divider>
<el-form-item label="MQTT Broker URL" prop="mqtt_broker">
<el-input
v-model="editableConfig.mqtt_broker"
placeholder="tcp://localhost:1883"
/>
</el-form-item>
<el-form-item label="MQTT Client ID 前缀" prop="mqtt_client_id_prefix">
<el-input v-model="editableConfig.mqtt_client_id_prefix" />
</el-form-item>
<el-form-item label="Web 服务端口" prop="web_server_port">
<el-input-number
v-model="editableConfig.web_server_port"
:min="1"
:max="65535"
/>
</el-form-item>
<el-form-item label="TCP 服务端口 (逗号分隔)" prop="tcp_server_ports">
<el-input
v-model="editableTcpPortsString"
placeholder="例如: 12345, 12346"
/>
</el-form-item>
<el-divider content-position="left">文件路径</el-divider>
<el-form-item
label="数据存储DB (data_storage_db_path)"
prop="data_storage_db_path"
>
<el-input v-model="editableConfig.data_storage_db_path" />
</el-form-item>
<el-form-item
label="数据缓存DB (data_cache_db_path)"
prop="data_cache_db_path"
>
<el-input v-model="editableConfig.data_cache_db_path" />
</el-form-item>
<el-form-item
label="告警规则 (alarm_rules_path)"
prop="alarm_rules_path"
>
<el-input v-model="editableConfig.alarm_rules_path" />
</el-form-item>
<el-form-item
label="视频配置 (video_config_path)"
prop="video_config_path"
>
<el-input v-model="editableConfig.video_config_path" />
</el-form-item>
<el-form-item label="Piper 可执行文件" prop="piper_executable_path">
<el-input v-model="editableConfig.piper_executable_path" />
</el-form-item>
<el-form-item label="Piper 模型" prop="piper_model_path">
<el-input v-model="editableConfig.piper_model_path" />
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<style scoped>
/* 样式与之前相同,但我们确保卡片可以滚动 */
.config-manager-page {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
color: var(--el-text-color-primary);
flex-shrink: 0;
}
h1 {
font-size: 1.5em;
font-weight: 600;
margin: 0;
display: flex;
align-items: center;
}
.config-editor-card {
flex-grow: 1;
display: flex;
flex-direction: column;
overflow: hidden; /* 确保卡片可以滚动 */
}
:deep(.el-card__body) {
flex-grow: 1;
overflow-y: auto; /* [!! 关键 !!] 使表单内容可滚动 */
}
/* 限制表单宽度以便于阅读 */
.el-form {
max-width: 800px;
}
</style>

View File

@ -10,25 +10,25 @@ import {
} from "../api/video";
import { Plus, Edit, Delete, RefreshLeft } from "@element-plus/icons-vue";
// --- [] ---
// --- ---
// video_service.enabled
const videoServiceEnabled = ref(true);
// video_streams
// [!! 1 !!] ref **
const originalVideoServiceEnabled = ref(true);
const originalStreams = ref<VideoStreamConfig[]>([]);
// video_streams
const editableStreams = ref<VideoStreamConfig[]>([]);
const isLoading = ref(false);
const isSaving = ref(false);
const isReloading = ref(false);
// [!! 2 !!] isReloading
const isDialogVisible = ref(false);
const dialogMode = ref<"add" | "edit">("add");
const currentEditingStream = ref<Partial<VideoStreamConfig>>({});
const currentEditingIndex = ref(-1);
const formRef = ref<FormInstance>();
// --- [] ---
// --- ---
const handleRefresh = async (confirm = false) => {
if (isDirty.value && confirm) {
try {
@ -43,14 +43,14 @@ const handleRefresh = async (confirm = false) => {
}
isLoading.value = true;
try {
// 1.
const fullConfig = await getVideoConfig();
// 2.
videoServiceEnabled.value = fullConfig.video_service?.enabled ?? false;
originalStreams.value = fullConfig.video_streams || [];
// [!! 1 !!]
const enabledState = fullConfig.video_service?.enabled ?? false;
videoServiceEnabled.value = enabledState;
originalVideoServiceEnabled.value = enabledState; //
//
originalStreams.value = fullConfig.video_streams || [];
editableStreams.value = JSON.parse(JSON.stringify(originalStreams.value));
ElMessage.success("视频配置已刷新");
@ -65,25 +65,26 @@ onMounted(() => {
handleRefresh(false);
});
// --- [] ---
// --- [!! 1 !!] ---
const isDirty = computed(() => {
//
try {
// 使 *original*
const originalConfig = {
video_service: { enabled: videoServiceEnabled.value }, // 使
video_service: { enabled: originalVideoServiceEnabled.value }, // <-- 使
video_streams: originalStreams.value,
};
// 使 *v-model*
const currentConfig = {
video_service: { enabled: videoServiceEnabled.value }, // 使
video_service: { enabled: videoServiceEnabled.value },
video_streams: editableStreams.value,
};
return JSON.stringify(originalConfig) !== JSON.stringify(currentConfig);
} catch {
return true;
return true; // dirty
}
});
// --- [] ( VideoStreamConfig) ---
// --- () ---
const openAddDialog = () => {
dialogMode.value = "add";
currentEditingStream.value = {
@ -106,7 +107,6 @@ const openAddDialog = () => {
const openEditDialog = (row: VideoStreamConfig, index: number) => {
dialogMode.value = "edit";
currentEditingIndex.value = index;
// module_config textarea
currentEditingStream.value = {
...JSON.parse(JSON.stringify(row)),
module_config: JSON.stringify(row.module_config, null, 2),
@ -115,13 +115,12 @@ const openEditDialog = (row: VideoStreamConfig, index: number) => {
nextTick(() => formRef.value?.clearValidate());
};
// --- [] ( VideoStreamConfig) ---
// --- () ---
const handleDialogConfirm = async () => {
if (!formRef.value) return;
await formRef.value.validate((valid) => {
if (valid) {
try {
// module_config JSON
const streamToSave = {
...currentEditingStream.value,
module_config: JSON.parse(
@ -146,7 +145,7 @@ const handleDialogConfirm = async () => {
});
};
// --- [] ( editableStreams) ---
// --- () ---
const deleteRule = (index: number) => {
ElMessageBox.confirm(
`您确定要删除流 '${editableStreams.value[index].id}' 吗?`,
@ -159,11 +158,10 @@ const deleteRule = (index: number) => {
.catch(() => {});
};
// --- [] ( video API) ---
// --- () ---
const handleSaveAndApply = async () => {
isSaving.value = true;
try {
// 1.
const fullConfigToSave: VideoConfig = {
video_service: {
enabled: videoServiceEnabled.value,
@ -171,16 +169,15 @@ const handleSaveAndApply = async () => {
video_streams: editableStreams.value,
};
// 2.
await postVideoConfig(fullConfigToSave);
ElMessage.success("配置保存成功!");
// 3. ()
await postServiceReload();
ElMessage.info("服务重启命令已发送,请稍候...");
// 4. "original"
// [!! 1 !!] **
originalStreams.value = JSON.parse(JSON.stringify(editableStreams.value));
originalVideoServiceEnabled.value = videoServiceEnabled.value;
} catch (error: any) {
ElMessage.error(`操作失败: ${error.message}`);
} finally {
@ -188,18 +185,7 @@ const handleSaveAndApply = async () => {
}
};
// --- [] ( video API) ---
const handleApplyOnly = async () => {
isReloading.value = true;
try {
await postServiceReload();
ElMessage.success("服务重启命令已发送");
} catch (error: any) {
ElMessage.error(`应用失败: ${error.message}`);
} finally {
isReloading.value = false;
}
};
// [!! 2 !!] handleApplyOnly
</script>
<template>
@ -228,13 +214,6 @@ const handleApplyOnly = async () => {
>
刷新 (撤销更改)
</el-button>
<el-button
@click="handleApplyOnly"
:loading="isReloading"
type="warning"
>
仅应用 (重启服务)
</el-button>
<el-button
type="primary"
@click="handleSaveAndApply"
@ -387,7 +366,7 @@ const handleApplyOnly = async () => {
</template>
<style scoped>
/* 仿照 AlarmManager.vue 的 Flex 布局 */
/* 样式 (不变) */
.config-manager-page {
width: 100%;
height: 100%;
@ -395,7 +374,6 @@ const handleApplyOnly = async () => {
flex-direction: column;
}
/* 头部样式 */
.page-header {
display: flex;
justify-content: space-between;
@ -412,13 +390,11 @@ h1 {
align-items: center;
}
/* [新增] 顶部卡片样式 */
.top-config-card {
margin-bottom: 12px;
flex-shrink: 0; /* 不压缩 */
flex-shrink: 0;
}
/* 表格容器样式 (与 AlarmManager.vue 相同) */
.table-container {
flex-grow: 1;
min-height: 0;