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,
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
+6 -2
View File
@@ -94,7 +94,7 @@ export function useAgentsPaginated(params: {
const tagIds = params.tagIds ?? [];
const sort = params.sort ?? "time_desc";
const owner = params.owner ?? "";
const { data, isLoading, error } = useQuery({
const { data, isLoading, isFetching, error } = useQuery({
queryKey: [
"agents",
"paginated",
@@ -123,6 +123,9 @@ export function useAgentsPaginated(params: {
agents: data?.agents ?? [],
total: data?.total ?? 0,
isLoading,
// keepPreviousData keeps isLoading false on subsequent searches; expose
// isFetching so callers can show a "searching" indicator on every query.
isFetching,
error,
};
}
@@ -305,7 +308,7 @@ export function usePrivateAgentsPaginated(
const tagIds = params.tagIds ?? [];
const sort = params.sort ?? "time_desc";
const owner = params.owner ?? "";
const { data, isLoading, error, refetch } = useQuery({
const { data, isLoading, isFetching, error, refetch } = useQuery({
queryKey: [
"agents",
"private-square",
@@ -337,6 +340,7 @@ export function usePrivateAgentsPaginated(
agents: data?.agents ?? [],
total: data?.total ?? 0,
isLoading,
isFetching,
error,
refetch,
};
+43 -7
View File
@@ -149,13 +149,16 @@ export default function ScheduledTasksPage() {
const initialTaskId = searchParams.get("task");
const [search, setSearch] = useState("");
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
// only that creator's tasks. {id} is creator_user_id; {label} the display name.
const [publisher, setPublisher] = useState<{ id: string; label: string } | null>(
null,
);
// 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 { agents } = useAgents();
const actions = useScheduledTaskActions();
@@ -215,6 +218,24 @@ export default function ScheduledTasksPage() {
: sortedTasks,
[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 selectedTaskCanEditRuns = !!selectedTask && selectedTask.role !== "subscriber";
const { data: runs = [], isLoading: runsLoading } = useScheduledTaskRuns(selectedTaskId);
@@ -465,6 +486,16 @@ export default function ScheduledTasksPage() {
onSearchChange={setSearch}
tagIds={tagIds}
onTagIdsChange={setTagIds}
searching={isFetching}
/>
)}
{activeTab === "plaza" && (
<TagSearchBar
targetType="scheduled_task"
hideTags
onSearchChange={setPlazaSearch}
searching={publishedLoading}
searchPlaceholder="搜索广场任务(名称 / 摘要 / 创建者)"
/>
)}
{activeTab === "mine" && publisher && (
@@ -751,14 +782,19 @@ export default function ScheduledTasksPage() {
) : (
<div className="flex flex-col gap-3 p-4 pt-0">
{publishedLoading && (
<div className="text-muted-foreground text-sm">正在加载广场任务...</div>
)}
{!publishedLoading && published.length === 0 && (
<div className="text-muted-foreground rounded-md border p-4 text-sm">
广场上还没有发布的任务。
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2 className="size-4 animate-spin" />
正在加载广场任务...
</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);
return (
<div key={item.task_id} className="bg-card rounded-md border p-4">