feat: 推荐问题配置支持按标签搜索并在列表展示标签

- 后端列表接口为每个推荐问题附带 tags,并支持 tag_ids OR 过滤
- 前端配置页新增 TagSearchBar(标题/内容搜索 + 标签筛选),卡片展示标签
- 打标签后失效 recommended-questions 查询,列表即时刷新
- 过滤时禁用拖拽排序,避免破坏 sort_order

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
吉鑫升andClaude Opus 4.8 committed 2026-06-16 16:52:30 +08:00
1 parent 5182ea5d11
commit 97da7dc87f
6 files changed
+161 -7

No files matched your search

@@ -1,8 +1,12 @@
import type { Tag } from "@/core/tags";
export interface RecommendedQuestion {
id: string;
title: string;
content: string;
sort_order: number;
/** Tags assigned to this question (按岗位分发标签). */
tags?: Tag[];
created_at?: string;
updated_at?: string;
}
+1
View File
@@ -83,6 +83,7 @@ export function useSetResourceTags() {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
void queryClient.invalidateQueries({ queryKey: ["agents"] });
void queryClient.invalidateQueries({ queryKey: ["scheduled-tasks"] });
void queryClient.invalidateQueries({ queryKey: ["recommended-questions"] });
},
});
}
@@ -15,6 +15,8 @@ import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { ResourceTagDialog } from "@/components/workspace/tags/resource-tag-dialog";
import { TagChipList } from "@/components/workspace/tags/tag-chip";
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
import { useTheme } from "@/contexts/ThemeContext";
import {
Dialog,
@@ -83,6 +85,25 @@ export default function RecommendedQuestionsPage({
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
// 名称 / 标签搜索
const [search, setSearch] = useState("");
const [tagIds, setTagIds] = useState<string[]>([]);
const isFiltering = search.trim() !== "" || tagIds.length > 0;
const needle = search.trim().toLowerCase();
const visibleQuestions = isFiltering
? questions.filter((q) => {
const matchesText =
needle === "" ||
q.title.toLowerCase().includes(needle) ||
q.content.toLowerCase().includes(needle);
const matchesTags =
tagIds.length === 0 ||
(q.tags ?? []).some((t) => tagIds.includes(t.id));
return matchesText && matchesTags;
})
: questions;
function openAdd() {
setFormTitle("");
setFormContent("");
@@ -203,6 +224,16 @@ export default function RecommendedQuestionsPage({
</Button>
</div>
{!isLoading && !error && questions.length > 0 && (
<TagSearchBar
targetType="recommended_question"
searchPlaceholder="搜索标题 / 内容"
onSearchChange={setSearch}
tagIds={tagIds}
onTagIdsChange={setTagIds}
/>
)}
{isLoading ? (
<div className="flex items-center justify-center py-16">
<Loader2Icon className="text-muted-foreground size-6 animate-spin" />
@@ -219,15 +250,19 @@ export default function RecommendedQuestionsPage({
添加第一个问题
</Button>
</div>
) : visibleQuestions.length === 0 ? (
<div className="text-muted-foreground flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-sm dark:text-[#7FACF2] dark:!border-[#213F62]">
<p>没有匹配的推荐问题</p>
</div>
) : (
<div className="flex flex-col gap-2 ">
{questions.map((q, index) => (
{visibleQuestions.map((q, index) => (
<div
key={q.id}
draggable
onDragStart={() => handleDragStart(index)}
onDragOver={(e) => handleDragOver(e, index)}
onDrop={() => handleDrop(index)}
draggable={!isFiltering}
onDragStart={() => !isFiltering && handleDragStart(index)}
onDragOver={(e) => !isFiltering && handleDragOver(e, index)}
onDrop={() => !isFiltering && handleDrop(index)}
onDragEnd={() => {
setDragIndex(null);
setDragOverIndex(null);
@@ -238,7 +273,13 @@ export default function RecommendedQuestionsPage({
: "border-[#e4e4e7]"
}`}
>
<GripVerticalIcon className="text-muted-foreground/40 mt-0.5 size-4 shrink-0 cursor-grab active:cursor-grabbing dark:text-[#566B7F]" />
<GripVerticalIcon
className={`text-muted-foreground/40 mt-0.5 size-4 shrink-0 dark:text-[#566B7F] ${
isFiltering
? "cursor-not-allowed opacity-40"
: "cursor-grab active:cursor-grabbing"
}`}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-[#18181b] dark:text-white">
{q.title}
@@ -246,6 +287,9 @@ export default function RecommendedQuestionsPage({
<div className="text-muted-foreground mt-0.5 line-clamp-2 text-xs dark:text-[#7FACF2]">
{q.content}
</div>
{(q.tags?.length ?? 0) > 0 && (
<TagChipList tags={q.tags!} className="mt-2" />
)}
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
@@ -399,6 +399,11 @@ unassigned users inherit, else `null`); powers the top-bar 岗位 label
changes: `GET /api/recommended-questions` is now
filtered by the caller's position (`?all=1` bypasses for admin management) and
admin can tag questions via `/api/tags/assignments/recommended_question/{id}`.
Each listed question now carries its assigned `tags` (bulk-attached via
`tag_store.list_assignments`), and the list accepts a repeatable `tag_ids`
OR-filter — so the 推荐问题配置 page can display per-question 标签 and search by
them (frontend `RecommendedQuestionsPage` + `TagSearchBar`). Tests:
`tests/test_recommended_questions_tags.py`.
`GET /api/skills` no longer restricts by position; each skill carries a
`favorited` flag (true = in the caller's 我的技能, e.g. position-granted) so the
skills page's 我的 tab includes owned ∪ position-granted skills while the 广场
@@ -27,6 +27,7 @@ class RecommendedQuestionResponse(BaseModel):
title: str
content: str
sort_order: int = 0
tags: list[dict] = Field(default_factory=list, description="Tags assigned to this question (按岗位分发标签)")
created_at: datetime | str | None = None
updated_at: datetime | str | None = None
@@ -62,6 +63,7 @@ def _get_store(request: Request):
async def list_recommended_questions(
request: Request,
all: bool = Query(default=False, description="Admin management view: return every question, unfiltered by 岗位."),
tag_ids: list[str] | None = Query(default=None, description="Keep questions carrying any of these tags (OR)."),
) -> RecommendedQuestionsListResponse:
store = _get_store(request)
rows = await store.list_all()
@@ -78,7 +80,26 @@ async def list_recommended_questions(
visible = None
if visible is not None:
rows = [r for r in rows if r["id"] in visible]
return RecommendedQuestionsListResponse(questions=[RecommendedQuestionResponse(**r) for r in rows])
responses = [RecommendedQuestionResponse(**r) for r in rows]
# Bulk-attach the tags assigned to each question so the management list can
# display them and filter by 标签. Best-effort: a missing/failing tag store
# never breaks the question list.
tag_store = getattr(request.app.state, "tag_store", None)
if tag_store is not None and responses:
try:
assignments = await tag_store.list_assignments("recommended_question", [r.id for r in responses])
except Exception:
logger.debug("recommended-question tag attach failed", exc_info=True)
assignments = {}
for resp in responses:
resp.tags = assignments.get(resp.id, [])
if tag_ids:
wanted = set(tag_ids)
responses = [r for r in responses if any(t.get("id") in wanted for t in r.tags)]
return RecommendedQuestionsListResponse(questions=responses)
@router.post("/recommended-questions", response_model=RecommendedQuestionResponse, status_code=201)
@@ -0,0 +1,79 @@
"""Router-level tests for recommended-question tag embedding + filtering.
The list endpoint coroutine is invoked directly with a stub request whose
``app.state`` carries a fake question store and an in-memory tag store, mirroring
the lightweight router tests already in this suite (see ``test_tags_api.py``).
"""
from __future__ import annotations
from types import SimpleNamespace
import pytest
from app.gateway.routers import recommended_questions as rq_router
from deerflow.persistence.tags import MemoryTagStore
class _FakeQuestionStore:
def __init__(self, rows: list[dict]) -> None:
self._rows = rows
async def list_all(self) -> list[dict]:
return list(self._rows)
def _request(question_store, tag_store) -> SimpleNamespace:
state = SimpleNamespace(
user=None,
recommended_question_store=question_store,
tag_store=tag_store,
position_sync=None,
)
return SimpleNamespace(state=state, app=SimpleNamespace(state=state))
def _rows() -> list[dict]:
return [
{"id": "q1", "title": "市场分析", "content": "分析一下市场", "sort_order": 0},
{"id": "q2", "title": "理赔报告", "content": "生成理赔报告", "sort_order": 1},
]
@pytest.mark.asyncio
async def test_list_embeds_assigned_tags() -> None:
tag_store = MemoryTagStore()
tag = await tag_store.create_tag(
{"id": "t1", "name": "理赔", "tag_type": "recommended_question"}
)
await tag_store.set_target_tags("recommended_question", "q2", [tag["id"]])
req = _request(_FakeQuestionStore(_rows()), tag_store)
resp = await rq_router.list_recommended_questions(req, all=True)
by_id = {q.id: q for q in resp.questions}
assert by_id["q1"].tags == []
assert [t["name"] for t in by_id["q2"].tags] == ["理赔"]
@pytest.mark.asyncio
async def test_list_filters_by_tag_ids() -> None:
tag_store = MemoryTagStore()
tag = await tag_store.create_tag(
{"id": "t1", "name": "理赔", "tag_type": "recommended_question"}
)
await tag_store.set_target_tags("recommended_question", "q2", [tag["id"]])
req = _request(_FakeQuestionStore(_rows()), tag_store)
resp = await rq_router.list_recommended_questions(req, all=True, tag_ids=[tag["id"]])
assert [q.id for q in resp.questions] == ["q2"]
@pytest.mark.asyncio
async def test_list_without_tag_store_does_not_break() -> None:
req = _request(_FakeQuestionStore(_rows()), None)
resp = await rq_router.list_recommended_questions(req, all=True)
assert [q.id for q in resp.questions] == ["q1", "q2"]
assert all(q.tags == [] for q in resp.questions)