feat(*): 工作台界面 - 优化插件中心、管理侧栏及画布与短剧列表
This commit is contained in:
@@ -2,7 +2,7 @@ import { Dropdown, Input } from "antd";
|
||||
import { Download, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
|
||||
import type { KeyboardEvent } from "react";
|
||||
|
||||
import { ProjectPreview, formatProjectTime } from "@/components/canvas/canvas-project-card";
|
||||
import { ProjectPreview } from "@/components/canvas/canvas-project-card";
|
||||
import { exportCanvasProjects } from "@/lib/canvas/canvas-export";
|
||||
import { useCanvasStore, type CanvasProject } from "@/stores/canvas/use-canvas-store";
|
||||
import { useCanvasUiStore } from "@/stores/canvas/use-canvas-ui-store";
|
||||
@@ -67,17 +67,13 @@ export function CanvasFolderCard({ project, projectName, onClick }: CanvasFolder
|
||||
)}
|
||||
</div>
|
||||
<div className="canvas-folder-meta">
|
||||
<span className="canvas-folder-meta-item">{projectName || "自由画布"}</span>
|
||||
<span className="canvas-folder-meta-separator" aria-hidden="true">
|
||||
·
|
||||
</span>
|
||||
<span className="canvas-folder-meta-item">{projectName ? `所属项目:${projectName}` : "自由画布"}</span>
|
||||
<span className="canvas-folder-meta-separator" aria-hidden="true">·</span>
|
||||
<span className="canvas-folder-meta-item">{project.nodes.length} 节点</span>
|
||||
<span className="canvas-folder-meta-separator" aria-hidden="true">
|
||||
·
|
||||
</span>
|
||||
<time className="canvas-folder-meta-item" dateTime={project.updatedAt}>
|
||||
{formatProjectTime(project.updatedAt).replace(/\s*修改$/, "")}
|
||||
</time>
|
||||
</div>
|
||||
<div className="canvas-folder-dates">
|
||||
<span><small>创建时间</small><time dateTime={project.createdAt}>{formatCanvasDate(project.createdAt)}</time></span>
|
||||
<span><small>最后更新</small><time dateTime={project.updatedAt}>{formatCanvasDate(project.updatedAt)}</time></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -119,7 +115,7 @@ export function CanvasFolderCard({ project, projectName, onClick }: CanvasFolder
|
||||
],
|
||||
}}
|
||||
>
|
||||
<button type="button" className="canvas-folder-action" aria-label={`${project.title} 画布操作`} title="更多操作" onClick={(event) => event.stopPropagation()}>
|
||||
<button type="button" className="canvas-folder-more" aria-label={`${project.title} 画布操作`} title="更多操作" onClick={(event) => event.stopPropagation()}>
|
||||
<MoreHorizontal />
|
||||
</button>
|
||||
</Dropdown>
|
||||
@@ -127,3 +123,16 @@ export function CanvasFolderCard({ project, projectName, onClick }: CanvasFolder
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function formatCanvasDate(value: string) {
|
||||
const timestamp = Date.parse(value);
|
||||
return Number.isFinite(timestamp)
|
||||
? new Date(timestamp).toLocaleString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
: "时间不可用";
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ export const eagleAssetPlugin: RegisteredPlugin = {
|
||||
id: EAGLE_PLUGIN_ID,
|
||||
name: "Eagle 素材库",
|
||||
version: "0.3.0",
|
||||
publishedAt: "2026-08-21",
|
||||
updatedAt: "2026-08-22",
|
||||
apiVersion: "1",
|
||||
category: "asset-source",
|
||||
description: "把 Eagle 作为影策的外部素材来源,直接浏览原始文件夹并读写 Eagle 文件。",
|
||||
|
||||
@@ -18,6 +18,8 @@ export type PluginManifest = {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
publishedAt?: string;
|
||||
updatedAt?: string;
|
||||
apiVersion: string;
|
||||
category: PluginCategory;
|
||||
description: string;
|
||||
|
||||
@@ -92,7 +92,7 @@ export function AdminShell() {
|
||||
return (
|
||||
<ConfigProvider theme={getAdminAntThemeConfig(dark)}>
|
||||
<main className="admin-shell app-user-workspace flex h-full min-h-0 overflow-hidden text-foreground">
|
||||
<aside className={cn("app-workspace-sidebar admin-sidebar hidden shrink-0 flex-col overflow-hidden lg:flex", collapsed ? "w-[60px]" : "w-[212px]")}>
|
||||
<aside className={cn("app-workspace-sidebar admin-sidebar hidden shrink-0 flex-col overflow-hidden lg:flex", collapsed && "is-collapsed")}>
|
||||
<div className={cn("flex h-13 shrink-0 items-center", collapsed ? "justify-center" : "gap-2 px-3")}>
|
||||
{!collapsed ? (
|
||||
<Link to="/" className="app-workspace-brand-link flex min-w-0 flex-1 items-center gap-2" title="影策">
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { App, Button, Dropdown, Modal, Select } from "antd";
|
||||
import { ArrowDownAZ, Clock3, Download, FileUp, ListFilter, MoreHorizontal, Plus, Search, SlidersHorizontal, Trash2, X } from "lucide-react";
|
||||
|
||||
import { CollectionGrid, PageHeader, PaginationBar, WorkspacePage } from "@/components/layout/workspace-page";
|
||||
import { CollectionGrid, PageHeader, WorkspacePage } from "@/components/layout/workspace-page";
|
||||
import { WorkspaceLoadingState, WorkspaceState } from "@/components/layout/workspace-state";
|
||||
|
||||
import { readZip } from "@/lib/zip";
|
||||
@@ -29,8 +29,8 @@ export default function CanvasPage() {
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [sort, setSort] = useState<"updated" | "name" | "nodes">("updated");
|
||||
const [projectFilter, setProjectFilter] = useState("all");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(24);
|
||||
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||
const [loadedProjectCount, setLoadedProjectCount] = useState(50);
|
||||
const hydrated = useCanvasStore((state) => state.hydrated);
|
||||
const projects = useCanvasStore((state) => state.projects);
|
||||
const importProject = useCanvasStore((state) => state.importProject);
|
||||
@@ -62,7 +62,7 @@ export default function CanvasPage() {
|
||||
return values;
|
||||
}, [keyword, projectFilter, projects, sort]);
|
||||
const projectNames = useMemo(() => new Map((projectQuery.data?.projects || []).map(({ project }) => [project.id, project.name])), [projectQuery.data]);
|
||||
const visibleProjects = filteredProjects.slice((page - 1) * pageSize, page * pageSize);
|
||||
const visibleProjects = filteredProjects.slice(0, loadedProjectCount);
|
||||
const showCreateCard = !keyword.trim() && projectFilter === "all";
|
||||
const selectedProjects = projects.filter((project) => selectedIds.includes(project.id));
|
||||
const projectFilterLabel = projectFilter === "all" ? "全部画布" : projectFilter === "independent" ? "自由画布" : projectNames.get(projectFilter) || "项目画布";
|
||||
@@ -73,6 +73,21 @@ export default function CanvasPage() {
|
||||
{ key: "name", label: "按名称", icon: <ArrowDownAZ className="size-3.5" /> },
|
||||
{ key: "nodes", label: "按节点数量", icon: <ListFilter className="size-3.5" /> },
|
||||
];
|
||||
useEffect(() => {
|
||||
setLoadedProjectCount(50);
|
||||
}, [keyword, projectFilter, sort]);
|
||||
useEffect(() => {
|
||||
const node = loadMoreRef.current;
|
||||
if (!node || visibleProjects.length >= filteredProjects.length) return;
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry?.isIntersecting) setLoadedProjectCount((count) => Math.min(count + 50, filteredProjects.length));
|
||||
},
|
||||
{ rootMargin: "600px" },
|
||||
);
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, [filteredProjects.length, visibleProjects.length]);
|
||||
const associateSelected = async (nextProjectId = associationProjectId) => {
|
||||
const projectId = nextProjectId || undefined;
|
||||
selectedIds.forEach((id) => updateProject(id, { projectId }));
|
||||
@@ -213,7 +228,6 @@ export default function CanvasPage() {
|
||||
aria-label="搜索画布"
|
||||
onChange={(event) => {
|
||||
setKeyword(event.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
{keyword ? (
|
||||
@@ -222,7 +236,6 @@ export default function CanvasPage() {
|
||||
aria-label="清除搜索"
|
||||
onClick={() => {
|
||||
setKeyword("");
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<X />
|
||||
@@ -238,7 +251,6 @@ export default function CanvasPage() {
|
||||
selectedKeys: [projectFilter],
|
||||
onClick: ({ key }) => {
|
||||
setProjectFilter(String(key));
|
||||
setPage(1);
|
||||
},
|
||||
}}
|
||||
>
|
||||
@@ -255,7 +267,6 @@ export default function CanvasPage() {
|
||||
selectedKeys: [sort],
|
||||
onClick: ({ key }) => {
|
||||
setSort(key as typeof sort);
|
||||
setPage(1);
|
||||
},
|
||||
}}
|
||||
>
|
||||
@@ -272,7 +283,6 @@ export default function CanvasPage() {
|
||||
setKeyword("");
|
||||
setProjectFilter("all");
|
||||
setSort("updated");
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
重置
|
||||
@@ -338,17 +348,9 @@ export default function CanvasPage() {
|
||||
) : (
|
||||
<WorkspaceState icon="canvas" title="没有匹配的画布" description="换一个画布名称或重置筛选条件。" />
|
||||
)}
|
||||
|
||||
<PaginationBar
|
||||
current={page}
|
||||
pageSize={pageSize}
|
||||
total={filteredProjects.length}
|
||||
pageSizeOptions={[12, 24, 48]}
|
||||
onChange={(nextPage, nextPageSize) => {
|
||||
setPage(nextPageSize !== pageSize ? 1 : nextPage);
|
||||
setPageSize(nextPageSize);
|
||||
}}
|
||||
/>
|
||||
{hydrated && visibleProjects.length ? <div ref={loadMoreRef} className="library-load-more" aria-live="polite">
|
||||
{visibleProjects.length < filteredProjects.length ? `继续下滑加载更多(每页 50 条)` : `已加载全部 ${filteredProjects.length} 个画布`}
|
||||
</div> : null}
|
||||
</div>
|
||||
|
||||
<input ref={inputRef} type="file" accept="application/zip,.zip" className="hidden" onChange={(event) => void importCanvas(event.target.files?.[0])} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { App, Button, Input, Select, Switch, Tag, Typography } from "antd";
|
||||
import { CheckCircle2, ChevronDown, ExternalLink, FolderOpen, PlugZap, Settings2, ShieldCheck } from "lucide-react";
|
||||
import { App, Button, Input, Modal, Select, Switch, Typography } from "antd";
|
||||
import { CalendarDays, CheckCircle2, Clock3, ExternalLink, FolderOpen, PlugZap, Search, Settings2, ShieldCheck, SlidersHorizontal } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
@@ -9,6 +9,8 @@ import { EAGLE_PLUGIN_ID } from "@/lib/plugins/builtin/eagle";
|
||||
import { getEagleLibrary, type EagleFolder } from "@/services/api/eagle";
|
||||
import { usePluginStore } from "@/stores/use-plugin-store";
|
||||
|
||||
import "./plugins.css";
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
"asset-source": "素材来源",
|
||||
"canvas-node": "画布节点",
|
||||
@@ -36,6 +38,8 @@ const permissionLabels: Record<string, string> = {
|
||||
"external.open": "打开外部详情",
|
||||
};
|
||||
|
||||
const pluginDateFormatter = new Intl.DateTimeFormat("zh-CN", { year: "numeric", month: "short", day: "numeric" });
|
||||
|
||||
export default function PluginsPage() {
|
||||
const { message } = App.useApp();
|
||||
const navigate = useNavigate();
|
||||
@@ -44,7 +48,11 @@ export default function PluginsPage() {
|
||||
const setEnabled = usePluginStore((state) => state.setEnabled);
|
||||
const updateConfig = usePluginStore((state) => state.updateConfig);
|
||||
const registeredPlugins = useMemo(() => listRegisteredPlugins(), []);
|
||||
const [expandedPluginId, setExpandedPluginId] = useState<string | null>(null);
|
||||
const [settingsPluginId, setSettingsPluginId] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [categoryFilter, setCategoryFilter] = useState("all");
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | "enabled" | "disabled">("all");
|
||||
const [trustFilter, setTrustFilter] = useState<"all" | "trusted">("all");
|
||||
const [eagleBaseUrl, setEagleBaseUrl] = useState("http://localhost:41595");
|
||||
const [eagleAutoUploadGenerated, setEagleAutoUploadGenerated] = useState(true);
|
||||
const [eagleGeneratedFolderId, setEagleGeneratedFolderId] = useState("");
|
||||
@@ -67,6 +75,30 @@ export default function PluginsPage() {
|
||||
setEagleGeneratedFolderId(typeof folderId === "string" ? folderId : "");
|
||||
}, [eagle?.config.baseUrl, eagle?.config.autoUploadGenerated, eagle?.config.generatedFolderId]);
|
||||
|
||||
const categoryOptions = useMemo(() => [
|
||||
{ value: "all", label: "全部分类" },
|
||||
...Array.from(new Set(registeredPlugins.map((plugin) => plugin.manifest.category))).map((category) => ({ value: category, label: categoryLabels[category] ?? category })),
|
||||
], [registeredPlugins]);
|
||||
|
||||
const filteredPlugins = useMemo(() => {
|
||||
const normalizedSearch = search.trim().toLocaleLowerCase();
|
||||
return registeredPlugins.filter((plugin) => {
|
||||
const installation = installations.find((item) => item.manifest.id === plugin.manifest.id);
|
||||
const manifest = plugin.manifest;
|
||||
const searchableText = [manifest.name, manifest.description, manifest.author, manifest.id, categoryLabels[manifest.category]].filter(Boolean).join(" ").toLocaleLowerCase();
|
||||
if (normalizedSearch && !searchableText.includes(normalizedSearch)) return false;
|
||||
if (categoryFilter !== "all" && manifest.category !== categoryFilter) return false;
|
||||
if (trustFilter === "trusted" && !manifest.trusted) return false;
|
||||
if (statusFilter === "enabled" && !installation?.enabled) return false;
|
||||
if (statusFilter === "disabled" && installation?.enabled) return false;
|
||||
return true;
|
||||
});
|
||||
}, [categoryFilter, installations, registeredPlugins, search, statusFilter, trustFilter]);
|
||||
|
||||
const settingsPlugin = settingsPluginId ? registeredPlugins.find((plugin) => plugin.manifest.id === settingsPluginId) : undefined;
|
||||
const settingsInstallation = settingsPlugin ? installations.find((item) => item.manifest.id === settingsPlugin.manifest.id) : undefined;
|
||||
const settingsEnabled = Boolean(settingsInstallation?.enabled);
|
||||
|
||||
const loadEagleFolders = async (url = eagleBaseUrl) => {
|
||||
setEagleFoldersLoading(true);
|
||||
setEagleFoldersError("");
|
||||
@@ -80,6 +112,7 @@ export default function PluginsPage() {
|
||||
setEagleFoldersLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveEagleConfig = () => {
|
||||
const baseUrl = eagleBaseUrl.trim().replace(/\/$/, "");
|
||||
if (!/^https?:\/\//i.test(baseUrl)) {
|
||||
@@ -91,152 +124,187 @@ export default function PluginsPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="app-workspace-page flex h-full min-h-0 flex-col text-foreground">
|
||||
<header className="shrink-0 border-b border-[var(--workspace-border)] px-5 py-4 md:px-7">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="mb-2 flex items-center gap-2 text-[var(--fs-label)] text-foreground/45">
|
||||
<PlugZap className="size-3.5" aria-hidden="true" />
|
||||
<span>工作台管理</span>
|
||||
<span>/</span>
|
||||
<span>插件中心</span>
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold tracking-tight md:text-2xl">插件中心</h1>
|
||||
<p className="mt-1 text-sm text-foreground/55">管理可连接到影策的素材来源、画布能力和创作工作流。</p>
|
||||
</div>
|
||||
<Tag icon={<ShieldCheck className="size-3.5" />} color="gold">可信插件模式</Tag>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="app-workspace-scroll min-h-0 flex-1 overflow-y-auto px-5 py-5 md:px-7 md:py-6">
|
||||
<div className="mx-auto w-full max-w-5xl">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">已接入插件</h2>
|
||||
<p className="mt-1 text-xs text-foreground/48">插件状态和配置按当前账号隔离保存。</p>
|
||||
</div>
|
||||
<Tag className="m-0">{registeredPlugins.length} 个插件</Tag>
|
||||
<main className="app-workspace-page plugins-page flex h-full min-h-0 flex-col text-foreground">
|
||||
<div className="app-workspace-scroll min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="plugins-page-content">
|
||||
<div className="plugins-toolbar" aria-label="插件筛选">
|
||||
<Input
|
||||
className="plugins-search"
|
||||
prefix={<Search className="size-4 text-foreground/38" aria-hidden="true" />}
|
||||
value={search}
|
||||
allowClear
|
||||
placeholder="搜索插件名称、描述或作者"
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
/>
|
||||
<Select className="plugins-filter" value={categoryFilter} options={categoryOptions} onChange={setCategoryFilter} aria-label="按分类筛选" />
|
||||
<Select
|
||||
className="plugins-filter"
|
||||
value={statusFilter}
|
||||
options={[{ value: "all", label: "全部状态" }, { value: "enabled", label: "已启用" }, { value: "disabled", label: "未启用" }]}
|
||||
onChange={(value) => setStatusFilter(value as "all" | "enabled" | "disabled")}
|
||||
aria-label="按状态筛选"
|
||||
/>
|
||||
<Select
|
||||
className="plugins-filter"
|
||||
value={trustFilter}
|
||||
options={[{ value: "all", label: "全部来源" }, { value: "trusted", label: "可信插件" }]}
|
||||
onChange={(value) => setTrustFilter(value as "all" | "trusted")}
|
||||
aria-label="按来源筛选"
|
||||
/>
|
||||
<span className="plugins-filter-icon" aria-hidden="true"><SlidersHorizontal className="size-4" /></span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{registeredPlugins.map((plugin) => {
|
||||
const installation = installations.find((item) => item.manifest.id === plugin.manifest.id);
|
||||
const enabled = Boolean(installation?.enabled);
|
||||
const expanded = expandedPluginId === plugin.manifest.id;
|
||||
const settingsId = `plugin-settings-${plugin.manifest.id}`;
|
||||
return (
|
||||
<section key={plugin.manifest.id} className="library-card-surface rounded-[var(--r-xl)] p-4 transition-colors duration-200 md:p-5">
|
||||
<div className="flex min-w-0 flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex min-w-0 flex-1 items-start gap-3">
|
||||
<span className="grid size-11 shrink-0 place-items-center rounded-[var(--r-lg)] bg-[var(--workspace-accent-soft)] text-[var(--workspace-accent)]">
|
||||
<PlugZap className="size-5" aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="min-w-0 truncate text-base font-semibold">{plugin.manifest.name}</h3>
|
||||
<span className="whitespace-nowrap text-[var(--fs-tiny)] text-foreground/42">v{plugin.manifest.version}</span>
|
||||
<span className="whitespace-nowrap rounded-full bg-foreground/[.06] px-2 py-1 text-[var(--fs-tiny)] text-foreground/58">{categoryLabels[plugin.manifest.category] ?? plugin.manifest.category}</span>
|
||||
</div>
|
||||
<p className="mt-1 max-w-3xl text-sm leading-6 text-foreground/55">{plugin.manifest.description}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{plugin.manifest.surfaces.map((surface) => <span key={surface} className="rounded-full bg-foreground/[.06] px-2.5 py-1 text-[var(--fs-tiny)] text-foreground/62">{surfaceLabels[surface] ?? surface}</span>)}
|
||||
<span className="rounded-full bg-[var(--library-icon-surface)] px-2.5 py-1 text-[var(--fs-tiny)] text-foreground/52">{plugin.manifest.permissions.length} 项能力</span>
|
||||
{filteredPlugins.length ? (
|
||||
<div className="plugins-grid">
|
||||
{filteredPlugins.map((plugin) => {
|
||||
const installation = installations.find((item) => item.manifest.id === plugin.manifest.id);
|
||||
const enabled = Boolean(installation?.enabled);
|
||||
const trusted = Boolean(plugin.manifest.trusted);
|
||||
return (
|
||||
<section key={plugin.manifest.id} className={`plugin-card library-card-surface${trusted ? " is-trusted" : ""}`}>
|
||||
<div className="plugin-card-main">
|
||||
<div className="plugin-card-heading">
|
||||
<span className={`plugin-icon-tile${trusted ? " is-trusted" : ""}`} aria-hidden="true">
|
||||
<PlugZap className="size-5" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="plugin-card-title-row">
|
||||
<h3>{plugin.manifest.name}</h3>
|
||||
<span className="plugin-version">v{plugin.manifest.version}</span>
|
||||
</div>
|
||||
<div className="plugin-card-labels">
|
||||
{trusted ? <span className="plugin-trust-label"><ShieldCheck className="size-3.5" />可信插件</span> : null}
|
||||
<span className="plugin-category-label">{categoryLabels[plugin.manifest.category] ?? plugin.manifest.category}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="plugin-card-description">{plugin.manifest.description}</p>
|
||||
|
||||
<div className="plugin-card-meta">
|
||||
<span><CalendarDays className="size-3.5" />发布 {formatPluginDate(plugin.manifest.publishedAt)}</span>
|
||||
<span><Clock3 className="size-3.5" />更新 {formatPluginDate(plugin.manifest.updatedAt)}</span>
|
||||
</div>
|
||||
|
||||
<div className="plugin-card-tags">
|
||||
{plugin.manifest.surfaces.map((surface) => <span key={surface}>{surfaceLabels[surface] ?? surface}</span>)}
|
||||
<span>{plugin.manifest.permissions.length} 项能力</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-3 lg:justify-end">
|
||||
<div className="plugin-card-actions">
|
||||
<span role="status" className={`settings-channel-status ${enabled ? "is-ready" : ""}`}>
|
||||
<i aria-hidden="true" />
|
||||
{enabled ? "已启用" : "未启用"}
|
||||
</span>
|
||||
<Switch checked={enabled} aria-label={`${plugin.manifest.name}${enabled ? "停用" : "启用"}`} onChange={(checked) => setEnabled(plugin.manifest.id, checked)} />
|
||||
<Button
|
||||
className="min-h-9"
|
||||
className="plugin-settings-button"
|
||||
icon={<Settings2 className="size-4" />}
|
||||
aria-expanded={expanded}
|
||||
aria-controls={settingsId}
|
||||
onClick={() => setExpandedPluginId((current) => current === plugin.manifest.id ? null : plugin.manifest.id)}
|
||||
aria-expanded={settingsPluginId === plugin.manifest.id}
|
||||
aria-haspopup="dialog"
|
||||
onClick={() => setSettingsPluginId(plugin.manifest.id)}
|
||||
>
|
||||
{expanded ? "收起设置" : "设置"}
|
||||
<ChevronDown className={`ml-1 size-3.5 transition-transform duration-200${expanded ? " rotate-180" : ""}`} aria-hidden="true" />
|
||||
设置
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{installation?.lastError ? <Typography.Text type="danger" className="plugin-error" role="alert">{installation.lastError}</Typography.Text> : null}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="plugins-empty-state">
|
||||
<SlidersHorizontal className="size-7" aria-hidden="true" />
|
||||
<h3>没有匹配的插件</h3>
|
||||
<p>试试清空搜索词,或放宽筛选条件。</p>
|
||||
<Button onClick={() => { setSearch(""); setCategoryFilter("all"); setStatusFilter("all"); setTrustFilter("all"); }}>清除筛选</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
className="workspace-modal workspace-modal-wide plugin-settings-modal"
|
||||
title={settingsPlugin ? `${settingsPlugin.manifest.name} 设置` : null}
|
||||
open={Boolean(settingsPlugin)}
|
||||
centered
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
onCancel={() => setSettingsPluginId(null)}
|
||||
styles={{ body: { maxHeight: "min(72vh, 760px)", overflowY: "auto", overscrollBehavior: "contain" } }}
|
||||
>
|
||||
{settingsPlugin ? (
|
||||
<div className="plugin-settings-panel plugin-settings-modal-panel">
|
||||
<div className="plugin-settings-heading">
|
||||
<div>
|
||||
<p>只展示这个插件实际支持的配置项。</p>
|
||||
</div>
|
||||
{settingsPlugin.manifest.trusted ? <span className="plugin-trust-label"><ShieldCheck className="size-3.5" />可信插件</span> : <span className="plugin-category-label">第三方插件</span>}
|
||||
</div>
|
||||
|
||||
{expanded ? (
|
||||
<div id={settingsId} className="mt-4 border-t border-[var(--workspace-border)] pt-4">
|
||||
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold">{plugin.manifest.name} 设置</h4>
|
||||
<p className="mt-1 text-xs leading-5 text-foreground/48">只展示这个插件实际支持的配置项。</p>
|
||||
</div>
|
||||
<span className="whitespace-nowrap rounded-full bg-foreground/[.06] px-2.5 py-1 text-[var(--fs-tiny)] text-foreground/52">{plugin.manifest.trusted ? "可信插件" : "第三方插件"}</span>
|
||||
{settingsPlugin.manifest.id === EAGLE_PLUGIN_ID ? (
|
||||
<>
|
||||
<div className="plugin-settings-fields">
|
||||
<div className="min-w-0">
|
||||
<label htmlFor="eagle-base-url">Eagle 本地 API 地址</label>
|
||||
<Input id="eagle-base-url" aria-label="Eagle 本地 API 地址" value={eagleBaseUrl} onChange={(event) => setEagleBaseUrl(event.target.value)} placeholder="http://localhost:41595" />
|
||||
<p>Eagle 必须在本机运行;影策通过插件直接读取和写入 Eagle 原始文件。</p>
|
||||
</div>
|
||||
|
||||
{plugin.manifest.id === EAGLE_PLUGIN_ID ? (
|
||||
<>
|
||||
<div className="grid min-w-0 gap-4 lg:grid-cols-2">
|
||||
<div className="min-w-0">
|
||||
<label htmlFor="eagle-base-url" className="block text-xs font-medium text-foreground/58">Eagle 本地 API 地址</label>
|
||||
<Input id="eagle-base-url" aria-label="Eagle 本地 API 地址" value={eagleBaseUrl} onChange={(event) => setEagleBaseUrl(event.target.value)} placeholder="http://localhost:41595" className="mt-2 w-full" />
|
||||
<p className="mt-2 text-xs leading-5 text-foreground/45">Eagle 必须在本机运行;影策通过插件直接读取和写入 Eagle 原始文件。</p>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label htmlFor="eagle-auto-upload-generated" className="block text-xs font-medium text-foreground/58">自动归档生成结果</label>
|
||||
<Switch id="eagle-auto-upload-generated" checked={eagleAutoUploadGenerated} onChange={setEagleAutoUploadGenerated} aria-label="自动归档生成结果到 Eagle" />
|
||||
</div>
|
||||
<p className="mt-2 text-xs leading-5 text-foreground/45">图片、视频和音频生成成功后,自动写入 Eagle;影策本地素材仍会保留。</p>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label htmlFor="eagle-generated-folder" className="block text-xs font-medium text-foreground/58">生成结果写入文件夹</label>
|
||||
<Button type="link" size="small" loading={eagleFoldersLoading} onClick={() => void loadEagleFolders()}>读取文件夹</Button>
|
||||
</div>
|
||||
<Select
|
||||
id="eagle-generated-folder"
|
||||
aria-label="生成结果写入文件夹"
|
||||
className="mt-2 w-full"
|
||||
showSearch
|
||||
allowClear
|
||||
value={eagleGeneratedFolderId || undefined}
|
||||
placeholder="Eagle 根目录"
|
||||
optionFilterProp="label"
|
||||
options={[{ value: "__root__", label: "Eagle 根目录" }, ...eagleFolderOptions(eagleFolders)]}
|
||||
onChange={(value) => setEagleGeneratedFolderId(value === "__root__" || !value ? "" : value)}
|
||||
/>
|
||||
<p className="mt-2 text-xs leading-5 text-foreground/45">{eagleFoldersError || "默认写入 Eagle 根目录;选择文件夹后按 Eagle 原始目录归档。"}</p>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="plugin-setting-label-row">
|
||||
<label htmlFor="eagle-auto-upload-generated">自动归档生成结果</label>
|
||||
<Switch id="eagle-auto-upload-generated" checked={eagleAutoUploadGenerated} onChange={setEagleAutoUploadGenerated} aria-label="自动归档生成结果到 Eagle" />
|
||||
</div>
|
||||
<div className="mt-4 flex shrink-0 flex-wrap gap-2">
|
||||
<Button type="primary" className="min-h-9" icon={<CheckCircle2 className="size-4" />} onClick={saveEagleConfig}>保存配置</Button>
|
||||
<Button className="min-h-9" icon={<FolderOpen className="size-4" />} disabled={!enabled} onClick={() => navigate("/plugins/eagle")}>打开 Eagle 素材库</Button>
|
||||
<Button className="min-h-9" icon={<ExternalLink className="size-4" />} href="https://api.eagle.cool/" target="_blank">查看 API</Button>
|
||||
<p>图片、视频和音频生成成功后,自动写入 Eagle;影策本地素材仍会保留。</p>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="plugin-setting-label-row">
|
||||
<label htmlFor="eagle-generated-folder">生成结果写入文件夹</label>
|
||||
<Button type="link" size="small" loading={eagleFoldersLoading} onClick={() => void loadEagleFolders()}>读取文件夹</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="rounded-[var(--r-lg)] bg-foreground/[.035] p-4 text-sm text-foreground/55">该插件暂无可编辑设置项。当前接入位置和权限会根据插件清单自动生效。</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-x-6 gap-y-3 text-xs text-foreground/52">
|
||||
<div><span className="mr-2 text-foreground/38">接入位置</span>{plugin.manifest.surfaces.map((surface) => surfaceLabels[surface] ?? surface).join("、")}</div>
|
||||
<div><span className="mr-2 text-foreground/38">插件能力</span>{plugin.manifest.permissions.map((permission) => permissionLabels[permission] ?? permission).join("、")}</div>
|
||||
<Select
|
||||
id="eagle-generated-folder"
|
||||
aria-label="生成结果写入文件夹"
|
||||
showSearch
|
||||
allowClear
|
||||
value={eagleGeneratedFolderId || undefined}
|
||||
placeholder="Eagle 根目录"
|
||||
optionFilterProp="label"
|
||||
options={[{ value: "__root__", label: "Eagle 根目录" }, ...eagleFolderOptions(eagleFolders)]}
|
||||
onChange={(value) => setEagleGeneratedFolderId(value === "__root__" || !value ? "" : value)}
|
||||
/>
|
||||
<p>{eagleFoldersError || "默认写入 Eagle 根目录;选择文件夹后按 Eagle 原始目录归档。"}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="plugin-settings-actions">
|
||||
<Button type="primary" icon={<CheckCircle2 className="size-4" />} onClick={saveEagleConfig}>保存配置</Button>
|
||||
<Button icon={<FolderOpen className="size-4" />} disabled={!settingsEnabled} onClick={() => navigate("/plugins/eagle")}>打开 Eagle 素材库</Button>
|
||||
<Button icon={<ExternalLink className="size-4" />} href="https://api.eagle.cool/" target="_blank">查看 API</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="plugin-settings-empty">该插件暂无可编辑设置项。当前接入位置和权限会根据插件清单自动生效。</div>
|
||||
)}
|
||||
|
||||
{installation?.lastError ? <Typography.Text type="danger" className="mt-3 block text-xs" role="alert">{installation.lastError}</Typography.Text> : null}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="plugin-permissions">
|
||||
<div><span>接入位置</span>{settingsPlugin.manifest.surfaces.map((surface) => surfaceLabels[surface] ?? surface).join("、")}</div>
|
||||
<div><span>插件能力</span>{settingsPlugin.manifest.permissions.map((permission) => permissionLabels[permission] ?? permission).join("、")}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function formatPluginDate(value?: string) {
|
||||
if (!value) return "未记录";
|
||||
const timestamp = Date.parse(value);
|
||||
return Number.isFinite(timestamp) ? pluginDateFormatter.format(timestamp) : "未记录";
|
||||
}
|
||||
|
||||
function eagleFolderOptions(folders: EagleFolder[]) {
|
||||
const byId = new Map(folders.map((folder) => [folder.id, folder]));
|
||||
const pathFor = (folder: EagleFolder) => {
|
||||
|
||||
385
web/src/pages/plugins/plugins.css
Normal file
385
web/src/pages/plugins/plugins.css
Normal file
@@ -0,0 +1,385 @@
|
||||
.plugins-page {
|
||||
--plugins-card-radius: var(--r-xl);
|
||||
}
|
||||
|
||||
.plugins-page-content {
|
||||
width: min(100%, 1440px);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.plugin-trust-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
color: var(--status-success);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.plugins-page-content {
|
||||
padding: var(--space-5) var(--space-6) var(--space-8);
|
||||
}
|
||||
|
||||
.plugins-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(240px, 1fr) repeat(3, minmax(132px, 0.24fr)) auto;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.plugins-search,
|
||||
.plugins-filter {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.plugins-search.ant-input-affix-wrapper,
|
||||
.plugins-filter.ant-select .ant-select-selector {
|
||||
min-height: 38px;
|
||||
border-color: var(--workspace-border);
|
||||
border-radius: var(--r-lg);
|
||||
background: color-mix(in srgb, var(--workspace-surface-strong) 84%, transparent);
|
||||
}
|
||||
|
||||
.plugins-search.ant-input-affix-wrapper:hover,
|
||||
.plugins-search.ant-input-affix-wrapper:focus-within,
|
||||
.plugins-filter.ant-select:hover .ant-select-selector,
|
||||
.plugins-filter.ant-select-focused .ant-select-selector {
|
||||
border-color: color-mix(in srgb, var(--workspace-accent) 48%, var(--workspace-border));
|
||||
}
|
||||
|
||||
.plugins-filter-icon {
|
||||
display: grid;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
place-items: center;
|
||||
border-radius: var(--r-lg);
|
||||
background: color-mix(in srgb, var(--foreground) 5%, transparent);
|
||||
color: color-mix(in srgb, var(--foreground) 48%, transparent);
|
||||
}
|
||||
|
||||
.plugins-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
align-items: stretch;
|
||||
gap: var(--space-4);
|
||||
margin-top: var(--space-5);
|
||||
}
|
||||
|
||||
.plugin-card {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 286px;
|
||||
flex-direction: column;
|
||||
padding: var(--space-4);
|
||||
border-radius: var(--plugins-card-radius);
|
||||
transition: transform var(--motion-state) var(--motion-ease-out), background-color var(--motion-state) ease-out;
|
||||
}
|
||||
|
||||
.plugin-card:hover,
|
||||
.plugin-card:focus-within {
|
||||
transform: translateY(var(--library-hover-offset));
|
||||
}
|
||||
|
||||
.plugin-card-main {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.plugin-card-heading {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.plugin-icon-tile {
|
||||
display: grid;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
border-radius: var(--r-lg);
|
||||
background: color-mix(in srgb, var(--foreground) 7%, transparent);
|
||||
color: color-mix(in srgb, var(--foreground) 65%, transparent);
|
||||
transition: background-color var(--motion-state) ease-out, color var(--motion-state) ease-out, transform var(--motion-state) var(--motion-ease-out);
|
||||
}
|
||||
|
||||
.plugin-card:hover .plugin-icon-tile,
|
||||
.plugin-card:focus-within .plugin-icon-tile {
|
||||
transform: translateY(-2px);
|
||||
background: color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.plugin-icon-tile.is-trusted {
|
||||
background: color-mix(in srgb, var(--status-success) 14%, transparent);
|
||||
color: var(--status-success);
|
||||
}
|
||||
|
||||
.plugin-card-title-row {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.plugin-card-title-row h3 {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--foreground);
|
||||
font-size: var(--fs-body-lg);
|
||||
font-weight: 650;
|
||||
line-height: 1.35;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.plugin-version {
|
||||
flex: 0 0 auto;
|
||||
color: color-mix(in srgb, var(--foreground) 40%, transparent);
|
||||
font-size: var(--fs-tiny);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.plugin-card-labels,
|
||||
.plugin-card-tags,
|
||||
.plugin-card-meta {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.plugin-card-labels {
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.plugin-category-label,
|
||||
.plugin-card-tags span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
border-radius: var(--r-sm);
|
||||
background: color-mix(in srgb, var(--foreground) 6%, transparent);
|
||||
padding: var(--space-half) var(--space-2);
|
||||
color: color-mix(in srgb, var(--foreground) 50%, transparent);
|
||||
font-size: var(--fs-micro);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.plugin-trust-label {
|
||||
font-size: var(--fs-micro);
|
||||
}
|
||||
|
||||
.plugin-card-description {
|
||||
display: -webkit-box;
|
||||
min-height: 3.2em;
|
||||
margin-top: var(--space-4);
|
||||
overflow: hidden;
|
||||
color: color-mix(in srgb, var(--foreground) 52%, transparent);
|
||||
font-size: var(--fs-caption);
|
||||
line-height: 1.6;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.plugin-card-meta {
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-4);
|
||||
color: color-mix(in srgb, var(--foreground) 43%, transparent);
|
||||
font-size: var(--fs-label);
|
||||
}
|
||||
|
||||
.plugin-card-meta span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.plugin-card-tags {
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.plugin-card-actions {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-5);
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid color-mix(in srgb, var(--workspace-border) 72%, transparent);
|
||||
}
|
||||
|
||||
.plugin-card-actions .settings-channel-status {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.plugin-settings-button {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.plugin-settings-panel {
|
||||
margin-top: var(--space-4);
|
||||
padding-top: var(--space-4);
|
||||
border-top: 1px solid var(--workspace-border);
|
||||
}
|
||||
|
||||
.plugin-settings-modal-panel {
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.plugin-settings-heading,
|
||||
.plugin-setting-label-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.plugin-settings-heading h4 {
|
||||
color: var(--foreground);
|
||||
font-size: var(--fs-body);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.plugin-settings-heading p,
|
||||
.plugin-settings-fields p {
|
||||
margin-top: var(--space-1);
|
||||
color: color-mix(in srgb, var(--foreground) 44%, transparent);
|
||||
font-size: var(--fs-label);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.plugin-settings-fields {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.plugin-settings-fields label {
|
||||
display: block;
|
||||
color: color-mix(in srgb, var(--foreground) 62%, transparent);
|
||||
font-size: var(--fs-label);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.plugin-settings-fields .ant-input,
|
||||
.plugin-settings-fields .ant-select {
|
||||
width: 100%;
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.plugin-setting-label-row label {
|
||||
margin-top: var(--space-half);
|
||||
}
|
||||
|
||||
.plugin-setting-label-row .ant-btn-link {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.plugin-settings-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.plugin-settings-empty {
|
||||
margin-top: var(--space-4);
|
||||
border-radius: var(--r-lg);
|
||||
background: color-mix(in srgb, var(--foreground) 4%, transparent);
|
||||
padding: var(--space-3);
|
||||
color: color-mix(in srgb, var(--foreground) 52%, transparent);
|
||||
font-size: var(--fs-caption);
|
||||
}
|
||||
|
||||
.plugin-permissions {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-4);
|
||||
color: color-mix(in srgb, var(--foreground) 52%, transparent);
|
||||
font-size: var(--fs-label);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.plugin-permissions span {
|
||||
display: inline-block;
|
||||
margin-right: var(--space-2);
|
||||
color: color-mix(in srgb, var(--foreground) 36%, transparent);
|
||||
}
|
||||
|
||||
.plugin-error {
|
||||
display: block;
|
||||
margin-top: var(--space-3);
|
||||
font-size: var(--fs-label);
|
||||
}
|
||||
|
||||
.plugins-empty-state {
|
||||
display: grid;
|
||||
min-height: 260px;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: var(--space-2);
|
||||
border-radius: var(--plugins-card-radius);
|
||||
background: color-mix(in srgb, var(--foreground) 3%, transparent);
|
||||
color: color-mix(in srgb, var(--foreground) 45%, transparent);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.plugins-empty-state h3 {
|
||||
margin-top: var(--space-2);
|
||||
color: color-mix(in srgb, var(--foreground) 78%, transparent);
|
||||
font-size: var(--fs-body-lg);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.plugins-empty-state p {
|
||||
font-size: var(--fs-caption);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.plugins-toolbar {
|
||||
grid-template-columns: minmax(0, 1fr) repeat(3, minmax(120px, 1fr));
|
||||
}
|
||||
|
||||
.plugins-search {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.plugins-filter-icon {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.plugins-page-content {
|
||||
padding-inline: var(--space-4);
|
||||
}
|
||||
|
||||
.plugins-toolbar {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.plugins-search {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.plugins-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.plugin-card {
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.plugin-card,
|
||||
.plugin-icon-tile {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { App, Button, Form, Input, Modal, Select } from "antd";
|
||||
import { ArrowRight, BookOpenText, FileText, FolderKanban, Images, LayoutGrid, Palette, Plus, Search, Sparkles, Trash2 } from "lucide-react";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router";
|
||||
@@ -112,7 +112,14 @@ export default function ProjectsPage() {
|
||||
setGenerationPreview("");
|
||||
}
|
||||
};
|
||||
const query = useQuery({ queryKey: ["projects"], queryFn: listProjects });
|
||||
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||
const query = useInfiniteQuery({
|
||||
// 分页查询和画布页的全量项目查询不能共用缓存形状,否则两个页面会互相覆盖缓存数据。
|
||||
queryKey: ["projects", "paged"],
|
||||
queryFn: ({ pageParam }) => listProjects({ page: pageParam, pageSize: 50 }),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage) => (lastPage.hasMore ? lastPage.page + 1 : undefined),
|
||||
});
|
||||
const mutation = useMutation({
|
||||
mutationFn: createProject,
|
||||
onSuccess: ({ project }) => {
|
||||
@@ -140,9 +147,10 @@ export default function ProjectsPage() {
|
||||
onOk: () => deleteMutation.mutate(projectId),
|
||||
});
|
||||
};
|
||||
const allProjects = useMemo(() => query.data?.pages.flatMap((page) => page.projects) || [], [query.data]);
|
||||
const rows = useMemo(() => {
|
||||
const normalizedKeyword = keyword.trim().toLowerCase();
|
||||
return [...(query.data?.projects || [])]
|
||||
return [...allProjects]
|
||||
.filter(({ project }) => status === "all" || project.status === status)
|
||||
.filter(({ project }) => !normalizedKeyword || `${project.name} ${project.description} ${project.stylePresetId} ${parseStyleProfile(project.styleProfileJson)?.title || resolveCanvasStylePreset(project.stylePresetId)?.title || ""}`.toLowerCase().includes(normalizedKeyword))
|
||||
.sort((left, right) => {
|
||||
@@ -150,17 +158,23 @@ export default function ProjectsPage() {
|
||||
if (sort === "progress") return projectSummaryCompletion(right) - projectSummaryCompletion(left);
|
||||
return right.project.updatedAt.localeCompare(left.project.updatedAt);
|
||||
});
|
||||
}, [keyword, query.data, sort, status]);
|
||||
}, [allProjects, keyword, sort, status]);
|
||||
const totalProjectCount = query.data?.pages[0]?.total ?? allProjects.length;
|
||||
useEffect(() => {
|
||||
const node = loadMoreRef.current;
|
||||
if (!node || !query.hasNextPage || query.isError) return;
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry?.isIntersecting && !query.isFetchingNextPage) void query.fetchNextPage();
|
||||
},
|
||||
{ rootMargin: "600px" },
|
||||
);
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, [query.fetchNextPage, query.hasNextPage, query.isError, query.isFetchingNextPage]);
|
||||
const hasInitialError = query.isError && !query.data;
|
||||
return (
|
||||
<WorkspacePage className="library-page" grid>
|
||||
<div className="studio-band">
|
||||
<PageHeader
|
||||
title="短剧创作"
|
||||
description="从故事出发,推进章节、画布与镜头。"
|
||||
meta={<span className="app-projects-header-meta">{rows.length} 个</span>}
|
||||
actions={null}
|
||||
/>
|
||||
</div>
|
||||
<section className="app-story-create-panel mt-4" aria-label="开始一部新短剧">
|
||||
<div className="app-story-create-head">
|
||||
<div className="app-story-create-title">
|
||||
@@ -217,14 +231,17 @@ export default function ProjectsPage() {
|
||||
<Select className="w-32" value={sort} onChange={setSort} options={[{ label: "最近更新", value: "updated" }, { label: "章节进度", value: "progress" }, { label: "项目名称", value: "name" }]} />
|
||||
</ListToolbar>
|
||||
|
||||
{query.isError ? <WorkspaceErrorState description={query.error instanceof Error ? query.error.message : "项目列表加载失败"} onRetry={() => void query.refetch()} /> : null}
|
||||
{hasInitialError ? <WorkspaceErrorState description={query.error instanceof Error ? query.error.message : "项目列表加载失败"} onRetry={() => void query.refetch()} /> : null}
|
||||
{query.isLoading ? <WorkspaceLoadingState label="正在整理项目" detail="读取章节、画布与资产进度" /> : null}
|
||||
{!query.isLoading && !query.isError && rows.length ? (
|
||||
{!query.isLoading && !hasInitialError && rows.length ? (
|
||||
<CollectionGrid className="library-grid project-library-grid">
|
||||
{rows.map((row) => <ProjectRow key={row.project.id} row={row} onDelete={() => confirmDeleteProject(row.project.id, row.project.name)} />)}
|
||||
</CollectionGrid>
|
||||
) : null}
|
||||
{!query.isLoading && !rows.length && !query.isError && (keyword || status !== "all") ? (
|
||||
{!query.isLoading && !hasInitialError ? <div ref={loadMoreRef} className="library-load-more" aria-live="polite">
|
||||
{query.isFetchingNextPage ? "正在加载更多项目…" : query.isError ? <button type="button" onClick={() => void query.fetchNextPage()}>加载更多失败,点击重试</button> : query.hasNextPage ? "继续下滑加载更多(每页 50 条)" : allProjects.length ? `已加载全部 ${totalProjectCount} 个项目` : null}
|
||||
</div> : null}
|
||||
{!query.isLoading && !rows.length && !hasInitialError && (keyword || status !== "all") ? (
|
||||
<WorkspaceState
|
||||
icon="projects"
|
||||
title={keyword || status !== "all" ? "没有匹配的项目" : "创建第一个故事项目"}
|
||||
|
||||
@@ -170,6 +170,14 @@ export type ProjectSummary = {
|
||||
completedUnitCount: number;
|
||||
};
|
||||
|
||||
export type ProjectListPage = {
|
||||
projects: ProjectSummary[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
hasMore: boolean;
|
||||
};
|
||||
|
||||
export type ProjectDetail = {
|
||||
project: Project;
|
||||
units: ProjectUnit[];
|
||||
@@ -183,8 +191,10 @@ export type ProjectDetail = {
|
||||
assetCandidates: ProjectAssetCandidate[];
|
||||
};
|
||||
|
||||
export function listProjects() {
|
||||
return request<{ projects: ProjectSummary[] }>(api.get("/projects"));
|
||||
export function listProjects(): Promise<{ projects: ProjectSummary[] }>;
|
||||
export function listProjects(params: { page: number; pageSize: number }): Promise<ProjectListPage>;
|
||||
export function listProjects(params?: { page: number; pageSize: number }) {
|
||||
return request<{ projects: ProjectSummary[] } | ProjectListPage>(api.get("/projects", params ? { params: { page: params.page, page_size: params.pageSize } } : undefined));
|
||||
}
|
||||
|
||||
export function getProject(id: string) {
|
||||
|
||||
@@ -413,6 +413,9 @@
|
||||
--workspace-sidebar: var(--workspace-navigation);
|
||||
--workspace-accent: #171717;
|
||||
--workspace-accent-soft: rgba(17, 17, 17, 0.07);
|
||||
--workspace-nav-active-bg: color-mix(in srgb, var(--workspace-accent) 12%, transparent);
|
||||
--workspace-nav-active-hover-bg: color-mix(in srgb, var(--workspace-accent) 16%, transparent);
|
||||
--workspace-nav-active-indicator: color-mix(in srgb, var(--workspace-accent) 78%, transparent);
|
||||
--workspace-sidebar-width: 208px; /* 侧栏展开宽度:较原 260px 收窄 20% */
|
||||
--workspace-sidebar-nav-width: 208px; /* 展开态导航内容宽度 */
|
||||
--workspace-sidebar-collapsed-width: 56px; /* 折叠态保留可发现的图标菜单轨 */
|
||||
@@ -550,6 +553,9 @@
|
||||
--workspace-sidebar: var(--workspace-navigation);
|
||||
--workspace-accent: #f5f5f5;
|
||||
--workspace-accent-soft: rgba(255, 255, 255, 0.09);
|
||||
--workspace-nav-active-bg: color-mix(in srgb, var(--workspace-accent) 13%, transparent);
|
||||
--workspace-nav-active-hover-bg: color-mix(in srgb, var(--workspace-accent) 18%, transparent);
|
||||
--workspace-nav-active-indicator: color-mix(in srgb, var(--workspace-accent) 82%, transparent);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
@@ -1522,13 +1528,32 @@
|
||||
letter-spacing: 0.01em;
|
||||
transition: color var(--motion-instant) ease-out;
|
||||
}
|
||||
/* 侧栏激活项:主操作角色成对定义(浅色/深色主题同一 token) */
|
||||
.app-workspace-nav-link {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 侧栏激活项同时保留底色和左侧指示条,避免只靠轻微文字变色识别当前位置。 */
|
||||
.app-workspace-nav-link.is-active {
|
||||
background: var(--workspace-accent-soft);
|
||||
background: var(--workspace-nav-active-bg);
|
||||
color: var(--foreground);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.app-workspace-nav-link.is-active::before {
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
bottom: 7px;
|
||||
left: 0;
|
||||
width: 3px;
|
||||
border-radius: var(--r-full);
|
||||
background: var(--workspace-nav-active-indicator);
|
||||
content: "";
|
||||
}
|
||||
|
||||
.app-workspace-nav-link.is-active:hover {
|
||||
background: var(--workspace-nav-active-hover-bg);
|
||||
}
|
||||
|
||||
|
||||
|
||||
.app-workspace-icon-button {
|
||||
@@ -3739,7 +3764,6 @@
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
@@ -3768,7 +3792,12 @@
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.canvas-folder-preview > :where(.canvas-project-media, .canvas-project-video, .canvas-project-empty, .canvas-project-preview-canvas),
|
||||
.canvas-folder-preview > :where(.canvas-project-media, .canvas-project-video, .canvas-project-empty, .canvas-project-preview-canvas) {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.canvas-folder-preview .cached-resource-image-shell { width: 100%; height: 100%; }
|
||||
.canvas-folder-preview .canvas-project-media img, .canvas-folder-preview .cached-resource-image-shell > img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||
.canvas-folder-preview .canvas-project-media img { filter: saturate(.94) contrast(1.02); transition: transform 420ms var(--motion-ease-out), filter 240ms ease-out; }
|
||||
@@ -3798,6 +3827,10 @@
|
||||
}
|
||||
.canvas-folder-meta-item { min-width: 0; }
|
||||
.canvas-folder-meta-separator { color: color-mix(in srgb, var(--foreground) 24%, transparent); }
|
||||
.canvas-folder-dates { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; padding-top: 2px; color: color-mix(in srgb, var(--foreground) 52%, transparent); font-size: var(--fs-micro); font-variant-numeric: tabular-nums; }
|
||||
.canvas-folder-dates > span { display: flex; min-width: 0; flex-direction: column; gap: 2px; }
|
||||
.canvas-folder-dates small { color: color-mix(in srgb, var(--foreground) 34%, transparent); font-size: var(--fs-micro); }
|
||||
.canvas-folder-dates time { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.canvas-folder-select {
|
||||
position: absolute;
|
||||
@@ -3842,7 +3875,7 @@
|
||||
|
||||
.canvas-folder-card:hover .canvas-folder-actions, .canvas-folder-card:focus-within .canvas-folder-actions { opacity: 1; transform: translateY(0); }
|
||||
|
||||
.canvas-folder-rename, .canvas-folder-action {
|
||||
.canvas-folder-rename, .canvas-folder-more {
|
||||
display: grid;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
@@ -3855,8 +3888,8 @@
|
||||
transition: background-color var(--motion-instant) ease-out, color var(--motion-instant) ease-out;
|
||||
}
|
||||
|
||||
:where(.canvas-folder-rename, .canvas-folder-action):hover { background: color-mix(in srgb, var(--foreground) 8%, transparent); color: var(--foreground); }
|
||||
:where(.canvas-folder-rename, .canvas-folder-action) svg { width: 16px; height: 16px; }
|
||||
:where(.canvas-folder-rename, .canvas-folder-more):hover { background: color-mix(in srgb, var(--foreground) 8%, transparent); color: var(--foreground); }
|
||||
:where(.canvas-folder-rename, .canvas-folder-more) svg { width: 16px; height: 16px; }
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.canvas-folder-open { gap: 0; padding: 0; }
|
||||
@@ -4340,6 +4373,9 @@
|
||||
.project-library-progress i { display: block; height: 4px; margin-top: 7px; overflow: hidden; border-radius: var(--r-full); background: color-mix(in srgb, var(--foreground) 9%, transparent); }
|
||||
.project-library-progress b { display: block; height: 100%; border-radius: inherit; background: var(--workspace-accent); transition: width 420ms var(--motion-ease-out); }
|
||||
.project-library-stats { display: flex; align-items: center; gap: 14px; margin-top: 12px; color: color-mix(in srgb, var(--foreground) 40%, transparent); font-size: var(--fs-label); }
|
||||
.library-load-more { display: flex; min-height: 44px; align-items: center; justify-content: center; padding: 14px 0 6px; color: color-mix(in srgb, var(--foreground) 42%, transparent); font-size: var(--fs-caption); }
|
||||
.library-load-more button { cursor: pointer; border: 0; background: transparent; color: inherit; font: inherit; text-decoration: underline; text-underline-offset: 3px; }
|
||||
.library-load-more button:focus:not(:focus-visible) { outline: none; }
|
||||
|
||||
/* 任务列表:整屏几十条长 prompt 极易糊成一片,靠三件事拉开密度——
|
||||
行距(min-height + gap)、标题只给一行且宽度封顶、次要信息降到 42% 前景色。 */
|
||||
@@ -8428,6 +8464,17 @@ html:not(.dark) .creation-media-preview-modal .ant-modal-container { background:
|
||||
background: var(--admin-sidebar);
|
||||
}
|
||||
|
||||
/* 管理后台侧栏不复用通用 Tailwind 宽度:通用 sidebar 规则在后面会覆盖 w-*,导致视觉收缩但布局仍占展开宽度。 */
|
||||
.admin-shell .admin-sidebar {
|
||||
width: 212px;
|
||||
flex-basis: 212px;
|
||||
}
|
||||
|
||||
.admin-shell .admin-sidebar.is-collapsed {
|
||||
width: var(--workspace-sidebar-collapsed-width, 56px);
|
||||
flex-basis: var(--workspace-sidebar-collapsed-width, 56px);
|
||||
}
|
||||
|
||||
.admin-shell .app-user-content,
|
||||
.admin-shell .app-workspace-page,
|
||||
.admin-shell .admin-page-root {
|
||||
|
||||
Reference in New Issue
Block a user