From 1db6ea33a105caffeef7f461612323882d4d038b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 1 Jun 2026 07:59:03 +0000 Subject: [PATCH 1/4] feat(topic-feed): wizard create/edit, input labels, and preview Replace the topic feed create/edit modal with a multi-step wizard page. Add per-input description fields persisted via inputs[] on the API. Extract FeedPreviewPanel for reuse when previewing sub-feeds in the editor. Co-authored-by: Colin --- .gitignore | 1 + internal/controller/topic_feed.go | 14 + internal/dao/topic.go | 61 +- internal/dao/topic_test.go | 42 + web/admin/src/api/topic.ts | 6 + .../feed-preview/FeedPreviewPanel.vue | 101 +++ web/admin/src/locale/en-US/topic.ts | 21 + web/admin/src/locale/zh-CN/topic.ts | 18 + .../src/router/routes/modules/worktable.ts | 20 + .../components/TopicAggregatorEditor.vue | 227 ++++++ .../components/TopicInputSourcesEditor.vue | 284 +++++++ .../src/views/dashboard/topic_feed/detail.vue | 16 +- .../src/views/dashboard/topic_feed/editor.vue | 404 ++++++++++ .../dashboard/topic_feed/topicInputUtils.ts | 213 +++++ .../views/dashboard/topic_feed/topic_feed.vue | 728 +----------------- 15 files changed, 1439 insertions(+), 717 deletions(-) create mode 100644 internal/dao/topic_test.go create mode 100644 web/admin/src/components/feed-preview/FeedPreviewPanel.vue create mode 100644 web/admin/src/views/dashboard/topic_feed/components/TopicAggregatorEditor.vue create mode 100644 web/admin/src/views/dashboard/topic_feed/components/TopicInputSourcesEditor.vue create mode 100644 web/admin/src/views/dashboard/topic_feed/editor.vue create mode 100644 web/admin/src/views/dashboard/topic_feed/topicInputUtils.ts diff --git a/.gitignore b/.gitignore index 44e74a47..093539ac 100644 --- a/.gitignore +++ b/.gitignore @@ -152,3 +152,4 @@ db/ start.sh start-full.sh .claude/ +.dev-data/ diff --git a/internal/controller/topic_feed.go b/internal/controller/topic_feed.go index 8e2a6e56..f9fbe679 100644 --- a/internal/controller/topic_feed.go +++ b/internal/controller/topic_feed.go @@ -66,12 +66,14 @@ func CreateTopicFeed(c *gin.Context) { c.JSON(http.StatusBadRequest, util.APIResponse[any]{Msg: err.Error()}) return } + topicData.NormalizeInputs() db := util.GetDatabase() if err := dao.CreateTopicFeed(db, &topicData); err != nil { c.JSON(http.StatusInternalServerError, util.APIResponse[any]{Msg: err.Error()}) return } + topicData.NormalizeInputs() c.JSON(http.StatusCreated, util.APIResponse[any]{Data: topicData}) } @@ -89,6 +91,7 @@ func GetTopicFeed(c *gin.Context) { c.JSON(http.StatusInternalServerError, util.APIResponse[any]{Msg: err.Error()}) return } + topicData.NormalizeInputs() c.JSON(http.StatusOK, util.APIResponse[any]{Data: topicData}) } @@ -101,6 +104,10 @@ func ListTopicFeeds(c *gin.Context) { return } + for _, topic := range topicList { + topic.NormalizeInputs() + } + c.JSON(http.StatusOK, util.APIResponse[any]{Data: topicList}) } @@ -116,6 +123,7 @@ func UpdateTopicFeed(c *gin.Context) { if id != topicData.ID { topicData.ID = id } + topicData.NormalizeInputs() db := util.GetDatabase() @@ -133,6 +141,7 @@ func UpdateTopicFeed(c *gin.Context) { c.JSON(http.StatusInternalServerError, util.APIResponse[any]{Msg: err.Error()}) return } + topicData.NormalizeInputs() c.JSON(http.StatusOK, util.APIResponse[any]{Data: topicData}) } @@ -159,6 +168,7 @@ func ValidateTopicFeed(c *gin.Context) { c.JSON(http.StatusBadRequest, util.APIResponse[any]{Msg: err.Error()}) return } + topicData.NormalizeInputs() result, err := validateTopicConfig(c.Request.Context(), util.GetDatabase(), &topicData) if err != nil { @@ -182,6 +192,7 @@ func GetTopicFeedDetail(c *gin.Context) { c.JSON(http.StatusInternalServerError, util.APIResponse[any]{Msg: err.Error()}) return } + topicData.NormalizeInputs() health, err := dao.GetResourceHealth(db, dao.ResourceTypeTopic, id) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { @@ -273,6 +284,9 @@ func validateTopicConfig(ctx context.Context, db *gorm.DB, topicData *dao.TopicF Errors: []TopicValidationIssue{}, Warnings: []TopicValidationIssue{}, } + if topicData != nil { + topicData.NormalizeInputs() + } if topicData == nil { result.Valid = false result.Errors = append(result.Errors, TopicValidationIssue{ diff --git a/internal/dao/topic.go b/internal/dao/topic.go index 7d692a1f..39c1c964 100644 --- a/internal/dao/topic.go +++ b/internal/dao/topic.go @@ -1,9 +1,17 @@ package dao import ( + "strings" + "gorm.io/gorm" ) +// TopicInput is a single upstream source with an optional admin label. +type TopicInput struct { + URI string `json:"uri"` + Description string `json:"description,omitempty"` +} + // TopicFeed represents the persistence model for a multi-source aggregation node. type TopicFeed struct { BaseModelWithoutPK @@ -11,18 +19,67 @@ type TopicFeed struct { Title string `json:"title,omitempty"` Description string `json:"description,omitempty"` - // List of URIs representing inputs. + // Inputs carries URI plus optional description for admin display. + Inputs []TopicInput `json:"inputs,omitempty" gorm:"serializer:json"` + + // List of URIs representing inputs (derived from Inputs for runtime). // Uses a custom protocol for internal resources to make routing elegant and standard. // Examples: // - "feedcraft://recipe/my-tech-recipe" (Internal RecipeFeed) // - "feedcraft://topic/sub-topic-id" (Nested internal TopicFeed) // - "https://external.com/rss.xml" (External raw feed) - InputURIs []string `json:"input_uris" binding:"required" gorm:"serializer:json"` + InputURIs []string `json:"input_uris" gorm:"serializer:json"` // Configuration for the aggregator pipeline AggregatorConfig []AggregatorStep `json:"aggregator_config" gorm:"serializer:json"` } +// NormalizeInputs keeps Inputs and InputURIs in sync. +// When Inputs is provided it becomes the source of truth; otherwise legacy InputURIs are upgraded. +func (t *TopicFeed) NormalizeInputs() { + if t == nil { + return + } + + if len(t.Inputs) > 0 { + uris := make([]string, 0, len(t.Inputs)) + normalized := make([]TopicInput, 0, len(t.Inputs)) + for _, item := range t.Inputs { + uri := strings.TrimSpace(item.URI) + if uri == "" { + continue + } + uris = append(uris, uri) + normalized = append(normalized, TopicInput{ + URI: uri, + Description: strings.TrimSpace(item.Description), + }) + } + t.InputURIs = uris + t.Inputs = normalized + return + } + + if len(t.InputURIs) == 0 { + t.Inputs = nil + return + } + + inputs := make([]TopicInput, 0, len(t.InputURIs)) + for _, uri := range t.InputURIs { + uri = strings.TrimSpace(uri) + if uri == "" { + continue + } + inputs = append(inputs, TopicInput{URI: uri}) + } + t.Inputs = inputs + t.InputURIs = make([]string, 0, len(inputs)) + for _, item := range inputs { + t.InputURIs = append(t.InputURIs, item.URI) + } +} + // AggregatorStep defines a single processing step in an Aggregator pipeline. type AggregatorStep struct { Type string `json:"type" binding:"required"` // e.g., "deduplicate", "sort", "limit" diff --git a/internal/dao/topic_test.go b/internal/dao/topic_test.go new file mode 100644 index 00000000..a932031c --- /dev/null +++ b/internal/dao/topic_test.go @@ -0,0 +1,42 @@ +package dao + +import "testing" + +func TestTopicFeed_NormalizeInputs_FromInputs(t *testing.T) { + topic := &TopicFeed{ + Inputs: []TopicInput{ + {URI: "https://example.com/a.xml", Description: "Tech news"}, + {URI: " feedcraft://recipe/foo ", Description: ""}, + }, + } + + topic.NormalizeInputs() + + if len(topic.InputURIs) != 2 { + t.Fatalf("expected 2 input URIs, got %d", len(topic.InputURIs)) + } + if topic.InputURIs[0] != "https://example.com/a.xml" { + t.Fatalf("unexpected first URI: %q", topic.InputURIs[0]) + } + if topic.InputURIs[1] != "feedcraft://recipe/foo" { + t.Fatalf("unexpected second URI: %q", topic.InputURIs[1]) + } + if topic.Inputs[0].Description != "Tech news" { + t.Fatalf("unexpected description: %q", topic.Inputs[0].Description) + } +} + +func TestTopicFeed_NormalizeInputs_FromLegacyURIs(t *testing.T) { + topic := &TopicFeed{ + InputURIs: []string{"https://example.com/a.xml", "https://example.com/b.xml"}, + } + + topic.NormalizeInputs() + + if len(topic.Inputs) != 2 { + t.Fatalf("expected 2 inputs, got %d", len(topic.Inputs)) + } + if topic.Inputs[0].URI != "https://example.com/a.xml" { + t.Fatalf("unexpected first input URI: %q", topic.Inputs[0].URI) + } +} diff --git a/web/admin/src/api/topic.ts b/web/admin/src/api/topic.ts index 9f9a996c..23fe6560 100644 --- a/web/admin/src/api/topic.ts +++ b/web/admin/src/api/topic.ts @@ -11,10 +11,16 @@ export interface AggregatorStep { option: Record; } +export interface TopicInput { + uri: string; + description?: string; +} + export interface TopicFeed { id: string; title?: string; description?: string; + inputs?: TopicInput[]; input_uris: string[]; aggregator_config: AggregatorStep[]; } diff --git a/web/admin/src/components/feed-preview/FeedPreviewPanel.vue b/web/admin/src/components/feed-preview/FeedPreviewPanel.vue new file mode 100644 index 00000000..f5c7ab0f --- /dev/null +++ b/web/admin/src/components/feed-preview/FeedPreviewPanel.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/web/admin/src/locale/en-US/topic.ts b/web/admin/src/locale/en-US/topic.ts index ce33d598..9290ab90 100644 --- a/web/admin/src/locale/en-US/topic.ts +++ b/web/admin/src/locale/en-US/topic.ts @@ -39,6 +39,27 @@ export default { 'For External RSS, enter an http/https URL. For Recipe or Topic, pick from the dropdown to use internal FeedCraft resources.', 'topic.inputPlaceholder': 'For example: feedcraft://recipe/my-recipe', 'topic.addInput': 'Add Input', + 'topic.inputDescription.placeholder': + 'Label for this input (optional, helps you recognize it later)', + 'topic.inputPreview': 'Preview', + 'topic.inputPreview.title': 'Input #{index}', + 'topic.inputPreview.currentUri': 'Preview URI', + 'topic.inputPreview.empty': + 'Click Preview to load feed content for this input', + 'topic.inputPreview.failed': 'Preview failed', + 'topic.wizard.description': + 'Configure topic basics, input sources, and aggregation rules step by step.', + 'topic.wizard.step.basic': 'Basics', + 'topic.wizard.step.basic.desc': 'ID, title, and description', + 'topic.wizard.step.inputs': 'Inputs', + 'topic.wizard.step.inputs.desc': 'Add sub-feeds and preview when needed', + 'topic.wizard.step.aggregator': 'Aggregation', + 'topic.wizard.step.aggregator.desc': 'Deduplicate, sort, and limit items', + 'topic.wizard.step.review': 'Review & Save', + 'topic.wizard.step.review.desc': 'Validate and submit', + 'topic.wizard.prev': 'Previous', + 'topic.wizard.next': 'Next', + 'topic.wizard.inputsRequired': 'Add at least one valid input source', 'topic.removeInput': 'Remove', 'topic.removeStep': 'Remove Rule', 'topic.sectionInputs': 'Input Sources', diff --git a/web/admin/src/locale/zh-CN/topic.ts b/web/admin/src/locale/zh-CN/topic.ts index 094cb07a..4c6fd8d6 100644 --- a/web/admin/src/locale/zh-CN/topic.ts +++ b/web/admin/src/locale/zh-CN/topic.ts @@ -38,6 +38,24 @@ export default { '外部 RSS 直接填入 http/https 地址;选择 Recipe 或 Topic 可从下拉列表中选取 FeedCraft 内部资源。', 'topic.inputPlaceholder': '例如 feedcraft://recipe/my-recipe', 'topic.addInput': '添加输入源', + 'topic.inputDescription.placeholder': '备注名称,便于识别此输入源(可选)', + 'topic.inputPreview': '预览', + 'topic.inputPreview.title': '输入源 #{index}', + 'topic.inputPreview.currentUri': '当前预览地址', + 'topic.inputPreview.empty': '点击「预览」查看该输入源的 Feed 内容', + 'topic.inputPreview.failed': '预览失败', + 'topic.wizard.description': '按步骤配置主题基本信息、输入源与聚合规则。', + 'topic.wizard.step.basic': '基本信息', + 'topic.wizard.step.basic.desc': '设置 ID、标题与描述', + 'topic.wizard.step.inputs': '输入源', + 'topic.wizard.step.inputs.desc': '添加子 Feed 并可选预览', + 'topic.wizard.step.aggregator': '聚合规则', + 'topic.wizard.step.aggregator.desc': '去重、排序与数量限制', + 'topic.wizard.step.review': '确认保存', + 'topic.wizard.step.review.desc': '校验配置并提交', + 'topic.wizard.prev': '上一步', + 'topic.wizard.next': '下一步', + 'topic.wizard.inputsRequired': '请至少添加一个有效的输入源', 'topic.removeInput': '移除', 'topic.removeStep': '移除规则', 'topic.sectionInputs': '输入源', diff --git a/web/admin/src/router/routes/modules/worktable.ts b/web/admin/src/router/routes/modules/worktable.ts index 5f7a8c00..d5190d74 100644 --- a/web/admin/src/router/routes/modules/worktable.ts +++ b/web/admin/src/router/routes/modules/worktable.ts @@ -21,6 +21,26 @@ const WORKTABLE: AppRouteRecordRaw = { locale: 'menu.topicFeed', }, }, + { + path: 'topic_feed/create', + name: 'TopicFeedCreate', + component: () => import('@/views/dashboard/topic_feed/editor.vue'), + meta: { + requiresAuth: true, + locale: 'menu.topicFeed', + hideInMenu: true, + }, + }, + { + path: 'topic_feed/:id/edit', + name: 'TopicFeedEdit', + component: () => import('@/views/dashboard/topic_feed/editor.vue'), + meta: { + requiresAuth: true, + locale: 'menu.topicFeed', + hideInMenu: true, + }, + }, { path: 'topic_feed/:id', name: 'TopicFeedDetail', diff --git a/web/admin/src/views/dashboard/topic_feed/components/TopicAggregatorEditor.vue b/web/admin/src/views/dashboard/topic_feed/components/TopicAggregatorEditor.vue new file mode 100644 index 00000000..4d526f82 --- /dev/null +++ b/web/admin/src/views/dashboard/topic_feed/components/TopicAggregatorEditor.vue @@ -0,0 +1,227 @@ + + + + + + + diff --git a/web/admin/src/views/dashboard/topic_feed/components/TopicInputSourcesEditor.vue b/web/admin/src/views/dashboard/topic_feed/components/TopicInputSourcesEditor.vue new file mode 100644 index 00000000..f889ef2e --- /dev/null +++ b/web/admin/src/views/dashboard/topic_feed/components/TopicInputSourcesEditor.vue @@ -0,0 +1,284 @@ + + + + + + + diff --git a/web/admin/src/views/dashboard/topic_feed/detail.vue b/web/admin/src/views/dashboard/topic_feed/detail.vue index b0cd8f9f..1b7a51f5 100644 --- a/web/admin/src/views/dashboard/topic_feed/detail.vue +++ b/web/admin/src/views/dashboard/topic_feed/detail.vue @@ -82,10 +82,13 @@ - {{ uri }} + @@ -319,6 +322,13 @@ detail.value ? buildPublicFeedUrl(detail.value.public_url) : '' ); + const topicInputs = computed(() => { + if (!detail.value) return []; + const { topic } = detail.value; + if (topic.inputs?.length) return topic.inputs; + return topic.input_uris.map((uri) => ({ uri, description: '' })); + }); + const formatTime = (value?: string) => { if (!value) return '-'; return new Date(value).toLocaleString(); diff --git a/web/admin/src/views/dashboard/topic_feed/editor.vue b/web/admin/src/views/dashboard/topic_feed/editor.vue new file mode 100644 index 00000000..a788b221 --- /dev/null +++ b/web/admin/src/views/dashboard/topic_feed/editor.vue @@ -0,0 +1,404 @@ + + + + + + + diff --git a/web/admin/src/views/dashboard/topic_feed/topicInputUtils.ts b/web/admin/src/views/dashboard/topic_feed/topicInputUtils.ts new file mode 100644 index 00000000..0005b0fa --- /dev/null +++ b/web/admin/src/views/dashboard/topic_feed/topicInputUtils.ts @@ -0,0 +1,213 @@ +import type { AggregatorStep, TopicFeed, TopicInput } from '@/api/topic'; + +export type StepType = 'deduplicate' | 'sort' | 'limit'; +export type SourceType = 'external' | 'recipe' | 'topic'; + +export const STRATEGIES_WITH_THRESHOLD = [ + 'by_simhash', + 'by_embedding', +] as const; + +export interface InputSourceItem { + sourceType: SourceType; + externalUrl: string; + resourceId: string; + description: string; +} + +export interface StepFormItem { + type: StepType; + value: string | number; + threshold?: number; +} + +export interface TopicFormData { + id: string; + title: string; + description: string; + inputSources: InputSourceItem[]; + aggregator_config: StepFormItem[]; +} + +export const defaultThreshold = (strategy: string): number | undefined => { + if (strategy === 'by_simhash') return 0.05; + if (strategy === 'by_embedding') return 0.1; + return undefined; +}; + +export const createDefaultStep = (type: StepType = 'limit'): StepFormItem => { + if (type === 'deduplicate') return { type, value: 'by_link' }; + if (type === 'sort') return { type, value: 'date_desc' }; + return { type, value: 50 }; +}; + +export const defaultFormData = (): TopicFormData => ({ + id: '', + title: '', + description: '', + inputSources: [ + { + sourceType: 'external', + externalUrl: '', + resourceId: '', + description: '', + }, + ], + aggregator_config: [], +}); + +export const parseUriToSource = ( + input: TopicInput | string +): InputSourceItem => { + const uri = typeof input === 'string' ? input : input.uri; + const description = + typeof input === 'string' ? '' : input.description?.trim() || ''; + + if (uri.startsWith('feedcraft://recipe/')) { + return { + sourceType: 'recipe', + externalUrl: '', + resourceId: uri.slice('feedcraft://recipe/'.length), + description, + }; + } + if (uri.startsWith('feedcraft://topic/')) { + return { + sourceType: 'topic', + externalUrl: '', + resourceId: uri.slice('feedcraft://topic/'.length), + description, + }; + } + return { + sourceType: 'external', + externalUrl: uri, + resourceId: '', + description, + }; +}; + +export const sourceToUri = (source: InputSourceItem): string => { + if (source.sourceType === 'recipe') { + return `feedcraft://recipe/${source.resourceId}`; + } + if (source.sourceType === 'topic') { + return `feedcraft://topic/${source.resourceId}`; + } + return source.externalUrl.trim(); +}; + +export const topicFeedToFormData = (record: TopicFeed): TopicFormData => { + const inputs = + record.inputs && record.inputs.length > 0 + ? record.inputs + : record.input_uris.map((uri) => ({ uri, description: '' })); + + return { + id: record.id, + title: record.title || '', + description: record.description || '', + inputSources: + inputs.length > 0 + ? inputs.map(parseUriToSource) + : [ + { + sourceType: 'external', + externalUrl: '', + resourceId: '', + description: '', + }, + ], + aggregator_config: (record.aggregator_config || []).map((step) => { + if (step.type === 'deduplicate') { + const strategy = step.option?.strategy || 'by_link'; + const item: StepFormItem = { type: 'deduplicate', value: strategy }; + if ( + step.option?.threshold !== undefined && + STRATEGIES_WITH_THRESHOLD.includes( + strategy as (typeof STRATEGIES_WITH_THRESHOLD)[number] + ) + ) { + item.threshold = Number(step.option.threshold); + } else { + item.threshold = defaultThreshold(strategy); + } + return item; + } + if (step.type === 'sort') { + return { type: 'sort', value: step.option?.by || 'date_desc' }; + } + return { type: 'limit', value: Number(step.option?.max || 50) }; + }), + }; +}; + +export const normalizeTopicPayload = (formData: TopicFormData): TopicFeed => { + const inputs: TopicInput[] = formData.inputSources + .map((source) => ({ + uri: sourceToUri(source), + description: source.description.trim(), + })) + .filter((item) => item.uri !== ''); + + return { + id: formData.id.trim(), + title: formData.title.trim(), + description: formData.description.trim(), + inputs, + input_uris: inputs.map((item) => item.uri), + aggregator_config: formData.aggregator_config.map((step) => { + const option: Record = {}; + if (step.type === 'deduplicate') { + option.strategy = String(step.value); + if ( + step.threshold !== undefined && + STRATEGIES_WITH_THRESHOLD.includes( + step.value as (typeof STRATEGIES_WITH_THRESHOLD)[number] + ) + ) { + option.threshold = String(step.threshold); + } + } + if (step.type === 'sort') option.by = String(step.value); + if (step.type === 'limit') option.max = String(step.value); + return { + type: step.type, + option, + }; + }), + }; +}; + +export const formatAggregatorSummary = ( + steps: AggregatorStep[], + t: (key: string) => string +): string => { + if (!steps || steps.length === 0) return t('topic.noAggregator'); + return steps + .map((step) => { + if (step.type === 'deduplicate') { + const strategy = step.option?.strategy || 'by_link'; + const label = t(`topic.stepOption.strategy.${strategy}`); + if ( + (strategy === 'by_simhash' || strategy === 'by_embedding') && + step.option?.threshold + ) { + return `${t('topic.stepType.deduplicate')} · ${label} (${ + step.option.threshold + })`; + } + return `${t('topic.stepType.deduplicate')} · ${label}`; + } + if (step.type === 'sort') { + return `${t('topic.stepType.sort')} · ${t( + `topic.stepOption.sort.${step.option?.by || 'date_desc'}` + )}`; + } + if (step.type === 'limit') { + return `${t('topic.stepType.limit')} · ${step.option?.max || '-'}`; + } + return step.type; + }) + .join(' / '); +}; diff --git a/web/admin/src/views/dashboard/topic_feed/topic_feed.vue b/web/admin/src/views/dashboard/topic_feed/topic_feed.vue index 24a344be..7171277a 100644 --- a/web/admin/src/views/dashboard/topic_feed/topic_feed.vue +++ b/web/admin/src/views/dashboard/topic_feed/topic_feed.vue @@ -41,13 +41,13 @@ /> @@ -68,7 +68,11 @@ > {{ t('topic.preview') }} - + {{ t('topic.editAction') }} @@ -93,504 +97,27 @@ :description="t('topic.noTopics')" /> - - - - - - - - - - - - - - - {{ t('topic.sectionInputs') }} - - - -
-
- - {{ - t('topic.sourceType.external') - }} - Recipe - Topic - - - - - {{ r.id }} - - — {{ r.description }} - - - - - - {{ tp.id }} - - — {{ tp.title }} - - - - - {{ t('topic.removeInput') }} - -
- - - {{ t('topic.addInput') }} - -
-
- - - {{ t('topic.sectionAggregator') }} - - - -
-
-
- - - {{ t('topic.stepType.deduplicate') }} - - {{ - t('topic.stepType.sort') - }} - {{ - t('topic.stepType.limit') - }} - - - - - - - {{ t('topic.stepOption.sort.date_desc') }} - - - {{ t('topic.stepOption.sort.date_asc') }} - - - {{ t('topic.stepOption.sort.quality_desc') }} - - - {{ t('topic.stepOption.sort.quality_asc') }} - - - - - - - - {{ t('topic.removeStep') }} - -
-

- {{ t(`topic.stepOption.strategy.${step.value}.hint`) }} -

-
- - - - {{ t('topic.addStep') }} - -
-
- - - -
- {{ issue.field }}: {{ issue.message }} -
-
- - -
- {{ issue.field }}: {{ issue.message }} -
-
-
- - -
- - From 0e2b9d02bfdf14fb11082b40f40014283b6d929e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 1 Jun 2026 08:01:45 +0000 Subject: [PATCH 2/4] feat(topic-feed): add disabled flag for sub-feed inputs Persist optional disabled on each input; runtime uses only enabled URIs. Detail page supports toggling disable without opening the full editor. Co-authored-by: Colin --- internal/controller/topic_feed.go | 16 +- internal/dao/topic.go | 9 +- internal/dao/topic_test.go | 24 +++ web/admin/src/api/topic.ts | 1 + web/admin/src/locale/en-US/topic.ts | 8 + web/admin/src/locale/zh-CN/topic.ts | 7 + .../components/TopicInputSourcesEditor.vue | 62 ++++++-- .../src/views/dashboard/topic_feed/detail.vue | 144 +++++++++++++++--- .../src/views/dashboard/topic_feed/editor.vue | 13 +- .../dashboard/topic_feed/topicInputUtils.ts | 20 ++- 10 files changed, 260 insertions(+), 44 deletions(-) diff --git a/internal/controller/topic_feed.go b/internal/controller/topic_feed.go index f9fbe679..34b1679e 100644 --- a/internal/controller/topic_feed.go +++ b/internal/controller/topic_feed.go @@ -222,9 +222,9 @@ func GetTopicFeedDetail(c *gin.Context) { return } - subFeedHealth := make([]feedruntime.SubFeedHealth, 0, len(topicData.InputURIs)) - for _, uri := range topicData.InputURIs { - subFeedHealth = append(subFeedHealth, feedruntime.GetSubFeedHealth(uri)) + subFeedHealth := make([]feedruntime.SubFeedHealth, 0, len(topicData.Inputs)) + for _, input := range topicData.Inputs { + subFeedHealth = append(subFeedHealth, feedruntime.GetSubFeedHealth(input.URI)) } detail := TopicDetailResponse{ @@ -308,16 +308,16 @@ func validateTopicConfig(ctx context.Context, db *gorm.DB, topicData *dao.TopicF if len(topicData.InputURIs) == 0 { result.Valid = false result.Errors = append(result.Errors, TopicValidationIssue{ - Field: "input_uris", - Message: "At least one input source is required", + Field: "inputs", + Message: "At least one enabled input source is required", }) } - for idx, uri := range topicData.InputURIs { - if strings.TrimSpace(uri) == "" { + for idx, input := range topicData.Inputs { + if strings.TrimSpace(input.URI) == "" { result.Valid = false result.Errors = append(result.Errors, TopicValidationIssue{ - Field: fmt.Sprintf("input_uris[%d]", idx), + Field: fmt.Sprintf("inputs[%d].uri", idx), Message: "Input URI cannot be empty", }) } diff --git a/internal/dao/topic.go b/internal/dao/topic.go index 39c1c964..ed6b4ff0 100644 --- a/internal/dao/topic.go +++ b/internal/dao/topic.go @@ -6,10 +6,12 @@ import ( "gorm.io/gorm" ) -// TopicInput is a single upstream source with an optional admin label. +// TopicInput is a single upstream source with optional admin metadata. type TopicInput struct { URI string `json:"uri"` Description string `json:"description,omitempty"` + // Disabled excludes this input from topic aggregation when true. + Disabled bool `json:"disabled,omitempty"` } // TopicFeed represents the persistence model for a multi-source aggregation node. @@ -49,10 +51,13 @@ func (t *TopicFeed) NormalizeInputs() { if uri == "" { continue } - uris = append(uris, uri) + if !item.Disabled { + uris = append(uris, uri) + } normalized = append(normalized, TopicInput{ URI: uri, Description: strings.TrimSpace(item.Description), + Disabled: item.Disabled, }) } t.InputURIs = uris diff --git a/internal/dao/topic_test.go b/internal/dao/topic_test.go index a932031c..9c2f1ad4 100644 --- a/internal/dao/topic_test.go +++ b/internal/dao/topic_test.go @@ -26,6 +26,30 @@ func TestTopicFeed_NormalizeInputs_FromInputs(t *testing.T) { } } +func TestTopicFeed_NormalizeInputs_SkipsDisabled(t *testing.T) { + topic := &TopicFeed{ + Inputs: []TopicInput{ + {URI: "https://example.com/enabled.xml"}, + {URI: "https://example.com/disabled.xml", Disabled: true}, + }, + } + + topic.NormalizeInputs() + + if len(topic.InputURIs) != 1 { + t.Fatalf("expected 1 enabled URI, got %d", len(topic.InputURIs)) + } + if topic.InputURIs[0] != "https://example.com/enabled.xml" { + t.Fatalf("unexpected enabled URI: %q", topic.InputURIs[0]) + } + if len(topic.Inputs) != 2 { + t.Fatalf("expected 2 inputs retained, got %d", len(topic.Inputs)) + } + if !topic.Inputs[1].Disabled { + t.Fatal("expected second input to stay disabled") + } +} + func TestTopicFeed_NormalizeInputs_FromLegacyURIs(t *testing.T) { topic := &TopicFeed{ InputURIs: []string{"https://example.com/a.xml", "https://example.com/b.xml"}, diff --git a/web/admin/src/api/topic.ts b/web/admin/src/api/topic.ts index 23fe6560..892d196a 100644 --- a/web/admin/src/api/topic.ts +++ b/web/admin/src/api/topic.ts @@ -14,6 +14,7 @@ export interface AggregatorStep { export interface TopicInput { uri: string; description?: string; + disabled?: boolean; } export interface TopicFeed { diff --git a/web/admin/src/locale/en-US/topic.ts b/web/admin/src/locale/en-US/topic.ts index 9290ab90..fb4a6d05 100644 --- a/web/admin/src/locale/en-US/topic.ts +++ b/web/admin/src/locale/en-US/topic.ts @@ -41,6 +41,14 @@ export default { 'topic.addInput': 'Add Input', 'topic.inputDescription.placeholder': 'Label for this input (optional, helps you recognize it later)', + 'topic.inputDisabled.label': 'Disabled', + 'topic.inputDisabled.badge': 'Disabled', + 'topic.inputDisabled.hint': + 'Disabled inputs are excluded from aggregation but kept in config. You can re-enable them anytime.', + 'topic.inputDisabled.lastEnabled': + 'At least one input source must stay enabled', + 'topic.inputDisabled.disabledSuccess': 'Input source disabled', + 'topic.inputDisabled.enabledSuccess': 'Input source enabled', 'topic.inputPreview': 'Preview', 'topic.inputPreview.title': 'Input #{index}', 'topic.inputPreview.currentUri': 'Preview URI', diff --git a/web/admin/src/locale/zh-CN/topic.ts b/web/admin/src/locale/zh-CN/topic.ts index 4c6fd8d6..0b9f7064 100644 --- a/web/admin/src/locale/zh-CN/topic.ts +++ b/web/admin/src/locale/zh-CN/topic.ts @@ -39,6 +39,13 @@ export default { 'topic.inputPlaceholder': '例如 feedcraft://recipe/my-recipe', 'topic.addInput': '添加输入源', 'topic.inputDescription.placeholder': '备注名称,便于识别此输入源(可选)', + 'topic.inputDisabled.label': '禁用', + 'topic.inputDisabled.badge': '已禁用', + 'topic.inputDisabled.hint': + '禁用后该子 Feed 不会参与聚合输出,配置仍保留,可随时重新启用。', + 'topic.inputDisabled.lastEnabled': '至少需要保留一个启用的输入源', + 'topic.inputDisabled.disabledSuccess': '已禁用该输入源', + 'topic.inputDisabled.enabledSuccess': '已重新启用该输入源', 'topic.inputPreview': '预览', 'topic.inputPreview.title': '输入源 #{index}', 'topic.inputPreview.currentUri': '当前预览地址', diff --git a/web/admin/src/views/dashboard/topic_feed/components/TopicInputSourcesEditor.vue b/web/admin/src/views/dashboard/topic_feed/components/TopicInputSourcesEditor.vue index f889ef2e..50f2c405 100644 --- a/web/admin/src/views/dashboard/topic_feed/components/TopicInputSourcesEditor.vue +++ b/web/admin/src/views/dashboard/topic_feed/components/TopicInputSourcesEditor.vue @@ -4,6 +4,7 @@ v-for="(source, idx) in modelValue" :key="`source-${idx}`" class="input-source-card" + :class="{ 'input-source-card--disabled': source.disabled }" >
Recipe Topic - + +
+ {{ + t('topic.inputDisabled.label') + }} + +
@@ -134,13 +145,19 @@ sourceToUri, } from '../topicInputUtils'; - const props = defineProps<{ - modelValue: InputSourceItem[]; - availableRecipes: CustomRecipe[]; - availableTopics: TopicFeed[]; - pickerLoading?: boolean; - excludeTopicId?: string; - }>(); + const props = withDefaults( + defineProps<{ + modelValue: InputSourceItem[]; + availableRecipes: CustomRecipe[]; + availableTopics: TopicFeed[]; + pickerLoading?: boolean; + excludeTopicId?: string; + showDisabledToggle?: boolean; + }>(), + { + showDisabledToggle: true, + } + ); const emit = defineEmits<{ (event: 'update:modelValue', value: InputSourceItem[]): void; @@ -176,10 +193,17 @@ externalUrl: '', resourceId: '', description: '', + disabled: false, }, ]); }; + const setDisabled = (idx: number, disabled: boolean) => { + const next = [...props.modelValue]; + next[idx] = { ...next[idx], disabled }; + updateSources(next); + }; + const removeSource = (idx: number) => { const next = [...props.modelValue]; next.splice(idx, 1); @@ -189,6 +213,7 @@ externalUrl: '', resourceId: '', description: '', + disabled: false, }); } updateSources(next); @@ -196,11 +221,13 @@ const onSourceTypeChange = (idx: number, sourceType: SourceType) => { const next = [...props.modelValue]; + const current = next[idx]; next[idx] = { sourceType, externalUrl: '', resourceId: '', - description: next[idx]?.description || '', + description: current?.description || '', + disabled: current?.disabled || false, }; updateSources(next); }; @@ -256,6 +283,21 @@ background-color: var(--color-fill-2); } + .input-source-card--disabled { + opacity: 0.72; + } + + .disable-toggle { + display: inline-flex; + align-items: center; + gap: 8px; + } + + .disable-toggle-label { + font-size: 12px; + color: var(--color-text-3); + } + .source-header { display: flex; justify-content: space-between; diff --git a/web/admin/src/views/dashboard/topic_feed/detail.vue b/web/admin/src/views/dashboard/topic_feed/detail.vue index 1b7a51f5..9e1194c8 100644 --- a/web/admin/src/views/dashboard/topic_feed/detail.vue +++ b/web/admin/src/views/dashboard/topic_feed/detail.vue @@ -24,10 +24,14 @@ - + + + - - - - - + + + + @@ -308,7 +355,9 @@ AggregatorStep, SubFeedHealth, TopicDetail, + TopicInput, getTopicFeedDetail, + updateTopicFeed, } from '@/api/topic'; const { t } = useI18n(); @@ -318,17 +367,67 @@ const detail = ref(null); const detailsModalVisible = ref(false); const selectedExecutionDetails = ref(''); + const inputToggleSavingUri = ref(''); const publicUrl = computed(() => detail.value ? buildPublicFeedUrl(detail.value.public_url) : '' ); - const topicInputs = computed(() => { + const topicInputs = computed((): TopicInput[] => { if (!detail.value) return []; const { topic } = detail.value; if (topic.inputs?.length) return topic.inputs; - return topic.input_uris.map((uri) => ({ uri, description: '' })); + return topic.input_uris.map((uri) => ({ + uri, + description: '', + disabled: false, + })); }); + const enabledInputCount = computed( + () => topicInputs.value.filter((input) => !input.disabled).length + ); + + const disabledInputCount = computed( + () => topicInputs.value.filter((input) => input.disabled).length + ); + + const toggleInputDisabled = async (uri: string, disabled: boolean) => { + if (!detail.value) return; + + const nextInputs = topicInputs.value.map((input) => + input.uri === uri ? { ...input, disabled } : input + ); + const enabledCount = nextInputs.filter((input) => !input.disabled).length; + if (enabledCount === 0) { + Message.warning(t('topic.inputDisabled.lastEnabled')); + return; + } + + const { topic } = detail.value; + const payload = { + ...topic, + inputs: nextInputs, + input_uris: nextInputs + .filter((input) => !input.disabled) + .map((input) => input.uri), + }; + + inputToggleSavingUri.value = uri; + try { + await updateTopicFeed(topic.id, payload); + Message.success( + disabled + ? t('topic.inputDisabled.disabledSuccess') + : t('topic.inputDisabled.enabledSuccess') + ); + await fetchDetail(); + } catch (err: any) { + Message.error(err.message || t('topic.saveFailed')); + } finally { + inputToggleSavingUri.value = ''; + } + }; + const formatTime = (value?: string) => { if (!value) return '-'; return new Date(value).toLocaleString(); @@ -496,4 +595,15 @@ color: var(--color-danger-6, #f53f3f); font-size: 12px; } + + .input-count-muted { + font-size: 14px; + color: var(--color-text-3); + font-weight: normal; + } + + .input-disabled { + color: var(--color-text-3); + text-decoration: line-through; + } diff --git a/web/admin/src/views/dashboard/topic_feed/editor.vue b/web/admin/src/views/dashboard/topic_feed/editor.vue index a788b221..c511e1db 100644 --- a/web/admin/src/views/dashboard/topic_feed/editor.vue +++ b/web/admin/src/views/dashboard/topic_feed/editor.vue @@ -111,6 +111,11 @@ :title="source.description || source.uri" :description="source.description ? source.uri : undefined" /> + @@ -198,8 +203,8 @@ import { defaultFormData, formatAggregatorSummary, + countEnabledInputs, normalizeTopicPayload, - sourceToUri, topicFeedToFormData, type TopicFormData, } from './topicInputUtils'; @@ -229,10 +234,8 @@ const availableTopics = ref([]); const pickerLoading = ref(false); - const configuredInputCount = computed( - () => - formData.value.inputSources.filter((source) => sourceToUri(source) !== '') - .length + const configuredInputCount = computed(() => + countEnabledInputs(formData.value.inputSources) ); const reviewInputs = computed( diff --git a/web/admin/src/views/dashboard/topic_feed/topicInputUtils.ts b/web/admin/src/views/dashboard/topic_feed/topicInputUtils.ts index 0005b0fa..eb4a4d5c 100644 --- a/web/admin/src/views/dashboard/topic_feed/topicInputUtils.ts +++ b/web/admin/src/views/dashboard/topic_feed/topicInputUtils.ts @@ -13,6 +13,7 @@ export interface InputSourceItem { externalUrl: string; resourceId: string; description: string; + disabled: boolean; } export interface StepFormItem { @@ -51,6 +52,7 @@ export const defaultFormData = (): TopicFormData => ({ externalUrl: '', resourceId: '', description: '', + disabled: false, }, ], aggregator_config: [], @@ -62,6 +64,7 @@ export const parseUriToSource = ( const uri = typeof input === 'string' ? input : input.uri; const description = typeof input === 'string' ? '' : input.description?.trim() || ''; + const disabled = typeof input === 'string' ? false : Boolean(input.disabled); if (uri.startsWith('feedcraft://recipe/')) { return { @@ -69,6 +72,7 @@ export const parseUriToSource = ( externalUrl: '', resourceId: uri.slice('feedcraft://recipe/'.length), description, + disabled, }; } if (uri.startsWith('feedcraft://topic/')) { @@ -77,6 +81,7 @@ export const parseUriToSource = ( externalUrl: '', resourceId: uri.slice('feedcraft://topic/'.length), description, + disabled, }; } return { @@ -84,6 +89,7 @@ export const parseUriToSource = ( externalUrl: uri, resourceId: '', description, + disabled, }; }; @@ -97,11 +103,19 @@ export const sourceToUri = (source: InputSourceItem): string => { return source.externalUrl.trim(); }; +export const countEnabledInputs = (sources: InputSourceItem[]): number => + sources.filter((source) => sourceToUri(source) !== '' && !source.disabled) + .length; + export const topicFeedToFormData = (record: TopicFeed): TopicFormData => { const inputs = record.inputs && record.inputs.length > 0 ? record.inputs - : record.input_uris.map((uri) => ({ uri, description: '' })); + : record.input_uris.map((uri) => ({ + uri, + description: '', + disabled: false, + })); return { id: record.id, @@ -116,6 +130,7 @@ export const topicFeedToFormData = (record: TopicFeed): TopicFormData => { externalUrl: '', resourceId: '', description: '', + disabled: false, }, ], aggregator_config: (record.aggregator_config || []).map((step) => { @@ -147,6 +162,7 @@ export const normalizeTopicPayload = (formData: TopicFormData): TopicFeed => { .map((source) => ({ uri: sourceToUri(source), description: source.description.trim(), + disabled: source.disabled, })) .filter((item) => item.uri !== ''); @@ -155,7 +171,7 @@ export const normalizeTopicPayload = (formData: TopicFormData): TopicFeed => { title: formData.title.trim(), description: formData.description.trim(), inputs, - input_uris: inputs.map((item) => item.uri), + input_uris: inputs.filter((item) => !item.disabled).map((item) => item.uri), aggregator_config: formData.aggregator_config.map((step) => { const option: Record = {}; if (step.type === 'deduplicate') { From b2e2a1283a0554deac74518079728ed2adc931fe Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 1 Jun 2026 11:23:47 +0000 Subject: [PATCH 3/4] refactor(topic-feed): persist only structured inputs Co-authored-by: Colin --- internal/controller/feed_viewer_test.go | 2 +- internal/controller/topic_feed.go | 2 +- internal/controller/topic_feed_public_test.go | 28 +++---- internal/dao/migrate.go | 56 ++++++++++++++ internal/dao/topic.go | 66 ++++++----------- internal/dao/topic_test.go | 74 ++++++++++++++----- internal/feedruntime/builder.go | 5 +- internal/feedruntime/builder_test.go | 20 ++--- web/admin/src/api/topic.ts | 3 +- .../src/views/dashboard/topic_feed/detail.vue | 13 +--- .../dashboard/topic_feed/topicInputUtils.ts | 10 +-- .../views/dashboard/topic_feed/topic_feed.vue | 3 +- 12 files changed, 170 insertions(+), 112 deletions(-) diff --git a/internal/controller/feed_viewer_test.go b/internal/controller/feed_viewer_test.go index 0c18fc16..809cc23a 100644 --- a/internal/controller/feed_viewer_test.go +++ b/internal/controller/feed_viewer_test.go @@ -158,7 +158,7 @@ func TestPreviewFeedViewerSupportsTopicURI(t *testing.T) { ID: topicID, Title: "Preview Topic", Description: "Topic preview", - InputURIs: []string{"feedcraft://recipe/" + recipeID}, + Inputs: []dao.TopicInput{{URI: "feedcraft://recipe/" + recipeID}}, }) recorder := performFeedViewerPreviewRequest(t, http.MethodGet, "feedcraft://topic/"+topicID) diff --git a/internal/controller/topic_feed.go b/internal/controller/topic_feed.go index 34b1679e..3fb37723 100644 --- a/internal/controller/topic_feed.go +++ b/internal/controller/topic_feed.go @@ -305,7 +305,7 @@ func validateTopicConfig(ctx context.Context, db *gorm.DB, topicData *dao.TopicF return result, nil } - if len(topicData.InputURIs) == 0 { + if len(topicData.EnabledInputURIs()) == 0 { result.Valid = false result.Errors = append(result.Errors, TopicValidationIssue{ Field: "inputs", diff --git a/internal/controller/topic_feed_public_test.go b/internal/controller/topic_feed_public_test.go index 2918f2e1..696e3cb6 100644 --- a/internal/controller/topic_feed_public_test.go +++ b/internal/controller/topic_feed_public_test.go @@ -76,7 +76,7 @@ func TestPublicTopicFeed(t *testing.T) { ID: topicID, Title: "My Topic", Description: "Topic description", - InputURIs: []string{"feedcraft://recipe/" + recipeID}, + Inputs: []dao.TopicInput{{URI: "feedcraft://recipe/" + recipeID}}, }) req, err := http.NewRequest(http.MethodGet, "/topic/"+topicID, nil) @@ -105,9 +105,9 @@ func TestPublicTopicFeed(t *testing.T) { t.Run("returns 500 when topic build fails", func(t *testing.T) { topicID := uniqueTestID("topic-invalid") createTopicTestTopic(t, db, &dao.TopicFeed{ - ID: topicID, - Title: "Broken Topic", - InputURIs: []string{"feedcraft://broken/abc"}, + ID: topicID, + Title: "Broken Topic", + Inputs: []dao.TopicInput{{URI: "feedcraft://broken/abc"}}, }) req, err := http.NewRequest(http.MethodGet, "/topic/"+topicID, nil) @@ -125,9 +125,9 @@ func TestPublicTopicFeed(t *testing.T) { topicID := uniqueTestID("topic-partial") createTopicTestRecipe(t, db, recipeID) createTopicTestTopic(t, db, &dao.TopicFeed{ - ID: topicID, - Title: "Partial Topic", - InputURIs: []string{"feedcraft://recipe/" + recipeID, "http://127.0.0.1:1/unreachable.xml"}, + ID: topicID, + Title: "Partial Topic", + Inputs: []dao.TopicInput{{URI: "feedcraft://recipe/" + recipeID}, {URI: "http://127.0.0.1:1/unreachable.xml"}}, }) req, err := http.NewRequest(http.MethodGet, "/topic/"+topicID, nil) @@ -144,9 +144,9 @@ func TestPublicTopicFeed(t *testing.T) { t.Run("returns 500 when all upstreams fail", func(t *testing.T) { topicID := uniqueTestID("topic-all-failed") createTopicTestTopic(t, db, &dao.TopicFeed{ - ID: topicID, - Title: "Failed Topic", - InputURIs: []string{"http://127.0.0.1:1/a.xml", "http://127.0.0.1:1/b.xml"}, + ID: topicID, + Title: "Failed Topic", + Inputs: []dao.TopicInput{{URI: "http://127.0.0.1:1/a.xml"}, {URI: "http://127.0.0.1:1/b.xml"}}, }) req, err := http.NewRequest(http.MethodGet, "/topic/"+topicID, nil) @@ -182,7 +182,7 @@ func TestTopicFeedAdminEndpoints(t *testing.T) { body := `{ "id":"` + uniqueTestID("topic-validate-ok") + `", "title":"Tech Topic", - "input_uris":["feedcraft://recipe/` + recipeID + `"], + "inputs":[{"uri":"feedcraft://recipe/` + recipeID + `"}], "aggregator_config":[{"type":"limit","option":{"max":"10"}}] }` @@ -206,7 +206,7 @@ func TestTopicFeedAdminEndpoints(t *testing.T) { body := `{ "id":"` + uniqueTestID("topic-invalid-step") + `", "title":"Broken Step Topic", - "input_uris":["feedcraft://recipe/` + recipeID + `"], + "inputs":[{"uri":"feedcraft://recipe/` + recipeID + `"}], "aggregator_config":[{"type":"limit","option":{"max":"0"}}] }` @@ -230,7 +230,7 @@ func TestTopicFeedAdminEndpoints(t *testing.T) { body := `{ "id":"` + topicID + `", "title":"Cycle Topic", - "input_uris":["feedcraft://topic/` + topicID + `"], + "inputs":[{"uri":"feedcraft://topic/` + topicID + `"}], "aggregator_config":[{"type":"limit","option":{"max":"10"}}] }` @@ -255,7 +255,7 @@ func TestTopicFeedAdminEndpoints(t *testing.T) { ID: topicID, Title: "Detail Topic", Description: "detail description", - InputURIs: []string{"https://example.com/feed.xml"}, + Inputs: []dao.TopicInput{{URI: "https://example.com/feed.xml"}}, AggregatorConfig: []dao.AggregatorStep{ {Type: "limit", Option: map[string]string{"max": "20"}}, }, diff --git a/internal/dao/migrate.go b/internal/dao/migrate.go index a5176c28..330838de 100644 --- a/internal/dao/migrate.go +++ b/internal/dao/migrate.go @@ -35,6 +35,7 @@ func MigrateDatabases() { // Perform data migration from custom_recipes to custom_recipes_v2 migrateRecipesToV2(db) + migrateTopicFeedInputs(db) logrus.Info("migrate database done.") @@ -98,6 +99,61 @@ func migrateRecipesToV2(db *gorm.DB) { logrus.Info("recipe migration to v2 completed.") } +func migrateTopicFeedInputs(db *gorm.DB) { + if !db.Migrator().HasTable(&TopicFeed{}) { + return + } + if !db.Migrator().HasColumn("topic_feeds", "input_uris") { + return + } + + type legacyTopicInputRow struct { + ID string + Inputs string + InputURIs string + } + + var rows []legacyTopicInputRow + if err := db.Table("topic_feeds").Select("id, inputs, input_uris").Scan(&rows).Error; err != nil { + logrus.Errorf("failed to scan topic feed input_uris migration rows: %v", err) + return + } + + for _, row := range rows { + if row.InputURIs == "" || row.InputURIs == "null" || row.InputURIs == "[]" { + continue + } + if row.Inputs != "" && row.Inputs != "null" && row.Inputs != "[]" { + continue + } + + var uris []string + if err := json.Unmarshal([]byte(row.InputURIs), &uris); err != nil { + logrus.Errorf("failed to unmarshal legacy input_uris for topic %s: %v", row.ID, err) + continue + } + + inputs := make([]TopicInput, 0, len(uris)) + for _, uri := range uris { + inputs = append(inputs, TopicInput{URI: uri}) + } + inputsJSON, err := json.Marshal(inputs) + if err != nil { + logrus.Errorf("failed to marshal migrated inputs for topic %s: %v", row.ID, err) + continue + } + if err := db.Table("topic_feeds").Where("id = ?", row.ID).Update("inputs", string(inputsJSON)).Error; err != nil { + logrus.Errorf("failed to migrate topic inputs for topic %s: %v", row.ID, err) + } + } + + if err := db.Exec("ALTER TABLE topic_feeds DROP COLUMN input_uris").Error; err != nil { + logrus.Errorf("failed to drop legacy topic input_uris column: %v", err) + return + } + logrus.Info("topic feed input_uris migration completed.") +} + var defaultAdminUsername = "admin" var defaultPassword = "adminadmin" // default defaultPassword string diff --git a/internal/dao/topic.go b/internal/dao/topic.go index ed6b4ff0..bdb8da69 100644 --- a/internal/dao/topic.go +++ b/internal/dao/topic.go @@ -24,65 +24,45 @@ type TopicFeed struct { // Inputs carries URI plus optional description for admin display. Inputs []TopicInput `json:"inputs,omitempty" gorm:"serializer:json"` - // List of URIs representing inputs (derived from Inputs for runtime). - // Uses a custom protocol for internal resources to make routing elegant and standard. - // Examples: - // - "feedcraft://recipe/my-tech-recipe" (Internal RecipeFeed) - // - "feedcraft://topic/sub-topic-id" (Nested internal TopicFeed) - // - "https://external.com/rss.xml" (External raw feed) - InputURIs []string `json:"input_uris" gorm:"serializer:json"` - // Configuration for the aggregator pipeline AggregatorConfig []AggregatorStep `json:"aggregator_config" gorm:"serializer:json"` } -// NormalizeInputs keeps Inputs and InputURIs in sync. -// When Inputs is provided it becomes the source of truth; otherwise legacy InputURIs are upgraded. +// NormalizeInputs trims input metadata and removes blank URIs. func (t *TopicFeed) NormalizeInputs() { if t == nil { return } - if len(t.Inputs) > 0 { - uris := make([]string, 0, len(t.Inputs)) - normalized := make([]TopicInput, 0, len(t.Inputs)) - for _, item := range t.Inputs { - uri := strings.TrimSpace(item.URI) - if uri == "" { - continue - } - if !item.Disabled { - uris = append(uris, uri) - } - normalized = append(normalized, TopicInput{ - URI: uri, - Description: strings.TrimSpace(item.Description), - Disabled: item.Disabled, - }) + normalized := make([]TopicInput, 0, len(t.Inputs)) + for _, item := range t.Inputs { + uri := strings.TrimSpace(item.URI) + if uri == "" { + continue } - t.InputURIs = uris - t.Inputs = normalized - return + normalized = append(normalized, TopicInput{ + URI: uri, + Description: strings.TrimSpace(item.Description), + Disabled: item.Disabled, + }) } + t.Inputs = normalized +} - if len(t.InputURIs) == 0 { - t.Inputs = nil - return +// EnabledInputURIs returns input URIs that participate in topic aggregation. +func (t *TopicFeed) EnabledInputURIs() []string { + if t == nil { + return nil } - - inputs := make([]TopicInput, 0, len(t.InputURIs)) - for _, uri := range t.InputURIs { - uri = strings.TrimSpace(uri) - if uri == "" { + uris := make([]string, 0, len(t.Inputs)) + for _, item := range t.Inputs { + uri := strings.TrimSpace(item.URI) + if uri == "" || item.Disabled { continue } - inputs = append(inputs, TopicInput{URI: uri}) - } - t.Inputs = inputs - t.InputURIs = make([]string, 0, len(inputs)) - for _, item := range inputs { - t.InputURIs = append(t.InputURIs, item.URI) + uris = append(uris, uri) } + return uris } // AggregatorStep defines a single processing step in an Aggregator pipeline. diff --git a/internal/dao/topic_test.go b/internal/dao/topic_test.go index 9c2f1ad4..c683de03 100644 --- a/internal/dao/topic_test.go +++ b/internal/dao/topic_test.go @@ -1,6 +1,11 @@ package dao -import "testing" +import ( + "testing" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) func TestTopicFeed_NormalizeInputs_FromInputs(t *testing.T) { topic := &TopicFeed{ @@ -12,14 +17,15 @@ func TestTopicFeed_NormalizeInputs_FromInputs(t *testing.T) { topic.NormalizeInputs() - if len(topic.InputURIs) != 2 { - t.Fatalf("expected 2 input URIs, got %d", len(topic.InputURIs)) + enabled := topic.EnabledInputURIs() + if len(enabled) != 2 { + t.Fatalf("expected 2 enabled input URIs, got %d", len(enabled)) } - if topic.InputURIs[0] != "https://example.com/a.xml" { - t.Fatalf("unexpected first URI: %q", topic.InputURIs[0]) + if enabled[0] != "https://example.com/a.xml" { + t.Fatalf("unexpected first URI: %q", enabled[0]) } - if topic.InputURIs[1] != "feedcraft://recipe/foo" { - t.Fatalf("unexpected second URI: %q", topic.InputURIs[1]) + if enabled[1] != "feedcraft://recipe/foo" { + t.Fatalf("unexpected second URI: %q", enabled[1]) } if topic.Inputs[0].Description != "Tech news" { t.Fatalf("unexpected description: %q", topic.Inputs[0].Description) @@ -36,11 +42,12 @@ func TestTopicFeed_NormalizeInputs_SkipsDisabled(t *testing.T) { topic.NormalizeInputs() - if len(topic.InputURIs) != 1 { - t.Fatalf("expected 1 enabled URI, got %d", len(topic.InputURIs)) + enabled := topic.EnabledInputURIs() + if len(enabled) != 1 { + t.Fatalf("expected 1 enabled URI, got %d", len(enabled)) } - if topic.InputURIs[0] != "https://example.com/enabled.xml" { - t.Fatalf("unexpected enabled URI: %q", topic.InputURIs[0]) + if enabled[0] != "https://example.com/enabled.xml" { + t.Fatalf("unexpected enabled URI: %q", enabled[0]) } if len(topic.Inputs) != 2 { t.Fatalf("expected 2 inputs retained, got %d", len(topic.Inputs)) @@ -50,17 +57,50 @@ func TestTopicFeed_NormalizeInputs_SkipsDisabled(t *testing.T) { } } -func TestTopicFeed_NormalizeInputs_FromLegacyURIs(t *testing.T) { - topic := &TopicFeed{ - InputURIs: []string{"https://example.com/a.xml", "https://example.com/b.xml"}, +func TestMigrateTopicFeedInputs_FromLegacyInputURIs(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) } - topic.NormalizeInputs() + if err := db.Exec(` + CREATE TABLE topic_feeds ( + id TEXT PRIMARY KEY, + title TEXT, + input_uris TEXT, + aggregator_config TEXT, + created_at DATETIME, + updated_at DATETIME + ) + `).Error; err != nil { + t.Fatalf("create legacy topic_feeds table: %v", err) + } + if err := db.Exec( + `INSERT INTO topic_feeds (id, title, input_uris, aggregator_config) VALUES (?, ?, ?, ?)`, + "legacy-topic", + "Legacy Topic", + `["https://example.com/a.xml","feedcraft://recipe/foo"]`, + `[]`, + ).Error; err != nil { + t.Fatalf("insert legacy topic feed: %v", err) + } + if err := db.AutoMigrate(&TopicFeed{}); err != nil { + t.Fatalf("auto migrate topic feed: %v", err) + } + migrateTopicFeedInputs(db) + + var topic TopicFeed + if err := db.First(&topic, "id = ?", "legacy-topic").Error; err != nil { + t.Fatalf("load migrated topic feed: %v", err) + } if len(topic.Inputs) != 2 { - t.Fatalf("expected 2 inputs, got %d", len(topic.Inputs)) + t.Fatalf("expected 2 migrated inputs, got %d", len(topic.Inputs)) } if topic.Inputs[0].URI != "https://example.com/a.xml" { - t.Fatalf("unexpected first input URI: %q", topic.Inputs[0].URI) + t.Fatalf("unexpected first migrated input URI: %q", topic.Inputs[0].URI) + } + if db.Migrator().HasColumn("topic_feeds", "input_uris") { + t.Fatal("expected legacy input_uris column to be dropped") } } diff --git a/internal/feedruntime/builder.go b/internal/feedruntime/builder.go index 3cda31d4..a3ac0d9e 100644 --- a/internal/feedruntime/builder.go +++ b/internal/feedruntime/builder.go @@ -152,8 +152,9 @@ func (b *Builder) BuildTopic(ctx context.Context, topic *dao.TopicFeed, stack [] return nil, err } - inputs := make([]engine.FeedProvider, 0, len(topic.InputURIs)) - for _, inputURI := range topic.InputURIs { + enabledInputURIs := topic.EnabledInputURIs() + inputs := make([]engine.FeedProvider, 0, len(enabledInputURIs)) + for _, inputURI := range enabledInputURIs { spec := InputSpec{ Kind: InputKindURI, URI: inputURI, diff --git a/internal/feedruntime/builder_test.go b/internal/feedruntime/builder_test.go index b7cfb024..d6320296 100644 --- a/internal/feedruntime/builder_test.go +++ b/internal/feedruntime/builder_test.go @@ -363,14 +363,14 @@ func TestDeduplicate_ByEmbedding_ThresholdOutOfRange(t *testing.T) { func TestBuildTopicProvider_NestedTopics(t *testing.T) { db := newTestDB(t) require.NoError(t, db.Create(&dao.TopicFeed{ - ID: "child", - Title: "Child Topic", - InputURIs: []string{"https://example.com/feed.xml"}, + ID: "child", + Title: "Child Topic", + Inputs: []dao.TopicInput{{URI: "https://example.com/feed.xml"}}, }).Error) require.NoError(t, db.Create(&dao.TopicFeed{ - ID: "parent", - Title: "Parent Topic", - InputURIs: []string{"feedcraft://topic/child"}, + ID: "parent", + Title: "Parent Topic", + Inputs: []dao.TopicInput{{URI: "feedcraft://topic/child"}}, AggregatorConfig: []dao.AggregatorStep{ {Type: "limit", Option: map[string]string{"max": "5"}}, }, @@ -401,12 +401,12 @@ func TestBuildTopicProvider_NestedTopics(t *testing.T) { func TestBuildTopicProvider_CycleDetection(t *testing.T) { db := newTestDB(t) require.NoError(t, db.Create(&dao.TopicFeed{ - ID: "A", - InputURIs: []string{"feedcraft://topic/B"}, + ID: "A", + Inputs: []dao.TopicInput{{URI: "feedcraft://topic/B"}}, }).Error) require.NoError(t, db.Create(&dao.TopicFeed{ - ID: "B", - InputURIs: []string{"feedcraft://topic/A"}, + ID: "B", + Inputs: []dao.TopicInput{{URI: "feedcraft://topic/A"}}, }).Error) builder := NewBuilder(db) diff --git a/web/admin/src/api/topic.ts b/web/admin/src/api/topic.ts index 892d196a..e3329c00 100644 --- a/web/admin/src/api/topic.ts +++ b/web/admin/src/api/topic.ts @@ -21,8 +21,7 @@ export interface TopicFeed { id: string; title?: string; description?: string; - inputs?: TopicInput[]; - input_uris: string[]; + inputs: TopicInput[]; aggregator_config: AggregatorStep[]; } diff --git a/web/admin/src/views/dashboard/topic_feed/detail.vue b/web/admin/src/views/dashboard/topic_feed/detail.vue index 9e1194c8..abdb81ec 100644 --- a/web/admin/src/views/dashboard/topic_feed/detail.vue +++ b/web/admin/src/views/dashboard/topic_feed/detail.vue @@ -375,12 +375,7 @@ const topicInputs = computed((): TopicInput[] => { if (!detail.value) return []; const { topic } = detail.value; - if (topic.inputs?.length) return topic.inputs; - return topic.input_uris.map((uri) => ({ - uri, - description: '', - disabled: false, - })); + return topic.inputs || []; }); const enabledInputCount = computed( @@ -407,9 +402,6 @@ const payload = { ...topic, inputs: nextInputs, - input_uris: nextInputs - .filter((input) => !input.disabled) - .map((input) => input.uri), }; inputToggleSavingUri.value = uri; @@ -509,8 +501,7 @@ try { await navigator.clipboard.writeText(publicUrl.value); Message.success(t('topic.copyLink')); - } catch (err) { - console.error(err); + } catch { Message.error(t('topic.copyLinkFailed')); } }; diff --git a/web/admin/src/views/dashboard/topic_feed/topicInputUtils.ts b/web/admin/src/views/dashboard/topic_feed/topicInputUtils.ts index eb4a4d5c..97ff02e1 100644 --- a/web/admin/src/views/dashboard/topic_feed/topicInputUtils.ts +++ b/web/admin/src/views/dashboard/topic_feed/topicInputUtils.ts @@ -108,14 +108,7 @@ export const countEnabledInputs = (sources: InputSourceItem[]): number => .length; export const topicFeedToFormData = (record: TopicFeed): TopicFormData => { - const inputs = - record.inputs && record.inputs.length > 0 - ? record.inputs - : record.input_uris.map((uri) => ({ - uri, - description: '', - disabled: false, - })); + const inputs = record.inputs || []; return { id: record.id, @@ -171,7 +164,6 @@ export const normalizeTopicPayload = (formData: TopicFormData): TopicFeed => { title: formData.title.trim(), description: formData.description.trim(), inputs, - input_uris: inputs.filter((item) => !item.disabled).map((item) => item.uri), aggregator_config: formData.aggregator_config.map((step) => { const option: Record = {}; if (step.type === 'deduplicate') { diff --git a/web/admin/src/views/dashboard/topic_feed/topic_feed.vue b/web/admin/src/views/dashboard/topic_feed/topic_feed.vue index 7171277a..546f6421 100644 --- a/web/admin/src/views/dashboard/topic_feed/topic_feed.vue +++ b/web/admin/src/views/dashboard/topic_feed/topic_feed.vue @@ -116,8 +116,7 @@ const loading = ref(false); const inputCount = (record: TopicFeed) => { - if (record.inputs?.length) return record.inputs.length; - return record.input_uris.length; + return record.inputs?.length || 0; }; const fetchTopics = async () => { From 49535aff71f4d051e86e1d423ccc2b1770ba8d24 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 1 Jun 2026 12:31:11 +0000 Subject: [PATCH 4/4] refactor(topic-feed): clean redundant NormalizeInputs calls Remove GORM BeforeSave hook. Explicitly normalize inputs before Write/Update DB operations in Controller. Strip redundant read-path normalizations. Co-authored-by: Colin --- internal/controller/topic_feed.go | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/internal/controller/topic_feed.go b/internal/controller/topic_feed.go index 3fb37723..c9e47f63 100644 --- a/internal/controller/topic_feed.go +++ b/internal/controller/topic_feed.go @@ -66,14 +66,13 @@ func CreateTopicFeed(c *gin.Context) { c.JSON(http.StatusBadRequest, util.APIResponse[any]{Msg: err.Error()}) return } - topicData.NormalizeInputs() db := util.GetDatabase() + topicData.NormalizeInputs() if err := dao.CreateTopicFeed(db, &topicData); err != nil { c.JSON(http.StatusInternalServerError, util.APIResponse[any]{Msg: err.Error()}) return } - topicData.NormalizeInputs() c.JSON(http.StatusCreated, util.APIResponse[any]{Data: topicData}) } @@ -91,7 +90,6 @@ func GetTopicFeed(c *gin.Context) { c.JSON(http.StatusInternalServerError, util.APIResponse[any]{Msg: err.Error()}) return } - topicData.NormalizeInputs() c.JSON(http.StatusOK, util.APIResponse[any]{Data: topicData}) } @@ -104,10 +102,6 @@ func ListTopicFeeds(c *gin.Context) { return } - for _, topic := range topicList { - topic.NormalizeInputs() - } - c.JSON(http.StatusOK, util.APIResponse[any]{Data: topicList}) } @@ -123,7 +117,6 @@ func UpdateTopicFeed(c *gin.Context) { if id != topicData.ID { topicData.ID = id } - topicData.NormalizeInputs() db := util.GetDatabase() @@ -137,11 +130,11 @@ func UpdateTopicFeed(c *gin.Context) { return } + topicData.NormalizeInputs() if err := dao.UpdateTopicFeed(db, &topicData); err != nil { c.JSON(http.StatusInternalServerError, util.APIResponse[any]{Msg: err.Error()}) return } - topicData.NormalizeInputs() c.JSON(http.StatusOK, util.APIResponse[any]{Data: topicData}) } @@ -168,7 +161,6 @@ func ValidateTopicFeed(c *gin.Context) { c.JSON(http.StatusBadRequest, util.APIResponse[any]{Msg: err.Error()}) return } - topicData.NormalizeInputs() result, err := validateTopicConfig(c.Request.Context(), util.GetDatabase(), &topicData) if err != nil { @@ -192,7 +184,6 @@ func GetTopicFeedDetail(c *gin.Context) { c.JSON(http.StatusInternalServerError, util.APIResponse[any]{Msg: err.Error()}) return } - topicData.NormalizeInputs() health, err := dao.GetResourceHealth(db, dao.ResourceTypeTopic, id) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {