-
Notifications
You must be signed in to change notification settings - Fork 11
优化 Topic Feed 管理:分步向导、输入备注与预览 #806
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
1db6ea3
0e2b9d0
b2e2a12
49535af
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -152,3 +152,4 @@ db/ | |
| start.sh | ||
| start-full.sh | ||
| .claude/ | ||
| .dev-data/ | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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() | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. issue (bug_risk): NormalizeInputs is called on a copy of each TopicFeed, so list responses never expose normalized Inputs Ranging with Iterate by index instead so you mutate the slice elements: for i := range topicList {
topicList[i].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() | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
||
| 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() | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
||
| 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{ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,28 +1,85 @@ | ||
| 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 | ||
| ID string `gorm:"primaryKey" json:"id" binding:"required"` | ||
| 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"` | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 2. Inputs not validated on save CreateTopicFeed/UpdateTopicFeed 在写入数据库前未强制校验“至少一个启用输入源”,且 InputURIs 不再是 binding required,导致可保存 inputs/input_uris 为空的 TopicFeed,运行时构建 Topic 会得到 0 个输入或直接失败。 Agent Prompt
|
||
| // 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" | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,101 @@ | ||
| <template> | ||
| <div class="feed-preview-panel"> | ||
| <a-spin :loading="loading" style="width: 100%"> | ||
| <a-alert v-if="errorMessage" type="error" class="mb-4" show-icon> | ||
| {{ errorMessage }} | ||
| </a-alert> | ||
| <FeedViewContainer v-if="feedContent" :feed-data="feedContent" /> | ||
| <a-empty | ||
| v-else-if="!loading && !errorMessage" | ||
| :description="emptyDescription || t('feedViewer.resultPreview')" | ||
| /> | ||
| </a-spin> | ||
| </div> | ||
| </template> | ||
|
|
||
| <script lang="ts" setup> | ||
| import { ref, watch } from 'vue'; | ||
| import { useI18n } from 'vue-i18n'; | ||
| import { previewFeed, type FeedViewerPreview } from '@/api/feed_viewer'; | ||
| import FeedViewContainer from '@/views/dashboard/feed_viewer/feed_view_container.vue'; | ||
|
|
||
| const props = withDefaults( | ||
| defineProps<{ | ||
| inputUri?: string; | ||
| autoLoad?: boolean; | ||
| emptyDescription?: string; | ||
| }>(), | ||
| { | ||
| inputUri: '', | ||
| autoLoad: false, | ||
| emptyDescription: '', | ||
| } | ||
| ); | ||
|
|
||
| const { t } = useI18n(); | ||
| const loading = ref(false); | ||
| const errorMessage = ref(''); | ||
| const feedContent = ref<FeedViewerPreview | null>(null); | ||
| let requestSeq = 0; | ||
|
|
||
| const resetState = () => { | ||
| requestSeq += 1; | ||
| feedContent.value = null; | ||
| errorMessage.value = ''; | ||
| }; | ||
|
|
||
| const loadPreview = async (inputUri = props.inputUri) => { | ||
| const uri = inputUri?.trim(); | ||
| if (!uri) { | ||
| resetState(); | ||
| return; | ||
| } | ||
|
|
||
| const currentSeq = requestSeq + 1; | ||
| requestSeq = currentSeq; | ||
| loading.value = true; | ||
| errorMessage.value = ''; | ||
|
|
||
| try { | ||
| const response = await previewFeed(uri); | ||
| if (currentSeq !== requestSeq) return; | ||
| feedContent.value = response.data ?? null; | ||
| if (!feedContent.value) { | ||
| errorMessage.value = response.msg || t('topic.inputPreview.failed'); | ||
| } | ||
| } catch (err: any) { | ||
| if (currentSeq !== requestSeq) return; | ||
| feedContent.value = null; | ||
| errorMessage.value = err.message || t('topic.inputPreview.failed'); | ||
| } finally { | ||
| if (currentSeq === requestSeq) { | ||
| loading.value = false; | ||
| } | ||
| } | ||
| }; | ||
|
|
||
| watch( | ||
| () => props.inputUri, | ||
| (uri) => { | ||
| if (!props.autoLoad) { | ||
| resetState(); | ||
| return; | ||
| } | ||
| loadPreview(uri); | ||
| } | ||
| ); | ||
|
|
||
| defineExpose({ | ||
| loadPreview, | ||
| resetState, | ||
| loading, | ||
| feedContent, | ||
| errorMessage, | ||
| }); | ||
| </script> | ||
|
|
||
| <script lang="ts"> | ||
| export default { | ||
| name: 'FeedPreviewPanel', | ||
| }; | ||
| </script> | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
此处对
NormalizeInputs()的调用是多余的,因为在第 69 行保存之前已经对输入源进行了归一化,且 GORM 的Create操作并不会修改它们。移除此调用可以避免不必要的内存分配和处理开销。