From 5b91548246c84180753c322a5ce03fa6d65c046f Mon Sep 17 00:00:00 2001 From: chakcy <947105045@qq.com> Date: Fri, 18 Sep 2026 10:00:32 +0800 Subject: [PATCH] =?UTF-8?q?add:=20=E9=82=A6=E6=B3=BD=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E9=A1=B5=EF=BC=88=E9=9F=B3=E6=9F=B1=20/=20?= =?UTF-8?q?=E5=91=8A=E8=AD=A6=E7=BB=93=E6=9E=84=E5=8C=96=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增「参数配置」页(/config),按客户使用场景而非插件边界组织 aibox_sophon_adapter 与 ip_sound_column 的配置: - IP 音柱报警:音柱设备 IP(属 ip_sound_column)+ 启用/音量/播报文案 映射(属 aibox_sophon_adapter);两个插件在同一页配完,保存时按归属 分别下发并重启对应副本。 - 告警与联动:告警转发、心跳与离线、告警留存、豆包分析、IN2 联动。 - 未开放数据库、数据目录、MediaMTX、MQTT 通道等部署类配置,页面脚注 说明;仍可用 Worker 管理的原始编辑器维护。 关键实现约束(见 src/config/configEdits.ts): - 后端对 [workers.config] 只做顶层浅合并,被改动的嵌套节必须整节提交, 否则改一个音量会清掉同级的 enabled/tts(已用真实接口复现)。 - 区块开关默认值取自插件代码默认值,避免「配置里没有该键时开关显示为关 且被判为已改动」而在改其它字段时误关功能。 - 数值字段留空直接拦下,不把 NaN 下发成 null 触发插件侧回滚。 其他: - tts 映射的违规类型候选来自历史告警记录(list_alarms),手写拼错不易察觉。 - 登录态抽成 useAdminAuth 与 Worker 管理页共用,token 互通。 - Worker 管理弹窗对这两个插件给出跳转提示,原始编辑器保留为应急通道。 --- components.d.ts | 14 + src/App.vue | 6 +- src/api/aibox.ts | 50 ++++ src/components/PluginConfigForm.vue | 391 +++++++++++++++++++++++++ src/components/TtsMapEditor.vue | 255 ++++++++++++++++ src/composables/useAdminAuth.ts | 74 +++++ src/config/configEdits.ts | 213 ++++++++++++++ src/config/pluginSchema.ts | 267 +++++++++++++++++ src/router/index.ts | 6 + src/views/ConfigView.vue | 432 ++++++++++++++++++++++++++++ src/views/WorkerView.vue | 100 ++++--- 11 files changed, 1761 insertions(+), 47 deletions(-) create mode 100644 src/api/aibox.ts create mode 100644 src/components/PluginConfigForm.vue create mode 100644 src/components/TtsMapEditor.vue create mode 100644 src/composables/useAdminAuth.ts create mode 100644 src/config/configEdits.ts create mode 100644 src/config/pluginSchema.ts create mode 100644 src/views/ConfigView.vue diff --git a/components.d.ts b/components.d.ts index 523721d..078fa86 100644 --- a/components.d.ts +++ b/components.d.ts @@ -13,6 +13,8 @@ export {} declare module 'vue' { export interface GlobalComponents { ElAlert: typeof import('element-plus/es')['ElAlert'] + ElAutocomplete: typeof import('element-plus/es')['ElAutocomplete'] + ElBadge: typeof import('element-plus/es')['ElBadge'] ElButton: typeof import('element-plus/es')['ElButton'] ElCard: typeof import('element-plus/es')['ElCard'] ElContainer: typeof import('element-plus/es')['ElContainer'] @@ -31,13 +33,18 @@ declare module 'vue' { ElOption: typeof import('element-plus/es')['ElOption'] ElPagination: typeof import('element-plus/es')['ElPagination'] ElSelect: typeof import('element-plus/es')['ElSelect'] + ElSwitch: typeof import('element-plus/es')['ElSwitch'] ElTable: typeof import('element-plus/es')['ElTable'] ElTableColumn: typeof import('element-plus/es')['ElTableColumn'] + ElTabPane: typeof import('element-plus/es')['ElTabPane'] + ElTabs: typeof import('element-plus/es')['ElTabs'] ElTag: typeof import('element-plus/es')['ElTag'] ElUpload: typeof import('element-plus/es')['ElUpload'] HelloWorld: typeof import('./src/components/HelloWorld.vue')['default'] + PluginConfigForm: typeof import('./src/components/PluginConfigForm.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] + TtsMapEditor: typeof import('./src/components/TtsMapEditor.vue')['default'] WelcomeItem: typeof import('./src/components/WelcomeItem.vue')['default'] } export interface GlobalDirectives { @@ -48,6 +55,8 @@ declare module 'vue' { // For TSX support declare global { const ElAlert: typeof import('element-plus/es')['ElAlert'] + const ElAutocomplete: typeof import('element-plus/es')['ElAutocomplete'] + const ElBadge: typeof import('element-plus/es')['ElBadge'] const ElButton: typeof import('element-plus/es')['ElButton'] const ElCard: typeof import('element-plus/es')['ElCard'] const ElContainer: typeof import('element-plus/es')['ElContainer'] @@ -66,12 +75,17 @@ declare global { const ElOption: typeof import('element-plus/es')['ElOption'] const ElPagination: typeof import('element-plus/es')['ElPagination'] const ElSelect: typeof import('element-plus/es')['ElSelect'] + const ElSwitch: typeof import('element-plus/es')['ElSwitch'] const ElTable: typeof import('element-plus/es')['ElTable'] const ElTableColumn: typeof import('element-plus/es')['ElTableColumn'] + const ElTabPane: typeof import('element-plus/es')['ElTabPane'] + const ElTabs: typeof import('element-plus/es')['ElTabs'] const ElTag: typeof import('element-plus/es')['ElTag'] const ElUpload: typeof import('element-plus/es')['ElUpload'] const HelloWorld: typeof import('./src/components/HelloWorld.vue')['default'] + const PluginConfigForm: typeof import('./src/components/PluginConfigForm.vue')['default'] const RouterLink: typeof import('vue-router')['RouterLink'] const RouterView: typeof import('vue-router')['RouterView'] + const TtsMapEditor: typeof import('./src/components/TtsMapEditor.vue')['default'] const WelcomeItem: typeof import('./src/components/WelcomeItem.vue')['default'] } \ No newline at end of file diff --git a/src/App.vue b/src/App.vue index 5283bbb..bd9d035 100644 --- a/src/App.vue +++ b/src/App.vue @@ -23,6 +23,10 @@ 豆包测试 + + + 参数配置 + Worker 管理 @@ -46,7 +50,7 @@ diff --git a/src/components/TtsMapEditor.vue b/src/components/TtsMapEditor.vue new file mode 100644 index 0000000..0d1f36a --- /dev/null +++ b/src/components/TtsMapEditor.vue @@ -0,0 +1,255 @@ + + + + + diff --git a/src/composables/useAdminAuth.ts b/src/composables/useAdminAuth.ts new file mode 100644 index 0000000..5b86240 --- /dev/null +++ b/src/composables/useAdminAuth.ts @@ -0,0 +1,74 @@ +/** + * 管理端登录状态(跨页面共享单例)。 + * + * 参数配置页与 Worker 管理页共用同一份 token(localStorage 的 cm_admin_token), + * 在任一页面登录后另一页面无需重复登录。抽成模块级单例而非 Pinia store, + * 避免为这点状态引入额外样板。 + */ +import { computed, ref } from 'vue' +import { ElMessage } from 'element-plus' +import { login, parseJwt, TOKEN_KEY, type AuthUser } from '@/api/workerAdmin' + +const token = ref(localStorage.getItem(TOKEN_KEY) || '') +const currentUser = ref(null) +const username = ref('') +const password = ref('') +const logging = ref(false) + +/** 从持久化 token 恢复身份(进入页面时调用;权限校验仍以后端为准) */ +export function restoreAuth(): AuthUser | null { + const t = token.value.trim() + currentUser.value = t ? parseJwt(t) : null + return currentUser.value +} + +export function useAdminAuth() { + const isLoggedIn = computed(() => !!token.value.trim()) + + /** + * 登录并领取管理 Token。 + * @returns 登录成功时的用户信息;失败返回 null(已弹出提示)。 + */ + const doLogin = async (): Promise => { + const name = username.value.trim() + if (!name || !password.value) { + ElMessage.warning('请输入用户名和密码') + return null + } + logging.value = true + try { + const data = await login(name, password.value) + // 后端登录失败仍返回 HTTP 200,仅 result=fail + 空 token + if (data.result !== 'success' || !data.token) { + ElMessage.error(`登录失败:${data.message || '用户名或密码错误'}`) + return null + } + token.value = data.token + localStorage.setItem(TOKEN_KEY, data.token) + username.value = '' + password.value = '' + const info = parseJwt(data.token) + currentUser.value = info + ElMessage.success(`登录成功:${(info?.username || '').trim() || '未知用户'}`) + if (!info?.is_superuser) { + ElMessage.error('权限不足:仅超级管理员可管理配置,操作将被拒绝') + return null + } + return info + } catch (e: any) { + ElMessage.error(`登录失败:${e.message}`) + return null + } finally { + logging.value = false + } + } + + const logout = () => { + localStorage.removeItem(TOKEN_KEY) + token.value = '' + currentUser.value = null + ElMessage.success('已退出登录') + } + + return { token, currentUser, username, password, logging, isLoggedIn, doLogin, logout } +} diff --git a/src/config/configEdits.ts b/src/config/configEdits.ts new file mode 100644 index 0000000..b984a2a --- /dev/null +++ b/src/config/configEdits.ts @@ -0,0 +1,213 @@ +/** + * 配置表单的纯逻辑:取值、校验、改动比对、下发载荷拼装。 + * + * 与 Vue 组件分离,一是让「按区块整节下发」这条规则只有一处实现, + * 二是这类逻辑最容易出错(漏带同级键、把默认值固化进配置),独立出来可直接断言。 + * 本模块只做类型引用,不引入 pluginSchema 的运行时依赖。 + */ +import type { FieldSchema, SectionSchema } from './pluginSchema' + +/** 把未知值当作普通对象取用(数组/标量一律视为空对象) */ +export const asRecord = (value: unknown): Record => + value && typeof value === 'object' && !Array.isArray(value) + ? { ...(value as Record) } + : {} + +/** 区块开关对应的合成字段(渲染在标题栏,不放进 fields) */ +export const enabledField = (section: SectionSchema): FieldSchema | null => + section.enabledKey + ? { + key: section.enabledKey, + label: section.enabledLabel || '启用', + type: 'bool', + // 默认值必须带上:配置里没有这个键时,界面显示的开关状态要等于插件实际生效的值, + // 否则「关着但被判为已改动」会让用户在改别的字段时顺手关掉该功能 + default: section.enabledDefault ?? false, + } + : null + +/** 该区块实际参与编辑/下发的字段(含开关字段) */ +export const managedFields = (section: SectionSchema): FieldSchema[] => { + const head = enabledField(section) + return head ? [head, ...section.fields] : [...section.fields] +} + +/** 表单内部使用的字段标识 */ +export const fieldKey = (section: SectionSchema, field: FieldSchema) => `${section.id}.${field.key}` + +/** 读取原始节:有 path 取嵌套对象,否则整份配置即该节的载体 */ +export const rawSection = ( + section: SectionSchema, + config: Record, +): Record => (section.path ? asRecord(config[section.path]) : asRecord(config)) + +/** + * 该字段在「未改动」状态下应显示的值:配置里的值优先,缺失时用插件内生效的默认值。 + * 用于初始化和「撤销改动」。 + */ +export const baselineValue = ( + section: SectionSchema, + config: Record, + field: FieldSchema, +): unknown => { + const raw = rawSection(section, config) + return Object.prototype.hasOwnProperty.call(raw, field.key) ? raw[field.key] : field.default +} + +/** 配置里是否显式存在该键(不存在时界面提示「当前生效默认值」) */ +export const isExplicit = ( + section: SectionSchema, + config: Record, + field: FieldSchema, +): boolean => Object.prototype.hasOwnProperty.call(rawSection(section, config), field.key) + +/** 深度稳定序列化:键序无关,用于对象值比对 */ +export const stableStringify = (value: unknown): string => { + if (value === null || value === undefined) return '' + if (typeof value !== 'object') return String(value) + if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]` + const entries = Object.entries(value as Record).sort(([a], [b]) => + a < b ? -1 : 1, + ) + return `{${entries.map(([k, v]) => `${k}:${stableStringify(v)}`).join(',')}}` +} + +const NUMERIC_TYPES = new Set(['int', 'float']) + +/** + * 值是否相对基准发生了变化。 + * 数值按数值比较(配置里写成 5 还是 5.0 都不该算改动);对象按稳定序列化比较。 + */ +export const isFieldDirty = (field: FieldSchema, now: unknown, before: unknown): boolean => { + if (NUMERIC_TYPES.has(field.type)) { + const a = Number(now) + const b = Number(before) + if (!Number.isNaN(a) && !Number.isNaN(b)) return a !== b + } + if (typeof now === 'object' || typeof before === 'object') { + return stableStringify(now) !== stableStringify(before) + } + return (now ?? '') !== (before ?? '') +} + +/** IPv4 或主机名(网关会拼成 http://<值>/v1/api,所以不接受 scheme/端口/路径) */ +export const isValidHost = (host: string): boolean => { + const m = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/) + if (m) return m.slice(1).every((oct) => Number(oct) <= 255) + return /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/.test( + host, + ) +} + +/** 单字段校验:返回错误文案,空串表示通过 */ +export const validateField = ( + field: FieldSchema, + value: unknown, + sectionEnabled: boolean, +): string => { + const required = field.required === true || (field.required === 'when-enabled' && sectionEnabled) + + if (field.type === 'bool' || field.type === 'kvMap') return '' + + if (NUMERIC_TYPES.has(field.type)) { + const num = Number(value) + // 数值字段一律要求有效值:留空会下发 NaN(序列化成 null), + // 插件侧 float(None) 会抛错并触发配置回滚,不如在这里直接拦住 + if (value === null || value === undefined || value === '' || Number.isNaN(num)) { + return '请填写数值' + } + if (field.min !== undefined && num < field.min) return `不能小于 ${field.min}` + if (field.max !== undefined && num > field.max) return `不能大于 ${field.max}` + if (field.type === 'int' && !Number.isInteger(num)) return '必须是整数' + return '' + } + + const text = String(value ?? '').trim() + + if (field.type === 'ip') { + if (!text) return '请填写设备 IP' + if (text.includes('://')) return '不要带 http:// 前缀,只填 IP 或主机名' + if (text.includes(':') || text.includes('/')) return '不要带端口或路径,只填 IP 或主机名' + if (!isValidHost(text)) return 'IP 或主机名格式不正确' + return '' + } + + if (field.type === 'url') { + if (!text) return required ? '启用后必须填写' : '' + if (!/^https?:\/\/\S+$/i.test(text)) return '需以 http:// 或 https:// 开头' + return '' + } + + return required && !text ? '不能为空' : '' +} + +/** 把表单值转成下发用的类型(数字转 number、开关转 boolean、文本去空白) */ +export const submitValue = (field: FieldSchema, value: unknown): unknown => { + if (field.type === 'bool') return value === true + if (field.type === 'int' || field.type === 'float') { + const num = Number(value) + // 兜底:正常路径已被 validateField 拦住,这里绝不把 NaN 发给后端 + if (Number.isNaN(num)) return typeof field.default === 'number' ? field.default : 0 + return field.type === 'int' ? Math.trunc(num) : num + } + if (field.type === 'kvMap') return { ...asRecord(value) } + return String(value ?? '').trim() +} + +export interface FieldChange { + section: SectionSchema + field: FieldSchema + value: unknown +} + +/** 依据「当前表单值 + 原始配置」算出被改动的字段 */ +export const collectChanges = ( + sections: SectionSchema[], + config: Record, + values: Record, +): FieldChange[] => { + const changes: FieldChange[] = [] + for (const section of sections) { + for (const field of managedFields(section)) { + const now = values[fieldKey(section, field)] + const before = baselineValue(section, config, field) + if (isFieldDirty(field, now, before)) changes.push({ section, field, value: now }) + } + } + return changes +} + +/** + * 拼装下发的 updates。 + * + * 后端对 [workers.config] 只做**顶层浅合并**({**current, **updates}),所以被改动的 + * 嵌套节必须整节提交:以读到的原始节为底,只覆盖表单管理的字段,未在表单里出现的键 + * 原样带回——否则改一个音量就会顺手清掉同级的 enabled / tts。 + * 没有任何改动的区块完全不下发;返回 null 表示无需下发。 + */ +export const buildUpdates = ( + sections: SectionSchema[], + config: Record, + values: Record, +): Record | null => { + const changes = collectChanges(sections, config, values) + if (!changes.length) return null + + const updates: Record = {} + const grouped = new Map>() + + for (const { section, field, value } of changes) { + if (section.path) { + let next = grouped.get(section.path) + if (!next) { + next = { ...rawSection(section, config) } + grouped.set(section.path, next) + updates[section.path] = next + } + next[field.key] = submitValue(field, value) + } else { + updates[field.key] = submitValue(field, value) + } + } + return updates +} diff --git a/src/config/pluginSchema.ts b/src/config/pluginSchema.ts new file mode 100644 index 0000000..9995715 --- /dev/null +++ b/src/config/pluginSchema.ts @@ -0,0 +1,267 @@ +/** + * 参数配置页的声明式配置描述。 + * + * 只描述**面向客户开放**的配置项:数据库、数据目录、MediaMTX、软链接、MQTT 通道 + * (含凭据与 handler 名)等部署类配置一律不在此声明,仍可通过「Worker 管理 → + * 更新插件配置」的原始编辑器维护。未在此声明的键在保存时原样保留,不会被清掉。 + * + * 字段归属由 owner + path 共同决定: + * owner = 该键实际生效的插件类型(音柱的音量与播报文案存在 aibox_sophon_adapter 里) + * path = 该插件 [workers.config] 下的嵌套节名;缺省表示字段是顶层键 + * + * 保存时后端对顶层键做**浅合并**({**current, **updates}),因此每个被改动的 + * 区块必须整节完整下发,否则未提交的同级键会被清掉——见 PluginConfigForm 的 + * buildUpdates。 + */ + +/** 本页涉及的两个插件类型 */ +export const OWNER_PLUGINS = ['ip_sound_column', 'aibox_sophon_adapter'] as const +export type OwnerPlugin = (typeof OWNER_PLUGINS)[number] + +export const PLUGIN_LABELS: Record = { + ip_sound_column: 'IP 音柱', + aibox_sophon_adapter: 'AI Box 适配器', +} + +export type FieldType = 'bool' | 'int' | 'float' | 'string' | 'url' | 'ip' | 'kvMap' + +export interface FieldSchema { + /** 字段名(区块内的键,或 path 缺省时的顶层键) */ + key: string + label: string + type: FieldType + /** 灰字说明:单位、取值范围、生效时机等 */ + help?: string + min?: number + max?: number + step?: number + /** 输入框后缀单位,如「秒」「%」 */ + unit?: string + /** + * 插件内生效的默认值:仅在配置里**没有**这个键时用于展示「当前为默认值」提示。 + * 不参与下发——没有改动就不写,避免把默认值固化进配置。 + */ + default?: unknown + placeholder?: string + /** true=始终必填;'when-enabled'=所属区块开关打开时必填 */ + required?: boolean | 'when-enabled' +} + +export interface SectionSchema { + id: string + title: string + desc?: string + owner: OwnerPlugin + /** 嵌套节名;缺省表示字段是该插件 [workers.config] 的顶层键 */ + path?: string + /** + * 区块开关字段:该字段为 false 时其余字段置灰禁用(值保留,仍会随区块提交)。 + * 开关渲染在区块标题栏,避免与 fields 里出现两个开关。 + */ + enabledKey?: string + /** 开关的文案(enabledKey 存在时必填) */ + enabledLabel?: string + /** + * 开关在配置里**没有**这个键时的生效值(必须与插件的代码默认值一致)。 + * 不填会退化成 false,而开关在界面上关着却又被判定为「已改动」—— + * 用户只改别的字段时就会顺手把这个功能关掉。 + */ + enabledDefault?: boolean + /** 默认折叠(次要或低频配置) */ + collapsed?: boolean + fields: FieldSchema[] +} + +export interface TabSchema { + id: string + title: string + desc?: string + sections: SectionSchema[] +} + +/** 播报音量取值范围(插件无服务端校验,由前端约束) */ +const VOLUME_RANGE = { min: 0, max: 100, step: 5 } + +export const CONFIG_TABS: TabSchema[] = [ + { + id: 'sound-column', + title: 'IP 音柱报警', + desc: '告警发生时由音柱播报语音。设备地址与播报参数分属两个插件,本页一并配置。', + sections: [ + { + id: 'device', + title: '音柱设备', + desc: '音柱的局域网地址,网关按 http://<地址>/v1/api 调用。', + owner: 'ip_sound_column', + fields: [ + { + key: 'ip', + label: '音柱设备 IP', + type: 'ip', + default: 'unknown', + required: true, + placeholder: '如 172.19.11.244', + help: '只填 IP 或主机名,不要带 http:// 或端口;带端口会拼出错误地址。', + }, + ], + }, + { + id: 'tts-play', + title: '报警播报', + desc: '控制告警时是否播报、音量多大、不同违规类型播报什么内容。', + owner: 'aibox_sophon_adapter', + path: 'ip_sound_column', + enabledKey: 'enabled', + enabledLabel: '启用音柱报警播报', + enabledDefault: true, // aibox: ip_sound_column_cfg.get('enabled', True) + fields: [ + { + key: 'volume', + label: '播报音量', + type: 'int', + unit: '(0-100)', + default: 50, + ...VOLUME_RANGE, + help: '告警播报使用的音量。注意:IP 音柱插件自身的 volume 配置不生效,以本项为准。', + }, + { + key: 'tts', + label: '按违规类型自定义播报文案', + type: 'kvMap', + default: {}, + help: '左侧选告警的违规类型,右侧填播报内容;未匹配到的告警播报默认文案。', + }, + ], + }, + ], + }, + { + id: 'alarm', + title: '告警与联动', + desc: '告警上报、留存与设备联动的参数,全部由 AI Box 适配器插件生效。', + sections: [ + { + id: 'alarm-forward', + title: '告警转发外部平台', + desc: '开启后把盒子告警上报到外部平台(先上传视频再上报)。', + owner: 'aibox_sophon_adapter', + path: 'alarm_forward', + enabledKey: 'enabled', + enabledLabel: '启用告警转发', + enabledDefault: false, // aibox: alarm_fwd_cfg.get('enabled', False) + fields: [ + { + key: 'url', + label: '告警接收地址', + type: 'url', + required: 'when-enabled', + placeholder: 'http://平台地址/open-api/event-alarm/receive', + help: '外部平台接收告警的 HTTP 接口地址。', + }, + { + key: 'video_upload_url', + label: '视频上传地址', + type: 'url', + required: 'when-enabled', + placeholder: 'http://平台地址/open-api/event-alarm/receiveVideoUpload', + help: '告警关联视频的上传接口,上报前先上传并把返回的视频地址替换进报文。', + }, + { + key: 'timeout', + label: '转发超时', + type: 'float', + unit: '秒', + default: 10, + min: 1, + max: 300, + step: 1, + }, + ], + }, + { + id: 'heartbeat', + title: '心跳与离线判定', + desc: '设备按上报间隔上报心跳;超过超时阈值未上报即判离线。', + owner: 'aibox_sophon_adapter', + path: 'heartbeat', + collapsed: true, + fields: [ + { + key: 'interval', + label: '设备上报间隔', + type: 'int', + unit: '秒', + default: 5, + min: 1, + max: 3600, + required: 'when-enabled', + }, + { + key: 'timeout', + label: '离线超时阈值', + type: 'int', + unit: '秒', + default: 15, + min: 1, + max: 3600, + required: 'when-enabled', + help: '应明显大于上报间隔,否则设备会被反复判定离线。', + }, + { + key: 'check_interval', + label: '服务端检查间隔', + type: 'int', + unit: '秒', + default: 10, + min: 1, + max: 3600, + required: 'when-enabled', + }, + ], + }, + { + id: 'alarm-retention', + title: '告警记录留存', + owner: 'aibox_sophon_adapter', + path: 'alarm_retention', + collapsed: true, + fields: [ + { + key: 'max_records', + label: '最多保留告警记录数', + type: 'int', + default: 0, + min: 0, + max: 1000000, + required: 'when-enabled', + help: '超出后自动裁剪最旧的记录;填 0 表示不限制(长期运行会持续占用磁盘)。', + }, + ], + }, + { + id: 'doubao', + title: '告警 AI 分析', + desc: '对告警图片做豆包 AI 复核,判定为误报时忽略该告警。', + owner: 'aibox_sophon_adapter', + path: 'doubao_analysis', + enabledKey: 'enabled', + enabledLabel: '启用豆包 AI 分析', + enabledDefault: true, // aibox: doubao_cfg.get('enabled', True) + collapsed: true, + fields: [], + }, + { + id: 'task-switch', + title: '盒子 IN2 联动', + desc: 'IN2 有电(电平 1)启动全部算法任务,断电(电平 0)停止全部任务;只响应电平跳变,不按启动时的初始电平动作。', + owner: 'aibox_sophon_adapter', + path: 'task_switch', + enabledKey: 'enabled', + enabledLabel: '启用 IN2 电平联动', + enabledDefault: false, // aibox: switch_cfg.get('enabled', False) + collapsed: true, + fields: [], + }, + ], + }, +] diff --git a/src/router/index.ts b/src/router/index.ts index 71d3116..a91960d 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -23,6 +23,12 @@ const router = createRouter({ // 懒加载 Worker 管理页面(实例盘点 + 单/批量重启) component: () => import('../views/WorkerView.vue'), }, + { + path: '/config', + name: 'config', + // 懒加载参数配置页(面向客户的音柱/告警结构化配置) + component: () => import('../views/ConfigView.vue'), + }, ], }) diff --git a/src/views/ConfigView.vue b/src/views/ConfigView.vue new file mode 100644 index 0000000..01a79d7 --- /dev/null +++ b/src/views/ConfigView.vue @@ -0,0 +1,432 @@ + + + + + diff --git a/src/views/WorkerView.vue b/src/views/WorkerView.vue index 48f314d..0f596c0 100644 --- a/src/views/WorkerView.vue +++ b/src/views/WorkerView.vue @@ -256,6 +256,23 @@ :title="configAlertTitle" style="margin-bottom: 14px" /> + + + {{ workerType || '-' }} @@ -352,6 +369,7 @@