diff --git a/frontend/app/(app)/plugins/page.tsx b/frontend/app/(app)/plugins/page.tsx new file mode 100644 index 0000000..eff10e6 --- /dev/null +++ b/frontend/app/(app)/plugins/page.tsx @@ -0,0 +1,141 @@ +'use client' + +import { useState } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' +import { motion } from 'motion/react' +import { LibraryBig } from 'lucide-react' + +import { Ripple } from '@/components/motion/ripple' +import { RssCatalogImportDialog } from '@/components/plugins/rss-catalog-import-dialog' +import { TemplateCatalog } from '@/components/plugins/template-catalog' +import { EmptyState } from '@/components/shell/data-states' +import { PageContainer } from '@/components/shell/page-container' +import { Badge } from '@/components/ui/badge' +import { cn } from '@/lib/utils' + +type PluginSubtype = 'datasource' | 'template' | 'tool' | 'agent' | 'trigger' | 'extension' + +const SUBTYPES: { key: PluginSubtype; label: string }[] = [ + { key: 'datasource', label: '源库' }, + { key: 'template', label: '模板' }, + { key: 'tool', label: '工具' }, + { key: 'agent', label: 'Agent' }, + { key: 'trigger', label: '触发器' }, + { key: 'extension', label: '扩展' }, +] + +const PLACEHOLDER_DESCRIPTION: Record, string> = { + tool: '连接外部工具与 API,扩展工作流中可调用的能力。', + agent: '安装预置的分析与判断 Agent,用于富化采集到的数据。', + trigger: '安装可复用的触发规则,按事件或计划驱动自动化运行。', + extension: '扩展控制台能力的其他插件与集成。', +} + +function isPluginSubtype(value: string | null): value is PluginSubtype { + return SUBTYPES.some((subtype) => subtype.key === value) +} + +/** + * Segmented subtype switcher, styled to match `RouteTabs` + * (components/shell/route-tabs.tsx) — but this hub is a single page with + * subtype *content*, not sibling routes, so `RouteTabs` itself (which derives + * its active state from `usePathname()`) doesn't apply here. State lives in + * the `?type=` search param instead, so a tab is still linkable/shareable. + */ +function PluginTypeTabs({ active, onSelect }: { active: PluginSubtype; onSelect: (next: PluginSubtype) => void }) { + return ( + + ) +} + +function DatasourceLibraryTab() { + const [importOpen, setImportOpen] = useState(false) + return ( + <> +
+ +
+ + + ) +} + +export default function PluginHubPage() { + const router = useRouter() + const searchParams = useSearchParams() + const rawType = searchParams.get('type') + const active: PluginSubtype = isPluginSubtype(rawType) ? rawType : 'datasource' + + function selectSubtype(next: PluginSubtype) { + const params = new URLSearchParams(searchParams.toString()) + if (next === 'datasource') params.delete('type') + else params.set('type', next) + const query = params.toString() + router.push(query ? `/plugins?${query}` : '/plugins', { scroll: false }) + } + + return ( + } + > + {active === 'datasource' ? ( + + ) : active === 'template' ? ( + + ) : ( + + )} + + ) +} diff --git a/frontend/app/(app)/sources/page.tsx b/frontend/app/(app)/sources/page.tsx index 82a13db..e74d5e7 100644 --- a/frontend/app/(app)/sources/page.tsx +++ b/frontend/app/(app)/sources/page.tsx @@ -3,7 +3,7 @@ import { useState } from 'react' import Link from 'next/link' import { useMutation, useQueryClient } from '@tanstack/react-query' -import { ChevronRight, Download, ExternalLink, LibraryBig, Play } from 'lucide-react' +import { ChevronRight, Play } from 'lucide-react' import { toast } from 'sonner' import * as api from '@/lib/api/endpoints' @@ -17,16 +17,6 @@ import { StatusBadge } from '@/components/shell/status-badge' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card } from '@/components/ui/card' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' import { Switch } from '@/components/ui/switch' import { Table, @@ -48,46 +38,9 @@ const CHANNEL_LABEL: Record = { browser_act: 'BrowserAct 采集', } -const RSS_CATALOG_COMMIT = '3a7a9e28943d28b8acb6d9197fb168a8be5267f6' -const RSS_CATALOG_BASE = - `https://raw.githubusercontent.com/plenaryapp/awesome-rss-feeds/${RSS_CATALOG_COMMIT}/recommended/with_category` - -const RSS_CATALOG_PRESETS = [ - { - id: 'business', - label: '商业与经济', - description: '市场、公司、宏观经济与商业分析', - count: 17, - url: `${RSS_CATALOG_BASE}/Business%20%26%20Economy.opml`, - }, - { - id: 'personal-finance', - label: '个人财经', - description: '理财、投资、税务与消费金融', - count: 30, - url: `${RSS_CATALOG_BASE}/Personal%20finance.opml`, - }, - { - id: 'news', - label: '综合新闻', - description: '全球新闻与公共事件', - count: 10, - url: `${RSS_CATALOG_BASE}/News.opml`, - }, - { - id: 'tech', - label: '科技', - description: '开发、产品、平台与技术产业', - count: 28, - url: `${RSS_CATALOG_BASE}/Tech.opml`, - }, -] as const - export default function SourcesPage() { const [enabledFilter, setEnabledFilter] = useState<'all' | 'enabled' | 'disabled'>('all') const [page, setPage] = useState(1) - const [catalogOpen, setCatalogOpen] = useState(false) - const [catalogUrl, setCatalogUrl] = useState(RSS_CATALOG_PRESETS[0].url) const queryClient = useQueryClient() const params = { page, @@ -115,20 +68,6 @@ export default function SourcesPage() { onError: (e: Error) => toast.error(e.message), }) - const importCatalog = useMutation({ - mutationFn: () => api.importRssCatalog(catalogUrl.trim()), - onSuccess: (result) => { - queryClient.invalidateQueries({ queryKey: ['sources'] }) - toast.success( - `已导入 ${result.created.length} 个 RSS 数据源,跳过 ${result.skipped_existing.length} 个重复项`, - ) - setCatalogOpen(false) - setEnabledFilter('disabled') - setPage(1) - }, - onError: (e: Error) => toast.error(e.message), - }) - const sources = data?.data ?? [] const pagination = data?.meta @@ -139,211 +78,134 @@ export default function SourcesPage() { ] return ( - <> - } - actions={ -
- -
- {filters.map((f) => ( + ))} +
+ } + > + {isLoading ? ( + + ) : isError ? ( + + ) : sources.length === 0 ? ( + + ) : ( + + + + + 名称 + 渠道 + 标签 + 状态 + 更新时间 + 操作 + + + + {sources.map((s) => ( + + + + {s.name} + + + {s.review_required ? ( + + 待复核 + + ) : null} + + + {CHANNEL_LABEL[s.channel_type] ?? s.channel_type} + + +
+ {s.tags.slice(0, 3).map((t) => ( + + {t} + + ))} + {s.tags.length === 0 ? : null} +
+
+ + + + {formatRelative(s.updated_at)} + +
+ + toggle.mutate({ id: s.id, enabled: v })} + aria-label="启用/停用" + /> +
+
+
+ ))} +
+
+ {pagination && pagination.pages > 1 ? ( +
+ + 共 {pagination.total} 个数据源 · 第 {pagination.page}/{pagination.pages} 页 + +
- ))} -
-
- } - > - {isLoading ? ( - - ) : isError ? ( - - ) : sources.length === 0 ? ( - - ) : ( - - - - - 名称 - 渠道 - 标签 - 状态 - 更新时间 - 操作 - - - - {sources.map((s) => ( - - - - {s.name} - - - {s.review_required ? ( - - 待复核 - - ) : null} - - - {CHANNEL_LABEL[s.channel_type] ?? s.channel_type} - - -
- {s.tags.slice(0, 3).map((t) => ( - - {t} - - ))} - {s.tags.length === 0 ? : null} -
-
- - - - {formatRelative(s.updated_at)} - -
- - toggle.mutate({ id: s.id, enabled: v })} - aria-label="启用/停用" - /> -
-
-
- ))} -
-
- {pagination && pagination.pages > 1 ? ( -
- - 共 {pagination.total} 个数据源 · 第 {pagination.page}/{pagination.pages} 页 - -
- - -
-
- ) : null} -
- )} - - - - - - 导入开源 RSS 源库 - - 从 GitHub OPML 源包批量创建 RSS 数据源。所有源默认停用,并保留分类和源库出处,审核后再启用。 - - - -
- {RSS_CATALOG_PRESETS.map((preset) => { - const selected = catalogUrl === preset.url - return ( - - ) - })} -
- -
- - setCatalogUrl(event.target.value)} - placeholder="https://raw.githubusercontent.com/.../feeds.opml" - /> -
- 也可以粘贴其他公开 OPML 源库地址,单次上限 2 MB。 - - 查看源库 - - + 下一页 + +
-
- - - - - - - - + ) : null} + + )} +
) } diff --git a/frontend/components/plugins/rss-catalog-import-dialog.tsx b/frontend/components/plugins/rss-catalog-import-dialog.tsx new file mode 100644 index 0000000..2c47f93 --- /dev/null +++ b/frontend/components/plugins/rss-catalog-import-dialog.tsx @@ -0,0 +1,158 @@ +'use client' + +import { useState } from 'react' +import Link from 'next/link' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { Download, ExternalLink } from 'lucide-react' +import { toast } from 'sonner' + +import * as api from '@/lib/api/endpoints' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' + +const RSS_CATALOG_COMMIT = '3a7a9e28943d28b8acb6d9197fb168a8be5267f6' +const RSS_CATALOG_BASE = + `https://raw.githubusercontent.com/plenaryapp/awesome-rss-feeds/${RSS_CATALOG_COMMIT}/recommended/with_category` + +const RSS_CATALOG_PRESETS = [ + { + id: 'business', + label: '商业与经济', + description: '市场、公司、宏观经济与商业分析', + count: 17, + url: `${RSS_CATALOG_BASE}/Business%20%26%20Economy.opml`, + }, + { + id: 'personal-finance', + label: '个人财经', + description: '理财、投资、税务与消费金融', + count: 30, + url: `${RSS_CATALOG_BASE}/Personal%20finance.opml`, + }, + { + id: 'news', + label: '综合新闻', + description: '全球新闻与公共事件', + count: 10, + url: `${RSS_CATALOG_BASE}/News.opml`, + }, + { + id: 'tech', + label: '科技', + description: '开发、产品、平台与技术产业', + count: 28, + url: `${RSS_CATALOG_BASE}/Tech.opml`, + }, +] as const + +/** + * Installs the open-source RSS OPML catalog as disabled data sources. + * + * Extracted from the old sources/page.tsx "导入 RSS 源库" action — this is a + * catalog/install action (Plugin Hub concern), not instance management, so it + * now lives under the Plugin Hub's 源库 tab. sources/page.tsx only manages + * already-installed data source instances. + */ +export function RssCatalogImportDialog({ + open, + onOpenChange, +}: { + open: boolean + onOpenChange: (open: boolean) => void +}) { + const [catalogUrl, setCatalogUrl] = useState(RSS_CATALOG_PRESETS[0].url) + const queryClient = useQueryClient() + + const importCatalog = useMutation({ + mutationFn: () => api.importRssCatalog(catalogUrl.trim()), + onSuccess: (result) => { + queryClient.invalidateQueries({ queryKey: ['sources'] }) + toast.success( + `已导入 ${result.created.length} 个 RSS 数据源,跳过 ${result.skipped_existing.length} 个重复项`, + ) + onOpenChange(false) + }, + onError: (e: Error) => toast.error(e.message), + }) + + return ( + + + + 导入开源 RSS 源库 + + 从 GitHub OPML 源包批量创建 RSS 数据源。所有源默认停用,并保留分类和源库出处,审核后再启用。 + + + +
+ {RSS_CATALOG_PRESETS.map((preset) => { + const selected = catalogUrl === preset.url + return ( + + ) + })} +
+ +
+ + setCatalogUrl(event.target.value)} + placeholder="https://raw.githubusercontent.com/.../feeds.opml" + /> +
+ 也可以粘贴其他公开 OPML 源库地址,单次上限 2 MB。 + + 查看源库 + + +
+
+ + + + + +
+
+ ) +} diff --git a/frontend/components/plugins/template-catalog.tsx b/frontend/components/plugins/template-catalog.tsx new file mode 100644 index 0000000..87540f6 --- /dev/null +++ b/frontend/components/plugins/template-catalog.tsx @@ -0,0 +1,274 @@ +'use client' + +import Link from 'next/link' +import { Blocks, Database, Search, Send, Sparkles, Workflow } from 'lucide-react' +import { useRouter, useSearchParams } from 'next/navigation' +import { useEffect, useMemo, useState } from 'react' +import { toast } from 'sonner' + +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { useBootstrapWorkspaceProject, useMyWorkspaces } from '@/lib/api/hooks' +import { + STUDIO_TEMPLATES, + studioAppTypeForTemplate, + studioGraphForTemplate, + studioSlug, + type StudioTemplateId, +} from '@/lib/workflow/studio-templates' + +const CATEGORIES = ['全部', '采集与监控', '内容处理', 'Agent 分析', '分发与集成', '完整链路'] as const +const ICONS = { + '采集与监控': Database, + '内容处理': Blocks, + 'Agent 分析': Sparkles, + '分发与集成': Send, + '完整链路': Workflow, +} as const + +/** + * Template catalog grid + create-from-template flow. + * + * Extracted from studio/templates/page.tsx as the Plugin Hub's canonical + * 模板 tab. studio/templates/page.tsx is intentionally left untouched (still + * its own standalone entry point reachable from Studio) — this is a parallel + * call site, not a shared import, so the two carry duplicated logic by design + * for this skeleton PR. A follow-up can fold studio/templates/page.tsx down + * to reuse this component once the Plugin Hub is the confirmed canonical home. + */ +export function TemplateCatalog() { + const router = useRouter() + const searchParams = useSearchParams() + const workspaces = useMyWorkspaces() + const bootstrapProject = useBootstrapWorkspaceProject() + const [workspaceId, setWorkspaceId] = useState(searchParams.get('workspace')) + const [category, setCategory] = useState<(typeof CATEGORIES)[number]>('全部') + const [query, setQuery] = useState('') + const [selected, setSelected] = useState(null) + const [name, setName] = useState('') + + useEffect(() => { + if (!workspaces.data?.length) return + if (!workspaceId || !workspaces.data.some((workspace) => workspace.id === workspaceId)) { + setWorkspaceId(workspaces.data[0].id) + } + }, [workspaceId, workspaces.data]) + + const visible = useMemo( + () => + STUDIO_TEMPLATES.filter((template) => { + const matchesCategory = category === '全部' || template.category === category + const text = `${template.title} ${template.description} ${template.steps.join(' ')}`.toLowerCase() + return matchesCategory && text.includes(query.trim().toLowerCase()) + }), + [category, query], + ) + const selectedTemplate = STUDIO_TEMPLATES.find((template) => template.id === selected) ?? null + + function selectTemplate(templateId: StudioTemplateId, templateName: string) { + setSelected(templateId) + setName(templateName) + } + + function returnToTemplates() { + setSelected(null) + } + + async function createFromTemplate() { + if (!workspaceId) { + toast.error('请先选择工作区,再用模板创建项目') + return + } + if (!selected || !name.trim() || bootstrapProject.isPending) return + try { + const result = await bootstrapProject.mutateAsync({ + workspaceId, + data: { + project: { + name: name.trim(), + slug: `${studioSlug(name)}-${Date.now().toString(36)}`, + description: '由应用模板创建', + app_type: studioAppTypeForTemplate(selected), + }, + workflow: { + name: name.trim(), + description: '模板工作流', + graph: studioGraphForTemplate(selected, name.trim()), + }, + }, + }) + toast.success('模板已创建,可以继续编排') + router.push( + `/studio/workflow?workspace=${workspaceId}&project=${result.project.id}&workflow=${result.primary_workflow.id}`, + ) + } catch (reason) { + toast.error(reason instanceof Error ? reason.message : '创建失败') + } + } + + return ( +
+
+
+
+ + {visible.length} 个模板 + +
+ + + + {visible.length ? ( +
+ {visible.map((template) => { + const Icon = ICONS[template.category] + return ( + + ) + })} +
+ ) : ( +
+ 没有匹配的模板,换一个关键词试试。 +
+ )} + + { + if (!open && !bootstrapProject.isPending) returnToTemplates() + }} + > + + {selectedTemplate ? ( +
{ + event.preventDefault() + void createFromTemplate() + }} + > + + 用“{selectedTemplate.title}”创建项目 + + 确认项目名称后会创建项目和第一份工作流草稿,并直接打开画布继续编排。 + + + + {!workspaceId && !workspaces.isLoading ? ( +

+ 当前没有可用工作区。返回项目页选择或创建工作区后,即可继续使用这个模板。 +

+ ) : null} + + + {workspaceId ? ( + + ) : workspaces.isLoading ? ( + + ) : ( + + )} + +
+ ) : null} +
+
+
+ ) +} diff --git a/frontend/lib/navigation.ts b/frontend/lib/navigation.ts index 88ef871..4ab33bc 100644 --- a/frontend/lib/navigation.ts +++ b/frontend/lib/navigation.ts @@ -1,5 +1,6 @@ import { Activity, + Blocks, Database, LayoutDashboard, PanelsTopLeft, @@ -42,6 +43,7 @@ export const NAV_GROUPS: NavGroup[] = [ label: '构建', items: [ { href: '/studio', label: '项目', icon: PanelsTopLeft, match: ['/studio', '/canvas'] }, + { href: '/plugins', label: '插件中心', icon: Blocks }, { href: '/sources', label: '自动化与 Agent', @@ -82,6 +84,7 @@ export const ROUTE_LABELS: Record = { '/studio': '项目', '/studio/workflow': '工作流编排', '/canvas': '节点工作流(兼容入口)', + '/plugins': '插件中心', '/sources': '自动化与 Agent', '/schedules': '触发与调度', '/tasks': '工作项',