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/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 8e2a6e56..c9e47f63 100644 --- a/internal/controller/topic_feed.go +++ b/internal/controller/topic_feed.go @@ -68,6 +68,7 @@ func CreateTopicFeed(c *gin.Context) { } db := util.GetDatabase() + topicData.NormalizeInputs() if err := dao.CreateTopicFeed(db, &topicData); err != nil { c.JSON(http.StatusInternalServerError, util.APIResponse[any]{Msg: err.Error()}) return @@ -129,6 +130,7 @@ 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 @@ -211,9 +213,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{ @@ -273,6 +275,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{ @@ -291,19 +296,19 @@ 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: "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/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 7d692a1f..bdb8da69 100644 --- a/internal/dao/topic.go +++ b/internal/dao/topic.go @@ -1,9 +1,19 @@ package dao import ( + "strings" + "gorm.io/gorm" ) +// 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. type TopicFeed struct { BaseModelWithoutPK @@ -11,18 +21,50 @@ type TopicFeed struct { Title string `json:"title,omitempty"` Description string `json:"description,omitempty"` - // List of URIs representing inputs. - // 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"` + // Inputs carries URI plus optional description for admin display. + Inputs []TopicInput `json:"inputs,omitempty" gorm:"serializer:json"` // Configuration for the aggregator pipeline AggregatorConfig []AggregatorStep `json:"aggregator_config" gorm:"serializer:json"` } +// NormalizeInputs trims input metadata and removes blank URIs. +func (t *TopicFeed) NormalizeInputs() { + if t == nil { + return + } + + normalized := make([]TopicInput, 0, len(t.Inputs)) + for _, item := range t.Inputs { + uri := strings.TrimSpace(item.URI) + if uri == "" { + continue + } + normalized = append(normalized, TopicInput{ + URI: uri, + Description: strings.TrimSpace(item.Description), + Disabled: item.Disabled, + }) + } + t.Inputs = normalized +} + +// EnabledInputURIs returns input URIs that participate in topic aggregation. +func (t *TopicFeed) EnabledInputURIs() []string { + if t == nil { + return nil + } + uris := make([]string, 0, len(t.Inputs)) + for _, item := range t.Inputs { + uri := strings.TrimSpace(item.URI) + if uri == "" || item.Disabled { + continue + } + uris = append(uris, uri) + } + return uris +} + // 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..c683de03 --- /dev/null +++ b/internal/dao/topic_test.go @@ -0,0 +1,106 @@ +package dao + +import ( + "testing" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +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() + + enabled := topic.EnabledInputURIs() + if len(enabled) != 2 { + t.Fatalf("expected 2 enabled input URIs, got %d", len(enabled)) + } + if enabled[0] != "https://example.com/a.xml" { + t.Fatalf("unexpected first URI: %q", enabled[0]) + } + 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) + } +} + +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() + + enabled := topic.EnabledInputURIs() + if len(enabled) != 1 { + t.Fatalf("expected 1 enabled URI, got %d", len(enabled)) + } + 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)) + } + if !topic.Inputs[1].Disabled { + t.Fatal("expected second input to stay disabled") + } +} + +func TestMigrateTopicFeedInputs_FromLegacyInputURIs(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + + 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 migrated inputs, got %d", len(topic.Inputs)) + } + if topic.Inputs[0].URI != "https://example.com/a.xml" { + 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 9f9a996c..e3329c00 100644 --- a/web/admin/src/api/topic.ts +++ b/web/admin/src/api/topic.ts @@ -11,11 +11,17 @@ export interface AggregatorStep { option: Record; } +export interface TopicInput { + uri: string; + description?: string; + disabled?: boolean; +} + export interface TopicFeed { id: string; title?: string; description?: string; - input_uris: string[]; + inputs: TopicInput[]; 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..fb4a6d05 100644 --- a/web/admin/src/locale/en-US/topic.ts +++ b/web/admin/src/locale/en-US/topic.ts @@ -39,6 +39,35 @@ 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.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', + '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..0b9f7064 100644 --- a/web/admin/src/locale/zh-CN/topic.ts +++ b/web/admin/src/locale/zh-CN/topic.ts @@ -38,6 +38,31 @@ export default { '外部 RSS 直接填入 http/https 地址;选择 Recipe 或 Topic 可从下拉列表中选取 FeedCraft 内部资源。', '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': '当前预览地址', + '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..50f2c405 --- /dev/null +++ b/web/admin/src/views/dashboard/topic_feed/components/TopicInputSourcesEditor.vue @@ -0,0 +1,326 @@ + + + + + + + diff --git a/web/admin/src/views/dashboard/topic_feed/detail.vue b/web/admin/src/views/dashboard/topic_feed/detail.vue index b0cd8f9f..abdb81ec 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 @@ - + + + - - - {{ uri }} - - + + + + @@ -305,7 +355,9 @@ AggregatorStep, SubFeedHealth, TopicDetail, + TopicInput, getTopicFeedDetail, + updateTopicFeed, } from '@/api/topic'; const { t } = useI18n(); @@ -315,10 +367,59 @@ 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((): TopicInput[] => { + if (!detail.value) return []; + const { topic } = detail.value; + return topic.inputs || []; + }); + + 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, + }; + + 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(); @@ -400,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')); } }; @@ -486,4 +586,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 new file mode 100644 index 00000000..c511e1db --- /dev/null +++ b/web/admin/src/views/dashboard/topic_feed/editor.vue @@ -0,0 +1,407 @@ + + + + + + + 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..97ff02e1 --- /dev/null +++ b/web/admin/src/views/dashboard/topic_feed/topicInputUtils.ts @@ -0,0 +1,221 @@ +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; + disabled: boolean; +} + +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: '', + disabled: false, + }, + ], + 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() || ''; + const disabled = typeof input === 'string' ? false : Boolean(input.disabled); + + if (uri.startsWith('feedcraft://recipe/')) { + return { + sourceType: 'recipe', + externalUrl: '', + resourceId: uri.slice('feedcraft://recipe/'.length), + description, + disabled, + }; + } + if (uri.startsWith('feedcraft://topic/')) { + return { + sourceType: 'topic', + externalUrl: '', + resourceId: uri.slice('feedcraft://topic/'.length), + description, + disabled, + }; + } + return { + sourceType: 'external', + externalUrl: uri, + resourceId: '', + description, + disabled, + }; +}; + +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 countEnabledInputs = (sources: InputSourceItem[]): number => + sources.filter((source) => sourceToUri(source) !== '' && !source.disabled) + .length; + +export const topicFeedToFormData = (record: TopicFeed): TopicFormData => { + const inputs = record.inputs || []; + + return { + id: record.id, + title: record.title || '', + description: record.description || '', + inputSources: + inputs.length > 0 + ? inputs.map(parseUriToSource) + : [ + { + sourceType: 'external', + externalUrl: '', + resourceId: '', + description: '', + disabled: false, + }, + ], + 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(), + disabled: source.disabled, + })) + .filter((item) => item.uri !== ''); + + return { + id: formData.id.trim(), + title: formData.title.trim(), + description: formData.description.trim(), + inputs, + 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..546f6421 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,26 @@ :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 }} -
-
-
- - -
- -