From 6925ac25c4d8a9e9af77403c54529a5f06e78c91 Mon Sep 17 00:00:00 2001 From: erio Date: Tue, 21 Apr 2026 14:39:19 +0800 Subject: [PATCH] feat(channel-monitor): apply template via subset picker; CC 2.1.114 baseline doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../admin/channel_monitor_template_handler.go | 43 ++++- .../channel_monitor_template_repo.go | 39 +++- backend/internal/server/routes/admin.go | 1 + .../channel_monitor_template_service.go | 38 +++- .../service/channel_monitor_template_types.go | 3 + .../src/api/admin/channelMonitorTemplate.ts | 30 ++- .../MonitorTemplateApplyPickerDialog.vue | 174 ++++++++++++++++++ .../monitor/MonitorTemplateManagerDialog.vue | 48 ++--- frontend/src/i18n/locales/en.ts | 9 +- frontend/src/i18n/locales/zh.ts | 7 + 10 files changed, 341 insertions(+), 51 deletions(-) create mode 100644 frontend/src/components/admin/monitor/MonitorTemplateApplyPickerDialog.vue diff --git a/backend/internal/handler/admin/channel_monitor_template_handler.go b/backend/internal/handler/admin/channel_monitor_template_handler.go index 8c1191ea..bebe0929 100644 --- a/backend/internal/handler/admin/channel_monitor_template_handler.go +++ b/backend/internal/handler/admin/channel_monitor_template_handler.go @@ -179,17 +179,56 @@ func (h *ChannelMonitorRequestTemplateHandler) Delete(c *gin.Context) { response.Success(c, nil) } +type channelMonitorTemplateApplyRequest struct { + // MonitorIDs 必填、非空:用户在 picker 里勾选的要被覆盖的监控 ID 列表。 + // 仅当对应监控当前 template_id == :id 时才会真的被覆盖。 + MonitorIDs []int64 `json:"monitor_ids" binding:"required,min=1"` +} + // Apply POST /api/v1/admin/channel-monitor-templates/:id/apply -// 一键把模板当前配置覆盖到所有关联监控上。 +// 把模板当前配置覆盖到 monitor_ids 列表里的关联监控(picker 选中的子集)。 func (h *ChannelMonitorRequestTemplateHandler) Apply(c *gin.Context) { id, ok := parseTemplateID(c) if !ok { return } - affected, err := h.templateService.ApplyToMonitors(c.Request.Context(), id) + var req channelMonitorTemplateApplyRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.ErrorFrom(c, infraerrors.BadRequest("VALIDATION_ERROR", err.Error())) + return + } + affected, err := h.templateService.ApplyToMonitors(c.Request.Context(), id, req.MonitorIDs) if err != nil { response.ErrorFrom(c, err) return } response.Success(c, gin.H{"affected": affected}) } + +type associatedMonitorBriefResponse struct { + ID int64 `json:"id"` + Name string `json:"name"` + Provider string `json:"provider"` + Enabled bool `json:"enabled"` +} + +// AssociatedMonitors GET /api/v1/admin/channel-monitor-templates/:id/monitors +// 列出关联监控(picker 弹窗用)。 +func (h *ChannelMonitorRequestTemplateHandler) AssociatedMonitors(c *gin.Context) { + id, ok := parseTemplateID(c) + if !ok { + return + } + items, err := h.templateService.ListAssociatedMonitors(c.Request.Context(), id) + if err != nil { + response.ErrorFrom(c, err) + return + } + out := make([]associatedMonitorBriefResponse, 0, len(items)) + for _, m := range items { + out = append(out, associatedMonitorBriefResponse{ + ID: m.ID, Name: m.Name, Provider: m.Provider, Enabled: m.Enabled, + }) + } + response.Success(c, gin.H{"items": out}) +} diff --git a/backend/internal/repository/channel_monitor_template_repo.go b/backend/internal/repository/channel_monitor_template_repo.go index 03f3692b..845d186b 100644 --- a/backend/internal/repository/channel_monitor_template_repo.go +++ b/backend/internal/repository/channel_monitor_template_repo.go @@ -103,11 +103,13 @@ func (r *channelMonitorRequestTemplateRepository) List(ctx context.Context, para return out, nil } -// ApplyToMonitors 把模板当前配置批量覆盖到 template_id = id 的监控上。 -// -// 用一条 UPDATE 完成:extra_headers / body_override_mode / body_override 都覆盖。 -// 走 ent 的 UpdateMany 保证走 ent hooks;走原生 SQL 也可以但 ent jsonb 序列化更省心。 -func (r *channelMonitorRequestTemplateRepository) ApplyToMonitors(ctx context.Context, id int64) (int64, error) { +// ApplyToMonitors 把模板当前配置覆盖到 monitorIDs 列表里的关联监控。 +// WHERE 双重过滤:template_id = id AND id IN (monitorIDs),防止用户传了未关联本模板的 id +// 就被覆盖。走 ent UpdateMany 保留 hooks。 +func (r *channelMonitorRequestTemplateRepository) ApplyToMonitors(ctx context.Context, id int64, monitorIDs []int64) (int64, error) { + if len(monitorIDs) == 0 { + return 0, nil + } client := clientFromContext(ctx, r.client) tpl, err := client.ChannelMonitorRequestTemplate.Query(). Where(channelmonitorrequesttemplate.IDEQ(id)). @@ -117,7 +119,10 @@ func (r *channelMonitorRequestTemplateRepository) ApplyToMonitors(ctx context.Co } updater := client.ChannelMonitor.Update(). - Where(channelmonitor.TemplateIDEQ(id)). + Where( + channelmonitor.TemplateIDEQ(id), + channelmonitor.IDIn(monitorIDs...), + ). SetExtraHeaders(emptyHeadersIfNilRepo(tpl.ExtraHeaders)). SetBodyOverrideMode(defaultBodyModeRepo(tpl.BodyOverrideMode)) if tpl.BodyOverride != nil { @@ -144,6 +149,28 @@ func (r *channelMonitorRequestTemplateRepository) CountAssociatedMonitors(ctx co return int64(count), nil } +// ListAssociatedMonitors 列出模板关联的所有监控简略字段。 +// ORDER BY name 稳定输出方便前端展示。 +func (r *channelMonitorRequestTemplateRepository) ListAssociatedMonitors(ctx context.Context, id int64) ([]*service.AssociatedMonitorBrief, error) { + rows, err := r.client.ChannelMonitor.Query(). + Where(channelmonitor.TemplateIDEQ(id)). + Order(dbent.Asc(channelmonitor.FieldName)). + All(ctx) + if err != nil { + return nil, fmt.Errorf("list associated monitors for template %d: %w", id, err) + } + out := make([]*service.AssociatedMonitorBrief, 0, len(rows)) + for _, row := range rows { + out = append(out, &service.AssociatedMonitorBrief{ + ID: row.ID, + Name: row.Name, + Provider: string(row.Provider), + Enabled: row.Enabled, + }) + } + return out, nil +} + // ---------- helpers ---------- func entToServiceTemplate(row *dbent.ChannelMonitorRequestTemplate) *service.ChannelMonitorRequestTemplate { diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 13cecd59..4b796d55 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -587,6 +587,7 @@ func registerChannelMonitorRoutes(admin *gin.RouterGroup, h *handler.Handlers) { templates.GET("/:id", h.Admin.ChannelMonitorTemplate.Get) templates.PUT("/:id", h.Admin.ChannelMonitorTemplate.Update) templates.DELETE("/:id", h.Admin.ChannelMonitorTemplate.Delete) + templates.GET("/:id/monitors", h.Admin.ChannelMonitorTemplate.AssociatedMonitors) templates.POST("/:id/apply", h.Admin.ChannelMonitorTemplate.Apply) } } diff --git a/backend/internal/service/channel_monitor_template_service.go b/backend/internal/service/channel_monitor_template_service.go index 98fc930b..8d2e8173 100644 --- a/backend/internal/service/channel_monitor_template_service.go +++ b/backend/internal/service/channel_monitor_template_service.go @@ -15,10 +15,23 @@ type ChannelMonitorRequestTemplateRepository interface { Delete(ctx context.Context, id int64) error List(ctx context.Context, params ChannelMonitorRequestTemplateListParams) ([]*ChannelMonitorRequestTemplate, error) // ApplyToMonitors 把模板当前的 extra_headers / body_override_mode / body_override - // 批量覆盖到所有 template_id = id 的监控上。返回被覆盖的监控数量。 - ApplyToMonitors(ctx context.Context, id int64) (int64, error) + // 批量覆盖到指定 monitorIDs 的监控上(同时还要求这些监控当前 template_id = id, + // 防止误覆盖未关联的监控)。monitorIDs 必须非空;空列表直接返回 0 不写库。 + // 返回被覆盖的监控数量。 + ApplyToMonitors(ctx context.Context, id int64, monitorIDs []int64) (int64, error) // CountAssociatedMonitors 统计 template_id = id 的监控数(用于 UI 展示「应用到 N 个配置」)。 CountAssociatedMonitors(ctx context.Context, id int64) (int64, error) + // ListAssociatedMonitors 列出所有 template_id = id 的监控简略信息(id/name/provider/enabled) + // 给 apply picker UI 用,避免前端再做一次 list+filter。 + ListAssociatedMonitors(ctx context.Context, id int64) ([]*AssociatedMonitorBrief, error) +} + +// AssociatedMonitorBrief 模板关联监控的简略信息(picker / 列表展示用)。 +type AssociatedMonitorBrief struct { + ID int64 + Name string + Provider string + Enabled bool } // ChannelMonitorRequestTemplateService 模板管理 service。 @@ -90,13 +103,17 @@ func (s *ChannelMonitorRequestTemplateService) Delete(ctx context.Context, id in return nil } -// ApplyToMonitors 把模板当前配置一键应用到所有关联监控。 -// 返回被影响的监控数。 -func (s *ChannelMonitorRequestTemplateService) ApplyToMonitors(ctx context.Context, id int64) (int64, error) { +// ApplyToMonitors 把模板当前配置应用到 monitorIDs 列表里的关联监控。 +// monitorIDs 必须非空且每个 id 都必须当前 template_id = id;不满足条件的会被 SQL WHERE 过滤掉。 +// 返回实际被覆盖的监控数。 +func (s *ChannelMonitorRequestTemplateService) ApplyToMonitors(ctx context.Context, id int64, monitorIDs []int64) (int64, error) { if _, err := s.repo.GetByID(ctx, id); err != nil { return 0, err } - affected, err := s.repo.ApplyToMonitors(ctx, id) + if len(monitorIDs) == 0 { + return 0, ErrChannelMonitorTemplateApplyEmpty + } + affected, err := s.repo.ApplyToMonitors(ctx, id, monitorIDs) if err != nil { return 0, fmt.Errorf("apply template to monitors: %w", err) } @@ -108,6 +125,15 @@ func (s *ChannelMonitorRequestTemplateService) CountAssociatedMonitors(ctx conte return s.repo.CountAssociatedMonitors(ctx, id) } +// ListAssociatedMonitors 返回模板关联的所有监控简略信息。 +// 给前端 apply picker 用,handler 直接吐 JSON 不再做 join。 +func (s *ChannelMonitorRequestTemplateService) ListAssociatedMonitors(ctx context.Context, id int64) ([]*AssociatedMonitorBrief, error) { + if _, err := s.repo.GetByID(ctx, id); err != nil { + return nil, err + } + return s.repo.ListAssociatedMonitors(ctx, id) +} + // ---------- 校验 & 工具 ---------- // validateTemplateCreateParams 聚合 create 入参校验,避免函数超过 30 行。 diff --git a/backend/internal/service/channel_monitor_template_types.go b/backend/internal/service/channel_monitor_template_types.go index a6e2bb59..e5bf7568 100644 --- a/backend/internal/service/channel_monitor_template_types.go +++ b/backend/internal/service/channel_monitor_template_types.go @@ -71,4 +71,7 @@ var ( ErrChannelMonitorTemplateProviderMismatch = infraerrors.BadRequest( "CHANNEL_MONITOR_TEMPLATE_PROVIDER_MISMATCH", "monitor provider does not match template provider", ) + ErrChannelMonitorTemplateApplyEmpty = infraerrors.BadRequest( + "CHANNEL_MONITOR_TEMPLATE_APPLY_EMPTY", "monitor_ids must be a non-empty array", + ) ) diff --git a/frontend/src/api/admin/channelMonitorTemplate.ts b/frontend/src/api/admin/channelMonitorTemplate.ts index 258adab8..01b3c2d0 100644 --- a/frontend/src/api/admin/channelMonitorTemplate.ts +++ b/frontend/src/api/admin/channelMonitorTemplate.ts @@ -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 { const { data } = await apiClient.get('/admin/channel-monitor-templates', { params, @@ -86,12 +97,24 @@ export async function del(id: number): Promise { } /** - * 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 { +export async function apply(id: number, monitorIds: number[]): Promise { const { data } = await apiClient.post( `/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 { + const { data } = await apiClient.get( + `/admin/channel-monitor-templates/${id}/monitors`, ) return data } @@ -103,6 +126,7 @@ export const channelMonitorTemplateAPI = { update, del, apply, + listAssociatedMonitors, } export default channelMonitorTemplateAPI diff --git a/frontend/src/components/admin/monitor/MonitorTemplateApplyPickerDialog.vue b/frontend/src/components/admin/monitor/MonitorTemplateApplyPickerDialog.vue new file mode 100644 index 00000000..427b75ff --- /dev/null +++ b/frontend/src/components/admin/monitor/MonitorTemplateApplyPickerDialog.vue @@ -0,0 +1,174 @@ + + + diff --git a/frontend/src/components/admin/monitor/MonitorTemplateManagerDialog.vue b/frontend/src/components/admin/monitor/MonitorTemplateManagerDialog.vue index 992a402e..3a03f5bc 100644 --- a/frontend/src/components/admin/monitor/MonitorTemplateManagerDialog.vue +++ b/frontend/src/components/admin/monitor/MonitorTemplateManagerDialog.vue @@ -180,14 +180,12 @@ - ({ +// --- 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 --- diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index be99cb7c..1d49efbf 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -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)', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index a3ce8716..fb84dfd2 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -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} 个自定义请求头',