feat(channel-monitor): request templates with snapshot apply + headers/body override
Problem:
Upstream channels can reject monitor probes based on client fingerprint
(e.g. "only Claude Code clients allowed"). The monitor had no way to
customize the outgoing request to bypass such restrictions.
Solution:
Introduce reusable request templates that carry extra_headers plus an
optional body override; monitors reference a template and receive a
snapshot copy on apply. Template edits do NOT auto-propagate — users
must click "apply to associated monitors" to refresh snapshots, so a
bad template edit cannot instantly break all production monitors.
Data model (migration 112):
- channel_monitor_request_templates: id, name, provider, description,
extra_headers jsonb, body_override_mode ('off'|'merge'|'replace'),
body_override jsonb. Unique (provider, name).
- channel_monitors: +template_id (FK, ON DELETE SET NULL), +extra_headers,
+body_override_mode, +body_override (the three runtime snapshot fields).
Checker (channel_monitor_checker.go):
- callProvider + runCheckForModel accept a CheckOptions carrying the
snapshot fields. mergeHeaders applies user headers on top of adapter
defaults (forbidden list: Host / Content-Length / Transfer-Encoding /
Connection / Content-Encoding).
- buildRequestBody:
off -> adapter default body
merge -> shallow-merge over default; per-provider deny list
(model/messages/contents) protects the challenge contract
replace -> user body verbatim
- Replace mode skips challenge validation; instead HTTP 2xx + non-empty
extracted response text = operational, empty = failed.
- 4 new unit tests cover all three modes + replace/empty-response case.
Admin API:
- /admin/channel-monitor-templates CRUD + /:id/apply (overwrite snapshot
on all template_id=id monitors, returns affected count).
- channel_monitor request/response DTOs gain the 4 new fields.
Frontend:
- channelMonitorTemplate.ts API client.
- MonitorAdvancedRequestConfig.vue shared component for headers textarea
+ body mode radio + body JSON editor; used by both template and monitor
forms.
- MonitorTemplateManagerDialog.vue: provider tabs, list/create/edit/
delete/apply, live "associated monitors" count per row.
- MonitorFiltersBar: new 模板管理 button next to 新增监控.
- MonitorFormDialog: collapsible 高级 section with template dropdown
(filtered by form.provider, clears on provider change) + embedded
AdvancedRequestConfig. Picking a template copies its fields into the
form (snapshot semantics mirrored on the client).
- i18n zh/en entries for all new copy.
chore: bump version to 0.1.114.32
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Headers textarea -->
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.channelMonitor.advanced.headers') }}</label>
|
||||
<textarea
|
||||
v-model="headersText"
|
||||
rows="4"
|
||||
:placeholder="t('admin.channelMonitor.advanced.headersPlaceholder')"
|
||||
class="input font-mono text-xs"
|
||||
@blur="commitHeaders"
|
||||
/>
|
||||
<p v-if="headersError" class="mt-1 text-xs text-red-500">{{ headersError }}</p>
|
||||
<p v-else class="mt-1 text-xs text-gray-400">
|
||||
{{ t('admin.channelMonitor.advanced.headersHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Body mode radio -->
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.channelMonitor.advanced.bodyMode') }}</label>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<button
|
||||
v-for="opt in bodyModeOptions"
|
||||
:key="opt.value"
|
||||
type="button"
|
||||
class="rounded-lg border-2 px-3 py-2 text-sm font-medium transition-colors"
|
||||
:class="bodyModeButtonClass(opt.value)"
|
||||
@click="updateBodyMode(opt.value)"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-400">
|
||||
{{ bodyModeHint }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Body JSON (仅当 mode != off) -->
|
||||
<div v-if="bodyOverrideMode !== 'off'">
|
||||
<label class="input-label">{{ t('admin.channelMonitor.advanced.bodyJson') }}</label>
|
||||
<textarea
|
||||
v-model="bodyText"
|
||||
rows="8"
|
||||
:placeholder="bodyPlaceholder"
|
||||
class="input font-mono text-xs"
|
||||
@blur="commitBody"
|
||||
/>
|
||||
<p v-if="bodyError" class="mt-1 text-xs text-red-500">{{ bodyError }}</p>
|
||||
<p v-else class="mt-1 text-xs text-gray-400">
|
||||
{{ t('admin.channelMonitor.advanced.bodyJsonHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { BodyOverrideMode } from '@/api/admin/channelMonitor'
|
||||
|
||||
const props = defineProps<{
|
||||
extraHeaders: Record<string, string>
|
||||
bodyOverrideMode: BodyOverrideMode
|
||||
bodyOverride: Record<string, unknown> | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:extraHeaders', value: Record<string, string>): void
|
||||
(e: 'update:bodyOverrideMode', value: BodyOverrideMode): void
|
||||
(e: 'update:bodyOverride', value: Record<string, unknown> | null): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// ---- Headers textarea (Key: Value per line) ----
|
||||
const headersText = ref(serializeHeaders(props.extraHeaders))
|
||||
const headersError = ref('')
|
||||
|
||||
watch(
|
||||
() => props.extraHeaders,
|
||||
(v) => {
|
||||
// 外部重置时(如切换平台 / 应用模板)同步文本
|
||||
headersText.value = serializeHeaders(v)
|
||||
headersError.value = ''
|
||||
},
|
||||
)
|
||||
|
||||
function commitHeaders() {
|
||||
const parsed = parseHeaders(headersText.value)
|
||||
if (parsed.error) {
|
||||
headersError.value = parsed.error
|
||||
return
|
||||
}
|
||||
headersError.value = ''
|
||||
emit('update:extraHeaders', parsed.headers)
|
||||
}
|
||||
|
||||
function serializeHeaders(h: Record<string, string>): string {
|
||||
return Object.entries(h || {})
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function parseHeaders(raw: string): { headers: Record<string, string>; error: string } {
|
||||
const result: Record<string, string> = {}
|
||||
const lines = raw.split(/\r?\n/).map((l) => l.trim()).filter(Boolean)
|
||||
for (const line of lines) {
|
||||
const idx = line.indexOf(':')
|
||||
if (idx <= 0) {
|
||||
return { headers: {}, error: t('admin.channelMonitor.advanced.headersParseError', { line }) }
|
||||
}
|
||||
const key = line.slice(0, idx).trim()
|
||||
const value = line.slice(idx + 1).trim()
|
||||
if (!key) {
|
||||
return { headers: {}, error: t('admin.channelMonitor.advanced.headersParseError', { line }) }
|
||||
}
|
||||
result[key] = value
|
||||
}
|
||||
return { headers: result, error: '' }
|
||||
}
|
||||
|
||||
// ---- Body mode + JSON ----
|
||||
const bodyText = ref(serializeBody(props.bodyOverride))
|
||||
const bodyError = ref('')
|
||||
|
||||
watch(
|
||||
() => props.bodyOverride,
|
||||
(v) => {
|
||||
bodyText.value = serializeBody(v)
|
||||
bodyError.value = ''
|
||||
},
|
||||
)
|
||||
|
||||
function commitBody() {
|
||||
if (props.bodyOverrideMode === 'off') {
|
||||
return
|
||||
}
|
||||
const trimmed = bodyText.value.trim()
|
||||
if (trimmed === '') {
|
||||
emit('update:bodyOverride', null)
|
||||
bodyError.value = ''
|
||||
return
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed)
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
bodyError.value = t('admin.channelMonitor.advanced.bodyJsonObjectError')
|
||||
return
|
||||
}
|
||||
emit('update:bodyOverride', parsed as Record<string, unknown>)
|
||||
bodyError.value = ''
|
||||
} catch (e) {
|
||||
bodyError.value =
|
||||
t('admin.channelMonitor.advanced.bodyJsonError') +
|
||||
': ' +
|
||||
(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}
|
||||
|
||||
function serializeBody(body: Record<string, unknown> | null): string {
|
||||
if (!body || Object.keys(body).length === 0) return ''
|
||||
return JSON.stringify(body, null, 2)
|
||||
}
|
||||
|
||||
function updateBodyMode(mode: BodyOverrideMode) {
|
||||
emit('update:bodyOverrideMode', mode)
|
||||
// 切换到 off 时清掉 body(提示用户)
|
||||
if (mode === 'off') {
|
||||
emit('update:bodyOverride', null)
|
||||
}
|
||||
}
|
||||
|
||||
const bodyModeOptions = computed<{ value: BodyOverrideMode; label: string }[]>(() => [
|
||||
{ value: 'off', label: t('admin.channelMonitor.advanced.bodyModeOff') },
|
||||
{ value: 'merge', label: t('admin.channelMonitor.advanced.bodyModeMerge') },
|
||||
{ value: 'replace', label: t('admin.channelMonitor.advanced.bodyModeReplace') },
|
||||
])
|
||||
|
||||
function bodyModeButtonClass(mode: BodyOverrideMode): string {
|
||||
const active = props.bodyOverrideMode === mode
|
||||
if (active) {
|
||||
return 'border-primary-500 bg-primary-50 text-primary-700 dark:bg-primary-500/15 dark:text-primary-300 dark:border-primary-400'
|
||||
}
|
||||
return 'border-gray-200 bg-white text-gray-600 hover:border-primary-300 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-400'
|
||||
}
|
||||
|
||||
const bodyModeHint = computed(() => {
|
||||
switch (props.bodyOverrideMode) {
|
||||
case 'merge':
|
||||
return t('admin.channelMonitor.advanced.bodyModeHintMerge')
|
||||
case 'replace':
|
||||
return t('admin.channelMonitor.advanced.bodyModeHintReplace')
|
||||
default:
|
||||
return t('admin.channelMonitor.advanced.bodyModeHintOff')
|
||||
}
|
||||
})
|
||||
|
||||
const bodyPlaceholder = computed(() => {
|
||||
if (props.bodyOverrideMode === 'merge') {
|
||||
return '{\n "system": "You are Claude Code..."\n}'
|
||||
}
|
||||
return '{\n "model": "claude-x",\n "messages": [{"role":"user","content":"hi"}],\n "max_tokens": 10\n}'
|
||||
})
|
||||
</script>
|
||||
@@ -44,6 +44,14 @@
|
||||
>
|
||||
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
|
||||
</button>
|
||||
<button
|
||||
@click="$emit('manage-templates')"
|
||||
class="btn btn-secondary"
|
||||
:title="t('admin.channelMonitor.template.manageButton')"
|
||||
>
|
||||
<Icon name="cog" size="md" class="mr-2" />
|
||||
{{ t('admin.channelMonitor.template.manageButton') }}
|
||||
</button>
|
||||
<button @click="$emit('create')" class="btn btn-primary">
|
||||
<Icon name="plus" size="md" class="mr-2" />
|
||||
{{ t('admin.channelMonitor.createButton') }}
|
||||
@@ -71,6 +79,7 @@ defineProps<{
|
||||
defineEmits<{
|
||||
(e: 'reload'): void
|
||||
(e: 'create'): void
|
||||
(e: 'manage-templates'): void
|
||||
(e: 'search-input'): void
|
||||
}>()
|
||||
|
||||
|
||||
@@ -95,6 +95,35 @@
|
||||
<label class="input-label mb-0">{{ t('admin.channelMonitor.form.enabled') }}</label>
|
||||
<Toggle v-model="form.enabled" />
|
||||
</div>
|
||||
|
||||
<!-- 高级设置区:请求模板 + 自定义 headers/body -->
|
||||
<details class="rounded-lg border border-gray-200 bg-gray-50/50 p-3 dark:border-dark-700 dark:bg-dark-900/30">
|
||||
<summary class="cursor-pointer text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.channelMonitor.advanced.section') }}
|
||||
</summary>
|
||||
<p class="mt-1 text-xs text-gray-400">{{ t('admin.channelMonitor.advanced.sectionHint') }}</p>
|
||||
|
||||
<div class="mt-4 space-y-4">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.channelMonitor.templateField.label') }}</label>
|
||||
<Select
|
||||
v-model="templateSelectValue"
|
||||
:options="templateOptions"
|
||||
:placeholder="t('admin.channelMonitor.templateField.placeholder')"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-400">{{ t('admin.channelMonitor.templateField.applyHint') }}</p>
|
||||
</div>
|
||||
|
||||
<MonitorAdvancedRequestConfig
|
||||
:extra-headers="form.extra_headers"
|
||||
:body-override-mode="form.body_override_mode"
|
||||
:body-override="form.body_override"
|
||||
@update:extra-headers="form.extra_headers = $event"
|
||||
@update:body-override-mode="form.body_override_mode = $event"
|
||||
@update:body-override="form.body_override = $event"
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
@@ -136,17 +165,21 @@ import { adminAPI } from '@/api/admin'
|
||||
import { keysAPI } from '@/api/keys'
|
||||
import { userGroupsAPI } from '@/api/groups'
|
||||
import type {
|
||||
BodyOverrideMode,
|
||||
ChannelMonitor,
|
||||
CreateParams,
|
||||
Provider,
|
||||
UpdateParams,
|
||||
} from '@/api/admin/channelMonitor'
|
||||
import type { ChannelMonitorTemplate } from '@/api/admin/channelMonitorTemplate'
|
||||
import type { ApiKey } from '@/types'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import Toggle from '@/components/common/Toggle.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import ModelTagInput from '@/components/admin/channel/ModelTagInput.vue'
|
||||
import { getPlatformTextClass } from '@/components/admin/channel/types'
|
||||
import MonitorKeyPickerDialog from '@/components/admin/monitor/MonitorKeyPickerDialog.vue'
|
||||
import MonitorAdvancedRequestConfig from '@/components/admin/monitor/MonitorAdvancedRequestConfig.vue'
|
||||
import ProviderIcon from '@/components/user/monitor/ProviderIcon.vue'
|
||||
import { useChannelMonitorFormat } from '@/composables/useChannelMonitorFormat'
|
||||
import {
|
||||
@@ -198,11 +231,16 @@ interface MonitorForm {
|
||||
group_name: string
|
||||
interval_seconds: number
|
||||
enabled: boolean
|
||||
// 高级设置快照
|
||||
template_id: number | null
|
||||
extra_headers: Record<string, string>
|
||||
body_override_mode: BodyOverrideMode
|
||||
body_override: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
const form = reactive<MonitorForm>({
|
||||
name: '',
|
||||
provider: PROVIDER_OPENAI,
|
||||
provider: PROVIDER_ANTHROPIC,
|
||||
endpoint: '',
|
||||
api_key: '',
|
||||
primary_model: '',
|
||||
@@ -210,6 +248,57 @@ const form = reactive<MonitorForm>({
|
||||
group_name: '',
|
||||
interval_seconds: systemDefaultInterval.value,
|
||||
enabled: true,
|
||||
template_id: null,
|
||||
extra_headers: {},
|
||||
body_override_mode: 'off',
|
||||
body_override: null,
|
||||
})
|
||||
|
||||
// 可用模板列表(进入 dialog 时一次性拉取 cache;按 provider 过滤)。
|
||||
const templatesCache = ref<ChannelMonitorTemplate[]>([])
|
||||
const templatesLoading = ref(false)
|
||||
|
||||
const templateOptions = computed(() => {
|
||||
const items = templatesCache.value.filter((t) => t.provider === form.provider)
|
||||
return [
|
||||
{ value: '', label: t('admin.channelMonitor.templateField.none') },
|
||||
...items.map((t) => ({ value: String(t.id), label: t.name })),
|
||||
]
|
||||
})
|
||||
|
||||
async function loadTemplates() {
|
||||
if (templatesCache.value.length > 0) return
|
||||
templatesLoading.value = true
|
||||
try {
|
||||
const { items } = await adminAPI.channelMonitorTemplate.list()
|
||||
templatesCache.value = items
|
||||
} catch (err: unknown) {
|
||||
// 模板拉取失败不阻塞监控表单,用户可以不选模板
|
||||
console.warn('load monitor templates failed', err)
|
||||
} finally {
|
||||
templatesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 模板下拉绑定:value 是 string(Select 组件约束),需要与 number | null 互转。
|
||||
const templateSelectValue = computed<string>({
|
||||
get: () => (form.template_id == null ? '' : String(form.template_id)),
|
||||
set: (raw: string) => {
|
||||
if (raw === '') {
|
||||
form.template_id = null
|
||||
return
|
||||
}
|
||||
const id = Number(raw)
|
||||
if (!Number.isFinite(id)) return
|
||||
form.template_id = id
|
||||
// 应用模板 = 拷贝快照
|
||||
const tpl = templatesCache.value.find((t) => t.id === id)
|
||||
if (tpl) {
|
||||
form.extra_headers = { ...(tpl.extra_headers || {}) }
|
||||
form.body_override_mode = tpl.body_override_mode
|
||||
form.body_override = tpl.body_override ? { ...tpl.body_override } : null
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
interface ProviderOption {
|
||||
@@ -218,8 +307,8 @@ interface ProviderOption {
|
||||
}
|
||||
|
||||
const providerOptions = computed<ProviderOption[]>(() => [
|
||||
{ value: PROVIDER_OPENAI, label: t('monitorCommon.providers.openai') },
|
||||
{ value: PROVIDER_ANTHROPIC, label: t('monitorCommon.providers.anthropic') },
|
||||
{ value: PROVIDER_OPENAI, label: t('monitorCommon.providers.openai') },
|
||||
{ value: PROVIDER_GEMINI, label: t('monitorCommon.providers.gemini') },
|
||||
])
|
||||
|
||||
@@ -227,13 +316,15 @@ const providerOptions = computed<ProviderOption[]>(() => [
|
||||
// Editing mode loads api_key='' via loadFromMonitor and only sets it on user
|
||||
// typing, so clearing on provider change is always a safe no-op until the user
|
||||
// picks a new key.
|
||||
// 同时清空 template_id(模板有 provider 归属,跨平台不通用)。
|
||||
watch(() => form.provider, () => {
|
||||
form.api_key = ''
|
||||
form.template_id = null
|
||||
})
|
||||
|
||||
function resetForm() {
|
||||
form.name = ''
|
||||
form.provider = PROVIDER_OPENAI
|
||||
form.provider = PROVIDER_ANTHROPIC
|
||||
form.endpoint = ''
|
||||
form.api_key = ''
|
||||
form.primary_model = ''
|
||||
@@ -241,6 +332,10 @@ function resetForm() {
|
||||
form.group_name = ''
|
||||
form.interval_seconds = systemDefaultInterval.value
|
||||
form.enabled = true
|
||||
form.template_id = null
|
||||
form.extra_headers = {}
|
||||
form.body_override_mode = 'off'
|
||||
form.body_override = null
|
||||
}
|
||||
|
||||
function loadFromMonitor(m: ChannelMonitor) {
|
||||
@@ -253,13 +348,19 @@ function loadFromMonitor(m: ChannelMonitor) {
|
||||
form.group_name = m.group_name || ''
|
||||
form.interval_seconds = m.interval_seconds || systemDefaultInterval.value
|
||||
form.enabled = m.enabled
|
||||
form.template_id = m.template_id ?? null
|
||||
form.extra_headers = { ...(m.extra_headers || {}) }
|
||||
form.body_override_mode = m.body_override_mode || 'off'
|
||||
form.body_override = m.body_override ? { ...m.body_override } : null
|
||||
}
|
||||
|
||||
// Re-sync form whenever the dialog is opened or the target monitor changes.
|
||||
// 同时拉取模板列表(cache 过的话一次性返回)。
|
||||
watch(
|
||||
() => [props.show, props.monitor] as const,
|
||||
([show, m]) => {
|
||||
if (!show) return
|
||||
void loadTemplates()
|
||||
if (m) loadFromMonitor(m)
|
||||
else resetForm()
|
||||
},
|
||||
@@ -310,6 +411,10 @@ function buildPayload(): CreateParams {
|
||||
group_name: form.group_name.trim(),
|
||||
enabled: form.enabled,
|
||||
interval_seconds: form.interval_seconds,
|
||||
template_id: form.template_id,
|
||||
extra_headers: form.extra_headers,
|
||||
body_override_mode: form.body_override_mode,
|
||||
body_override: form.body_override,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,9 +434,14 @@ async function handleSubmit() {
|
||||
const target = editing.value
|
||||
if (target) {
|
||||
const { api_key, ...rest } = buildPayload()
|
||||
const req: UpdateParams = rest
|
||||
const req: UpdateParams = { ...rest }
|
||||
// Only send api_key if user typed a new value
|
||||
if (api_key) req.api_key = api_key
|
||||
// template_id=null 用 clear_template=true 明确告诉后端清空(pointer 语义)
|
||||
if (form.template_id == null) {
|
||||
req.clear_template = true
|
||||
delete req.template_id
|
||||
}
|
||||
await adminAPI.channelMonitor.update(target.id, req)
|
||||
appStore.showSuccess(t('admin.channelMonitor.updateSuccess'))
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
:show="show"
|
||||
:title="t('admin.channelMonitor.template.managerTitle')"
|
||||
width="wide"
|
||||
@close="$emit('close')"
|
||||
>
|
||||
<!-- provider tabs -->
|
||||
<div class="mb-4 border-b border-gray-200 dark:border-dark-700">
|
||||
<div role="tablist" class="flex gap-1">
|
||||
<button
|
||||
v-for="tab in providerTabs"
|
||||
:key="tab.value"
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="activeProvider === tab.value"
|
||||
class="px-4 py-2 text-sm font-medium transition-colors"
|
||||
:class="tabClass(tab.value)"
|
||||
@click="activeProvider = tab.value"
|
||||
>
|
||||
{{ tab.label }}
|
||||
<span
|
||||
v-if="countByProvider[tab.value] > 0"
|
||||
class="ml-1.5 rounded-full bg-gray-100 px-2 py-0.5 text-xs dark:bg-dark-700"
|
||||
>
|
||||
{{ countByProvider[tab.value] }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- active provider list -->
|
||||
<div v-if="!editing" class="space-y-2">
|
||||
<div class="flex justify-end">
|
||||
<button class="btn btn-primary btn-sm" @click="openCreateForm">
|
||||
<Icon name="plus" size="sm" class="mr-1" />
|
||||
{{ t('admin.channelMonitor.template.createButton') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="py-8 text-center text-sm text-gray-400">
|
||||
{{ t('common.loading') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="templatesForActiveProvider.length === 0"
|
||||
class="py-8 text-center text-sm text-gray-400"
|
||||
>
|
||||
{{ t('admin.channelMonitor.template.emptyState') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="tpl in templatesForActiveProvider"
|
||||
v-else
|
||||
:key="tpl.id"
|
||||
class="rounded-lg border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-800"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ tpl.name }}</span>
|
||||
<span
|
||||
class="inline-flex items-center rounded-md px-1.5 py-0.5 text-xs"
|
||||
:class="modeBadgeClass(tpl.body_override_mode)"
|
||||
>
|
||||
{{ modeLabel(tpl.body_override_mode) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="tpl.associated_monitors > 0"
|
||||
class="text-xs text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{{ t('admin.channelMonitor.template.associatedCount', { n: tpl.associated_monitors }) }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="tpl.description" class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ tpl.description }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-400">
|
||||
{{ t('admin.channelMonitor.template.headersSummary', {
|
||||
n: Object.keys(tpl.extra_headers || {}).length,
|
||||
}) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-shrink-0 gap-2">
|
||||
<button
|
||||
class="btn btn-secondary btn-sm"
|
||||
:disabled="tpl.associated_monitors === 0"
|
||||
:title="t('admin.channelMonitor.template.applyTooltip')"
|
||||
@click="confirmApply(tpl)"
|
||||
>
|
||||
<Icon name="refresh" size="sm" class="mr-1" />
|
||||
{{ t('admin.channelMonitor.template.applyButton') }}
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm" @click="openEditForm(tpl)">
|
||||
{{ t('common.edit') }}
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm text-red-600" @click="handleDelete(tpl)">
|
||||
{{ t('common.delete') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- edit / create form -->
|
||||
<div v-else class="space-y-4">
|
||||
<div>
|
||||
<label class="input-label">
|
||||
{{ t('admin.channelMonitor.template.form.name') }}
|
||||
<span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
required
|
||||
class="input"
|
||||
:placeholder="t('admin.channelMonitor.template.form.namePlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="editing === 'new'">
|
||||
<label class="input-label">
|
||||
{{ t('admin.channelMonitor.form.provider') }}
|
||||
<span class="text-red-500">*</span>
|
||||
</label>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<button
|
||||
v-for="opt in providerTabs"
|
||||
:key="opt.value"
|
||||
type="button"
|
||||
class="rounded-lg border-2 px-3 py-2 text-sm font-medium transition-colors"
|
||||
:class="providerPickerClass(opt.value, form.provider === opt.value)"
|
||||
@click="form.provider = opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">
|
||||
{{ t('admin.channelMonitor.template.form.description') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.description"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="t('admin.channelMonitor.template.form.descriptionPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MonitorAdvancedRequestConfig
|
||||
:extra-headers="form.extra_headers"
|
||||
:body-override-mode="form.body_override_mode"
|
||||
:body-override="form.body_override"
|
||||
@update:extra-headers="form.extra_headers = $event"
|
||||
@update:body-override-mode="form.body_override_mode = $event"
|
||||
@update:body-override="form.body_override = $event"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex w-full items-center justify-between">
|
||||
<!-- Left: back to list / nothing -->
|
||||
<div>
|
||||
<button v-if="editing" class="btn btn-secondary" @click="backToList">
|
||||
{{ t('common.back') }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- Right: save or close -->
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-secondary" @click="$emit('close')">
|
||||
{{ t('common.close') }}
|
||||
</button>
|
||||
<button v-if="editing" class="btn btn-primary" :disabled="submitting" @click="handleSubmit">
|
||||
{{ submitting ? t('common.submitting') : editing === 'new' ? t('common.create') : t('common.update') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
:show="confirmApply_.show"
|
||||
:title="t('admin.channelMonitor.template.applyTitle')"
|
||||
:message="confirmApplyMessage"
|
||||
:confirm-text="t('admin.channelMonitor.template.applyConfirm')"
|
||||
:cancel-text="t('common.cancel')"
|
||||
@confirm="doApply"
|
||||
@cancel="confirmApply_.show = false"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
:show="confirmDelete.show"
|
||||
:title="t('common.delete')"
|
||||
:message="confirmDeleteMessage"
|
||||
:confirm-text="t('common.delete')"
|
||||
:cancel-text="t('common.cancel')"
|
||||
:danger="true"
|
||||
@confirm="doDelete"
|
||||
@cancel="confirmDelete.show = false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type {
|
||||
BodyOverrideMode,
|
||||
Provider,
|
||||
} from '@/api/admin/channelMonitor'
|
||||
import type { ChannelMonitorTemplate } from '@/api/admin/channelMonitorTemplate'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import MonitorAdvancedRequestConfig from '@/components/admin/monitor/MonitorAdvancedRequestConfig.vue'
|
||||
import { useChannelMonitorFormat } from '@/composables/useChannelMonitorFormat'
|
||||
import {
|
||||
PROVIDER_ANTHROPIC,
|
||||
PROVIDER_OPENAI,
|
||||
PROVIDER_GEMINI,
|
||||
} from '@/constants/channelMonitor'
|
||||
|
||||
const props = defineProps<{ show: boolean }>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
/** Fired when any template changed (create / update / delete / apply). */
|
||||
(e: 'updated'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
const { providerPickerClass } = useChannelMonitorFormat()
|
||||
|
||||
const providerTabs = computed<{ value: Provider; label: string }[]>(() => [
|
||||
{ value: PROVIDER_ANTHROPIC, label: t('monitorCommon.providers.anthropic') },
|
||||
{ value: PROVIDER_OPENAI, label: t('monitorCommon.providers.openai') },
|
||||
{ value: PROVIDER_GEMINI, label: t('monitorCommon.providers.gemini') },
|
||||
])
|
||||
|
||||
const activeProvider = ref<Provider>(PROVIDER_ANTHROPIC)
|
||||
const templates = ref<ChannelMonitorTemplate[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
const templatesForActiveProvider = computed(() =>
|
||||
templates.value.filter((t) => t.provider === activeProvider.value),
|
||||
)
|
||||
|
||||
const countByProvider = computed<Record<Provider, number>>(() => {
|
||||
const out: Record<Provider, number> = {
|
||||
anthropic: 0,
|
||||
openai: 0,
|
||||
gemini: 0,
|
||||
}
|
||||
for (const t of templates.value) out[t.provider]++
|
||||
return out
|
||||
})
|
||||
|
||||
// --- form state ---
|
||||
interface TemplateForm {
|
||||
id: number | null
|
||||
name: string
|
||||
provider: Provider
|
||||
description: string
|
||||
extra_headers: Record<string, string>
|
||||
body_override_mode: BodyOverrideMode
|
||||
body_override: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
const editing = ref<null | 'new' | number>(null) // null = list view; 'new' = create; <id> = edit
|
||||
const submitting = ref(false)
|
||||
const form = reactive<TemplateForm>(emptyForm(PROVIDER_ANTHROPIC))
|
||||
|
||||
function emptyForm(provider: Provider): TemplateForm {
|
||||
return {
|
||||
id: null,
|
||||
name: '',
|
||||
provider,
|
||||
description: '',
|
||||
extra_headers: {},
|
||||
body_override_mode: 'off',
|
||||
body_override: null,
|
||||
}
|
||||
}
|
||||
|
||||
function loadForm(tpl: ChannelMonitorTemplate) {
|
||||
form.id = tpl.id
|
||||
form.name = tpl.name
|
||||
form.provider = tpl.provider
|
||||
form.description = tpl.description
|
||||
form.extra_headers = { ...(tpl.extra_headers || {}) }
|
||||
form.body_override_mode = tpl.body_override_mode
|
||||
form.body_override = tpl.body_override ? { ...tpl.body_override } : null
|
||||
}
|
||||
|
||||
function openCreateForm() {
|
||||
Object.assign(form, emptyForm(activeProvider.value))
|
||||
editing.value = 'new'
|
||||
}
|
||||
|
||||
function openEditForm(tpl: ChannelMonitorTemplate) {
|
||||
loadForm(tpl)
|
||||
editing.value = tpl.id
|
||||
}
|
||||
|
||||
function backToList() {
|
||||
editing.value = null
|
||||
}
|
||||
|
||||
// --- data fetch ---
|
||||
async function fetchTemplates() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { items } = await adminAPI.channelMonitorTemplate.list()
|
||||
templates.value = items
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(show) => {
|
||||
if (show) {
|
||||
editing.value = null
|
||||
fetchTemplates()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// --- submit ---
|
||||
async function handleSubmit() {
|
||||
if (submitting.value) return
|
||||
if (!form.name.trim()) {
|
||||
appStore.showError(t('admin.channelMonitor.template.missingName'))
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
if (editing.value === 'new') {
|
||||
await adminAPI.channelMonitorTemplate.create({
|
||||
name: form.name.trim(),
|
||||
provider: form.provider,
|
||||
description: form.description.trim(),
|
||||
extra_headers: form.extra_headers,
|
||||
body_override_mode: form.body_override_mode,
|
||||
body_override: form.body_override,
|
||||
})
|
||||
appStore.showSuccess(t('admin.channelMonitor.template.createSuccess'))
|
||||
} else if (typeof editing.value === 'number') {
|
||||
await adminAPI.channelMonitorTemplate.update(editing.value, {
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim(),
|
||||
extra_headers: form.extra_headers,
|
||||
body_override_mode: form.body_override_mode,
|
||||
body_override: form.body_override,
|
||||
})
|
||||
appStore.showSuccess(t('admin.channelMonitor.template.updateSuccess'))
|
||||
}
|
||||
await fetchTemplates()
|
||||
emit('updated')
|
||||
editing.value = null
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- apply to monitors ---
|
||||
const confirmApply_ = reactive<{ show: boolean; tpl: ChannelMonitorTemplate | null }>({
|
||||
show: false,
|
||||
tpl: null,
|
||||
})
|
||||
|
||||
function confirmApply(tpl: ChannelMonitorTemplate) {
|
||||
confirmApply_.tpl = tpl
|
||||
confirmApply_.show = true
|
||||
}
|
||||
|
||||
const confirmApplyMessage = computed(() => {
|
||||
const tpl = confirmApply_.tpl
|
||||
if (!tpl) return ''
|
||||
return t('admin.channelMonitor.template.applyConfirmMessage', {
|
||||
name: tpl.name,
|
||||
n: tpl.associated_monitors,
|
||||
})
|
||||
})
|
||||
|
||||
async function doApply() {
|
||||
const tpl = confirmApply_.tpl
|
||||
confirmApply_.show = false
|
||||
if (!tpl) return
|
||||
try {
|
||||
const { affected } = await adminAPI.channelMonitorTemplate.apply(tpl.id)
|
||||
appStore.showSuccess(t('admin.channelMonitor.template.applySuccess', { n: affected }))
|
||||
await fetchTemplates()
|
||||
emit('updated')
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||
}
|
||||
}
|
||||
|
||||
// --- delete ---
|
||||
const confirmDelete = reactive<{ show: boolean; tpl: ChannelMonitorTemplate | null }>({
|
||||
show: false,
|
||||
tpl: null,
|
||||
})
|
||||
|
||||
function handleDelete(tpl: ChannelMonitorTemplate) {
|
||||
confirmDelete.tpl = tpl
|
||||
confirmDelete.show = true
|
||||
}
|
||||
|
||||
const confirmDeleteMessage = computed(() => {
|
||||
const tpl = confirmDelete.tpl
|
||||
if (!tpl) return ''
|
||||
return t('admin.channelMonitor.template.deleteConfirm', {
|
||||
name: tpl.name,
|
||||
n: tpl.associated_monitors,
|
||||
})
|
||||
})
|
||||
|
||||
async function doDelete() {
|
||||
const tpl = confirmDelete.tpl
|
||||
confirmDelete.show = false
|
||||
if (!tpl) return
|
||||
try {
|
||||
await adminAPI.channelMonitorTemplate.del(tpl.id)
|
||||
appStore.showSuccess(t('admin.channelMonitor.template.deleteSuccess'))
|
||||
await fetchTemplates()
|
||||
emit('updated')
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||
}
|
||||
}
|
||||
|
||||
// --- misc ---
|
||||
function tabClass(value: Provider): string {
|
||||
return activeProvider.value === value
|
||||
? 'border-b-2 border-primary-500 text-primary-600 dark:text-primary-400'
|
||||
: 'border-b-2 border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
||||
}
|
||||
|
||||
function modeBadgeClass(mode: BodyOverrideMode): string {
|
||||
switch (mode) {
|
||||
case 'merge':
|
||||
return 'bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300'
|
||||
case 'replace':
|
||||
return 'bg-purple-100 text-purple-700 dark:bg-purple-500/15 dark:text-purple-300'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-600 dark:bg-dark-700 dark:text-gray-300'
|
||||
}
|
||||
}
|
||||
|
||||
function modeLabel(mode: BodyOverrideMode): string {
|
||||
return t(`admin.channelMonitor.advanced.bodyMode${mode.charAt(0).toUpperCase()}${mode.slice(1)}`)
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user