feat(channel-monitor): apply template via subset picker; CC 2.1.114 baseline doc

Apply flow:
- POST /admin/channel-monitor-templates/:id/apply now requires monitor_ids
  (non-empty array). Service applies the template only to the selected
  subset, gated by AND template_id = :id (so users can't sneak in
  unrelated monitor IDs).
- New GET /admin/channel-monitor-templates/:id/monitors returns the
  associated monitor briefs (id/name/provider/enabled) for the picker.
- ApplyToMonitors signature gains monitorIDs []int64; empty list returns
  ErrChannelMonitorTemplateApplyEmpty.

Frontend:
- New MonitorTemplateApplyPickerDialog.vue: list of associated monitors
  with checkboxes (default all checked), 全选 / 全不选 shortcuts, live
  selected/total count. Submit calls apply(id, ids).
- MonitorTemplateManagerDialog replaces the old ConfirmDialog flow with
  the picker; onApplied refetches the list to refresh associated counts.

i18n: applyPicker* + common.selectAll keys.

chore: bump version to 0.1.114.33

The CC 2.1.114 (sdk-cli) UA / APIKeyBetaHeader / JSON metadata.user_id
baseline (already verified working via the in-process apply on prod
template id=1) is documented in internal/pkg/claude/constants.go and
is what the seed template in the manager UI should follow.
This commit is contained in:
erio
2026-04-21 14:39:19 +08:00
parent a296425994
commit 6925ac25c4
10 changed files with 341 additions and 51 deletions

View File

