diff --git a/web/package.json b/web/package.json index 74967598..36b25dae 100644 --- a/web/package.json +++ b/web/package.json @@ -50,6 +50,7 @@ "react-dom": "^19.0.0", "react-hook-form": "^7.56.3", "react-i18next": "^15.5.1", + "react-markdown": "^10.1.0", "react-photo-view": "^1.2.7", "sonner": "^2.0.3", "tailwind-merge": "^3.2.0", diff --git a/web/src/app/home/bots/components/bot-form/BotForm.tsx b/web/src/app/home/bots/components/bot-form/BotForm.tsx index 40a902c2..de42e2ed 100644 --- a/web/src/app/home/bots/components/bot-form/BotForm.tsx +++ b/web/src/app/home/bots/components/bot-form/BotForm.tsx @@ -47,7 +47,7 @@ import { SelectValue, } from '@/components/ui/select'; import { Switch } from '@/components/ui/switch'; -import { i18nObj } from '@/i18n/I18nProvider'; +import { extractI18nObject } from '@/i18n/I18nProvider'; const getFormSchema = (t: (key: string) => string) => z.object({ @@ -166,7 +166,7 @@ export default function BotForm({ setAdapterNameList( adaptersRes.adapters.map((item) => { return { - label: i18nObj(item.label), + label: extractI18nObject(item.label), value: item.name, }; }), @@ -187,7 +187,7 @@ export default function BotForm({ setAdapterDescriptionList( adaptersRes.adapters.reduce( (acc, item) => { - acc[item.name] = i18nObj(item.description); + acc[item.name] = extractI18nObject(item.description); return acc; }, {} as Record, diff --git a/web/src/app/home/bots/page.tsx b/web/src/app/home/bots/page.tsx index d4305898..df257836 100644 --- a/web/src/app/home/bots/page.tsx +++ b/web/src/app/home/bots/page.tsx @@ -9,7 +9,7 @@ import { httpClient } from '@/app/infra/http/HttpClient'; import { Bot, Adapter } from '@/app/infra/entities/api'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; -import { i18nObj } from '@/i18n/I18nProvider'; +import { extractI18nObject } from '@/i18n/I18nProvider'; import BotDetailDialog from '@/app/home/bots/BotDetailDialog'; export default function BotConfigPage() { @@ -27,7 +27,7 @@ export default function BotConfigPage() { const adapterListResp = await httpClient.getAdapters(); const adapterList = adapterListResp.adapters.map((adapter: Adapter) => { return { - label: i18nObj(adapter.label), + label: extractI18nObject(adapter.label), value: adapter.name, }; }); diff --git a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx index f3df9e87..6c97cae4 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx +++ b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx @@ -12,7 +12,7 @@ import { } from '@/components/ui/form'; import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent'; import { useEffect } from 'react'; -import { i18nObj } from '@/i18n/I18nProvider'; +import { extractI18nObject } from '@/i18n/I18nProvider'; export default function DynamicFormComponent({ itemConfigList, @@ -142,7 +142,7 @@ export default function DynamicFormComponent({ render={({ field }) => ( - {i18nObj(config.label)}{' '} + {extractI18nObject(config.label)}{' '} {config.required && *} @@ -150,7 +150,7 @@ export default function DynamicFormComponent({ {config.description && (

- {i18nObj(config.description)} + {extractI18nObject(config.description)}

)} diff --git a/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx index 28d963d3..b7883bc0 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx +++ b/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx @@ -24,7 +24,7 @@ import { HoverCardTrigger, } from '@/components/ui/hover-card'; import { useTranslation } from 'react-i18next'; -import { i18nObj } from '@/i18n/I18nProvider'; +import { extractI18nObject } from '@/i18n/I18nProvider'; export default function DynamicFormItemComponent({ config, @@ -124,7 +124,7 @@ export default function DynamicFormItemComponent({ {config.options?.map((option) => ( - {i18nObj(option.label)} + {extractI18nObject(option.label)} ))} diff --git a/web/src/app/home/components/dynamic-form/DynamicFormItemConfig.ts b/web/src/app/home/components/dynamic-form/DynamicFormItemConfig.ts index 74fd4a0b..6b52ece0 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormItemConfig.ts +++ b/web/src/app/home/components/dynamic-form/DynamicFormItemConfig.ts @@ -3,16 +3,16 @@ import { DynamicFormItemType, IDynamicFormItemOption, } from '@/app/infra/entities/form/dynamic'; -import { I18nLabel } from '@/app/infra/entities/common'; +import { I18nObject } from '@/app/infra/entities/common'; export class DynamicFormItemConfig implements IDynamicFormItemSchema { id: string; name: string; default: string | number | boolean | Array; - label: I18nLabel; + label: I18nObject; required: boolean; type: DynamicFormItemType; - description?: I18nLabel; + description?: I18nObject; options?: IDynamicFormItemOption[]; constructor(params: IDynamicFormItemSchema) { diff --git a/web/src/app/home/components/dynamic-form/N8nAuthFormComponent.tsx b/web/src/app/home/components/dynamic-form/N8nAuthFormComponent.tsx index 1c71befc..5605cae2 100644 --- a/web/src/app/home/components/dynamic-form/N8nAuthFormComponent.tsx +++ b/web/src/app/home/components/dynamic-form/N8nAuthFormComponent.tsx @@ -12,7 +12,7 @@ import { } from '@/components/ui/form'; import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic'; import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent'; -import { i18nObj } from '@/i18n/I18nProvider'; +import { extractI18nObject } from '@/i18n/I18nProvider'; /** * N8n认证表单组件 @@ -182,7 +182,7 @@ export default function N8nAuthFormComponent({ render={({ field }) => ( - {i18nObj(config.label)}{' '} + {extractI18nObject(config.label)}{' '} {config.required && *} @@ -190,7 +190,7 @@ export default function N8nAuthFormComponent({ {config.description && (

- {i18nObj(config.description)} + {extractI18nObject(config.description)}

)} diff --git a/web/src/app/home/components/home-sidebar/HomeSidebarChild.tsx b/web/src/app/home/components/home-sidebar/HomeSidebarChild.tsx index 8529d410..031bc8db 100644 --- a/web/src/app/home/components/home-sidebar/HomeSidebarChild.tsx +++ b/web/src/app/home/components/home-sidebar/HomeSidebarChild.tsx @@ -1,5 +1,5 @@ import styles from './HomeSidebar.module.css'; -import { I18nLabel } from '@/app/infra/entities/common'; +import { I18nObject } from '@/app/infra/entities/common'; export interface ISidebarChildVO { id: string; @@ -7,7 +7,7 @@ export interface ISidebarChildVO { name: string; route: string; description: string; - helpLink: I18nLabel; + helpLink: I18nObject; } export class SidebarChildVO { @@ -16,7 +16,7 @@ export class SidebarChildVO { name: string; route: string; description: string; - helpLink: I18nLabel; + helpLink: I18nObject; constructor(props: ISidebarChildVO) { this.id = props.id; diff --git a/web/src/app/home/components/home-titlebar/HomeTitleBar.tsx b/web/src/app/home/components/home-titlebar/HomeTitleBar.tsx index 56e849fa..0749b8fe 100644 --- a/web/src/app/home/components/home-titlebar/HomeTitleBar.tsx +++ b/web/src/app/home/components/home-titlebar/HomeTitleBar.tsx @@ -1,6 +1,6 @@ -import { i18nObj } from '@/i18n/I18nProvider'; +import { extractI18nObject } from '@/i18n/I18nProvider'; import styles from './HomeTittleBar.module.css'; -import { I18nLabel } from '@/app/infra/entities/common'; +import { I18nObject } from '@/app/infra/entities/common'; export default function HomeTitleBar({ title, @@ -9,7 +9,7 @@ export default function HomeTitleBar({ }: { title: string; subtitle: string; - helpLink: I18nLabel; + helpLink: I18nObject; }) { return (
@@ -19,7 +19,7 @@ export default function HomeTitleBar({
{ - window.open(i18nObj(helpLink), '_blank'); + window.open(extractI18nObject(helpLink), '_blank'); }} className="cursor-pointer" > diff --git a/web/src/app/home/layout.tsx b/web/src/app/home/layout.tsx index 7dd68b25..d84eb6af 100644 --- a/web/src/app/home/layout.tsx +++ b/web/src/app/home/layout.tsx @@ -5,7 +5,7 @@ import HomeSidebar from '@/app/home/components/home-sidebar/HomeSidebar'; import HomeTitleBar from '@/app/home/components/home-titlebar/HomeTitleBar'; import React, { useState } from 'react'; import { SidebarChildVO } from '@/app/home/components/home-sidebar/HomeSidebarChild'; -import { I18nLabel } from '@/app/infra/entities/common'; +import { I18nObject } from '@/app/infra/entities/common'; export default function HomeLayout({ children, @@ -14,7 +14,7 @@ export default function HomeLayout({ }>) { const [title, setTitle] = useState(''); const [subtitle, setSubtitle] = useState(''); - const [helpLink, setHelpLink] = useState({ + const [helpLink, setHelpLink] = useState({ en_US: '', zh_Hans: '', }); diff --git a/web/src/app/home/models/component/llm-form/LLMForm.tsx b/web/src/app/home/models/component/llm-form/LLMForm.tsx index f483f183..3f023336 100644 --- a/web/src/app/home/models/component/llm-form/LLMForm.tsx +++ b/web/src/app/home/models/component/llm-form/LLMForm.tsx @@ -39,7 +39,7 @@ import { } from '@/components/ui/select'; import { Checkbox } from '@/components/ui/checkbox'; import { toast } from 'sonner'; -import { i18nObj } from '@/i18n/I18nProvider'; +import { extractI18nObject } from '@/i18n/I18nProvider'; const getExtraArgSchema = (t: (key: string) => string) => z @@ -201,7 +201,7 @@ export default function LLMForm({ setRequesterNameList( requesterNameList.requesters.map((item) => { return { - label: i18nObj(item.label), + label: extractI18nObject(item.label), value: item.name, }; }), diff --git a/web/src/app/home/models/page.tsx b/web/src/app/home/models/page.tsx index 3ccec486..5b23622d 100644 --- a/web/src/app/home/models/page.tsx +++ b/web/src/app/home/models/page.tsx @@ -16,7 +16,7 @@ import { } from '@/components/ui/dialog'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; -import { i18nObj } from '@/i18n/I18nProvider'; +import { extractI18nObject } from '@/i18n/I18nProvider'; export default function LLMConfigPage() { const { t } = useTranslation(); @@ -33,7 +33,7 @@ export default function LLMConfigPage() { const requesterNameListResp = await httpClient.getProviderRequesters(); const requesterNameList = requesterNameListResp.requesters.map((item) => { return { - label: i18nObj(item.label), + label: extractI18nObject(item.label), value: item.name, }; }); diff --git a/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx b/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx index c92b553d..d7dd4c07 100644 --- a/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx +++ b/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx @@ -31,7 +31,7 @@ import { } from '@/components/ui/dialog'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; -import { i18nObj } from '@/i18n/I18nProvider'; +import { extractI18nObject } from '@/i18n/I18nProvider'; export default function PipelineFormComponent({ initValues, @@ -220,10 +220,12 @@ export default function PipelineFormComponent({ if (stage.name === 'runner') { return (
-
{i18nObj(stage.label)}
+
+ {extractI18nObject(stage.label)} +
{stage.description && (
- {i18nObj(stage.description)} + {extractI18nObject(stage.description)}
)} -
{i18nObj(stage.label)}
+
+ {extractI18nObject(stage.label)} +
{stage.description && (
- {i18nObj(stage.description)} + {extractI18nObject(stage.description)}
)} -
{i18nObj(stage.label)}
+
+ {extractI18nObject(stage.label)} +
{stage.description && (
- {i18nObj(stage.description)} + {extractI18nObject(stage.description)}
)} ('local'); + const [installInfo, setInstallInfo] = useState>({}); // eslint-disable-line @typescript-eslint/no-explicit-any const [pluginInstallStatus, setPluginInstallStatus] = useState(PluginInstallStatus.WAIT_INPUT); const [installError, setInstallError] = useState(null); @@ -83,12 +85,12 @@ export default function PluginConfigPage() { } function handleModalConfirm() { - installPlugin('github', { url: githubURL }); + installPlugin(installSource, installInfo as Record); // eslint-disable-line @typescript-eslint/no-explicit-any } function installPlugin( installSource: string, - installInfo: Record, + installInfo: Record, // eslint-disable-line @typescript-eslint/no-explicit-any ) { setPluginInstallStatus(PluginInstallStatus.INSTALLING); if (installSource === 'github') { @@ -115,6 +117,17 @@ export default function PluginConfigPage() { setInstallError(err.message); setPluginInstallStatus(PluginInstallStatus.ERROR); }); + } else if (installSource === 'marketplace') { + httpClient + .installPluginFromMarketplace( + installInfo.plugin_author, + installInfo.plugin_name, + installInfo.plugin_version, + ) + .then((resp) => { + const taskId = resp.task_id; + watchTask(taskId); + }); } } @@ -244,12 +257,16 @@ export default function PluginConfigPage() { - { - setGithubURL(githubURL); + { + setInstallSource('marketplace'); + setInstallInfo({ + plugin_author: plugin.author, + plugin_name: plugin.name, + plugin_version: plugin.latest_version, + }); + setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM); setModalOpen(true); - setPluginInstallStatus(PluginInstallStatus.WAIT_INPUT); - setInstallError(null); }} /> @@ -274,6 +291,11 @@ export default function PluginConfigPage() { />
)} + {pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && ( +
+

{t('plugins.askConfirm')}

+
+ )} {pluginInstallStatus === PluginInstallStatus.INSTALLING && (

{t('plugins.installing')}

diff --git a/web/src/app/home/plugins/plugin-installed/PluginCardVO.ts b/web/src/app/home/plugins/plugin-installed/PluginCardVO.ts index 4e1b68f2..11ce2154 100644 --- a/web/src/app/home/plugins/plugin-installed/PluginCardVO.ts +++ b/web/src/app/home/plugins/plugin-installed/PluginCardVO.ts @@ -5,10 +5,11 @@ export interface IPluginCardVO { version: string; enabled: boolean; priority: number; + install_source: string; + install_info: Record; // eslint-disable-line @typescript-eslint/no-explicit-any status: string; tools: object[]; event_handlers: object; - repository: string; debug: boolean; } @@ -20,10 +21,11 @@ export class PluginCardVO implements IPluginCardVO { enabled: boolean; priority: number; debug: boolean; + install_source: string; + install_info: Record; // eslint-disable-line @typescript-eslint/no-explicit-any status: string; tools: object[]; event_handlers: object; - repository: string; constructor(prop: IPluginCardVO) { this.author = prop.author; @@ -32,10 +34,11 @@ export class PluginCardVO implements IPluginCardVO { this.event_handlers = prop.event_handlers; this.name = prop.name; this.priority = prop.priority; - this.repository = prop.repository; this.status = prop.status; this.tools = prop.tools; this.version = prop.version; this.debug = prop.debug; + this.install_source = prop.install_source; + this.install_info = prop.install_info; } } diff --git a/web/src/app/home/plugins/plugin-installed/PluginInstalledComponent.tsx b/web/src/app/home/plugins/plugin-installed/PluginInstalledComponent.tsx index 3c496c1b..732e15ab 100644 --- a/web/src/app/home/plugins/plugin-installed/PluginInstalledComponent.tsx +++ b/web/src/app/home/plugins/plugin-installed/PluginInstalledComponent.tsx @@ -13,7 +13,7 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { useTranslation } from 'react-i18next'; -import { i18nObj } from '@/i18n/I18nProvider'; +import { extractI18nObject } from '@/i18n/I18nProvider'; export interface PluginInstalledComponentRef { refreshPluginList: () => void; @@ -44,7 +44,7 @@ const PluginInstalledComponent = forwardRef( value.plugins.map((plugin) => { return new PluginCardVO({ author: plugin.manifest.manifest.metadata.author ?? '', - description: i18nObj( + description: extractI18nObject( plugin.manifest.manifest.metadata.description ?? { en_US: '', zh_Hans: '', @@ -57,8 +57,9 @@ const PluginInstalledComponent = forwardRef( status: plugin.status, tools: [], event_handlers: {}, - repository: plugin.manifest.manifest.metadata.repository ?? '', priority: plugin.priority, + install_source: plugin.install_source, + install_info: plugin.install_info, }); }), ); diff --git a/web/src/app/home/plugins/plugin-installed/plugin-card/PluginCardComponent.tsx b/web/src/app/home/plugins/plugin-installed/plugin-card/PluginCardComponent.tsx index b83720dc..bcbc02d4 100644 --- a/web/src/app/home/plugins/plugin-installed/plugin-card/PluginCardComponent.tsx +++ b/web/src/app/home/plugins/plugin-installed/plugin-card/PluginCardComponent.tsx @@ -5,6 +5,8 @@ import { Badge } from '@/components/ui/badge'; import { Switch } from '@/components/ui/switch'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; +import { ExternalLink } from 'lucide-react'; +import { getCloudServiceClientSync } from '@/app/infra/http'; export default function PluginCardComponent({ cardVO, @@ -66,6 +68,46 @@ export default function PluginCardComponent({ {t('plugins.debugging')} )} + {cardVO.install_source === 'github' && ( + { + e.stopPropagation(); + window.open(cardVO.install_info.github_url, '_blank'); + }} + > + {t('plugins.fromGithub')} + + + )} + {cardVO.install_source === 'local' && ( + + {t('plugins.fromLocal')} + + )} + {cardVO.install_source === 'marketplace' && ( + { + e.stopPropagation(); + window.open( + getCloudServiceClientSync().getPluginMarketplaceURL( + cardVO.author, + cardVO.name, + ), + '_blank', + ); + }} + > + {t('plugins.fromMarketplace')} + + + )}
diff --git a/web/src/app/home/plugins/plugin-installed/plugin-form/PluginForm.tsx b/web/src/app/home/plugins/plugin-installed/plugin-form/PluginForm.tsx index 0e7b5ac5..081253b6 100644 --- a/web/src/app/home/plugins/plugin-installed/plugin-form/PluginForm.tsx +++ b/web/src/app/home/plugins/plugin-installed/plugin-form/PluginForm.tsx @@ -13,7 +13,7 @@ import { DialogFooter, } from '@/components/ui/dialog'; import { toast } from 'sonner'; -import { i18nObj } from '@/i18n/I18nProvider'; +import { extractI18nObject } from '@/i18n/I18nProvider'; import { useTranslation } from 'react-i18next'; enum PluginRemoveStatus { @@ -185,20 +185,18 @@ export default function PluginForm({
- {i18nObj(pluginInfo.manifest.manifest.metadata.label)} + {extractI18nObject(pluginInfo.manifest.manifest.metadata.label)}
- {i18nObj( + {extractI18nObject( pluginInfo.manifest.manifest.metadata.description ?? { en_US: '', zh_Hans: '', }, )}
- {/* @ts-ignore */} {pluginInfo.manifest.manifest.spec.config.length > 0 && ( } onSubmit={(values) => { @@ -213,7 +211,6 @@ export default function PluginForm({ }} /> )} - {/* @ts-ignore */} {pluginInfo.manifest.manifest.spec.config.length === 0 && (
{t('plugins.pluginNoConfig')} diff --git a/web/src/app/home/plugins/plugin-market/PluginMarketComponent.tsx b/web/src/app/home/plugins/plugin-market/PluginMarketComponent.tsx index 054836e1..27b181b8 100644 --- a/web/src/app/home/plugins/plugin-market/PluginMarketComponent.tsx +++ b/web/src/app/home/plugins/plugin-market/PluginMarketComponent.tsx @@ -1,20 +1,8 @@ 'use client'; -import { useEffect, useState, useRef } from 'react'; -import styles from '@/app/home/plugins/plugins.module.css'; -import { PluginMarketCardVO } from '@/app/home/plugins/plugin-market/plugin-market-card/PluginMarketCardVO'; -import PluginMarketCardComponent from '@/app/home/plugins/plugin-market/plugin-market-card/PluginMarketCardComponent'; -import { getCloudServiceClientSync } from '@/app/infra/http'; -import { useTranslation } from 'react-i18next'; +import { useState, useEffect, useCallback, useRef, Suspense } from 'react'; +import { useSearchParams } from 'next/navigation'; import { Input } from '@/components/ui/input'; -import { - Pagination, - PaginationContent, - PaginationItem, - PaginationLink, - PaginationNext, - PaginationPrevious, -} from '@/components/ui/pagination'; import { Select, SelectContent, @@ -22,232 +10,402 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { Search, Loader2 } from 'lucide-react'; +import PluginMarketCardComponent from './plugin-market-card/PluginMarketCardComponent'; +import { PluginMarketCardVO } from './plugin-market-card/PluginMarketCardVO'; +import PluginDetailDialog from './plugin-detail-dialog/PluginDetailDialog'; +import { getCloudServiceClientSync } from '@/app/infra/http'; +import { useTranslation } from 'react-i18next'; +import { PluginV4 } from '@/app/infra/entities/plugin'; +import { extractI18nObject } from '@/i18n/I18nProvider'; +import { toast } from 'sonner'; +import { ApiRespMarketplacePlugins } from '@/app/infra/entities/api'; -export default function PluginMarketComponent({ - askInstallPlugin, +interface SortOption { + value: string; + label: string; + sortBy: string; + sortOrder: string; +} + +// 内部组件,用于处理搜索参数 +function MarketPageContent({ + installPlugin, }: { - askInstallPlugin: (githubURL: string) => void; + installPlugin: (plugin: PluginV4) => void; }) { const { t } = useTranslation(); - const [marketPluginList, setMarketPluginList] = useState< - PluginMarketCardVO[] - >([]); - const [totalCount, setTotalCount] = useState(0); - const [nowPage, setNowPage] = useState(1); - const [searchKeyword, setSearchKeyword] = useState(''); - const [loading, setLoading] = useState(false); - const [sortByValue, setSortByValue] = useState('pushed_at'); - const [sortOrderValue, setSortOrderValue] = useState('DESC'); - const searchTimeout = useRef(null); - const pageSize = 10; + const searchParams = useSearchParams(); - const cloudServiceClient = getCloudServiceClientSync(); + const [searchQuery, setSearchQuery] = useState(''); + const [plugins, setPlugins] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [hasMore, setHasMore] = useState(true); + const [currentPage, setCurrentPage] = useState(1); + const [total, setTotal] = useState(0); + const [sortOption, setSortOption] = useState('install_count_desc'); + // Plugin detail dialog state + const [selectedPluginAuthor, setSelectedPluginAuthor] = useState< + string | null + >(null); + const [selectedPluginName, setSelectedPluginName] = useState( + null, + ); + const [dialogOpen, setDialogOpen] = useState(false); + + const pageSize = 16; // 每页16个,4行x4列 + const searchTimeoutRef = useRef(null); + + // 排序选项 + const sortOptions: SortOption[] = [ + { + value: 'created_at_desc', + label: t('market.sort.recentlyAdded'), + sortBy: 'created_at', + sortOrder: 'DESC', + }, + { + value: 'updated_at_desc', + label: t('market.sort.recentlyUpdated'), + sortBy: 'updated_at', + sortOrder: 'DESC', + }, + { + value: 'install_count_desc', + label: t('market.sort.mostDownloads'), + sortBy: 'install_count', + sortOrder: 'DESC', + }, + { + value: 'install_count_asc', + label: t('market.sort.leastDownloads'), + sortBy: 'install_count', + sortOrder: 'ASC', + }, + ]; + + // 获取当前排序参数 + const getCurrentSort = useCallback(() => { + const option = sortOptions.find((opt) => opt.value === sortOption); + return option + ? { sortBy: option.sortBy, sortOrder: option.sortOrder } + : { sortBy: 'install_count', sortOrder: 'DESC' }; + }, [sortOption]); + + // 将API响应转换为VO对象 + const transformToVO = useCallback((plugin: PluginV4): PluginMarketCardVO => { + return new PluginMarketCardVO({ + pluginId: plugin.author + ' / ' + plugin.name, + author: plugin.author, + pluginName: plugin.name, + label: extractI18nObject(plugin.label), + description: + extractI18nObject(plugin.description) || t('market.noDescription'), + installCount: plugin.install_count, + iconURL: getCloudServiceClientSync().getPluginIconURL( + plugin.author, + plugin.name, + ), + githubURL: plugin.repository, + version: plugin.latest_version, + }); + }, []); + + // 获取插件列表 + const fetchPlugins = useCallback( + async (page: number, isSearch: boolean = false, reset: boolean = false) => { + if (page === 1) { + setIsLoading(true); + } else { + setIsLoadingMore(true); + } + + try { + let response; + const { sortBy, sortOrder } = getCurrentSort(); + + if (isSearch && searchQuery.trim()) { + response = await getCloudServiceClientSync().searchMarketplacePlugins( + searchQuery.trim(), + page, + pageSize, + sortBy, + sortOrder, + ); + } else { + response = await getCloudServiceClientSync().getMarketplacePlugins( + page, + pageSize, + sortBy, + sortOrder, + ); + } + + const data: ApiRespMarketplacePlugins = response; + const newPlugins = data.plugins.map(transformToVO); + const total = data.total; + + if (reset || page === 1) { + setPlugins(newPlugins); + } else { + setPlugins((prev) => [...prev, ...newPlugins]); + } + + setTotal(total); + setHasMore( + data.plugins.length === pageSize && + plugins.length + newPlugins.length < total, + ); + } catch (error) { + console.error('Failed to fetch plugins:', error); + toast.error(t('market.loadFailed')); + } finally { + setIsLoading(false); + setIsLoadingMore(false); + } + }, + [searchQuery, pageSize, transformToVO, plugins.length, getCurrentSort], + ); + + // 初始加载 useEffect(() => { - initData(); + fetchPlugins(1, false, true); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - function initData() { - getPluginList(); - } + // 搜索功能 + const handleSearch = useCallback( + (query: string) => { + setSearchQuery(query); + setCurrentPage(1); + setPlugins([]); + fetchPlugins(1, !!query.trim(), true); + }, + [fetchPlugins], + ); - function onInputSearchKeyword(keyword: string) { - setSearchKeyword(keyword); + // 防抖搜索 + const handleSearchInputChange = useCallback( + (value: string) => { + setSearchQuery(value); - // 清除之前的定时器 - if (searchTimeout.current) { - clearTimeout(searchTimeout.current); + // 清除之前的定时器 + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + + // 设置新的定时器 + searchTimeoutRef.current = setTimeout(() => { + handleSearch(value); + }, 300); + }, + [handleSearch], + ); + + // 排序选项变化处理 + const handleSortChange = useCallback((value: string) => { + setSortOption(value); + setCurrentPage(1); + setPlugins([]); + // fetchPlugins will be called by useEffect when sortOption changes + }, []); + + // 当排序选项变化时重新加载数据 + useEffect(() => { + fetchPlugins(1, !!searchQuery.trim(), true); + }, [sortOption]); + + // 处理URL参数,检查是否需要打开插件详情对话框 + useEffect(() => { + const author = searchParams.get('author'); + const pluginName = searchParams.get('plugin'); + + if (author && pluginName) { + setSelectedPluginAuthor(author); + setSelectedPluginName(pluginName); + setDialogOpen(true); } + }, [searchParams]); - // 设置新的定时器 - searchTimeout.current = setTimeout(() => { - setNowPage(1); - getPluginList(1, keyword); - }, 500); - } + // 插件详情对话框处理函数 + const handlePluginClick = useCallback( + (author: string, pluginName: string) => { + setSelectedPluginAuthor(author); + setSelectedPluginName(pluginName); + setDialogOpen(true); + }, + [], + ); - function getPluginList( - page: number = nowPage, - keyword: string = searchKeyword, - sortBy: string = sortByValue, - sortOrder: string = sortOrderValue, - ) { - setLoading(true); - cloudServiceClient - .getMarketPlugins(page, pageSize, keyword, sortBy, sortOrder) - .then((res) => { - setMarketPluginList( - res.plugins.map((marketPlugin) => { - let repository = marketPlugin.repository; - if (repository.startsWith('https://github.com/')) { - repository = repository.replace('https://github.com/', ''); - } + const handleDialogClose = useCallback(() => { + setDialogOpen(false); + setSelectedPluginAuthor(null); + setSelectedPluginName(null); + }, []); - if (repository.startsWith('github.com/')) { - repository = repository.replace('github.com/', ''); - } + // 清理定时器 + useEffect(() => { + return () => { + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + }; + }, []); - const author = repository.split('/')[0]; - const name = repository.split('/')[1]; - return new PluginMarketCardVO({ - author: author, - description: marketPlugin.description, - githubURL: `https://github.com/${repository}`, - name: name, - pluginId: String(marketPlugin.ID), - starCount: marketPlugin.stars, - version: - 'version' in marketPlugin - ? String(marketPlugin.version) - : '1.0.0', // Default version if not provided - }); - }), - ); - setTotalCount(res.total); - setLoading(false); - console.log('market plugins:', res); - }) - .catch((error) => { - console.error(t('plugins.getPluginListError'), error); - setLoading(false); - }); - } + // 加载更多 + const loadMore = useCallback(() => { + if (!isLoadingMore && hasMore) { + const nextPage = currentPage + 1; + setCurrentPage(nextPage); + fetchPlugins(nextPage, !!searchQuery.trim()); + } + }, [currentPage, isLoadingMore, hasMore, fetchPlugins, searchQuery]); - function handlePageChange(page: number) { - setNowPage(page); - getPluginList(page); - } + // 监听滚动事件 + useEffect(() => { + const handleScroll = () => { + if ( + window.innerHeight + document.documentElement.scrollTop >= + document.documentElement.offsetHeight - 100 + ) { + loadMore(); + } + }; - function handleSortChange(value: string) { - const [newSortBy, newSortOrder] = value.split(',').map((s) => s.trim()); - setSortByValue(newSortBy); - setSortOrderValue(newSortOrder); - setNowPage(1); - getPluginList(1, searchKeyword, newSortBy, newSortOrder); - } + window.addEventListener('scroll', handleScroll); + return () => window.removeEventListener('scroll', handleScroll); + }, [loadMore]); + + // 安装插件 + // const handleInstallPlugin = (plugin: PluginV4) => { + // console.log('install plugin', plugin); + // }; return ( -
-
- onInputSearchKeyword(e.target.value)} - /> - - - -
- {totalCount > 0 && ( - - - - handlePageChange(nowPage - 1)} - className={ - nowPage <= 1 ? 'pointer-events-none opacity-50' : '' - } - /> - - - {/* 如果总页数大于5,则只显示5页,如果总页数小于5,则显示所有页 */} - {(() => { - const totalPages = Math.ceil(totalCount / pageSize); - const maxVisiblePages = 5; - let startPage = Math.max( - 1, - nowPage - Math.floor(maxVisiblePages / 2), - ); - const endPage = Math.min( - totalPages, - startPage + maxVisiblePages - 1, - ); - - if (endPage - startPage + 1 < maxVisiblePages) { - startPage = Math.max(1, endPage - maxVisiblePages + 1); - } - - return Array.from( - { length: endPage - startPage + 1 }, - (_, i) => { - const pageNum = startPage + i; - return ( - - handlePageChange(pageNum)} - > - - {pageNum} - - - - ); - }, - ); - })()} - - - handlePageChange(nowPage + 1)} - className={ - nowPage >= Math.ceil(totalCount / pageSize) - ? 'pointer-events-none opacity-50' - : '' - } - /> - - - - )} +
+ {/* 搜索框 */} +
+
+ + handleSearchInputChange(e.target.value)} + onKeyPress={(e) => { + if (e.key === 'Enter') { + // 立即搜索,清除防抖定时器 + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + handleSearch(searchQuery); + } + }} + className="pl-10 pr-4" + />
-
- {loading ? ( -
- {t('plugins.loading')} -
- ) : marketPluginList.length === 0 ? ( -
- {t('plugins.noMatchingPlugins')} -
- ) : ( - marketPluginList.map((vo, index) => ( -
- { - askInstallPlugin(githubURL); - }} - /> -
- )) - )} + {/* 排序下拉框 */} +
+
+ + {t('market.sortBy')}: + + +
+ + {/* 搜索结果统计 */} + {total > 0 && ( +
+ {searchQuery + ? t('market.searchResults', { count: total }) + : t('market.totalPlugins', { count: total })} +
+ )} + + {/* 插件列表 */} + {isLoading ? ( +
+ + {t('cloud.loading')} +
+ ) : plugins.length === 0 ? ( +
+
+ {searchQuery ? t('market.noResults') : t('market.noPlugins')} +
+
+ ) : ( +
+ {plugins.map((plugin) => ( + + ))} +
+ )} + + {/* 加载更多指示器 */} + {isLoadingMore && ( +
+ + {t('market.loadingMore')} +
+ )} + + {/* 没有更多数据提示 */} + {!hasMore && plugins.length > 0 && ( +
+ {t('market.allLoaded')} +
+ )} + + {/* 插件详情对话框 */} +
); } + +// 主组件,包装在 Suspense 中 +export default function MarketPage({ + installPlugin, +}: { + installPlugin: (plugin: PluginV4) => void; +}) { + return ( + +
+ + 加载中... +
+
+ } + > + + + ); +} diff --git a/web/src/app/home/plugins/plugin-market/plugin-detail-dialog/PluginDetailDialog.tsx b/web/src/app/home/plugins/plugin-market/plugin-detail-dialog/PluginDetailDialog.tsx new file mode 100644 index 00000000..79e4a698 --- /dev/null +++ b/web/src/app/home/plugins/plugin-market/plugin-detail-dialog/PluginDetailDialog.tsx @@ -0,0 +1,277 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Dialog, DialogContent } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Loader2, Download, Users } from 'lucide-react'; +import ReactMarkdown from 'react-markdown'; +import { PluginV4 } from '@/app/infra/entities/plugin'; +import { extractI18nObject } from '@/i18n/I18nProvider'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; +import { getCloudServiceClientSync } from '@/app/infra/http'; + +interface PluginDetailDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + author: string | null; + pluginName: string | null; + installPlugin: (plugin: PluginV4) => void; +} + +export default function PluginDetailDialog({ + open, + onOpenChange, + author, + pluginName, + installPlugin, +}: PluginDetailDialogProps) { + const { t } = useTranslation(); + const [plugin, setPlugin] = useState(null); + const [readme, setReadme] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [isLoadingReadme, setIsLoadingReadme] = useState(false); + + // 获取插件详情和README + useEffect(() => { + if (open && author && pluginName) { + fetchPluginData(); + } + }, [open, author, pluginName]); + + const fetchPluginData = async () => { + if (!author || !pluginName) return; + + setIsLoading(true); + try { + // 获取插件详情 + const detailResponse = await getCloudServiceClientSync().getPluginDetail( + author, + pluginName, + ); + console.log('detailResponse', detailResponse); + setPlugin(detailResponse.plugin); + + // 获取README + setIsLoadingReadme(true); + try { + const readmeResponse = + await getCloudServiceClientSync().getPluginREADME(author, pluginName); + console.log('readmeResponse', readmeResponse); + setReadme(readmeResponse.readme); + } catch (error) { + console.warn('Failed to load README:', error); + setReadme(t('market.noReadme')); + } finally { + setIsLoadingReadme(false); + } + } catch (error) { + console.error('Failed to fetch plugin details:', error); + toast.error(t('market.loadFailed')); + onOpenChange(false); + } finally { + setIsLoading(false); + } + }; + + if (!open) return null; + + return ( + + + {isLoading ? ( +
+ + {t('cloud.loading')} +
+ ) : plugin ? ( +
+ {/* 左侧:插件基本信息 */} +
+ {/* 插件图标和标题 */} +
+ {plugin.name} +
+

+ {extractI18nObject(plugin.label) || plugin.name} +

+
+ + + {plugin.author} / {plugin.name} + +
+ +
+ + v{plugin.latest_version} + + + + + + {plugin.install_count.toLocaleString()}{' '} + {t('market.downloads')} + + + + {plugin.repository && ( + { + e.stopPropagation(); + window.open(plugin.repository, '_blank'); + }} + > + + + )} +
+
+
+ + {/* 插件描述 */} +
+

+ {t('market.description')} +

+

+ {extractI18nObject(plugin.description) || + t('market.noDescription')} +

+
+ + {/* 标签 */} + {plugin.tags && plugin.tags.length > 0 && ( +
+

+ {t('market.tags')} +

+
+ {plugin.tags.map((tag) => ( + + {tag} + + ))} +
+
+ )} + + {/* 操作按钮 */} +
+ + {/* {plugin.repository && ( + + )} */} +
+
+ + {/* 右侧:README内容 */} +
+
+ {isLoadingReadme ? ( +
+ + + {t('cloud.loading')} + +
+ ) : ( +
+ ( +

+ {children} +

+ ), + h2: ({ children }) => ( +

+ {children} +

+ ), + h3: ({ children }) => ( +

+ {children} +

+ ), + p: ({ children }) => ( +

+ {children} +

+ ), + ul: ({ children }) => ( +
    + {children} +
+ ), + ol: ({ children }) => ( +
    + {children} +
+ ), + li: ({ children }) => ( +
  • {children}
  • + ), + code: ({ children }) => ( + + {children} + + ), + blockquote: ({ children }) => ( +
    + {children} +
    + ), + a: ({ href, children }) => ( + + {children} + + ), + }} + > + {readme} +
    +
    + )} +
    +
    +
    + ) : null} +
    +
    + ); +} diff --git a/web/src/app/home/plugins/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx b/web/src/app/home/plugins/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx index bd62cdec..4d961433 100644 --- a/web/src/app/home/plugins/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx +++ b/web/src/app/home/plugins/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx @@ -1,40 +1,33 @@ -import { PluginMarketCardVO } from '@/app/home/plugins/plugin-market/plugin-market-card/PluginMarketCardVO'; -import { Button } from '@/components/ui/button'; -import { useTranslation } from 'react-i18next'; +import { PluginMarketCardVO } from './PluginMarketCardVO'; export default function PluginMarketCardComponent({ cardVO, - installPlugin, + onPluginClick, }: { cardVO: PluginMarketCardVO; - installPlugin: (pluginURL: string) => void; + onPluginClick?: (author: string, pluginName: string) => void; }) { - const { t } = useTranslation(); - - function handleInstallClick(pluginURL: string) { - installPlugin(pluginURL); + function handleCardClick() { + if (onPluginClick) { + onPluginClick(cardVO.author, cardVO.pluginName); + } } return ( -
    -
    - - - +
    +
    + {/* 上部分:插件信息 */} +
    + plugin icon -
    -
    +
    -
    - {cardVO.author} /{' '} -
    +
    {cardVO.pluginId}
    -
    {cardVO.name}
    +
    {cardVO.label}
    @@ -43,42 +36,40 @@ export default function PluginMarketCardComponent({
    -
    -
    +
    + {cardVO.githubURL && ( - - -
    - {t('plugins.starCount', { count: cardVO.starCount })} -
    -
    - -
    - window.open(cardVO.githubURL, '_blank')} + onClick={(e) => { + e.stopPropagation(); + window.open(cardVO.githubURL, '_blank'); + }} > - -
    + )} +
    +
    + + {/* 下部分:下载量 */} +
    + + + + + +
    + {cardVO.installCount.toLocaleString()}
    diff --git a/web/src/app/home/plugins/plugin-market/plugin-market-card/PluginMarketCardVO.ts b/web/src/app/home/plugins/plugin-market/plugin-market-card/PluginMarketCardVO.ts index fe0a1e75..b4c38bbe 100644 --- a/web/src/app/home/plugins/plugin-market/plugin-market-card/PluginMarketCardVO.ts +++ b/web/src/app/home/plugins/plugin-market/plugin-market-card/PluginMarketCardVO.ts @@ -1,9 +1,11 @@ export interface IPluginMarketCardVO { pluginId: string; author: string; - name: string; + pluginName: string; + label: string; description: string; - starCount: number; + installCount: number; + iconURL: string; githubURL: string; version: string; } @@ -11,18 +13,22 @@ export interface IPluginMarketCardVO { export class PluginMarketCardVO implements IPluginMarketCardVO { pluginId: string; description: string; - name: string; + label: string; author: string; + pluginName: string; + iconURL: string; githubURL: string; - starCount: number; + installCount: number; version: string; constructor(prop: IPluginMarketCardVO) { this.description = prop.description; - this.name = prop.name; + this.label = prop.label; this.author = prop.author; + this.pluginName = prop.pluginName; + this.iconURL = prop.iconURL; this.githubURL = prop.githubURL; - this.starCount = prop.starCount; + this.installCount = prop.installCount; this.pluginId = prop.pluginId; this.version = prop.version; } diff --git a/web/src/app/home/plugins/plugin-sort/PluginSortDialog.tsx b/web/src/app/home/plugins/plugin-sort/PluginSortDialog.tsx index ad6874eb..acde5741 100644 --- a/web/src/app/home/plugins/plugin-sort/PluginSortDialog.tsx +++ b/web/src/app/home/plugins/plugin-sort/PluginSortDialog.tsx @@ -32,7 +32,7 @@ import { } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { useTranslation } from 'react-i18next'; -import { i18nObj } from '@/i18n/I18nProvider'; +import { extractI18nObject } from '@/i18n/I18nProvider'; interface PluginSortDialogProps { open: boolean; @@ -87,7 +87,7 @@ export default function PluginSortDialog({ value.plugins.map((plugin) => { return new PluginCardVO({ author: plugin.author, - description: i18nObj(plugin.description), + description: extractI18nObject(plugin.description), enabled: plugin.enabled, name: plugin.name, version: plugin.version, diff --git a/web/src/app/infra/entities/api/index.ts b/web/src/app/infra/entities/api/index.ts index a1c8d8cb..accfe5b4 100644 --- a/web/src/app/infra/entities/api/index.ts +++ b/web/src/app/infra/entities/api/index.ts @@ -1,8 +1,8 @@ import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic'; import { PipelineConfigTab } from '@/app/infra/entities/pipeline'; -import { I18nLabel } from '@/app/infra/entities/common'; +import { I18nObject } from '@/app/infra/entities/common'; import { Message } from '@/app/infra/entities/message'; -import { Plugin } from '@/app/infra/entities/plugin'; +import { Plugin, PluginV4 } from '@/app/infra/entities/plugin'; export interface ApiResponse { code: number; @@ -24,8 +24,8 @@ export interface ApiRespProviderRequester { export interface Requester { name: string; - label: I18nLabel; - description: I18nLabel; + label: I18nObject; + description: I18nObject; icon?: string; spec: { config: IDynamicFormItemSchema[]; @@ -82,8 +82,8 @@ export interface ApiRespPlatformAdapter { export interface Adapter { name: string; - label: I18nLabel; - description: I18nLabel; + label: I18nObject; + description: I18nObject; icon?: string; spec: { config: IDynamicFormItemSchema[]; @@ -183,26 +183,13 @@ export interface ApiRespUserToken { token: string; } -export interface MarketPlugin { - ID: number; - CreatedAt: string; // ISO 8601 格式日期 - UpdatedAt: string; - DeletedAt: string | null; - name: string; - author: string; - description: string; - repository: string; // GitHub 仓库路径 - artifacts_path: string; - stars: number; - downloads: number; - status: 'initialized' | 'mounted'; // 可根据实际状态值扩展联合类型 - synced_at: string; - pushed_at: string; // 最后一次代码推送时间 +export interface ApiRespMarketplacePlugins { + plugins: PluginV4[]; + total: number; } -export interface MarketPluginResponse { - plugins: MarketPlugin[]; - total: number; +export interface ApiRespMarketplacePluginDetail { + plugin: PluginV4; } interface GetPipelineConfig { diff --git a/web/src/app/infra/entities/common.ts b/web/src/app/infra/entities/common.ts index 9805d377..35dcc9f7 100644 --- a/web/src/app/infra/entities/common.ts +++ b/web/src/app/infra/entities/common.ts @@ -1,4 +1,4 @@ -export interface I18nLabel { +export interface I18nObject { en_US: string; zh_Hans: string; ja_JP?: string; @@ -9,12 +9,12 @@ export interface ComponentManifest { kind: string; metadata: { name: string; - label: I18nLabel; - description?: I18nLabel; + label: I18nObject; + description?: I18nObject; icon?: string; repository?: string; version?: string; author?: string; }; - spec: object; + spec: Record; // eslint-disable-line @typescript-eslint/no-explicit-any } diff --git a/web/src/app/infra/entities/form/dynamic.ts b/web/src/app/infra/entities/form/dynamic.ts index 6a185c8b..d46528c9 100644 --- a/web/src/app/infra/entities/form/dynamic.ts +++ b/web/src/app/infra/entities/form/dynamic.ts @@ -1,13 +1,13 @@ -import { I18nLabel } from '@/app/infra/entities/common'; +import { I18nObject } from '@/app/infra/entities/common'; export interface IDynamicFormItemSchema { id: string; default: string | number | boolean | Array; - label: I18nLabel; + label: I18nObject; name: string; required: boolean; type: DynamicFormItemType; - description?: I18nLabel; + description?: I18nObject; options?: IDynamicFormItemOption[]; } @@ -25,5 +25,5 @@ export enum DynamicFormItemType { export interface IDynamicFormItemOption { name: string; - label: I18nLabel; + label: I18nObject; } diff --git a/web/src/app/infra/entities/pipeline/index.ts b/web/src/app/infra/entities/pipeline/index.ts index 29a5f6af..cc411c9f 100644 --- a/web/src/app/infra/entities/pipeline/index.ts +++ b/web/src/app/infra/entities/pipeline/index.ts @@ -1,4 +1,4 @@ -import { I18nLabel } from '@/app/infra/entities/common'; +import { I18nObject } from '@/app/infra/entities/common'; import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic'; export interface PipelineFormEntity { @@ -11,13 +11,13 @@ export interface PipelineFormEntity { export interface PipelineConfigTab { name: string; - label: I18nLabel; + label: I18nObject; stages: PipelineConfigStage[]; } export interface PipelineConfigStage { name: string; - label: I18nLabel; - description?: I18nLabel; + label: I18nObject; + description?: I18nObject; config: IDynamicFormItemSchema[]; } diff --git a/web/src/app/infra/entities/plugin/index.ts b/web/src/app/infra/entities/plugin/index.ts index ba239d0a..9de4ba4b 100644 --- a/web/src/app/infra/entities/plugin/index.ts +++ b/web/src/app/infra/entities/plugin/index.ts @@ -1,4 +1,4 @@ -import { ComponentManifest } from '@/app/infra/entities/common'; +import { ComponentManifest, I18nObject } from '@/app/infra/entities/common'; export interface Plugin { status: 'intialized' | 'mounted' | 'unmounted'; @@ -9,6 +9,8 @@ export interface Plugin { }; debug: boolean; enabled: boolean; + install_source: string; + install_info: Record; // eslint-disable-line @typescript-eslint/no-explicit-any components: { component_config: object; manifest: { @@ -16,3 +18,27 @@ export interface Plugin { }; }; } + +// marketplace plugin v4 +export enum PluginV4Status { + Any = 'any', + Live = 'live', + Deleted = 'deleted', +} + +export interface PluginV4 { + id: number; + plugin_id: string; + author: string; + name: string; + label: I18nObject; + description: I18nObject; + icon: string; + repository: string; + tags: string[]; + install_count: number; + latest_version: string; + status: PluginV4Status; + created_at: string; + updated_at: string; +} diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index 529bd5de..d077abf6 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -259,6 +259,18 @@ export class BackendClient extends BaseHttpClient { return this.postFile('/api/v1/plugins/install/local', formData); } + public installPluginFromMarketplace( + author: string, + name: string, + version: string, + ): Promise { + return this.post('/api/v1/plugins/install/marketplace', { + plugin_author: author, + plugin_name: name, + plugin_version: version, + }); + } + public removePlugin( author: string, name: string, diff --git a/web/src/app/infra/http/CloudServiceClient.ts b/web/src/app/infra/http/CloudServiceClient.ts index 668808ab..6d67316e 100644 --- a/web/src/app/infra/http/CloudServiceClient.ts +++ b/web/src/app/infra/http/CloudServiceClient.ts @@ -1,5 +1,8 @@ import { BaseHttpClient } from './BaseHttpClient'; -import { MarketPluginResponse } from '@/app/infra/entities/api'; +import { + ApiRespMarketplacePluginDetail, + ApiRespMarketplacePlugins, +} from '@/app/infra/entities/api'; /** * 云服务客户端 @@ -11,29 +14,59 @@ export class CloudServiceClient extends BaseHttpClient { super(baseURL, true); } - /** - * 获取插件市场插件列表 - * @param page 页码 - * @param page_size 每页大小 - * @param query 搜索关键词 - * @param sort_by 排序字段 - * @param sort_order 排序顺序 - */ - public getMarketPlugins( + public getMarketplacePlugins( page: number, page_size: number, - query: string, - sort_by: string = 'stars', - sort_order: string = 'DESC', - ): Promise { - return this.post(`/api/v1/market/plugins`, { - page, - page_size, - query, - sort_by, - sort_order, + sort_by?: string, + sort_order?: string, + ): Promise { + return this.get('/api/v1/marketplace/plugins', { + params: { page, page_size, sort_by, sort_order }, }); } - // 未来可以在这里添加更多 cloud service 相关的方法 + public searchMarketplacePlugins( + query: string, + page: number, + page_size: number, + sort_by?: string, + sort_order?: string, + ): Promise { + return this.post( + '/api/v1/marketplace/plugins/search', + { + query, + page, + page_size, + sort_by, + sort_order, + }, + ); + } + + public getPluginDetail( + author: string, + pluginName: string, + ): Promise { + return this.get( + `/api/v1/marketplace/plugins/${author}/${pluginName}`, + ); + } + + public getPluginREADME( + author: string, + pluginName: string, + ): Promise<{ readme: string }> { + return this.get<{ readme: string }>( + `/api/v1/marketplace/plugins/${author}/${pluginName}/resources/README`, + ); + } + + public getPluginIconURL(author: string, name: string): string { + return `${this.baseURL}/api/v1/marketplace/plugins/${author}/${name}/resources/icon`; + } + + public getPluginMarketplaceURL(author: string, name: string): string { + return `${this.baseURL}/market?author=${author}&plugin=${name}`; + } } diff --git a/web/src/app/infra/http/README.md b/web/src/app/infra/http/README.md index b305d8a9..2a2e976b 100644 --- a/web/src/app/infra/http/README.md +++ b/web/src/app/infra/http/README.md @@ -32,7 +32,11 @@ const marketPlugins = await cloudClient.getMarketPlugins(1, 10, 'search term'); // 使用云服务客户端(同步方式,可能使用默认 URL) import { cloudServiceClient } from '@/app/infra/http'; -const marketPlugins = await cloudServiceClient.getMarketPlugins(1, 10, 'search term'); +const marketPlugins = await cloudServiceClient.getMarketPlugins( + 1, + 10, + 'search term', +); ``` ### 向后兼容(不推荐) diff --git a/web/src/i18n/I18nProvider.tsx b/web/src/i18n/I18nProvider.tsx index ef3ea0b7..2a78941d 100644 --- a/web/src/i18n/I18nProvider.tsx +++ b/web/src/i18n/I18nProvider.tsx @@ -2,7 +2,7 @@ import { ReactNode } from 'react'; import '@/i18n'; -import { I18nLabel } from '@/app/infra/entities/common'; +import { I18nObject } from '@/app/infra/entities/common'; interface I18nProviderProps { children: ReactNode; @@ -11,7 +11,7 @@ interface I18nProviderProps { export default function I18nProvider({ children }: I18nProviderProps) { return <>{children}; } -export function i18nObj(i18nLabel: I18nLabel): string { +export function extractI18nObject(i18nLabel: I18nObject): string { const language = localStorage.getItem('langbot_language'); if ((language === 'zh-Hans' && i18nLabel.zh_Hans) || !i18nLabel.en_US) { return i18nLabel.zh_Hans; diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 1a7f4499..ec91858c 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -189,6 +189,51 @@ const enUS = { uploadSuccess: 'Upload successful', uploadFailed: 'Upload failed', selectFileToUpload: 'Select plugin file to upload', + fromGithub: 'From GitHub', + fromLocal: 'From Local', + fromMarketplace: 'From Marketplace', + }, + market: { + searchPlaceholder: 'Search plugins...', + searchResults: 'Found {{count}} plugins', + totalPlugins: 'Total {{count}} plugins', + noPlugins: 'No plugins available', + noResults: 'No relevant plugins found', + loadingMore: 'Loading more...', + allLoaded: 'All plugins displayed', + install: 'Install', + installConfirm: + 'Are you sure you want to install plugin "{{name}}" ({{version}})?', + downloadComplete: 'Plugin "{{name}}" download completed', + installFailed: 'Installation failed, please try again later', + loadFailed: 'Failed to get plugin list, please try again later', + noDescription: 'No description available', + notFound: 'Plugin information not found', + sortBy: 'Sort by', + sort: { + recentlyAdded: 'Recently Added', + recentlyUpdated: 'Recently Updated', + mostDownloads: 'Most Downloads', + leastDownloads: 'Least Downloads', + }, + downloads: 'downloads', + download: 'Download', + repository: 'Repository', + downloadFailed: 'Download failed', + noReadme: 'This plugin does not provide README documentation', + description: 'Description', + tags: 'Tags', + submissionTitle: 'You have a plugin submission under review: {{name}}', + submissionPending: 'Your plugin submission is under review: {{name}}', + submissionApproved: 'Your plugin submission has been approved: {{name}}', + submissionRejected: 'Your plugin submission has been rejected: {{name}}', + clickToRevoke: 'Revoke', + revokeSuccess: 'Revoke success', + revokeFailed: 'Revoke failed', + submissionDetails: 'Plugin Submission Details', + markAsRead: 'Mark as Read', + markAsReadSuccess: 'Marked as read', + markAsReadFailed: 'Mark as read failed', }, pipelines: { title: 'Pipelines', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 1576500f..2294a254 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -189,6 +189,52 @@ const jaJP = { uploadSuccess: 'アップロード成功', uploadFailed: 'アップロード失敗', selectFileToUpload: 'アップロードするプラグインファイルを選択', + fromGithub: 'GitHubから', + fromLocal: 'ローカルから', + fromMarketplace: 'プラグインマーケットから', + }, + market: { + searchPlaceholder: 'プラグインを検索...', + searchResults: '{{count}} 個のプラグインが見つかりました', + totalPlugins: '合計 {{count}} 個のプラグイン', + noPlugins: '利用可能なプラグインがありません', + noResults: '関連するプラグインが見つかりません', + loadingMore: 'さらに読み込み中...', + allLoaded: 'すべてのプラグインが表示されました', + install: 'インストール', + installConfirm: + 'プラグイン "{{name}}" ({{version}}) をインストールしますか?', + downloadComplete: 'プラグイン "{{name}}" のダウンロードが完了しました', + installFailed: 'インストールに失敗しました。後でもう一度お試しください', + loadFailed: + 'プラグインリストの取得に失敗しました。後でもう一度お試しください', + noDescription: '説明がありません', + notFound: 'プラグイン情報が見つかりません', + sortBy: '並び順', + sort: { + recentlyAdded: '最近追加', + recentlyUpdated: '最近更新', + mostDownloads: 'ダウンロード数多', + leastDownloads: 'ダウンロード数少', + }, + downloads: '回ダウンロード', + download: 'ダウンロード', + repository: 'リポジトリ', + downloadFailed: 'ダウンロード失敗', + noReadme: 'このプラグインはREADMEドキュメントを提供していません', + description: '説明', + tags: 'タグ', + submissionTitle: 'プラグインの提出が審査中です: {{name}}', + submissionPending: 'プラグインの提出が審査中です: {{name}}', + submissionApproved: 'プラグインの提出が承認されました: {{name}}', + submissionRejected: 'プラグインの提出が拒否されました: {{name}}', + clickToRevoke: '取り消し', + revokeSuccess: '取り消し成功', + revokeFailed: '取り消し失敗', + submissionDetails: 'プラグイン提出詳細', + markAsRead: '既読', + markAsReadSuccess: '既読に設定しました', + markAsReadFailed: '既読に設定に失敗しました', }, pipelines: { title: 'パイプライン', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 867410f1..67e1a9c8 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -184,6 +184,49 @@ const zhHans = { uploadSuccess: '上传成功', uploadFailed: '上传失败', selectFileToUpload: '选择要上传的插件文件', + fromGithub: '来自 GitHub', + fromLocal: '来自本地', + fromMarketplace: '来自市场', + }, + market: { + searchPlaceholder: '搜索插件...', + searchResults: '搜索到 {{count}} 个插件', + totalPlugins: '共 {{count}} 个插件', + noPlugins: '暂无插件', + noResults: '未找到相关插件', + loadingMore: '加载更多...', + allLoaded: '已显示全部插件', + install: '安装', + installConfirm: '确定要安装插件 "{{name}}" ({{version}}) 吗?', + downloadComplete: '插件 "{{name}}" 下载完成', + installFailed: '安装失败,请稍后重试', + loadFailed: '获取插件列表失败,请稍后重试', + noDescription: '暂无描述', + notFound: '插件信息未找到', + sortBy: '排序方式', + sort: { + recentlyAdded: '最近新增', + recentlyUpdated: '最近更新', + mostDownloads: '最多下载', + leastDownloads: '最少下载', + }, + downloads: '次下载', + download: '下载', + repository: '代码仓库', + downloadFailed: '下载失败', + noReadme: '该插件没有提供 README 文档', + description: '描述', + tags: '标签', + submissionTitle: '您有插件提交正在审核中: {{name}}', + submissionApproved: '您的插件提交已通过审核: {{name}}', + submissionRejected: '您的插件提交已被拒绝: {{name}}', + clickToRevoke: '撤回', + revokeSuccess: '撤回成功', + revokeFailed: '撤回失败', + submissionDetails: '插件提交详情', + markAsRead: '已读', + markAsReadSuccess: '已标记为已读', + markAsReadFailed: '标记为已读失败', }, pipelines: { title: '流水线',