fix(platform): 存储与 Agent - 修复配额准入、Live2D 与用户筛选
This commit is contained in:
@@ -32,7 +32,9 @@ import { CanvasAgentImageApprovalSettings } from "./canvas-agent-image-approval-
|
||||
import { markdownPlainText } from "@/lib/markdown-plain-text";
|
||||
import type { ApprovalState } from "./canvas-cloud-agent-events";
|
||||
|
||||
export function AgentLauncher({ theme, statusColor, approvalPending, reducedMotion, onOpen }: { theme: CanvasTheme; statusColor: string; approvalPending: boolean; reducedMotion: boolean; onOpen: () => void }) {
|
||||
// hidden 为 true 时入口保持挂载但不显示:Live2D 模型加载开销大,面板开合不能卸载它,
|
||||
// 否则每次重新挂载都要重新下载模型并重建渲染上下文,期间只能显示默认形象。
|
||||
export function AgentLauncher({ theme, statusColor, approvalPending, reducedMotion, hidden = false, onOpen }: { theme: CanvasTheme; statusColor: string; approvalPending: boolean; reducedMotion: boolean; hidden?: boolean; onOpen: () => void }) {
|
||||
const appearance = useAppearanceStore((state) => state.appearance.canvas) || DEFAULT_CANVAS_APPEARANCE;
|
||||
const live = appearance.avatarType === "live2d" && Boolean(appearance.live2dResourceId && appearance.live2dEntry);
|
||||
const [viewport, setViewport] = useState(() => ({ width: window.innerWidth, height: window.innerHeight }));
|
||||
@@ -50,7 +52,9 @@ export function AgentLauncher({ theme, statusColor, approvalPending, reducedMoti
|
||||
aria-label={`打开${appearance.agentName}`}
|
||||
title={`${approvalPending ? "Agent 等待你的审批" : "打开 Agent 助手"} · 拖动可调整位置,聚焦后可用方向键移动`}
|
||||
className={cn("canvas-agent-launcher fixed z-[calc(var(--z-toast)+1)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/35", dragging && "is-dragging", live && "canvas-agent-launcher-live2d")}
|
||||
style={{ ...position, width, height, color: theme.node.text, "--canvas-agent-launcher-shadow": theme.spatial.shadow } as CSSProperties}
|
||||
style={{ ...position, width, height, color: theme.node.text, "--canvas-agent-launcher-shadow": theme.spatial.shadow, ...(hidden ? { display: "none" } : null) } as CSSProperties}
|
||||
aria-hidden={hidden || undefined}
|
||||
tabIndex={hidden ? -1 : undefined}
|
||||
data-canvas-no-zoom
|
||||
{...handlers}
|
||||
whileHover={reducedMotion || dragging ? undefined : { scale: 1.035 }}
|
||||
|
||||
@@ -857,7 +857,7 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se
|
||||
|
||||
return (
|
||||
<>
|
||||
{!open ? <AgentLauncher theme={theme} statusColor={statusColor} approvalPending={Boolean(approval)} reducedMotion={Boolean(reducedMotion)} onOpen={onOpen} /> : null}
|
||||
<AgentLauncher theme={theme} statusColor={statusColor} approvalPending={Boolean(approval)} reducedMotion={Boolean(reducedMotion)} hidden={open} onOpen={onOpen} />
|
||||
<AnimatePresence>
|
||||
{open ? (
|
||||
<motion.aside
|
||||
|
||||
@@ -20,11 +20,11 @@ export default function StorageResourcesPanel() {
|
||||
const kind = normalizeOption(searchParams.get("kind"), ["image", "video", "audio", "file", "live2d"]);
|
||||
const status = normalizeOption(searchParams.get("status"), ["pending", "ready", "failed", "deleted"]);
|
||||
const provider = normalizeOption(searchParams.get("provider"), ["local", "aliyun", "tencent", "qiniu", "s3"]);
|
||||
const userId = searchParams.get("userId") || "";
|
||||
const userQuery = searchParams.get("user") || searchParams.get("userId") || "";
|
||||
const page = positiveInt(searchParams.get("page"), 1);
|
||||
const pageSize = normalizePageSize(searchParams.get("pageSize"));
|
||||
const debouncedKeyword = useDebouncedValue(keyword);
|
||||
const debouncedUserId = useDebouncedValue(userId);
|
||||
const debouncedUserQuery = useDebouncedValue(userQuery);
|
||||
const [resources, setResources] = useState<AdminStorageResource[]>([]);
|
||||
const [stats, setStats] = useState<AdminStorageStats | null>(null);
|
||||
const [total, setTotal] = useState(0);
|
||||
@@ -35,12 +35,12 @@ export default function StorageResourcesPanel() {
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const requestSequence = useRef(0);
|
||||
const hasFilters = Boolean(keyword || userId || kind !== "all" || status !== "all" || provider !== "all");
|
||||
const hasFilters = Boolean(keyword || userQuery || kind !== "all" || status !== "all" || provider !== "all");
|
||||
|
||||
const updateUrl = (patch: Record<string, string | number>, replace = false) => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
Object.entries(patch).forEach(([key, value]) => {
|
||||
const isDefault = ((key === "filter" || key === "userId") && value === "") || (["kind", "status", "provider"].includes(key) && value === "all") || (key === "page" && value === 1) || (key === "pageSize" && value === 20);
|
||||
const isDefault = ((key === "filter" || key === "user" || key === "userId") && value === "") || (["kind", "status", "provider"].includes(key) && value === "all") || (key === "page" && value === 1) || (key === "pageSize" && value === 20);
|
||||
if (isDefault) next.delete(key);
|
||||
else next.set(key, String(value));
|
||||
});
|
||||
@@ -68,7 +68,8 @@ export default function StorageResourcesPanel() {
|
||||
kind: kind === "all" ? undefined : kind,
|
||||
status: status === "all" ? undefined : status,
|
||||
provider: provider === "all" ? undefined : provider,
|
||||
userId: debouncedUserId || undefined,
|
||||
user: debouncedUserQuery || undefined,
|
||||
userId: debouncedUserQuery || undefined,
|
||||
page,
|
||||
pageSize: pageSize,
|
||||
},
|
||||
@@ -86,7 +87,7 @@ export default function StorageResourcesPanel() {
|
||||
})
|
||||
.finally(() => sequence === requestSequence.current && setLoading(false));
|
||||
return () => controller.abort();
|
||||
}, [debouncedKeyword, debouncedUserId, kind, status, provider, page, pageSize, refreshKey]);
|
||||
}, [debouncedKeyword, debouncedUserQuery, kind, status, provider, page, pageSize, refreshKey]);
|
||||
|
||||
const columns = useMemo<ColumnsType<AdminStorageResource>>(
|
||||
() => [
|
||||
@@ -210,7 +211,7 @@ export default function StorageResourcesPanel() {
|
||||
placeholder="资源 ID 或对象路径"
|
||||
onChange={(event) => updateUrl({ filter: event.target.value, page: 1 }, true)}
|
||||
/>
|
||||
<Input aria-label="按用户 ID 筛选" autoComplete="off" allowClear className="w-48" value={userId} placeholder="用户" onChange={(event) => updateUrl({ userId: event.target.value, page: 1 }, true)} />
|
||||
<Input aria-label="按用户名、昵称、邮箱或用户 ID 筛选" autoComplete="off" allowClear className="w-64" value={userQuery} placeholder="用户名 / 昵称 / 邮箱 / 用户 ID" onChange={(event) => updateUrl({ user: event.target.value, userId: "", page: 1 }, true)} />
|
||||
<Select aria-label="筛选资源类型" className="w-32" value={kind} onChange={(value) => updateUrl({ kind: value, page: 1 })} options={kindOptions} />
|
||||
<Select aria-label="筛选资源状态" className="w-32" value={status} onChange={(value) => updateUrl({ status: value, page: 1 })} options={statusOptions} />
|
||||
<Select aria-label="筛选存储类型" className="w-36" value={provider} onChange={(value) => updateUrl({ provider: value, page: 1 })} options={providerOptions} />
|
||||
@@ -219,14 +220,14 @@ export default function StorageResourcesPanel() {
|
||||
toolbarActiveFilters={
|
||||
<>
|
||||
{keyword ? <AdminFilterChip label={`搜索:${keyword}`} onRemove={() => updateUrl({ filter: "", page: 1 })} /> : null}
|
||||
{userId ? <AdminFilterChip label={`用户:${userId}`} onRemove={() => updateUrl({ userId: "", page: 1 })} /> : null}
|
||||
{userQuery ? <AdminFilterChip label={`用户:${userQuery}`} onRemove={() => updateUrl({ user: "", userId: "", page: 1 })} /> : null}
|
||||
{kind !== "all" ? <AdminFilterChip label={`类型:${kindLabel(kind)}`} onRemove={() => updateUrl({ kind: "all", page: 1 })} /> : null}
|
||||
{status !== "all" ? <AdminFilterChip label={`状态:${statusLabel(status)}`} onRemove={() => updateUrl({ status: "all", page: 1 })} /> : null}
|
||||
{provider !== "all" ? <AdminFilterChip label={`存储:${providerLabel(provider)}`} onRemove={() => updateUrl({ provider: "all", page: 1 })} /> : null}
|
||||
</>
|
||||
}
|
||||
toolbarActive={hasFilters}
|
||||
onReset={() => updateUrl({ filter: "", userId: "", kind: "all", status: "all", provider: "all", page: 1 })}
|
||||
onReset={() => updateUrl({ filter: "", user: "", userId: "", kind: "all", status: "all", provider: "all", page: 1 })}
|
||||
batchActions={
|
||||
<AdminBatchBar count={selectedIds.length} onClear={() => setSelectedIds([])}>
|
||||
<Button danger size="small" icon={<Trash2 className="size-3.5" />} loading={deleting} onClick={() => confirmDelete(selectedIds)}>
|
||||
|
||||
@@ -49,6 +49,7 @@ export type AdminResourceQuery = {
|
||||
kind?: string;
|
||||
status?: string;
|
||||
provider?: string;
|
||||
user?: string;
|
||||
userId?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
|
||||
@@ -248,7 +248,9 @@ test("admin tables keep requested filters and actions in the intended positions"
|
||||
const storageToolbar = sourceSection(storageSource, "toolbar={", "toolbarActiveFilters=");
|
||||
expect(storageToolbar).toContain('className="admin-storage-resource-filters"');
|
||||
expect(storageToolbar).toContain('placeholder="资源 ID 或对象路径"');
|
||||
expect(storageToolbar).toContain('placeholder="用户"');
|
||||
expect(storageToolbar).toContain('placeholder="用户名 / 昵称 / 邮箱 / 用户 ID"');
|
||||
expect(storageSource).toContain("user: debouncedUserQuery || undefined");
|
||||
expect(storageSource).toContain("userId: debouncedUserQuery || undefined");
|
||||
expect(storageToolbar).toContain('aria-label="筛选资源类型"');
|
||||
expect(storageToolbar).toContain('aria-label="筛选资源状态"');
|
||||
expect(storageToolbar).toContain('aria-label="筛选存储类型"');
|
||||
|
||||
12
web/test/agent-live2d-lifecycle.test.ts
Normal file
12
web/test/agent-live2d-lifecycle.test.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { expect, test } from "bun:test";
|
||||
|
||||
import panelSource from "../src/components/canvas/canvas-cloud-agent-panel.tsx" with { type: "text" };
|
||||
import partsSource from "../src/components/canvas/canvas-cloud-agent-panel-parts.tsx" with { type: "text" };
|
||||
|
||||
test("Live2D launcher remains mounted while the Agent panel is open", () => {
|
||||
expect(panelSource).toContain("<AgentLauncher");
|
||||
expect(panelSource).toContain("hidden={open}");
|
||||
expect(panelSource).not.toContain("{!open ? <AgentLauncher");
|
||||
expect(partsSource).toContain("hidden = false");
|
||||
expect(partsSource).toContain('display: "none"');
|
||||
});
|
||||
Reference in New Issue
Block a user