@@ -51,6 +51,17 @@ export interface ApplyResponse {
affected: number
}
export interface AssociatedMonitorBrief {
id: number
name: string
provider: Provider
enabled: boolean
}
export interface AssociatedMonitorsResponse {
items: AssociatedMonitorBrief[]
}
export async function list(params: ListParams = {}): Promise<ListResponse> {
const { data } = await apiClient.get<ListResponse>('/admin/channel-monitor-templates', {
params,
@@ -86,12 +97,24 @@ export async function del(id: number): Promise<void> {
}
/**
* Apply the template to all associated monitors (overwrite snapshot fields).
* Returns count of affected monitors.
* Apply the template to the specified associated monitors (overwrite snapshot fields).
* monitorIds must be a non-empty subset of the template's associated monitors.
* Returns count of actually affected monitors.
*/
export async function apply(id: number): Promise<ApplyResponse> {
export async function apply(id: number, monitorIds: number[]): Promise<ApplyResponse> {
const { data } = await apiClient.post<ApplyResponse>(
`/admin/channel-monitor-templates/${id}/apply`,
{ monitor_ids: monitorIds },
)
return data
}
/**
* List monitors currently associated to this template (used by apply picker).
*/
export async function listAssociatedMonitors(id: number): Promise<AssociatedMonitorsResponse> {
const { data } = await apiClient.get<AssociatedMonitorsResponse>(
`/admin/channel-monitor-templates/${id}/monitors`,
)
return data
}
@@ -103,6 +126,7 @@ export const channelMonitorTemplateAPI = {
update,
del,
apply,
listAssociatedMonitors,
}
export default channelMonitorTemplateAPI

View File

@@ -0,0 +1,174 @@
<template>
<BaseDialog
:show="show"
:title="t('admin.channelMonitor.template.applyPickerTitle', { name: templateName })"
@close="$emit('close')"
>
<p class="mb-3 text-sm text-gray-600 dark:text-gray-400">
{{ t('admin.channelMonitor.template.applyPickerHint') }}
</p>
<div v-if="loading" class="py-6 text-center text-sm text-gray-400">
{{ t('common.loading') }}
</div>
<div v-else-if="monitors.length === 0" class="py-6 text-center text-sm text-gray-400">
{{ t('admin.channelMonitor.template.applyPickerEmpty') }}
</div>
<div v-else>
<!-- 全选/全不选 -->
<div class="mb-2 flex items-center gap-3 text-xs">
<button
type="button"
class="text-primary-600 hover:underline dark:text-primary-400"
@click="selectAll"
>
{{ t('common.selectAll') }}
</button>
<button
type="button"
class="text-gray-500 hover:underline dark:text-gray-400"
@click="selectNone"
>
{{ t('admin.channelMonitor.template.selectNone') }}
</button>
<span class="ml-auto text-gray-500 dark:text-gray-400">
{{ t('admin.channelMonitor.template.selectedCount', {
n: selectedIds.length,
total: monitors.length,
}) }}
</span>
</div>
<ul class="max-h-80 divide-y divide-gray-100 overflow-y-auto rounded-lg border border-gray-200 dark:divide-dark-700 dark:border-dark-700">
<li
v-for="m in monitors"
:key="m.id"
class="flex cursor-pointer items-center gap-3 px-3 py-2 hover:bg-gray-50 dark:hover:bg-dark-800"
@click="toggle(m.id)"
>
<input
type="checkbox"
:checked="selectedSet.has(m.id)"
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
@click.stop="toggle(m.id)"
/>
<span class="font-medium text-gray-900 dark:text-white">{{ m.name }}</span>
<span class="text-xs text-gray-400">{{ m.provider }}</span>
<span
v-if="!m.enabled"
class="ml-auto rounded bg-gray-100 px-1.5 py-0.5 text-xs text-gray-500 dark:bg-dark-700 dark:text-gray-400"
>
{{ t('admin.channelMonitor.onlyDisabled').replace(/^仅|^Only /, '') }}
</span>
</li>
</ul>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<button class="btn btn-secondary" @click="$emit('close')">
{{ t('common.cancel') }}
</button>
<button
class="btn btn-primary"
:disabled="submitting || selectedIds.length === 0"
@click="handleApply"
>
{{ submitting
? t('common.submitting')
: t('admin.channelMonitor.template.applyPickerConfirm', { n: selectedIds.length }) }}
</button>
</div>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { computed, 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 { AssociatedMonitorBrief } from '@/api/admin/channelMonitorTemplate'
import BaseDialog from '@/components/common/BaseDialog.vue'
const props = defineProps<{
show: boolean
templateId: number | null
templateName: string
}>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'applied', affected: number): void
}>()
const { t } = useI18n()
const appStore = useAppStore()
const loading = ref(false)
const submitting = ref(false)
const monitors = ref<AssociatedMonitorBrief[]>([])
const selectedIds = ref<number[]>([])
const selectedSet = computed(() => new Set(selectedIds.value))
watch(
() => [props.show, props.templateId] as const,
([show, id]) => {
if (!show || id == null) return
void fetchMonitors(id)
},
{ immediate: true },
)
async function fetchMonitors(id: number) {
loading.value = true
monitors.value = []
selectedIds.value = []
try {
const { items } = await adminAPI.channelMonitorTemplate.listAssociatedMonitors(id)
monitors.value = items
// 默认全选
selectedIds.value = items.map((m) => m.id)
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
} finally {
loading.value = false
}
}
function toggle(id: number) {
const idx = selectedIds.value.indexOf(id)
if (idx >= 0) selectedIds.value.splice(idx, 1)
else selectedIds.value.push(id)
}
function selectAll() {
selectedIds.value = monitors.value.map((m) => m.id)
}
function selectNone() {
selectedIds.value = []
}
async function handleApply() {
if (props.templateId == null || selectedIds.value.length === 0 || submitting.value) return
submitting.value = true
try {
const { affected } = await adminAPI.channelMonitorTemplate.apply(
props.templateId,
[...selectedIds.value],
)
appStore.showSuccess(t('admin.channelMonitor.template.applySuccess', { n: affected }))
emit('applied', affected)
emit('close')
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
} finally {
submitting.value = false
}
}
</script>

View File

@@ -180,14 +180,12 @@
</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"
<MonitorTemplateApplyPickerDialog
:show="applyPicker.show"
:template-id="applyPicker.tpl ? applyPicker.tpl.id : null"
:template-name="applyPicker.tpl ? applyPicker.tpl.name : ''"
@close="applyPicker.show = false"
@applied="onApplied"
/>
<ConfirmDialog
@@ -217,6 +215,7 @@ 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 MonitorTemplateApplyPickerDialog from '@/components/admin/monitor/MonitorTemplateApplyPickerDialog.vue'
import { useChannelMonitorFormat } from '@/composables/useChannelMonitorFormat'
import {
PROVIDER_ANTHROPIC,
@@ -373,38 +372,21 @@ async function handleSubmit() {
}
}
// --- apply to monitors ---
const confirmApply_ = reactive<{ show: boolean; tpl: ChannelMonitorTemplate | null }>({
// --- apply to monitors (picker 流程) ---
const applyPicker = reactive<{ show: boolean; tpl: ChannelMonitorTemplate | null }>({
show: false,
tpl: null,
})
function confirmApply(tpl: ChannelMonitorTemplate) {
confirmApply_.tpl = tpl
confirmApply_.show = true
applyPicker.tpl = tpl
applyPicker.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')))
}
// picker 提交后触发:刷新模板列表(拿最新 associated_monitors+ 通知父组件
async function onApplied(_affected: number) {
await fetchTemplates()
emit('updated')
}
// --- delete ---

View File

@@ -273,6 +273,7 @@ export default {
no: 'No',
all: 'All',
none: 'None',
selectAll: 'Select all',
noData: 'No data',
expand: 'Expand',
collapse: 'Collapse',
@@ -2192,11 +2193,17 @@ export default {
updateSuccess: 'Template updated',
deleteSuccess: 'Template deleted',
applyButton: 'Apply to monitors',
applyTooltip: 'Overwrite snapshot fields on all associated monitors',
applyTooltip: 'Overwrite snapshot fields on associated monitors',
applyTitle: 'Apply template',
applyConfirm: 'Apply',
applyConfirmMessage: 'Overwrite {n} associated monitor(s) with the current configuration of "{name}"? Any local customizations on those monitors will be discarded.',
applySuccess: 'Applied to {n} monitor(s)',
applyPickerTitle: 'Apply template "{name}"',
applyPickerHint: 'Select which monitors to overwrite (all selected by default). Any local customizations will be discarded.',
applyPickerEmpty: 'No monitors are currently associated to this template',
applyPickerConfirm: 'Apply to {n} monitor(s)',
selectNone: 'Select none',
selectedCount: 'Selected {n} / {total}',
deleteConfirm: 'Delete template "{name}"? {n} associated monitor(s) will be disassociated but keep their current snapshot and continue running.',
associatedCount: '{n} associated monitor(s)',
headersSummary: '{n} custom header(s)',

View File

@@ -273,6 +273,7 @@ export default {
no: '否',
all: '全部',
none: '无',
selectAll: '全选',
noData: '暂无数据',
expand: '展开',
collapse: '收起',
@@ -2276,6 +2277,12 @@ export default {
applyConfirm: '确认应用',
applyConfirmMessage: '将把模板「{name}」的当前配置覆盖到 {n} 个关联监控。监控本地已编辑的自定义修改会被丢弃,是否继续?',
applySuccess: '已应用到 {n} 个监控',
applyPickerTitle: '应用模板「{name}」',
applyPickerHint: '勾选要覆盖请求头/请求体的监控(默认全选)。监控本地已编辑的自定义修改会被丢弃。',
applyPickerEmpty: '当前模板没有关联监控',
applyPickerConfirm: '应用到 {n} 个监控',
selectNone: '全不选',
selectedCount: '已选 {n} / {total}',
deleteConfirm: '确定要删除模板「{name}」吗?{n} 个关联监控会解除关联但保留自己的快照继续工作。',
associatedCount: '{n} 个关联监控',
headersSummary: '{n} 个自定义请求头',