3 Commits
Author SHA1 Message Date
chakcy d11497c790 modify: 授权 2026-10-13 14:58:32 +08:00
chakcy 35f71fc858 modify 2026-09-18 16:16:04 +08:00
chakcy 5b91548246 add: 邦泽参数配置页(音柱 / 告警结构化配置)
新增「参数配置」页(/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 管理弹窗对这两个插件给出跳转提示,原始编辑器保留为应急通道。
2026-09-18 10:00:32 +08:00
14 changed files with 2364 additions and 324 deletions
+18 -2
View File
@@ -13,9 +13,13 @@ 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']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDivider: typeof import('element-plus/es')['ElDivider']
ElFooter: typeof import('element-plus/es')['ElFooter']
@@ -31,13 +35,17 @@ 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,9 +56,13 @@ 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']
const ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
const ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
const ElDialog: typeof import('element-plus/es')['ElDialog']
const ElDivider: typeof import('element-plus/es')['ElDivider']
const ElFooter: typeof import('element-plus/es')['ElFooter']
@@ -66,12 +78,16 @@ 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']
}
+7 -3
View File
@@ -20,13 +20,17 @@
:ellipsis="false"
>
<el-menu-item index="/">
<el-icon><HomeFilled /></el-icon>
<span>豆包测试</span>
<el-icon><Setting /></el-icon>
<span>参数配置</span>
</el-menu-item>
<el-menu-item index="/workers">
<el-icon><Monitor /></el-icon>
<span>Worker 管理</span>
</el-menu-item>
<el-menu-item index="/license">
<el-icon><Key /></el-icon>
<span>授权</span>
</el-menu-item>
<el-menu-item index="/about">
<el-icon><InfoFilled /></el-icon>
<span>关于</span>
@@ -46,7 +50,7 @@
</template>
<script setup lang="ts">
import { Cpu, HomeFilled, InfoFilled, Monitor } from '@element-plus/icons-vue'
import { Cpu, HomeFilled, InfoFilled, Key, Monitor, Setting } from '@element-plus/icons-vue'
</script>
<style scoped>
+50
View File
@@ -0,0 +1,50 @@
/**
* AI Box 适配器的只读业务接口(网关按插件 router_prefix 挂在 /api/v1/aibox_sophon_adapter 下)。
*
* 这里只用一个端点:拉取历史告警里出现过的「违规类型」(Summary) 取值,作为
* 音柱播报文案映射的候选键——映射表的键必须与盒子上报的 Summary 完全一致才命中,
* 手写极易拼错,用真实取值做候选能避免「配了却不播报」。
*
* 该端点 auth_required=false,无需管理 Token;任何失败都降级为空候选,
* 不阻塞配置页(自由输入仍可用)。
*/
// 去掉尾斜杠,避免与以 '/' 开头的 path 拼接成 '//host...'
const API_BASE: string = ((import.meta.env.VITE_API_BASE_URL as string) || '').replace(/\/+$/, '')
interface AlarmRecordLite {
summary?: unknown
}
export interface AlarmSummaries {
/** 去重后的违规类型,按最近告警优先 */
summaries: string[]
/** 后端已有告警记录总数(用于提示候选只覆盖最近若干条) */
total: number
}
/** 拉取最近告警中出现过的违规类型(去重、保序)。失败返回空结果。 */
export async function listAlarmSummaries(pageSize = 200): Promise<AlarmSummaries> {
const empty: AlarmSummaries = { summaries: [], total: 0 }
try {
// board_id / summary 在路由描述符里没有默认值,被网关标成必填查询参数,
// 传空串才是「不筛选」(漏传会被判 422,候选就永远是空的)
const qs = new URLSearchParams({
board_id: '',
summary: '',
page: '1',
page_size: String(pageSize),
})
const res = await fetch(`${API_BASE}/api/v1/aibox_sophon_adapter/list_alarms?${qs}`)
if (!res.ok) return empty
const data = (await res.json()) as { alarms?: AlarmRecordLite[]; total?: number }
const seen = new Set<string>()
for (const alarm of data?.alarms ?? []) {
const summary = String(alarm?.summary ?? '').trim()
if (summary) seen.add(summary)
}
return { summaries: [...seen], total: Number(data?.total) || 0 }
} catch {
// 插件未启动 / 网关不可达:静默降级,候选为空
return empty
}
}
+159
View File
@@ -0,0 +1,159 @@
/**
* 机器码授权 API(管理端点 /license/*)
*
* 授权状态与机器码属设备标识信息,导入 license 又会改变整个网关的插件加载行为,
* 故与 /admin/workers 同级:均要求超级管理员的 Bearer token。
*/
// 去掉尾斜杠,避免与以 '/' 开头的 path 拼接成 '//host...'(协议相对 URL 导致 ERR_NAME_NOT_RESOLVED)
const API_BASE: string = ((import.meta.env.VITE_API_BASE_URL as string) || '').replace(/\/+$/, '')
/** 指纹比对结论 */
export interface LicenseMatch {
ok: boolean
matched: number
total: number
reason: string
matched_sources: string[]
mismatched_sources: string[]
/** 可信来源不足、判定纳入了 weak 来源(安全性下降) */
degraded: boolean
}
/** license 载荷 */
export interface LicensePayload {
v: number
lic_id: string
customer: string
machine_code: string
sources: Record<string, string>
plugins: string[]
not_before: string | null
expires_at: string | null
issued_at: string | null
note: string
}
/** 整份 license 的校验结论 */
export interface LicenseStatus {
ok: boolean
/** 原因码:ok / expired / machine_mismatch / signature_invalid / license_missing … */
code: string
message: string
license_path: string
machine_code: string
public_key_fingerprint: string
sources: Record<string, string>
warnings: string[]
match: LicenseMatch | null
payload: LicensePayload | null
licensed_plugins: string[]
}
/** 单个硬件来源的采集明细 */
export interface SourceDetail {
name: string
label: string
/** weak 来源不参与判定阈值(同镜像批量刷机时可能全机相同) */
weak: boolean
present: boolean
value: string
raw: string
detail: string
}
/** 插件视角的授权全景 */
export interface PluginOverview {
discovered: string[]
licensed: string[]
denied: Record<string, { code: string; message: string }>
loaded: string[]
denied_from_last_scan: Record<string, string>
}
export interface LicenseInfo {
enforce: boolean
enforce_forced_by_packaging: boolean
license_path: string
min_match: number
status: LicenseStatus
sources_detail: SourceDetail[]
warnings: string[]
/** 申请码:把本机指纹打包成一段可抄写文本,发给厂商签发 */
request_code: string
plugins: PluginOverview
}
export interface LicenseUploadResult {
success: boolean
license_path: string
status: LicenseStatus
licensed_plugins: string[]
restart_required: boolean
pending_plugins: string[]
restart_hint: string
}
async function request<T>(path: string, token: string, init: RequestInit = {}): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
...init,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(init.headers || {}),
},
})
if (!res.ok) {
let detail = `HTTP ${res.status}`
try {
const body = await res.json()
if (body?.detail) {
detail = typeof body.detail === 'string' ? body.detail : JSON.stringify(body.detail)
}
} catch {
/* ignore non-JSON error body */
}
throw new Error(detail)
}
return res.json() as Promise<T>
}
/** 读取授权状态、机器码、申请码与各来源明细 */
export function getLicenseInfo(token: string) {
return request<LicenseInfo>('/api/v1/license/info', token)
}
/** 重新读取磁盘上的授权文件(手工替换文件后无需重启即可刷新状态) */
export function reloadLicense(token: string) {
return request<{ success: boolean; status: LicenseStatus }>('/api/v1/license/reload', token, {
method: 'POST',
})
}
/**
* 导入 license 文件。
*
* 单独实现而不复用 request():multipart 必须让浏览器自行生成 boundary,
* 手写 Content-Type 会把 boundary 弄丢导致后端解析失败。
*/
export async function uploadLicense(token: string, file: File): Promise<LicenseUploadResult> {
const form = new FormData()
form.append('file', file)
const res = await fetch(`${API_BASE}/api/v1/license/file`, {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: form,
})
if (!res.ok) {
let detail = `HTTP ${res.status}`
try {
const body = await res.json()
if (body?.detail) {
detail = typeof body.detail === 'string' ? body.detail : JSON.stringify(body.detail)
}
} catch {
/* ignore non-JSON error body */
}
throw new Error(detail)
}
return res.json() as Promise<LicenseUploadResult>
}
+391
View File
@@ -0,0 +1,391 @@
<template>
<div class="cfg-form">
<div
v-for="section in sections"
:key="section.id"
class="cfg-section"
:class="{ 'is-dirty': changedFields(section).length > 0 }"
>
<div class="cfg-head" @click="toggle(section)">
<el-icon class="cfg-chev" :class="{ 'is-open': isExpanded(section) }">
<ArrowRight />
</el-icon>
<span class="cfg-title">{{ section.title }}</span>
<el-tag size="small" effect="plain" type="info">
归属:{{ PLUGIN_LABELS[section.owner] }}
</el-tag>
<el-tag v-if="changedFields(section).length" size="small" type="warning">已改动</el-tag>
<span class="cfg-head-spacer" />
<el-button
v-if="changedFields(section).length"
link
size="small"
type="primary"
:disabled="disabled"
@click.stop="resetSection(section)"
>
撤销改动
</el-button>
<template v-if="enabledField(section)">
<span class="cfg-switch-label">{{ section.enabledLabel }}</span>
<el-switch
v-model="model[fieldKey(section, enabledField(section) as FieldSchema)]"
:disabled="disabled"
@click.stop
/>
</template>
</div>
<div v-show="isExpanded(section)" class="cfg-body">
<div v-if="section.desc" class="cfg-desc">{{ section.desc }}</div>
<div
v-for="field in section.fields"
:key="field.key"
class="cfg-field"
:class="{ 'is-disabled': !sectionEnabled(section) }"
>
<label class="cfg-label">
{{ field.label }}
<span v-if="field.unit" class="cfg-unit">{{ field.unit }}</span>
</label>
<div class="cfg-control">
<!-- 开关 -->
<el-switch
v-if="field.type === 'bool'"
v-model="model[fieldKey(section, field)]"
:disabled="disabled || !sectionEnabled(section)"
/>
<!-- 数值 -->
<el-input-number
v-else-if="field.type === 'int' || field.type === 'float'"
v-model="model[fieldKey(section, field)]"
:disabled="disabled || !sectionEnabled(section)"
:min="field.min"
:max="field.max"
:step="field.step ?? (field.type === 'int' ? 1 : 0.5)"
:precision="field.type === 'int' ? 0 : undefined"
controls-position="right"
class="cfg-number"
/>
<!-- 映射表(违规类型 → 播报文案) -->
<TtsMapEditor
v-else-if="field.type === 'kvMap'"
v-model="model[fieldKey(section, field)]"
v-model:valid="valid[fieldKey(section, field)]"
:disabled="disabled || !sectionEnabled(section)"
:suggestions="alarmSuggestions"
:suggestions-total="alarmSuggestionsTotal"
:suggestions-loaded="alarmSuggestionsLoaded"
:fallback-text="TTS_FALLBACK_TEXT"
/>
<!-- 文本 / 地址 / IP -->
<template v-else>
<el-input
v-model="model[fieldKey(section, field)]"
:disabled="disabled || !sectionEnabled(section)"
:placeholder="field.placeholder || ''"
clearable
class="cfg-text"
/>
<span v-if="field.type === 'ip' && ttsUrlPreview" class="cfg-inline-tip">
实际请求 {{ ttsUrlPreview }}
</span>
</template>
<div v-if="isImplicit(section, field)" class="cfg-default-tip">
配置中未设置,当前生效默认值:{{ displayDefault(field) }}
</div>
<div v-if="errorOf(section, field)" class="cfg-error">
{{ errorOf(section, field) }}
</div>
<div v-else-if="field.help" class="cfg-help">{{ field.help }}</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
/**
* 按 pluginSchema 渲染配置表单,并把编辑结果整理成可安全下发的 updates。
*
* 取值/校验/改动比对/载荷拼装都在 config/configEdits 里(纯函数,可独立断言);
* 本组件只负责把状态接到 Element Plus 控件上。
*/
import { computed, reactive, watch } from 'vue'
import { ArrowRight } from '@element-plus/icons-vue'
import TtsMapEditor from './TtsMapEditor.vue'
import { PLUGIN_LABELS, type FieldSchema, type SectionSchema } from '@/config/pluginSchema'
import {
baselineValue,
buildUpdates as composeUpdates,
collectChanges,
enabledField,
fieldKey,
isExplicit,
isFieldDirty,
managedFields,
validateField,
} from '@/config/configEdits'
const props = withDefaults(
defineProps<{
sections: SectionSchema[]
/** 该插件 [workers.config] 的原始值(GET 接口返回) */
config: Record<string, unknown>
disabled?: boolean
alarmSuggestions?: string[]
alarmSuggestionsTotal?: number
alarmSuggestionsLoaded?: boolean
}>(),
{
disabled: false,
alarmSuggestions: () => [],
alarmSuggestionsTotal: 0,
alarmSuggestionsLoaded: false,
},
)
/** 未命中映射时的默认播报文案,来自 aibox_sophon_adapter 的告警处理逻辑 */
const TTS_FALLBACK_TEXT = '告警: {区域},{任务描述}'
const model = reactive<Record<string, any>>({})
const valid = reactive<Record<string, boolean>>({})
const expanded = reactive<Record<string, boolean>>({})
/** 配置里是否显式存在该键(不存在时展示「当前生效默认值」提示) */
const explicit = reactive<Record<string, boolean>>({})
const initialise = () => {
for (const section of props.sections) {
for (const field of managedFields(section)) {
const key = fieldKey(section, field)
explicit[key] = isExplicit(section, props.config, field)
const value = baselineValue(section, props.config, field)
model[key] = value && typeof value === 'object' ? JSON.parse(JSON.stringify(value)) : value
if (field.type === 'kvMap') valid[key] = true
}
expanded[section.id] = !section.collapsed
}
}
// 仅在整份配置对象被替换时重建表单(父组件加载完成后赋值一次)
watch(() => props.config, initialise, { immediate: true })
const sectionEnabled = (section: SectionSchema): boolean => {
const head = enabledField(section)
if (!head) return true
return model[fieldKey(section, head)] !== false
}
const isImplicit = (section: SectionSchema, field: FieldSchema) =>
!explicit[fieldKey(section, field)] && field.type !== 'bool'
const displayDefault = (field: FieldSchema) => {
if (field.default === undefined) return '—'
if (typeof field.default === 'object') return JSON.stringify(field.default)
return String(field.default)
}
const changedFields = (section: SectionSchema): FieldSchema[] =>
managedFields(section).filter((field) =>
isFieldDirty(
field,
model[fieldKey(section, field)],
baselineValue(section, props.config, field),
),
)
const isExpanded = (section: SectionSchema) => expanded[section.id] !== false
const toggle = (section: SectionSchema) => {
expanded[section.id] = !isExpanded(section)
}
const resetSection = (section: SectionSchema) => {
for (const field of managedFields(section)) {
const key = fieldKey(section, field)
const value = baselineValue(section, props.config, field)
model[key] = value && typeof value === 'object' ? JSON.parse(JSON.stringify(value)) : value
}
}
// ---------- 校验 ----------
const errorOf = (section: SectionSchema, field: FieldSchema): string => {
const key = fieldKey(section, field)
if (field.type === 'kvMap') {
return valid[key] === false ? '映射内容有误,请按每行提示修正' : ''
}
return validateField(field, model[key], sectionEnabled(section))
}
/** 所有错误(含被折叠区块,避免折叠着保存却报不出原因) */
const allErrors = computed(() =>
props.sections.flatMap((section) =>
managedFields(section)
.map((field) => ({ section, field, message: errorOf(section, field) }))
.filter((item) => item.message),
),
)
const hasErrors = computed(() => allErrors.value.length > 0)
const firstError = computed(() => allErrors.value[0]?.message ?? '')
const firstErrorSectionId = computed(() => allErrors.value[0]?.section.id ?? '')
/** 展开出错的区块(校验失败时调用) */
const revealError = () => {
const id = firstErrorSectionId.value
if (id) expanded[id] = true
}
/** 音柱实际请求地址预览:让「IP 填错会拼出什么地址」当场可见 */
const ttsUrlPreview = computed(() => {
for (const section of props.sections) {
for (const field of section.fields) {
if (field.type !== 'ip') continue
const value = String(model[fieldKey(section, field)] ?? '').trim()
if (!value || value.includes('://') || value.includes(':') || value.includes('/')) return ''
return `http://${value}/v1/api`
}
}
return ''
})
const updates = computed(() => composeUpdates(props.sections, props.config, model))
const hasChanges = computed(() => updates.value !== null)
/** 被改动的区块标题(确认弹窗里逐条列出,避免「到底改了什么」不可见) */
const changedSectionTitles = computed(() => {
const seen = new Set<string>()
for (const change of collectChanges(props.sections, props.config, model)) {
seen.add(change.section.title)
}
return [...seen]
})
defineExpose({
buildUpdates: () => updates.value,
hasChanges,
hasErrors,
firstError,
changedSectionTitles,
revealError,
})
</script>
<style scoped>
.cfg-form {
display: flex;
flex-direction: column;
gap: 12px;
}
.cfg-section {
border: 1px solid var(--cm-border);
border-radius: 10px;
background: var(--cm-bg-soft);
overflow: hidden;
}
.cfg-section.is-dirty {
border-color: rgba(230, 162, 60, 0.55);
}
.cfg-head {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
padding: 12px 14px;
cursor: pointer;
user-select: none;
}
.cfg-head:hover {
background: var(--cm-bg-elevated);
}
.cfg-chev {
color: var(--cm-text-muted);
transition: transform 0.2s;
}
.cfg-chev.is-open {
transform: rotate(90deg);
}
.cfg-title {
color: var(--cm-text);
font-weight: 600;
}
.cfg-head-spacer {
flex: 1;
}
.cfg-switch-label {
color: var(--cm-text-muted);
font-size: 13px;
}
.cfg-body {
padding: 4px 14px 16px;
border-top: 1px dashed var(--cm-border);
}
.cfg-desc {
color: var(--cm-text-muted);
font-size: 13px;
line-height: 1.7;
margin: 12px 0 4px;
}
.cfg-field {
display: flex;
align-items: flex-start;
gap: 14px;
padding: 12px 0;
border-bottom: 1px solid rgba(42, 48, 58, 0.55);
}
.cfg-field:last-child {
border-bottom: none;
}
.cfg-field.is-disabled .cfg-label {
opacity: 0.55;
}
.cfg-label {
flex: none;
width: 170px;
padding-top: 6px;
color: var(--cm-text);
font-size: 14px;
line-height: 1.5;
}
.cfg-unit {
color: var(--cm-text-muted);
font-size: 12px;
}
.cfg-control {
flex: 1;
min-width: 0;
}
.cfg-number {
width: 180px;
}
.cfg-text {
max-width: 520px;
}
.cfg-inline-tip {
margin-left: 10px;
color: var(--cm-text-muted);
font-size: 12px;
}
.cfg-help,
.cfg-default-tip {
color: var(--cm-text-muted);
font-size: 12px;
line-height: 1.7;
margin-top: 6px;
}
.cfg-default-tip {
color: var(--el-color-info);
}
.cfg-error {
color: var(--el-color-danger);
font-size: 12px;
line-height: 1.7;
margin-top: 6px;
}
</style>
+255
View File
@@ -0,0 +1,255 @@
<template>
<div class="tts-map">
<div v-if="rows.length" class="tts-rows">
<div v-for="row in rows" :key="row.id" class="tts-row">
<el-autocomplete
v-model="row.key"
class="tts-key"
:disabled="disabled"
:fetch-suggestions="fetchSuggestions"
placeholder="违规类型(如 Intrude)"
clearable
/>
<span class="tts-arrow">→</span>
<el-input
v-model="row.value"
class="tts-text"
:disabled="disabled"
placeholder="播报内容(如 请勿靠近)"
clearable
/>
<el-button
class="tts-del"
type="danger"
link
:disabled="disabled"
title="删除该条"
@click="removeRow(row)"
>
删除
</el-button>
<div v-if="rowError(row)" class="tts-row-error">{{ rowError(row) }}</div>
</div>
</div>
<div v-else class="tts-empty">暂无自定义文案,所有告警都会播报默认文案。</div>
<div class="tts-actions">
<el-button size="small" :disabled="disabled" @click="addRow">+ 新增一条</el-button>
<span v-if="suggestions.length" class="tts-suggest-hint">
候选来自最近 {{ suggestionsTotal }} 条告警记录中的 {{ suggestions.length }} 种违规类型
</span>
<span v-else-if="suggestionsLoaded" class="tts-suggest-hint">
暂未取到历史告警类型,可直接手填(需与盒子上报的违规类型完全一致)
</span>
</div>
<div v-if="fallbackText" class="tts-fallback">
未匹配到上述类型的告警,播报默认文案:<code>{{ fallbackText }}</code>
</div>
</div>
</template>
<script setup lang="ts">
/**
* 告警播报文案映射编辑器(Summary → 播报文本)。
*
* 映射必须整表提交(后端对 [workers.config] 的 ip_sound_column 节不做深合并),
* 因此本组件以「行」为编辑单位、整体向父组件回传对象,父组件再随区块一并下发。
*
* 违规类型的候选来自历史告警记录(见 api/aibox.ts),避免手写拼错导致
* 配置看起来生效、实际永远走默认文案。
*/
import { ref, watch } from 'vue'
interface Row {
/** 稳定 key:仅用于 v-for,避免输入时复用错误节点导致焦点丢失 */
id: number
key: string
value: string
}
const props = withDefaults(
defineProps<{
modelValue: Record<string, string>
/** 配合 v-model:valid 使用的双向绑定占位:有效性由本组件内部校验后回传 */
valid?: boolean
disabled?: boolean
suggestions?: string[]
suggestionsTotal?: number
/** 历史告警是否已尝试加载(用于区分「加载中」与「确实没有」) */
suggestionsLoaded?: boolean
/** 未命中映射时的默认播报文案,仅用于展示 */
fallbackText?: string
}>(),
{
valid: true,
disabled: false,
suggestions: () => [],
suggestionsTotal: 0,
suggestionsLoaded: false,
fallbackText: '',
},
)
const emit = defineEmits<{
'update:modelValue': [value: Record<string, string>]
'update:valid': [valid: boolean]
}>()
let rowSeq = 0
const rows = ref<Row[]>([])
/** 把行拼成映射对象:跳过整行空白的行,重复键后者覆盖前者 */
const buildMap = (list: Row[]): Record<string, string> => {
const map: Record<string, string> = {}
for (const row of list) {
const key = row.key.trim()
if (!key) continue
map[key] = row.value
}
return map
}
const sameMap = (a: Record<string, string>, b: Record<string, string>) => {
const ka = Object.keys(a).sort()
const kb = Object.keys(b).sort()
if (ka.length !== kb.length || ka.some((k, i) => k !== kb[i])) return false
return ka.every((k) => a[k] === b[k])
}
const syncFromModel = (map: Record<string, string>) => {
rows.value = Object.entries(map || {}).map(([key, value]) => ({
id: ++rowSeq,
key,
value: String(value ?? ''),
}))
}
// 外部值变化时重建行;但由自身编辑触发的回流要跳过,否则每敲一个字都会重建 DOM
watch(
() => props.modelValue,
(value) => {
const incoming = value || {}
if (sameMap(buildMap(rows.value), incoming)) return
syncFromModel(incoming)
},
{ immediate: true, deep: true },
)
/**
* 行内容的任何变化(手输、下拉选中、增删行)都回传父组件。
* 用深度 watch 而非 @input:el-autocomplete 从候选里选中时只发 change,
* 只监听 input 会漏掉这条最主要的填写路径。
*/
watch(
rows,
() => {
const map = buildMap(rows.value)
emit('update:valid', validate())
// 与外部值一致时不回传,避免「外部值变化 → 重建行 → 再回传」的来回抖动
if (!sameMap(map, props.modelValue || {})) emit('update:modelValue', map)
},
{ deep: true },
)
const rowError = (row: Row): string => {
const key = row.key.trim()
if (!key) {
return row.value.trim() ? '请填写违规类型(需与盒子上报的类型一致)' : ''
}
const duplicated = rows.value.some((r) => r !== row && r.key.trim() === key)
if (duplicated) return `违规类型「${key}」重复,请只保留一条`
if (!row.value.trim()) return '请填写播报内容'
return ''
}
const validate = (): boolean => {
const errors = rows.value.filter((row) => rowError(row) !== '')
// 键不允许重复:重复时 buildMap 会丢行,必须拦在下发之前
const keys = rows.value.map((r) => r.key.trim()).filter(Boolean)
const hasDuplicate = new Set(keys).size !== keys.length
return errors.length === 0 && !hasDuplicate
}
const addRow = () => {
rows.value.push({ id: ++rowSeq, key: '', value: '' })
}
const removeRow = (row: Row) => {
rows.value = rows.value.filter((r) => r !== row)
}
const fetchSuggestions = (query: string, cb: (items: Array<{ value: string }>) => void) => {
const kw = query.trim().toLowerCase()
const used = new Set(rows.value.map((r) => r.key.trim()))
cb(
props.suggestions
.filter((s) => !used.has(s))
.filter((s) => !kw || s.toLowerCase().includes(kw))
.map((s) => ({ value: s })),
)
}
</script>
<style scoped>
.tts-map {
width: 100%;
}
.tts-rows {
display: flex;
flex-direction: column;
gap: 8px;
}
.tts-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.tts-key {
width: 220px;
}
.tts-text {
width: calc(100% - 300px);
min-width: 200px;
}
.tts-arrow {
color: var(--cm-text-muted);
}
.tts-del {
flex: none;
}
.tts-row-error {
width: 100%;
color: var(--el-color-danger);
font-size: 12px;
}
.tts-empty {
color: var(--cm-text-muted);
font-size: 13px;
padding: 4px 0 10px;
}
.tts-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
margin-top: 10px;
}
.tts-suggest-hint {
color: var(--cm-text-muted);
font-size: 12px;
}
.tts-fallback {
margin-top: 8px;
color: var(--cm-text-muted);
font-size: 12px;
}
.tts-fallback code {
color: var(--cm-text);
background: var(--cm-bg-elevated);
border: 1px solid var(--cm-border);
border-radius: 4px;
padding: 1px 5px;
}
</style>
+74
View File
@@ -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<AuthUser | null>(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<AuthUser | null> => {
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 }
}
+213
View File
@@ -0,0 +1,213 @@
/**
* 配置表单的纯逻辑:取值、校验、改动比对、下发载荷拼装。
*
* 与 Vue 组件分离,一是让「按区块整节下发」这条规则只有一处实现,
* 二是这类逻辑最容易出错(漏带同级键、把默认值固化进配置),独立出来可直接断言。
* 本模块只做类型引用,不引入 pluginSchema 的运行时依赖。
*/
import type { FieldSchema, SectionSchema } from './pluginSchema'
/** 把未知值当作普通对象取用(数组/标量一律视为空对象) */
export const asRecord = (value: unknown): Record<string, unknown> =>
value && typeof value === 'object' && !Array.isArray(value)
? { ...(value as Record<string, unknown>) }
: {}
/** 区块开关对应的合成字段(渲染在标题栏,不放进 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<string, unknown>,
): Record<string, unknown> => (section.path ? asRecord(config[section.path]) : asRecord(config))
/**
* 该字段在「未改动」状态下应显示的值:配置里的值优先,缺失时用插件内生效的默认值。
* 用于初始化和「撤销改动」。
*/
export const baselineValue = (
section: SectionSchema,
config: Record<string, unknown>,
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<string, unknown>,
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<string, unknown>).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<string, unknown>,
values: Record<string, unknown>,
): 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<string, unknown>,
values: Record<string, unknown>,
): Record<string, unknown> | null => {
const changes = collectChanges(sections, config, values)
if (!changes.length) return null
const updates: Record<string, unknown> = {}
const grouped = new Map<string, Record<string, unknown>>()
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
}
+267
View File
@@ -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<OwnerPlugin, string> = {
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: [],
},
],
},
]
+17 -6
View File
@@ -1,14 +1,13 @@
import { createRouter, createWebHistory } from 'vue-router'
import DoubaoView from '../views/DoubaoView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'doubao',
component: DoubaoView,
},
// {
// path: '/',
// name: 'doubao',
// component: DoubaoView,
// },
{
path: '/about',
name: 'about',
@@ -23,6 +22,18 @@ const router = createRouter({
// 懒加载 Worker 管理页面(实例盘点 + 单/批量重启)
component: () => import('../views/WorkerView.vue'),
},
{
path: '/license',
name: 'license',
// 懒加载机器码授权页(机器码/申请码展示 + 授权状态 + 导入授权文件)
component: () => import('../views/LicenseView.vue'),
},
{
path: '/',
name: 'config',
// 懒加载参数配置页(面向客户的音柱/告警结构化配置)
component: () => import('../views/ConfigView.vue'),
},
],
})
+432
View File
@@ -0,0 +1,432 @@
<template>
<div class="config-container">
<!-- 未登录:先领管理 Token,配置读写都需要超管权限 -->
<el-card v-if="!isLoggedIn" class="toolbar-card" shadow="hover">
<el-form inline>
<el-form-item label="用户名">
<el-input
v-model="username"
placeholder="用户名"
style="width: 200px"
@keyup.enter="onLogin"
/>
</el-form-item>
<el-form-item label="密码">
<el-input
v-model="password"
type="password"
show-password
placeholder="密码"
style="width: 220px"
@keyup.enter="onLogin"
/>
</el-form-item>
<el-button type="primary" :loading="logging" style="margin-left: 8px" @click="onLogin">
登录
</el-button>
</el-form>
<el-alert
type="info"
:closable="false"
title="通过 auth 插件登录领取管理 Token。参数配置需要超级管理员权限,权限不足将弹出提示。"
show-icon
style="margin-top: 8px"
/>
</el-card>
<template v-else>
<el-card class="toolbar-card" shadow="hover">
<div class="top-bar">
<el-tag effect="plain" type="primary">
{{ currentUser?.username || '已登录' }}
</el-tag>
<el-tag v-if="currentUser?.is_superuser" type="success" size="small">超级管理员</el-tag>
<el-tag v-else type="danger" size="small">非超管(操作将被拒绝)</el-tag>
<el-button
type="primary"
:loading="loadingAny"
style="margin-left: 12px"
@click="loadAll"
>
重新读取
</el-button>
<el-button type="info" plain style="margin-left: 12px" @click="logout">退出</el-button>
</div>
</el-card>
<!-- 生效来源:值到底来自运行中的副本还是配置文件,以及改动能否跨网关重启保留 -->
<el-card class="toolbar-card" shadow="hover">
<div class="source-bar">
<span class="source-bar-title">当前配置来源</span>
<el-tag
v-for="plugin in OWNER_PLUGINS"
:key="plugin"
size="small"
:type="pluginMeta(plugin).source === 'cache' ? 'success' : 'warning'"
>
{{ PLUGIN_LABELS[plugin] }}:
{{ pluginMeta(plugin).source === 'cache' ? '运行时缓存' : 'config.toml(回退读取)' }}
</el-tag>
</div>
<el-alert
v-if="tomlModePlugins.length"
type="warning"
:closable="false"
show-icon
style="margin-top: 10px"
:title="`TOML 为准模式:${tomlModePlugins.map((p) => PLUGIN_LABELS[p]).join('、')} 的改动会立即生效,但网关重启后会被各自的 config.toml 覆盖。如需长期保留,请同步修改配置文件,或把根 config.toml 的 [cache].launch_worker_count 改为 cache。`"
/>
<el-alert
v-if="tomlSourcePlugins.length"
type="info"
:closable="false"
show-icon
style="margin-top: 10px"
:title="`${tomlSourcePlugins.map((p) => PLUGIN_LABELS[p]).join('、')}:未能从运行中的副本读到配置(可能未启动),以下展示取自配置文件,可能不是实际生效值。`"
/>
</el-card>
<el-card class="table-card" shadow="hover">
<el-tabs v-model="activeTab">
<el-tab-pane v-for="tab in CONFIG_TABS" :key="tab.id" :label="tab.title" :name="tab.id">
<template #label>
<span class="tab-label" :class="{ 'has-change': tabChanged(tab) }">
{{ tab.title }}
<el-badge v-if="tabChanged(tab)" is-dot />
</span>
</template>
<div class="tab-desc">{{ tab.desc }}</div>
<el-alert
v-for="plugin in ownersOf(tab)"
v-show="loadError[plugin]"
:key="`err-${plugin}`"
type="error"
:closable="false"
show-icon
class="load-error"
:title="`${PLUGIN_LABELS[plugin]} 配置读取失败:${loadError[plugin]}`"
/>
<PluginConfigForm
v-for="group in sectionsOf(tab)"
:key="`${tab.id}:${group.owner}`"
:ref="formRefSetter(`${tab.id}:${group.owner}`)"
:sections="group.sections"
:config="configs[group.owner]"
:disabled="!!loadError[group.owner]"
:alarm-suggestions="alarmSummaries"
:alarm-suggestions-total="alarmSummariesTotal"
:alarm-suggestions-loaded="alarmLoaded"
/>
</el-tab-pane>
</el-tabs>
<div class="save-bar">
<span class="save-tip">
改动按区块整节下发,未在本页开放的配置项(数据库、数据目录、MQTT 通道等)保持原样,
由部署配置文件管理。
</span>
<el-button type="primary" :loading="saving" :disabled="!hasAnyChange" @click="save">
保存并重启
</el-button>
</div>
</el-card>
</template>
</div>
</template>
<script setup lang="ts">
/**
* 参数配置页:把「IP 音柱」与「AI Box 适配器」两个插件的客户可见配置
* 按使用场景(而不是插件边界)组织成一页。
*
* 音柱的音量与播报文案实际存在 aibox_sophon_adapter 的 [workers.config.ip_sound_column]
* 节里,ip_sound_column 插件自身的 volume 键不生效——所以本页按「归属插件」分区渲染,
* 保存时把各区块的改动分别下发给它真正生效的那个插件并重启其副本。
*/
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import PluginConfigForm from '@/components/PluginConfigForm.vue'
import { listAlarmSummaries } from '@/api/aibox'
import { useAdminAuth, restoreAuth } from '@/composables/useAdminAuth'
import {
CONFIG_TABS,
OWNER_PLUGINS,
PLUGIN_LABELS,
type OwnerPlugin,
type TabSchema,
} from '@/config/pluginSchema'
import {
getWorkerConfig,
updateWorkerConfig,
type ConfigMode,
type ConfigSource,
} from '@/api/workerAdmin'
const { token, currentUser, username, password, logging, isLoggedIn, doLogin, logout } =
useAdminAuth()
const activeTab = ref(CONFIG_TABS[0]?.id ?? '')
const saving = ref(false)
const configs = reactive<Record<OwnerPlugin, Record<string, unknown>>>({
ip_sound_column: {},
aibox_sophon_adapter: {},
})
/** 各插件的 mode/source(后端如实回报:值来自缓存还是回退读的文件、改动能否跨重启保留) */
const meta = reactive<Record<OwnerPlugin, { mode: ConfigMode | ''; source: ConfigSource | '' }>>({
ip_sound_column: { mode: '', source: '' },
aibox_sophon_adapter: { mode: '', source: '' },
})
const pluginMeta = (plugin: OwnerPlugin) => meta[plugin]
const loadError = reactive<Record<OwnerPlugin, string>>({
ip_sound_column: '',
aibox_sophon_adapter: '',
})
const loading = reactive<Record<OwnerPlugin, boolean>>({
ip_sound_column: false,
aibox_sophon_adapter: false,
})
const loadingAny = computed(() => OWNER_PLUGINS.some((p) => loading[p]))
// 播报文案的候选违规类型,来自历史告警记录
const alarmSummaries = ref<string[]>([])
const alarmSummariesTotal = ref(0)
const alarmLoaded = ref(false)
/** 表单实例:键为 `${tabId}:${owner}`——同一插件可能出现在多个页签,各自持有不同区块 */
interface ConfigFormApi {
buildUpdates: () => Record<string, unknown> | null
hasChanges: boolean
hasErrors: boolean
firstError: string
changedSectionTitles: string[]
revealError: () => void
}
const formRefs = ref<Record<string, ConfigFormApi | null>>({})
/**
* 稳定的 ref 回调:每次渲染返回同一个函数,避免内联箭头函数在每次更新时
* 先以 null 清空、再重新赋值,导致「已改动」状态闪烁。
*/
const refSetterCache = new Map<string, (el: unknown) => void>()
const formRefSetter = (key: string) => {
let setter = refSetterCache.get(key)
if (!setter) {
setter = (el: unknown) => {
formRefs.value[key] = (el as ConfigFormApi | null) ?? null
}
refSetterCache.set(key, setter)
}
return setter
}
const ownersOf = (tab: TabSchema): OwnerPlugin[] =>
OWNER_PLUGINS.filter((owner) => tab.sections.some((s) => s.owner === owner))
const sectionsOf = (tab: TabSchema) =>
ownersOf(tab).map((owner) => ({
owner,
sections: tab.sections.filter((s) => s.owner === owner),
}))
const tabChanged = (tab: TabSchema): boolean =>
Object.entries(formRefs.value).some(
([key, form]) => key.startsWith(`${tab.id}:`) && !!form?.hasChanges,
)
const hasAnyChange = computed(() =>
Object.values(formRefs.value).some((form) => !!form?.hasChanges),
)
const tomlModePlugins = computed(() => OWNER_PLUGINS.filter((p) => meta[p].mode === 'toml'))
const tomlSourcePlugins = computed(() => OWNER_PLUGINS.filter((p) => meta[p].source === 'toml'))
const onLogin = async () => {
const info = await doLogin()
if (info?.is_superuser) await loadAll()
}
const loadConfig = async (plugin: OwnerPlugin) => {
const t = token.value.trim()
if (!t) return
loading[plugin] = true
loadError[plugin] = ''
try {
const data = await getWorkerConfig(t, plugin)
// 整体替换对象:表单以「配置对象被替换」为重新初始化表单的信号
configs[plugin] = { ...(data.config || {}) }
meta[plugin] = { mode: data.mode || '', source: data.source || '' }
} catch (e: any) {
// 读取失败(插件未注册/无副本且配置文件缺失)时保留可编辑表单,但禁止下发
loadError[plugin] = e.message
meta[plugin] = { mode: '', source: '' }
ElMessage.warning(`${PLUGIN_LABELS[plugin]} 配置读取失败:${e.message}`)
} finally {
loading[plugin] = false
}
}
const loadAlarmSummaries = async () => {
const data = await listAlarmSummaries()
alarmSummaries.value = data.summaries
alarmSummariesTotal.value = data.total
alarmLoaded.value = true
}
const loadAll = async () => {
await Promise.all([...OWNER_PLUGINS.map(loadConfig), loadAlarmSummaries()])
}
/** 汇总各表单的改动,按归属插件分组;同一插件的多个页签区块互不重叠,直接合并 */
const collectUpdates = (): Array<{ plugin: OwnerPlugin; updates: Record<string, unknown> }> => {
const perPlugin: Partial<Record<OwnerPlugin, Record<string, unknown>>> = {}
for (const [key, form] of Object.entries(formRefs.value)) {
if (!form) continue
const plugin = key.split(':')[1] as OwnerPlugin
const updates = form.buildUpdates()
if (!updates) continue
perPlugin[plugin] = { ...(perPlugin[plugin] ?? {}), ...updates }
}
return OWNER_PLUGINS.filter((p) => perPlugin[p]).map((plugin) => ({
plugin,
updates: perPlugin[plugin] as Record<string, unknown>,
}))
}
const save = async () => {
const t = token.value.trim()
if (!t) {
ElMessage.warning('请先登录')
return
}
// 校验先行:任一处不合法就展开并定位,绝不带着错误下发(下发即重启副本)
const invalid = Object.values(formRefs.value).find((form) => !!form?.hasErrors)
if (invalid) {
invalid.revealError()
ElMessage.warning(`配置有误:${invalid.firstError}`)
return
}
const pending = collectUpdates()
if (!pending.length) {
ElMessage.info('没有检测到改动')
return
}
const changedTitles = Object.values(formRefs.value)
.flatMap((form) => form?.changedSectionTitles ?? [])
.join('、')
const plugins = pending.map((p) => PLUGIN_LABELS[p.plugin]).join('、')
try {
await ElMessageBox.confirm(
`即将保存并重启以下插件的全部副本:${plugins}。\n改动区块:${changedTitles}。\n重启期间相关接口短暂不可用。`,
'保存配置确认',
{ type: 'warning', confirmButtonText: '保存并重启', cancelButtonText: '取消' },
)
} catch {
return
}
saving.value = true
const failures: string[] = []
const succeeded: string[] = []
try {
for (const { plugin, updates } of pending) {
try {
const result = await updateWorkerConfig(t, plugin, updates)
if (result.ok) {
succeeded.push(PLUGIN_LABELS[plugin])
} else {
failures.push(
`${PLUGIN_LABELS[plugin]}(成功 ${result.total - result.failed}/${result.total}):${result.errors.join(';')}`,
)
}
} catch (e: any) {
failures.push(`${PLUGIN_LABELS[plugin]}:${e.message}`)
}
}
if (succeeded.length) ElMessage.success(`已保存并重启:${succeeded.join('、')}`)
// 失败项逐条展示,避免「部分成功」被一句成功提示盖掉
failures.forEach((msg) => ElMessage.error(`保存失败 ${msg}`))
if (failures.length && succeeded.length) {
ElMessage.warning('部分插件保存失败,请按错误信息处理后重试')
}
await loadAll()
} finally {
saving.value = false
}
}
onMounted(async () => {
// 与 Worker 管理页共用同一 token:在那边登录过这里可直接进入
if (!restoreAuth()) return
if (currentUser.value?.is_superuser) {
await loadAll()
} else {
ElMessage.error('权限不足:仅超级管理员可管理配置')
}
})
</script>
<style scoped>
.config-container {
max-width: 1100px;
margin: 0 auto;
}
.toolbar-card,
.table-card {
border-radius: 12px;
margin-bottom: 16px;
border: 1px solid var(--cm-border);
background: var(--cm-bg-soft);
}
.top-bar,
.source-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.source-bar-title {
color: var(--cm-text-muted);
font-size: 13px;
margin-right: 4px;
}
.tab-label {
display: inline-flex;
align-items: center;
gap: 4px;
}
.tab-label.has-change {
font-weight: 600;
}
.tab-desc {
color: var(--cm-text-muted);
font-size: 13px;
line-height: 1.7;
margin-bottom: 14px;
}
.load-error {
margin-bottom: 12px;
}
.save-bar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
margin-top: 8px;
padding-top: 16px;
border-top: 1px solid var(--cm-border);
}
.save-tip {
color: var(--cm-text-muted);
font-size: 12px;
line-height: 1.7;
max-width: 720px;
}
</style>
-267
View File
@@ -1,267 +0,0 @@
<template>
<div class="doubao-container">
<el-card class="main-card" shadow="hover">
<template #header>
<div class="card-header">
<el-icon :size="24"><Cpu /></el-icon>
<span style="font-weight: 600; font-size: 18px; margin-left: 8px"
>豆包视觉理解测试</span
>
</div>
</template>
<el-form label-position="top">
<el-form-item label="提示词 (prompt)">
<el-input
v-model="prompt"
type="textarea"
:rows="3"
placeholder="请输入对图片的提问(可选)"
maxlength="500"
show-word-limit
/>
</el-form-item>
</el-form>
<el-upload
class="upload-area"
drag
:auto-upload="false"
:show-file-list="false"
:on-change="handleFileChange"
accept="image/*"
>
<div v-if="!imagePreview" class="upload-placeholder">
<el-icon :size="48" class="el-icon--upload"><Upload /></el-icon>
<div class="el-upload__text">
拖拽图片到此处,或 <em>点击选择</em>
</div>
<div class="el-upload__tip">支持 JPG / PNG / WebP</div>
</div>
<div v-else class="preview-wrapper">
<img :src="imagePreview" class="preview-image" />
<div class="preview-actions">
<el-button
type="danger"
size="small"
circle
@click.stop="clearImage"
>
<el-icon><Close /></el-icon>
</el-button>
</div>
</div>
</el-upload>
<div v-if="fileInfo" style="margin: 12px 0">
<el-tag type="info"
><el-icon><Document /></el-icon> {{ fileInfo }}</el-tag
>
</div>
<div class="action-bar">
<el-button
type="primary"
size="large"
:loading="loading"
:disabled="!imageBase64"
@click="analyze"
>
{{ loading ? "分析中..." : "🚀 分析图片" }}
</el-button>
<el-button v-if="resultText" size="large" @click="copyResult">
<el-icon><CopyDocument /></el-icon> 复制结果
</el-button>
</div>
<div v-if="statusText" style="margin-top: 16px">
<el-alert
:title="statusText"
:type="statusType"
:closable="false"
show-icon
/>
</div>
<div v-if="resultText" style="margin-top: 20px">
<el-divider content-position="left">分析结果</el-divider>
<div class="result-content" v-html="resultHtml"></div>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
import { ElMessage } from "element-plus";
import {
Cpu,
Upload,
Close,
Document,
CopyDocument,
} from "@element-plus/icons-vue";
// 导入生成的 SDK 函数及类型
import { endpointApiV1DoubaoAnalyzePost } from "@/api/generated/sdk.gen";
import type { EndpointApiV1DoubaoAnalyzePostData } from "@/api/generated/types.gen";
const prompt = ref("描述这张图片的内容");
const imageBase64 = ref("");
const imagePreview = ref("");
const loading = ref(false);
const statusText = ref("");
const resultText = ref("");
const fileInfo = ref("");
const statusType = computed(() => {
if (statusText.value.includes("✅")) return "success";
if (statusText.value.includes("❌")) return "error";
return "info";
});
const resultHtml = computed(() => resultText.value.replace(/\n/g, "<br>"));
const handleFileChange = (file: any) => {
const raw = file.raw || file;
if (!raw) return;
if (!raw.type.startsWith("image/")) {
ElMessage.warning("请选择图片文件");
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const data = e.target?.result as string;
imageBase64.value = data;
imagePreview.value = data;
fileInfo.value = `${raw.name} (${(raw.size / 1024).toFixed(1)} KB)`;
statusText.value = "";
resultText.value = "";
};
reader.readAsDataURL(raw);
};
const clearImage = () => {
imageBase64.value = "";
imagePreview.value = "";
fileInfo.value = "";
statusText.value = "";
resultText.value = "";
};
const analyze = async () => {
if (!imageBase64.value) {
ElMessage.warning("请先选择一张图片");
return;
}
// 构造请求参数
const requestData: EndpointApiV1DoubaoAnalyzePostData = {
body: {
image_base64: imageBase64.value,
prompt: prompt.value.trim() || "描述这张图片的内容",
},
url: "/api/v1/doubao/analyze",
};
loading.value = true;
statusText.value = "⏳ 正在分析中...";
resultText.value = "";
try {
// 调用生成的 API 函数 (默认 throwOnError = false)
const response = await endpointApiV1DoubaoAnalyzePost(requestData);
// 检查 HTTP 错误
if (response.error) {
const errMsg = response.error || "请求失败";
resultText.value = `❌ 错误: ${errMsg}`;
statusText.value = "❌ 分析失败";
return;
}
// 获取业务数据
const data = response.data;
if (data?.error) {
resultText.value = `❌ 错误: ${data.error}`;
statusText.value = "❌ 分析失败";
} else {
resultText.value = data?.result || "(无返回内容)";
statusText.value = "✅ 分析完成";
}
} catch (err: any) {
// 网络错误或其他异常
resultText.value = `❌ 请求异常: ${err.message}`;
statusText.value = "❌ 请求失败";
} finally {
loading.value = false;
}
};
const copyResult = () => {
const text = resultText.value.replace(/<[^>]+>/g, "");
navigator.clipboard
.writeText(text)
.then(() => {
ElMessage.success("已复制");
})
.catch(() => {
ElMessage.error("复制失败");
});
};
</script>
<style scoped>
.doubao-container {
max-width: 700px;
margin: 0 auto;
}
.main-card {
border-radius: 12px;
}
.card-header {
display: flex;
align-items: center;
}
.upload-area {
width: 100%;
}
.upload-placeholder {
padding: 30px 0;
text-align: center;
}
.preview-wrapper {
position: relative;
display: inline-block;
width: 100%;
}
.preview-image {
max-width: 100%;
max-height: 400px;
border-radius: 8px;
border: 1px solid var(--cm-border);
}
.preview-actions {
position: absolute;
top: 8px;
right: 8px;
background: rgba(22, 27, 34, 0.75);
border-radius: 50%;
backdrop-filter: blur(4px);
}
.action-bar {
margin-top: 16px;
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.result-content {
background: var(--el-fill-color-lighter);
padding: 16px;
border-radius: 8px;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.8;
color: var(--cm-text);
}
</style>
+427
View File
@@ -0,0 +1,427 @@
<template>
<div class="license-container">
<el-card class="toolbar-card" shadow="hover">
<template v-if="!token">
<el-form inline>
<el-form-item label="用户名">
<el-input
v-model="username"
placeholder="用户名"
style="width: 200px"
@keyup.enter="doLogin"
/>
</el-form-item>
<el-form-item label="密码">
<el-input
v-model="password"
type="password"
show-password
placeholder="密码"
style="width: 220px"
@keyup.enter="doLogin"
/>
</el-form-item>
<el-button type="primary" :loading="logging" style="margin-left: 8px" @click="doLogin">
登录
</el-button>
</el-form>
<el-alert
type="info"
:closable="false"
title="机器码与授权状态属设备标识信息,仅超级管理员可查看与导入授权。"
show-icon
style="margin-top: 8px"
/>
</template>
<template v-else>
<div class="login-bar">
<el-tag effect="plain" type="primary">{{ currentUser?.username || '已登录' }}</el-tag>
<el-tag v-if="currentUser?.is_superuser" type="success" size="small">超级管理员</el-tag>
<el-tag v-else type="danger" size="small">非超管(操作将被拒绝)</el-tag>
<el-button type="primary" :loading="loading" style="margin-left: 12px" @click="fetchInfo">
刷新
</el-button>
<el-button type="info" plain @click="logout">退出</el-button>
</div>
</template>
</el-card>
<el-alert
v-if="token && info"
:type="statusType"
:closable="false"
show-icon
:title="statusTitle"
:description="statusDescription"
style="margin-bottom: 16px"
/>
<template v-if="token && info">
<el-card class="machine-card" shadow="hover">
<template #header>
<div class="card-header">本机机器码</div>
</template>
<div class="machine-code">{{ info.status.machine_code || '(未采集到硬件指纹)' }}</div>
<div class="hint">
把机器码(或下方申请码)发给厂商即可申请授权。申请码包含各硬件来源指纹,
厂商据此签发的授权可容忍更换单块网卡或单颗 eMMC;只报机器码则只能签出精确匹配的授权。
</div>
<div class="copy-bar">
<el-button size="small" :disabled="!info.status.machine_code" @click="copy(info.status.machine_code)">
复制机器码
</el-button>
<el-button size="small" type="primary" :disabled="!info.request_code" @click="copy(info.request_code)">
复制申请码
</el-button>
<el-input
v-model="info.request_code"
readonly
size="small"
class="request-code"
placeholder="(申请码生成失败)"
/>
</div>
</el-card>
<el-card class="detail-card" shadow="hover">
<template #header>
<div class="card-header">授权信息</div>
</template>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="授权文件">
{{ info.license_path }}
</el-descriptions-item>
<el-descriptions-item label="校验方式">
{{ info.enforce ? '强制校验' : '未启用(开发/测试)' }}
<el-tag v-if="info.enforce_forced_by_packaging" type="warning" size="small" style="margin-left: 6px">
打包产物强制
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="授权编号">
{{ info.status.payload?.lic_id || '—' }}
</el-descriptions-item>
<el-descriptions-item label="客户">
{{ info.status.payload?.customer || '—' }}
</el-descriptions-item>
<el-descriptions-item label="签发时间">
{{ info.status.payload?.issued_at || '—' }}
</el-descriptions-item>
<el-descriptions-item label="有效期">
{{ info.status.payload?.not_before || '立即生效' }} →
{{ info.status.payload?.expires_at || '永久' }}
</el-descriptions-item>
<el-descriptions-item label="指纹比对">
{{ info.status.match?.reason || '—' }}
<el-tag v-if="info.status.match?.degraded" type="warning" size="small" style="margin-left: 6px">
已降级
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="厂商公钥指纹">
<code>{{ info.status.public_key_fingerprint || '(未配置公钥)' }}</code>
</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">
{{ info.status.payload?.note || '—' }}
</el-descriptions-item>
</el-descriptions>
<el-alert
v-for="(w, i) in info.warnings"
:key="i"
type="warning"
:closable="false"
:title="w"
show-icon
style="margin-top: 8px"
/>
</el-card>
<el-card class="detail-card" shadow="hover">
<template #header>
<div class="card-header">
插件授权
<span class="count-tip">
盘扫 {{ info.plugins.discovered.length }} 个 / 已授权 {{ info.plugins.licensed.length }}
</span>
</div>
</template>
<el-table :data="pluginRows" size="small" stripe>
<el-table-column prop="type" label="插件类型" min-width="180" />
<el-table-column label="授权" width="100">
<template #default="{ row }">
<el-tag :type="row.authorized ? 'success' : 'danger'" size="small">
{{ row.authorized ? '已授权' : '未授权' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="已加载" width="100">
<template #default="{ row }">
<el-tag :type="row.loaded ? 'primary' : 'info'" size="small">
{{ row.loaded ? '已加载' : '未加载' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="reason" label="说明" min-width="280" show-overflow-tooltip />
</el-table>
</el-card>
<el-card class="detail-card" shadow="hover">
<template #header>
<div class="card-header">
硬件指纹来源
<span class="count-tip">带 * 的为 weak 来源,不参与判定阈值</span>
</div>
</template>
<el-table :data="info.sources_detail" size="small" stripe>
<el-table-column prop="name" label="来源" width="140" />
<el-table-column prop="label" label="说明" min-width="200" />
<el-table-column label="采集" width="90">
<template #default="{ row }">
<el-tag :type="row.present ? 'success' : 'info'" size="small">
{{ row.present ? '成功' : '缺失' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="值" min-width="260">
<template #default="{ row }">
<code>{{ row.value || '—' }}{{ row.weak && row.present ? ' *' : '' }}</code>
<span v-if="row.detail" class="source-detail">{{ row.detail }}</span>
</template>
</el-table-column>
</el-table>
<div class="hint">
可信来源越多,授权的容错越好。若这里只有 1 个来源或大量"缺失",
说明该硬件上的序列号不可读 —— 建议先核实再签发(换一块网卡就会失授权)。
</div>
</el-card>
<el-card class="detail-card" shadow="hover">
<template #header>
<div class="card-header">导入授权文件</div>
</template>
<div class="upload-bar">
<input ref="fileInput" type="file" accept=".lic,application/json" class="file-input" @change="onFilePicked" />
<el-button :loading="uploading" @click="fileInput?.click()">
{{ pickedFile ? `重新选择(当前:${pickedFile.name})` : '选择 license 文件' }}
</el-button>
<el-button type="primary" :loading="uploading" :disabled="!pickedFile" @click="doUpload">
导入并校验
</el-button>
<el-button :loading="loading" @click="doReload">从磁盘重读</el-button>
</div>
<div class="hint">
导入前会完整校验签名、机器码与有效期,**校验不通过不会覆盖**现有授权文件。
新授权的插件需重启网关后才会加载。
</div>
</el-card>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useAdminAuth } from '@/composables/useAdminAuth'
import {
getLicenseInfo,
reloadLicense,
uploadLicense,
type LicenseInfo,
} from '@/api/license'
const { token, currentUser, username, password, logging, doLogin, logout } = useAdminAuth()
const info = ref<LicenseInfo | null>(null)
const loading = ref(false)
const uploading = ref(false)
const pickedFile = ref<File | null>(null)
const fileInput = ref<HTMLInputElement | null>(null)
const statusType = computed(() => {
if (!info.value) return 'info'
if (!info.value.enforce) return 'info'
return info.value.status.ok ? 'success' : 'error'
})
const statusTitle = computed(() => {
const i = info.value
if (!i) return ''
if (!i.enforce) return '授权校验未启用(开发/测试模式)'
return i.status.ok ? '授权有效' : `授权无效:${i.status.message}`
})
const statusDescription = computed(() => {
const i = info.value
if (!i) return ''
if (!i.enforce) {
return `当前未执行拒绝;真实校验结果:${i.status.message}`
}
if (i.status.ok) {
const plugins = i.status.licensed_plugins
return `已授权插件:${plugins.length ? plugins.join('、') : '无'}`
}
const denied = Object.keys(i.plugins.denied)
return denied.length
? `以下插件不会被加载:${denied.join('、')}`
: '导入有效授权文件后重启网关即可加载插件。'
})
interface PluginRow {
type: string
authorized: boolean
loaded: boolean
reason: string
}
const pluginRows = computed<PluginRow[]>(() => {
const i = info.value
if (!i) return []
const loaded = new Set(i.plugins.loaded)
return i.plugins.discovered.map((type) => {
const denied = i.plugins.denied[type]
const authorized = i.plugins.licensed.includes(type)
return {
type,
authorized,
loaded: loaded.has(type),
reason: denied
? denied.message
: authorized
? '在白名单内'
: '未在授权白名单内',
}
})
})
async function fetchInfo() {
if (!token.value) return
loading.value = true
try {
info.value = await getLicenseInfo(token.value)
} catch (e: any) {
ElMessage.error(`读取授权状态失败:${e.message}`)
} finally {
loading.value = false
}
}
async function copy(text: string) {
if (!text) return
try {
await navigator.clipboard.writeText(text)
ElMessage.success('已复制到剪贴板')
} catch {
// 非 HTTPS / 无剪贴板权限时回退到选中提示,避免静默失败
ElMessage.warning('复制失败,请手动选中复制')
}
}
function onFilePicked(ev: Event) {
const input = ev.target as HTMLInputElement
pickedFile.value = input.files?.[0] ?? null
}
async function doUpload() {
if (!pickedFile.value) return
uploading.value = true
try {
const res = await uploadLicense(token.value, pickedFile.value)
ElMessage.success(`导入成功:${res.status.message}`)
if (res.restart_required) {
ElMessage.warning(res.restart_hint)
}
pickedFile.value = null
if (fileInput.value) fileInput.value.value = ''
await fetchInfo()
} catch (e: any) {
ElMessage.error(`导入失败:${e.message}`)
} finally {
uploading.value = false
}
}
async function doReload() {
loading.value = true
try {
const res = await reloadLicense(token.value)
ElMessage.success(`已重读授权:${res.status.message}`)
await fetchInfo()
} catch (e: any) {
ElMessage.error(`重读失败:${e.message}`)
} finally {
loading.value = false
}
}
onMounted(() => {
if (token.value) fetchInfo()
})
</script>
<style scoped>
.license-container {
max-width: 1100px;
margin: 0 auto;
}
.toolbar-card,
.machine-card,
.detail-card {
border-radius: 12px;
margin-bottom: 16px;
border: 1px solid var(--cm-border);
background: var(--cm-bg-soft);
}
.card-header {
display: flex;
align-items: center;
color: var(--cm-text);
}
.login-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.count-tip {
margin-left: 12px;
color: var(--cm-text-muted);
font-size: 12px;
}
.machine-code {
font-family: 'JetBrains Mono', Consolas, monospace;
font-size: 26px;
letter-spacing: 2px;
color: var(--cm-text);
padding: 6px 0 10px;
user-select: all;
}
.hint {
color: var(--cm-text-muted);
font-size: 12px;
line-height: 1.7;
margin-top: 8px;
}
.copy-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.request-code {
max-width: 460px;
}
.source-detail {
margin-left: 8px;
color: var(--cm-text-muted);
font-size: 12px;
}
.upload-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.file-input {
display: none;
}
</style>
+54 -46
View File
@@ -256,6 +256,23 @@
:title="configAlertTitle"
style="margin-bottom: 14px"
/>
<el-alert
v-if="hasStructuredConfig"
type="success"
:closable="false"
show-icon
style="margin-bottom: 14px"
>
<template #title>
<span>
「{{ structuredConfigLabel }}」有结构化参数配置页(带字段说明与校验,按区块整节下发,
不会误清同级键),常用项建议在那边修改。
<el-button link type="primary" size="small" @click="goStructuredConfig">
打开参数配置 →
</el-button>
</span>
</template>
</el-alert>
<el-form label-position="top">
<el-form-item label="插件类型">
<el-tag effect="plain">{{ workerType || '-' }}</el-tag>
@@ -352,6 +369,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Monitor, Search } from '@element-plus/icons-vue'
import {
@@ -363,21 +381,25 @@ import {
scaleWorker,
listWorkerInstances,
scaleWorkerInstance,
login,
parseJwt,
TOKEN_KEY,
type WorkerInstance,
type WorkerInstanceGroup,
type AuthUser,
type ConfigMode,
type ConfigSource,
} from '@/api/workerAdmin'
import { useAdminAuth, restoreAuth } from '@/composables/useAdminAuth'
import { OWNER_PLUGINS, PLUGIN_LABELS } from '@/config/pluginSchema'
const token = ref(localStorage.getItem(TOKEN_KEY) || '')
const currentUser = ref<AuthUser | null>(null)
const username = ref('')
const password = ref('')
const logging = ref(false)
const router = useRouter()
// 登录态与参数配置页共用(同一 token),任一页登录后另一页无需重复登录
const {
token,
currentUser,
username,
password,
logging,
doLogin: authLogin,
logout: clearAuth,
} = useAdminAuth()
const loading = ref(false)
const workers = ref<WorkerInstance[]>([])
const selected = ref<WorkerInstance[]>([])
@@ -457,52 +479,39 @@ const configAlertTitle = computed(() => {
return '写入插件运行时配置 [workers.config](路由配置除外),下发并重启所有对应副本。'
})
// 这两个插件已有面向客户的结构化配置页:带字段说明与校验,且按区块整节下发,
// 不会因为改一个音量而清掉同级的 enabled/tts。原始编辑器保留作应急通道。
const hasStructuredConfig = computed(() => (OWNER_PLUGINS as readonly string[]).includes(workerType.value))
const structuredConfigLabel = computed(
() =>
PLUGIN_LABELS[workerType.value as keyof typeof PLUGIN_LABELS] ?? workerType.value,
)
const goStructuredConfig = () => {
configDialogVisible.value = false
router.push('/config')
}
const doLogin = async () => {
const name = username.value.trim()
if (!name || !password.value) {
ElMessage.warning('请输入用户名和密码')
const info = await authLogin()
if (info?.is_superuser) {
await fetchWorkers()
startPolling()
return
}
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
}
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) {
await fetchWorkers()
startPolling()
} else {
ElMessage.error('权限不足:仅超级管理员可管理 Worker,操作将被拒绝')
workers.value = []
selected.value = []
currentPage.value = 1
}
} catch (e: any) {
ElMessage.error(`登录失败:${e.message}`)
} finally {
logging.value = false
// 非超管:清空列表,避免停留在他人的数据上(登录失败时 currentUser 仍为空)
if (currentUser.value && !currentUser.value.is_superuser) {
workers.value = []
selected.value = []
currentPage.value = 1
}
}
const logout = () => {
stopPolling()
localStorage.removeItem(TOKEN_KEY)
token.value = ''
currentUser.value = null
clearAuth()
workers.value = []
selected.value = []
currentPage.value = 1
ElMessage.success('已退出登录')
}
// 自动轮询:让扩缩容/停止/重启/逐实例精细扩缩等操作的结果(副本关闭、模式、实例分组数)
@@ -898,9 +907,8 @@ const submitConfig = async () => {
}
onMounted(() => {
if (token.value.trim()) {
if (restoreAuth()) {
// 刷新/重新进入时从持久化 token 恢复身份(登录后 currentUser 只在此处与 doLogin 设置)
currentUser.value = parseJwt(token.value.trim())
fetchWorkers()
startPolling()
}