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:
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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user