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