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:
吉鑫升andClaude Opus 4.8 committed 2026-06-10 09:37:39 +08:00
1 parent 87e0ae683e
commit 8fbbfc8093
4 files changed
+127 -25

No files matched your search

@@ -74,6 +74,7 @@ export function AgentGallery() {
agents: mainAgents, agents: mainAgents,
total: mainTotal, total: mainTotal,
isLoading: mainLoading, isLoading: mainLoading,
isFetching: mainFetching,
} = useAgentsPaginated({ } = useAgentsPaginated({
scope: mainScope, scope: mainScope,
search, search,
@@ -89,6 +90,7 @@ export function AgentGallery() {
agents: privateAgents, agents: privateAgents,
total: privateTotal, total: privateTotal,
isLoading: isPrivateLoading, isLoading: isPrivateLoading,
isFetching: isPrivateFetching,
error: privateError, error: privateError,
} = usePrivateAgentsPaginated(privatePassword, { } = usePrivateAgentsPaginated(privatePassword, {
search, search,
@@ -315,9 +317,16 @@ export function AgentGallery() {
{/* Tabs */} {/* Tabs */}
<Tabs <Tabs
value={activeTab} value={activeTab}
onValueChange={(v) => onValueChange={(v) => {
setActiveTab(v as "all" | "mine" | "private") 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" className="flex flex-1 flex-col overflow-hidden"
> >
<div className="mx-6 mt-4 flex flex-col gap-3"> <div className="mx-6 mt-4 flex flex-col gap-3">
@@ -351,7 +360,11 @@ export function AgentGallery() {
</div> </div>
</div> </div>
<TagSearchBar <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" targetType="agent"
searching={activeTab === "private" ? isPrivateFetching : mainFetching}
onSearchChange={setSearch} onSearchChange={setSearch}
tagIds={tagIds} tagIds={tagIds}
onTagIdsChange={setTagIds} onTagIdsChange={setTagIds}
@@ -1,8 +1,9 @@
"use client"; "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 { useEffect, useRef, useState, type ReactNode } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { useTags, type TagType } from "@/core/tags"; 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 * The name input is debounced (300ms) before ``onSearchChange`` fires, so the
* caller can feed the value straight into a server-side query without a * 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): * 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 * "全部" 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. * 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({ export function TagSearchBar({
targetType, targetType,
onSearchChange, onSearchChange,
tagIds, tagIds = [],
onTagIdsChange, onTagIdsChange,
className, className,
actions, actions,
searching = false,
hideTags = false,
searchPlaceholder,
}: { }: {
targetType: TagType; targetType: TagType;
onSearchChange: (value: string) => void; onSearchChange: (value: string) => void;
tagIds: string[]; tagIds?: string[];
onTagIdsChange: (value: string[]) => void; onTagIdsChange?: (value: string[]) => void;
className?: string; className?: string;
actions?: ReactNode; 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 { t } = useI18n();
const { data: tags } = useTags(targetType); const { data: tags } = useTags(targetType);
@@ -47,11 +63,21 @@ export function TagSearchBar({
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const cbRef = useRef(onSearchChange); const cbRef = useRef(onSearchChange);
cbRef.current = 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(() => { useEffect(() => {
const id = setTimeout(() => cbRef.current(input.trim()), 300); timerRef.current = setTimeout(() => cbRef.current(input.trim()), 300);
return () => clearTimeout(id); return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [input]); }, [input]);
const flushSearch = () => {
if (timerRef.current) clearTimeout(timerRef.current);
cbRef.current(input.trim());
};
const allTags = tags ?? []; const allTags = tags ?? [];
const canCollapse = allTags.length > COLLAPSE_THRESHOLD; const canCollapse = allTags.length > COLLAPSE_THRESHOLD;
// When collapsed, always keep the selected tags visible so the active // When collapsed, always keep the selected tags visible so the active
@@ -62,7 +88,7 @@ export function TagSearchBar({
: allTags; : allTags;
const toggleTag = (id: string) => { const toggleTag = (id: string) => {
onTagIdsChange( onTagIdsChange?.(
tagIds.includes(id) ? tagIds.filter((x) => x !== id) : [...tagIds, id], tagIds.includes(id) ? tagIds.filter((x) => x !== id) : [...tagIds, id],
); );
}; };
@@ -75,8 +101,14 @@ export function TagSearchBar({
<Input <Input
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
placeholder={t.adminTags.searchPlaceholder} onKeyDown={(e) => {
className="pl-8" if (e.key === "Enter") {
e.preventDefault();
flushSearch();
}
}}
placeholder={searchPlaceholder ?? t.adminTags.searchPlaceholder}
className={cn("pl-8", searching && "pr-9")}
name="tag-search" name="tag-search"
// A `type="password"` field elsewhere on the page (e.g. the private // A `type="password"` field elsewhere on the page (e.g. the private
// square access gate) makes browsers / password managers pick the // square access gate) makes browsers / password managers pick the
@@ -88,7 +120,24 @@ export function TagSearchBar({
data-lpignore="true" data-lpignore="true"
data-form-type="other" 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> </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 && ( {actions && (
<div className="flex w-full shrink-0 justify-end sm:w-auto"> <div className="flex w-full shrink-0 justify-end sm:w-auto">
{actions} {actions}
@@ -96,11 +145,11 @@ export function TagSearchBar({
)} )}
</div> </div>
{allTags.length > 0 && ( {!hideTags && allTags.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1.5">
<button <button
type="button" type="button"
onClick={() => onTagIdsChange([])} onClick={() => onTagIdsChange?.([])}
className={cn( className={cn(
"inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium transition-all", "inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium transition-all",
tagIds.length === 0 tagIds.length === 0
+6 -2
View File
@@ -94,7 +94,7 @@ export function useAgentsPaginated(params: {
const tagIds = params.tagIds ?? []; const tagIds = params.tagIds ?? [];
const sort = params.sort ?? "time_desc"; const sort = params.sort ?? "time_desc";
const owner = params.owner ?? ""; const owner = params.owner ?? "";
const { data, isLoading, error } = useQuery({ const { data, isLoading, isFetching, error } = useQuery({
queryKey: [ queryKey: [
"agents", "agents",
"paginated", "paginated",
@@ -123,6 +123,9 @@ export function useAgentsPaginated(params: {
agents: data?.agents ?? [], agents: data?.agents ?? [],
total: data?.total ?? 0, total: data?.total ?? 0,
isLoading, isLoading,
// keepPreviousData keeps isLoading false on subsequent searches; expose
// isFetching so callers can show a "searching" indicator on every query.
isFetching,
error, error,
}; };
} }
@@ -305,7 +308,7 @@ export function usePrivateAgentsPaginated(
const tagIds = params.tagIds ?? []; const tagIds = params.tagIds ?? [];
const sort = params.sort ?? "time_desc"; const sort = params.sort ?? "time_desc";
const owner = params.owner ?? ""; const owner = params.owner ?? "";
const { data, isLoading, error, refetch } = useQuery({ const { data, isLoading, isFetching, error, refetch } = useQuery({
queryKey: [ queryKey: [
"agents", "agents",
"private-square", "private-square",
@@ -337,6 +340,7 @@ export function usePrivateAgentsPaginated(
agents: data?.agents ?? [], agents: data?.agents ?? [],
total: data?.total ?? 0, total: data?.total ?? 0,
isLoading, isLoading,
isFetching,
error, error,
refetch, refetch,
}; };
+43 -7
View File
@@ -149,13 +149,16 @@ export default function ScheduledTasksPage() {
const initialTaskId = searchParams.get("task"); const initialTaskId = searchParams.get("task");
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [tagIds, setTagIds] = useState<string[]>([]); const [tagIds, setTagIds] = useState<string[]>([]);
// Plaza (广场) name search is resolved client-side over the fetched published
// list, kept separate from the server-side 我的任务 search above.
const [plazaSearch, setPlazaSearch] = useState("");
// Publisher (创建者) filter for the 我的任务 tab — click a task's 创建者 to keep // Publisher (创建者) filter for the 我的任务 tab — click a task's 创建者 to keep
// only that creator's tasks. {id} is creator_user_id; {label} the display name. // only that creator's tasks. {id} is creator_user_id; {label} the display name.
const [publisher, setPublisher] = useState<{ id: string; label: string } | null>( const [publisher, setPublisher] = useState<{ id: string; label: string } | null>(
null, null,
); );
// Name + tag filtering is resolved server-side via the /api/scheduled-tasks query. // Name + tag filtering is resolved server-side via the /api/scheduled-tasks query.
const { data: tasks = [], isLoading } = useScheduledTasks({ search, tagIds }); const { data: tasks = [], isLoading, isFetching } = useScheduledTasks({ search, tagIds });
const { data: deliveryProfile } = useScheduledTaskDeliveryProfile(); const { data: deliveryProfile } = useScheduledTaskDeliveryProfile();
const { agents } = useAgents(); const { agents } = useAgents();
const actions = useScheduledTaskActions(); const actions = useScheduledTaskActions();
@@ -215,6 +218,24 @@ export default function ScheduledTasksPage() {
: sortedTasks, : sortedTasks,
[sortedTasks, publisher], [sortedTasks, publisher],
); );
// Plaza search: filter the fetched published list client-side. Match against
// both the raw and desensitized forms so typing what's shown on screen (the
// desensitized name) and the underlying text both hit.
const visiblePublished = useMemo(() => {
const q = plazaSearch.trim().toLowerCase();
if (!q) return published;
return published.filter((item) =>
[
item.name,
desensitize(item.name),
item.prompt_summary,
desensitize(item.prompt_summary ?? ""),
item.schedule_text,
displayUsername(item.creator_email),
].some((field) => (field ?? "").toLowerCase().includes(q)),
);
}, [published, plazaSearch]);
const selectedTask = sortedTasks.find((task) => task.task_id === selectedTaskId) ?? null; const selectedTask = sortedTasks.find((task) => task.task_id === selectedTaskId) ?? null;
const selectedTaskCanEditRuns = !!selectedTask && selectedTask.role !== "subscriber"; const selectedTaskCanEditRuns = !!selectedTask && selectedTask.role !== "subscriber";
const { data: runs = [], isLoading: runsLoading } = useScheduledTaskRuns(selectedTaskId); const { data: runs = [], isLoading: runsLoading } = useScheduledTaskRuns(selectedTaskId);
@@ -465,6 +486,16 @@ export default function ScheduledTasksPage() {
onSearchChange={setSearch} onSearchChange={setSearch}
tagIds={tagIds} tagIds={tagIds}
onTagIdsChange={setTagIds} onTagIdsChange={setTagIds}
searching={isFetching}
/>
)}
{activeTab === "plaza" && (
<TagSearchBar
targetType="scheduled_task"
hideTags
onSearchChange={setPlazaSearch}
searching={publishedLoading}
searchPlaceholder="搜索广场任务(名称 / 摘要 / 创建者)"
/> />
)} )}
{activeTab === "mine" && publisher && ( {activeTab === "mine" && publisher && (
@@ -751,14 +782,19 @@ export default function ScheduledTasksPage() {
) : ( ) : (
<div className="flex flex-col gap-3 p-4 pt-0"> <div className="flex flex-col gap-3 p-4 pt-0">
{publishedLoading && ( {publishedLoading && (
<div className="text-muted-foreground text-sm">正在加载广场任务...</div> <div className="text-muted-foreground flex items-center gap-2 text-sm">
)} <Loader2 className="size-4 animate-spin" />
{!publishedLoading && published.length === 0 && ( 正在加载广场任务...
<div className="text-muted-foreground rounded-md border p-4 text-sm">
广场上还没有发布的任务。
</div> </div>
)} )}
{published.map((item) => { {!publishedLoading && visiblePublished.length === 0 && (
<div className="text-muted-foreground rounded-md border p-4 text-sm">
{plazaSearch.trim()
? `没有匹配「${plazaSearch.trim()}」的任务。`
: "广场上还没有发布的任务。"}
</div>
)}
{visiblePublished.map((item) => {
const subscribed = tasks.some((t) => t.task_id === item.task_id); const subscribed = tasks.some((t) => t.task_id === item.task_id);
return ( return (
<div key={item.task_id} className="bg-card rounded-md border p-4"> <div key={item.task_id} className="bg-card rounded-md border p-4">