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