feat(frontend): 广场搜索体验优化与切换tab重置筛选
- 智能体广场:切换 我的/广场/私密广场 时清空重置搜索/标签/排序/发布者筛选 - 通用搜索栏(TagSearchBar):新增「搜索」按钮与回车立即触发(跳过防抖)、 搜索中转圈提示,新增 hideTags / searchPlaceholder 选项 - 定时任务广场:新增客户端搜索(名称/摘要/创建者,兼容脱敏文本) - agents 分页 hook 暴露 isFetching 以驱动搜索中状态 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
87e0ae683e
commit
8fbbfc8093
4 files changed
+127
-25
No files matched your search
@@ -74,6 +74,7 @@ export function AgentGallery() {
|
||||
agents: mainAgents,
|
||||
total: mainTotal,
|
||||
isLoading: mainLoading,
|
||||
isFetching: mainFetching,
|
||||
} = useAgentsPaginated({
|
||||
scope: mainScope,
|
||||
search,
|
||||
@@ -89,6 +90,7 @@ export function AgentGallery() {
|
||||
agents: privateAgents,
|
||||
total: privateTotal,
|
||||
isLoading: isPrivateLoading,
|
||||
isFetching: isPrivateFetching,
|
||||
error: privateError,
|
||||
} = usePrivateAgentsPaginated(privatePassword, {
|
||||
search,
|
||||
@@ -315,9 +317,16 @@ export function AgentGallery() {
|
||||
{/* Tabs */}
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) =>
|
||||
setActiveTab(v as "all" | "mine" | "private")
|
||||
}
|
||||
onValueChange={(v) => {
|
||||
setActiveTab(v as "all" | "mine" | "private");
|
||||
// Each tab starts from a clean slate: switching tabs clears the
|
||||
// keyword / tag / sort / publisher filters instead of carrying them
|
||||
// over. The TagSearchBar's own input box is reset via its key below.
|
||||
setSearch("");
|
||||
setTagIds([]);
|
||||
setSort("time_desc");
|
||||
setPublisher(null);
|
||||
}}
|
||||
className="flex flex-1 flex-col overflow-hidden"
|
||||
>
|
||||
<div className="mx-6 mt-4 flex flex-col gap-3">
|
||||
@@ -351,7 +360,11 @@ export function AgentGallery() {
|
||||
</div>
|
||||
</div>
|
||||
<TagSearchBar
|
||||
// Remount per tab so the internal keyword input / 展开 state also
|
||||
// resets when the parent-owned filters are cleared on tab switch.
|
||||
key={activeTab}
|
||||
targetType="agent"
|
||||
searching={activeTab === "private" ? isPrivateFetching : mainFetching}
|
||||
onSearchChange={setSearch}
|
||||
tagIds={tagIds}
|
||||
onTagIdsChange={setTagIds}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon, SearchIcon } from "lucide-react";
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon, Loader2, SearchIcon } from "lucide-react";
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useTags, type TagType } from "@/core/tags";
|
||||
@@ -18,27 +19,42 @@ const COLLAPSE_THRESHOLD = 16;
|
||||
*
|
||||
* The name input is debounced (300ms) before ``onSearchChange`` fires, so the
|
||||
* caller can feed the value straight into a server-side query without a
|
||||
* request per keystroke.
|
||||
* request per keystroke. Pressing Enter or clicking the 搜索 button flushes the
|
||||
* pending debounce immediately, so users never wait on the timer.
|
||||
*
|
||||
* Pass ``searching`` while a query is in flight to show an inline spinner — it
|
||||
* makes server-side searches feel responsive instead of looking like they kept
|
||||
* the previous results.
|
||||
*
|
||||
* Tags are rendered inline as a multi-select chip row (instead of a dropdown):
|
||||
* "全部" is selected when no tag is picked, and any number of tags can be
|
||||
* toggled. Filtering is OR — a resource matches if it carries any picked tag.
|
||||
* When there are many tags the row collapses behind a 展开/收起 toggle.
|
||||
* When there are many tags the row collapses behind a 展开/收起 toggle. Pass
|
||||
* ``hideTags`` for resources without tags (e.g. the published-task plaza).
|
||||
*/
|
||||
export function TagSearchBar({
|
||||
targetType,
|
||||
onSearchChange,
|
||||
tagIds,
|
||||
tagIds = [],
|
||||
onTagIdsChange,
|
||||
className,
|
||||
actions,
|
||||
searching = false,
|
||||
hideTags = false,
|
||||
searchPlaceholder,
|
||||
}: {
|
||||
targetType: TagType;
|
||||
onSearchChange: (value: string) => void;
|
||||
tagIds: string[];
|
||||
onTagIdsChange: (value: string[]) => void;
|
||||
tagIds?: string[];
|
||||
onTagIdsChange?: (value: string[]) => void;
|
||||
className?: string;
|
||||
actions?: ReactNode;
|
||||
/** Show an inline spinner while the caller's query is fetching. */
|
||||
searching?: boolean;
|
||||
/** Hide the tag chip row for resources that have no tags. */
|
||||
hideTags?: boolean;
|
||||
/** Override the search input placeholder. */
|
||||
searchPlaceholder?: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { data: tags } = useTags(targetType);
|
||||
@@ -47,11 +63,21 @@ export function TagSearchBar({
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const cbRef = useRef(onSearchChange);
|
||||
cbRef.current = onSearchChange;
|
||||
// Keep the pending debounce timer so an explicit search (Enter / 搜索 button)
|
||||
// can cancel it and fire immediately instead of racing the 300ms delay.
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => cbRef.current(input.trim()), 300);
|
||||
return () => clearTimeout(id);
|
||||
timerRef.current = setTimeout(() => cbRef.current(input.trim()), 300);
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, [input]);
|
||||
|
||||
const flushSearch = () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
cbRef.current(input.trim());
|
||||
};
|
||||
|
||||
const allTags = tags ?? [];
|
||||
const canCollapse = allTags.length > COLLAPSE_THRESHOLD;
|
||||
// When collapsed, always keep the selected tags visible so the active
|
||||
@@ -62,7 +88,7 @@ export function TagSearchBar({
|
||||
: allTags;
|
||||
|
||||
const toggleTag = (id: string) => {
|
||||
onTagIdsChange(
|
||||
onTagIdsChange?.(
|
||||
tagIds.includes(id) ? tagIds.filter((x) => x !== id) : [...tagIds, id],
|
||||
);
|
||||
};
|
||||
@@ -75,8 +101,14 @@ export function TagSearchBar({
|
||||
<Input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder={t.adminTags.searchPlaceholder}
|
||||
className="pl-8"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
flushSearch();
|
||||
}
|
||||
}}
|
||||
placeholder={searchPlaceholder ?? t.adminTags.searchPlaceholder}
|
||||
className={cn("pl-8", searching && "pr-9")}
|
||||
name="tag-search"
|
||||
// A `type="password"` field elsewhere on the page (e.g. the private
|
||||
// square access gate) makes browsers / password managers pick the
|
||||
@@ -88,7 +120,24 @@ export function TagSearchBar({
|
||||
data-lpignore="true"
|
||||
data-form-type="other"
|
||||
/>
|
||||
{searching && (
|
||||
<Loader2 className="text-muted-foreground absolute right-2.5 top-1/2 size-4 -translate-y-1/2 animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={flushSearch}
|
||||
className="shrink-0 gap-1.5"
|
||||
title="搜索"
|
||||
>
|
||||
{searching ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<SearchIcon className="size-4" />
|
||||
)}
|
||||
搜索
|
||||
</Button>
|
||||
{actions && (
|
||||
<div className="flex w-full shrink-0 justify-end sm:w-auto">
|
||||
{actions}
|
||||
@@ -96,11 +145,11 @@ export function TagSearchBar({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{allTags.length > 0 && (
|
||||
{!hideTags && allTags.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTagIdsChange([])}
|
||||
onClick={() => onTagIdsChange?.([])}
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium transition-all",
|
||||
tagIds.length === 0
|
||||
|
||||
Reference in new issue
Block a user