mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 03:55:55 +00:00
feat: marketplace page
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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<string, string>,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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 }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{i18nObj(config.label)}{' '}
|
||||
{extractI18nObject(config.label)}{' '}
|
||||
{config.required && <span className="text-red-500">*</span>}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
@@ -150,7 +150,7 @@ export default function DynamicFormComponent({
|
||||
</FormControl>
|
||||
{config.description && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{i18nObj(config.description)}
|
||||
{extractI18nObject(config.description)}
|
||||
</p>
|
||||
)}
|
||||
<FormMessage />
|
||||
|
||||
@@ -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({
|
||||
<SelectGroup>
|
||||
{config.options?.map((option) => (
|
||||
<SelectItem key={option.name} value={option.name}>
|
||||
{i18nObj(option.label)}
|
||||
{extractI18nObject(option.label)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
|
||||
@@ -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<unknown>;
|
||||
label: I18nLabel;
|
||||
label: I18nObject;
|
||||
required: boolean;
|
||||
type: DynamicFormItemType;
|
||||
description?: I18nLabel;
|
||||
description?: I18nObject;
|
||||
options?: IDynamicFormItemOption[];
|
||||
|
||||
constructor(params: IDynamicFormItemSchema) {
|
||||
|
||||
@@ -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 }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{i18nObj(config.label)}{' '}
|
||||
{extractI18nObject(config.label)}{' '}
|
||||
{config.required && <span className="text-red-500">*</span>}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
@@ -190,7 +190,7 @@ export default function N8nAuthFormComponent({
|
||||
</FormControl>
|
||||
{config.description && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{i18nObj(config.description)}
|
||||
{extractI18nObject(config.description)}
|
||||
</p>
|
||||
)}
|
||||
<FormMessage />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
<div className={`${styles.titleBarContainer}`}>
|
||||
@@ -19,7 +19,7 @@ export default function HomeTitleBar({
|
||||
<span className={`${styles.helpLink}`}>
|
||||
<div
|
||||
onClick={() => {
|
||||
window.open(i18nObj(helpLink), '_blank');
|
||||
window.open(extractI18nObject(helpLink), '_blank');
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
|
||||
@@ -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<string>('');
|
||||
const [subtitle, setSubtitle] = useState<string>('');
|
||||
const [helpLink, setHelpLink] = useState<I18nLabel>({
|
||||
const [helpLink, setHelpLink] = useState<I18nObject>({
|
||||
en_US: '',
|
||||
zh_Hans: '',
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<div key={stage.name} className="space-y-4 mb-6">
|
||||
<div className="text-lg font-medium">{i18nObj(stage.label)}</div>
|
||||
<div className="text-lg font-medium">
|
||||
{extractI18nObject(stage.label)}
|
||||
</div>
|
||||
{stage.description && (
|
||||
<div className="text-sm text-gray-500">
|
||||
{i18nObj(stage.description)}
|
||||
{extractI18nObject(stage.description)}
|
||||
</div>
|
||||
)}
|
||||
<DynamicFormComponent
|
||||
@@ -256,10 +258,12 @@ export default function PipelineFormComponent({
|
||||
if (stage.name === 'n8n-service-api') {
|
||||
return (
|
||||
<div key={stage.name} className="space-y-4 mb-6">
|
||||
<div className="text-lg font-medium">{i18nObj(stage.label)}</div>
|
||||
<div className="text-lg font-medium">
|
||||
{extractI18nObject(stage.label)}
|
||||
</div>
|
||||
{stage.description && (
|
||||
<div className="text-sm text-gray-500">
|
||||
{i18nObj(stage.description)}
|
||||
{extractI18nObject(stage.description)}
|
||||
</div>
|
||||
)}
|
||||
<N8nAuthFormComponent
|
||||
@@ -286,10 +290,12 @@ export default function PipelineFormComponent({
|
||||
|
||||
return (
|
||||
<div key={stage.name} className="space-y-4 mb-6">
|
||||
<div className="text-lg font-medium">{i18nObj(stage.label)}</div>
|
||||
<div className="text-lg font-medium">
|
||||
{extractI18nObject(stage.label)}
|
||||
</div>
|
||||
{stage.description && (
|
||||
<div className="text-sm text-gray-500">
|
||||
{i18nObj(stage.description)}
|
||||
{extractI18nObject(stage.description)}
|
||||
</div>
|
||||
)}
|
||||
<DynamicFormComponent
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import PluginInstalledComponent, {
|
||||
PluginInstalledComponentRef,
|
||||
} from '@/app/home/plugins/plugin-installed/PluginInstalledComponent';
|
||||
import PluginMarketComponent from '@/app/home/plugins/plugin-market/PluginMarketComponent';
|
||||
import MarketPage from '@/app/home/plugins/plugin-market/PluginMarketComponent';
|
||||
import PluginSortDialog from '@/app/home/plugins/plugin-sort/PluginSortDialog';
|
||||
import styles from './plugins.module.css';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
@@ -28,11 +28,11 @@ import {
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Upload } from 'lucide-react';
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PluginV4 } from '@/app/infra/entities/plugin';
|
||||
|
||||
enum PluginInstallStatus {
|
||||
WAIT_INPUT = 'wait_input',
|
||||
@@ -46,6 +46,8 @@ export default function PluginConfigPage() {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [sortModalOpen, setSortModalOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('installed');
|
||||
const [installSource, setInstallSource] = useState<string>('local');
|
||||
const [installInfo, setInstallInfo] = useState<Record<string, any>>({}); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
const [pluginInstallStatus, setPluginInstallStatus] =
|
||||
useState<PluginInstallStatus>(PluginInstallStatus.WAIT_INPUT);
|
||||
const [installError, setInstallError] = useState<string | null>(null);
|
||||
@@ -83,12 +85,12 @@ export default function PluginConfigPage() {
|
||||
}
|
||||
|
||||
function handleModalConfirm() {
|
||||
installPlugin('github', { url: githubURL });
|
||||
installPlugin(installSource, installInfo as Record<string, any>); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
}
|
||||
|
||||
function installPlugin(
|
||||
installSource: string,
|
||||
installInfo: Record<string, any>,
|
||||
installInfo: Record<string, any>, // 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() {
|
||||
<PluginInstalledComponent ref={pluginInstalledRef} />
|
||||
</TabsContent>
|
||||
<TabsContent value="market">
|
||||
<PluginMarketComponent
|
||||
askInstallPlugin={(githubURL) => {
|
||||
setGithubURL(githubURL);
|
||||
<MarketPage
|
||||
installPlugin={(plugin: PluginV4) => {
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
@@ -274,6 +291,11 @@ export default function PluginConfigPage() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && (
|
||||
<div className="mt-4">
|
||||
<p className="mb-2">{t('plugins.askConfirm')}</p>
|
||||
</div>
|
||||
)}
|
||||
{pluginInstallStatus === PluginInstallStatus.INSTALLING && (
|
||||
<div className="mt-4">
|
||||
<p className="mb-2">{t('plugins.installing')}</p>
|
||||
|
||||
@@ -5,10 +5,11 @@ export interface IPluginCardVO {
|
||||
version: string;
|
||||
enabled: boolean;
|
||||
priority: number;
|
||||
install_source: string;
|
||||
install_info: Record<string, any>; // 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<string, any>; // 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<PluginInstalledComponentRef>(
|
||||
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<PluginInstalledComponentRef>(
|
||||
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,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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')}
|
||||
</Badge>
|
||||
)}
|
||||
{cardVO.install_source === 'github' && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[0.7rem] border-blue-400 text-blue-400"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.open(cardVO.install_info.github_url, '_blank');
|
||||
}}
|
||||
>
|
||||
{t('plugins.fromGithub')}
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</Badge>
|
||||
)}
|
||||
{cardVO.install_source === 'local' && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[0.7rem] border-green-400 text-green-400"
|
||||
>
|
||||
{t('plugins.fromLocal')}
|
||||
</Badge>
|
||||
)}
|
||||
{cardVO.install_source === 'marketplace' && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[0.7rem] border-purple-400 text-purple-400"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.open(
|
||||
getCloudServiceClientSync().getPluginMarketplaceURL(
|
||||
cardVO.author,
|
||||
cardVO.name,
|
||||
),
|
||||
'_blank',
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t('plugins.fromMarketplace')}
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-lg font-medium">
|
||||
{i18nObj(pluginInfo.manifest.manifest.metadata.label)}
|
||||
{extractI18nObject(pluginInfo.manifest.manifest.metadata.label)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 pb-2">
|
||||
{i18nObj(
|
||||
{extractI18nObject(
|
||||
pluginInfo.manifest.manifest.metadata.description ?? {
|
||||
en_US: '',
|
||||
zh_Hans: '',
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
{/* @ts-ignore */}
|
||||
{pluginInfo.manifest.manifest.spec.config.length > 0 && (
|
||||
<DynamicFormComponent
|
||||
// @ts-ignore
|
||||
itemConfigList={pluginInfo.manifest.manifest.spec.config}
|
||||
initialValues={pluginConfig.config as Record<string, object>}
|
||||
onSubmit={(values) => {
|
||||
@@ -213,7 +211,6 @@ export default function PluginForm({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* @ts-ignore */}
|
||||
{pluginInfo.manifest.manifest.spec.config.length === 0 && (
|
||||
<div className="text-sm text-gray-500">
|
||||
{t('plugins.pluginNoConfig')}
|
||||
|
||||
@@ -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<string>('pushed_at');
|
||||
const [sortOrderValue, setSortOrderValue] = useState<string>('DESC');
|
||||
const searchTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||
const pageSize = 10;
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const cloudServiceClient = getCloudServiceClientSync();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [plugins, setPlugins] = useState<PluginMarketCardVO[]>([]);
|
||||
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<string | null>(
|
||||
null,
|
||||
);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
|
||||
const pageSize = 16; // 每页16个,4行x4列
|
||||
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(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 (
|
||||
<div className={`${styles.marketComponentBody}`}>
|
||||
<div className="flex items-center justify-start mb-2 mt-2 pl-[0.8rem] pr-[0.8rem]">
|
||||
<Input
|
||||
style={{
|
||||
width: '300px',
|
||||
}}
|
||||
value={searchKeyword}
|
||||
placeholder={t('plugins.searchPlugin')}
|
||||
onChange={(e) => onInputSearchKeyword(e.target.value)}
|
||||
/>
|
||||
|
||||
<Select
|
||||
value={`${sortByValue},${sortOrderValue}`}
|
||||
onValueChange={handleSortChange}
|
||||
>
|
||||
<SelectTrigger className="w-[180px] ml-2 cursor-pointer">
|
||||
<SelectValue placeholder={t('plugins.sortBy')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="stars,DESC">{t('plugins.mostStars')}</SelectItem>
|
||||
<SelectItem value="created_at,DESC">
|
||||
{t('plugins.recentlyAdded')}
|
||||
</SelectItem>
|
||||
<SelectItem value="pushed_at,DESC">
|
||||
{t('plugins.recentlyUpdated')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="flex items-center justify-end ml-2">
|
||||
{totalCount > 0 && (
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem className="cursor-pointer">
|
||||
<PaginationPrevious
|
||||
onClick={() => handlePageChange(nowPage - 1)}
|
||||
className={
|
||||
nowPage <= 1 ? 'pointer-events-none opacity-50' : ''
|
||||
}
|
||||
/>
|
||||
</PaginationItem>
|
||||
|
||||
{/* 如果总页数大于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 (
|
||||
<PaginationItem
|
||||
key={pageNum}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<PaginationLink
|
||||
isActive={pageNum === nowPage}
|
||||
onClick={() => handlePageChange(pageNum)}
|
||||
>
|
||||
<span className="text-black select-none">
|
||||
{pageNum}
|
||||
</span>
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
);
|
||||
},
|
||||
);
|
||||
})()}
|
||||
|
||||
<PaginationItem className="cursor-pointer">
|
||||
<PaginationNext
|
||||
onClick={() => handlePageChange(nowPage + 1)}
|
||||
className={
|
||||
nowPage >= Math.ceil(totalCount / pageSize)
|
||||
? 'pointer-events-none opacity-50'
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
)}
|
||||
<div className="container mx-auto px-4 py-6 space-y-6">
|
||||
{/* 搜索框 */}
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="relative w-full max-w-2xl">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
|
||||
<Input
|
||||
placeholder={t('market.searchPlaceholder')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearchInputChange(e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
// 立即搜索,清除防抖定时器
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current);
|
||||
}
|
||||
handleSearch(searchQuery);
|
||||
}
|
||||
}}
|
||||
className="pl-10 pr-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.pluginListContainer}`}>
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '20px' }}>
|
||||
{t('plugins.loading')}
|
||||
</div>
|
||||
) : marketPluginList.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '20px' }}>
|
||||
{t('plugins.noMatchingPlugins')}
|
||||
</div>
|
||||
) : (
|
||||
marketPluginList.map((vo, index) => (
|
||||
<div key={`${vo.pluginId}-${index}`}>
|
||||
<PluginMarketCardComponent
|
||||
cardVO={vo}
|
||||
installPlugin={(githubURL) => {
|
||||
askInstallPlugin(githubURL);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{/* 排序下拉框 */}
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="w-full max-w-2xl flex items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
{t('market.sortBy')}:
|
||||
</span>
|
||||
<Select value={sortOption} onValueChange={handleSortChange}>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sortOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 搜索结果统计 */}
|
||||
{total > 0 && (
|
||||
<div className="text-center text-muted-foreground">
|
||||
{searchQuery
|
||||
? t('market.searchResults', { count: total })
|
||||
: t('market.totalPlugins', { count: total })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 插件列表 */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
<span className="ml-2">{t('cloud.loading')}</span>
|
||||
</div>
|
||||
) : plugins.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-muted-foreground">
|
||||
{searchQuery ? t('market.noResults') : t('market.noPlugins')}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-6">
|
||||
{plugins.map((plugin) => (
|
||||
<PluginMarketCardComponent
|
||||
key={plugin.pluginId}
|
||||
cardVO={plugin}
|
||||
onPluginClick={handlePluginClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 加载更多指示器 */}
|
||||
{isLoadingMore && (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
<span className="ml-2">{t('market.loadingMore')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 没有更多数据提示 */}
|
||||
{!hasMore && plugins.length > 0 && (
|
||||
<div className="text-center text-muted-foreground py-6">
|
||||
{t('market.allLoaded')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 插件详情对话框 */}
|
||||
<PluginDetailDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={handleDialogClose}
|
||||
author={selectedPluginAuthor}
|
||||
pluginName={selectedPluginName}
|
||||
installPlugin={installPlugin}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 主组件,包装在 Suspense 中
|
||||
export default function MarketPage({
|
||||
installPlugin,
|
||||
}: {
|
||||
installPlugin: (plugin: PluginV4) => void;
|
||||
}) {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
<span className="ml-2">加载中...</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<MarketPageContent installPlugin={installPlugin} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<PluginV4 | null>(null);
|
||||
const [readme, setReadme] = useState<string>('');
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="!min-w-[65vw] !min-h-[65vh] max-h-[85vh] overflow-hidden p-0">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
<span className="ml-2">{t('cloud.loading')}</span>
|
||||
</div>
|
||||
) : plugin ? (
|
||||
<div className="flex h-full">
|
||||
{/* 左侧:插件基本信息 */}
|
||||
<div className="w-2/5 p-6 border-r border-gray-200 overflow-y-auto">
|
||||
{/* 插件图标和标题 */}
|
||||
<div className="flex items-start gap-4 mb-6">
|
||||
<img
|
||||
src={getCloudServiceClientSync().getPluginIconURL(
|
||||
author!,
|
||||
pluginName!,
|
||||
)}
|
||||
alt={plugin.name}
|
||||
className="w-16 h-16 rounded-xl border bg-gray-50 object-cover flex-shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
{extractI18nObject(plugin.label) || plugin.name}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600 mb-2">
|
||||
<Users className="w-4 h-4" />
|
||||
<span>
|
||||
{plugin.author} / {plugin.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-gray-600 mb-2">
|
||||
<Badge variant="outline" className="text-sm">
|
||||
v{plugin.latest_version}
|
||||
</Badge>
|
||||
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-sm flex items-center gap-1"
|
||||
>
|
||||
<Download className="w-5 h-5" />
|
||||
<span className="font-medium">
|
||||
{plugin.install_count.toLocaleString()}{' '}
|
||||
{t('market.downloads')}
|
||||
</span>
|
||||
</Badge>
|
||||
|
||||
{plugin.repository && (
|
||||
<svg
|
||||
className="w-[1.2rem] h-[1.2rem] text-black cursor-pointer hover:text-gray-600"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.open(plugin.repository, '_blank');
|
||||
}}
|
||||
>
|
||||
<path d="M12.001 2C6.47598 2 2.00098 6.475 2.00098 12C2.00098 16.425 4.86348 20.1625 8.83848 21.4875C9.33848 21.575 9.52598 21.275 9.52598 21.0125C9.52598 20.775 9.51348 19.9875 9.51348 19.15C7.00098 19.6125 6.35098 18.5375 6.15098 17.975C6.03848 17.6875 5.55098 16.8 5.12598 16.5625C4.77598 16.375 4.27598 15.9125 5.11348 15.9C5.90098 15.8875 6.46348 16.625 6.65098 16.925C7.55098 18.4375 8.98848 18.0125 9.56348 17.75C9.65098 17.1 9.91348 16.6625 10.201 16.4125C7.97598 16.1625 5.65098 15.3 5.65098 11.475C5.65098 10.3875 6.03848 9.4875 6.67598 8.7875C6.57598 8.5375 6.22598 7.5125 6.77598 6.1375C6.77598 6.1375 7.61348 5.875 9.52598 7.1625C10.326 6.9375 11.176 6.825 12.026 6.825C12.876 6.825 13.726 6.9375 14.526 7.1625C16.4385 5.8625 17.276 6.1375 17.276 6.1375C17.826 7.5125 17.476 8.5375 17.376 8.7875C18.0135 9.4875 18.401 10.375 18.401 11.475C18.401 15.3125 16.0635 16.1625 13.8385 16.4125C14.201 16.725 14.5135 17.325 14.5135 18.2625C14.5135 19.6 14.501 20.675 14.501 21.0125C14.501 21.275 14.6885 21.5875 15.1885 21.4875C19.259 20.1133 21.9999 16.2963 22.001 12C22.001 6.475 17.526 2 12.001 2Z"></path>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 插件描述 */}
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-3">
|
||||
{t('market.description')}
|
||||
</h3>
|
||||
<p className="text-gray-700 leading-relaxed">
|
||||
{extractI18nObject(plugin.description) ||
|
||||
t('market.noDescription')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 标签 */}
|
||||
{plugin.tags && plugin.tags.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-3">
|
||||
{t('market.tags')}
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{plugin.tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="text-sm">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
onClick={() => installPlugin(plugin)}
|
||||
className="w-full h-12 text-base font-medium"
|
||||
>
|
||||
<Download className="w-5 h-5 mr-2" />
|
||||
{t('market.install')}
|
||||
</Button>
|
||||
{/* {plugin.repository && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleOpenRepository}
|
||||
className="w-full h-12 text-base"
|
||||
>
|
||||
<ExternalLink className="w-5 h-5 mr-2" />
|
||||
{t('market.repository')}
|
||||
</Button>
|
||||
)} */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:README内容 */}
|
||||
<div className="w-3/5 p-2 overflow-y-auto">
|
||||
<div className=" rounded-lg p-6 min-h-[500px]">
|
||||
{isLoadingReadme ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
<span className="ml-3 text-gray-600">
|
||||
{t('cloud.loading')}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="prose prose-sm max-w-none text-gray-800">
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
// 自定义样式
|
||||
h1: ({ children }) => (
|
||||
<h1 className="text-xl font-bold mb-4 text-gray-900 border-b border-gray-200 pb-2">
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="text-lg font-semibold mb-3 text-gray-900 mt-6">
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="text-base font-medium mb-2 text-gray-900 mt-4">
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
p: ({ children }) => (
|
||||
<p className="mb-4 leading-relaxed text-gray-700">
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<ul className="mb-4 pl-6 list-disc space-y-1">
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="mb-4 pl-6 list-decimal space-y-1">
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({ children }) => (
|
||||
<li className="text-gray-700">{children}</li>
|
||||
),
|
||||
code: ({ children }) => (
|
||||
<code className="block bg-gray-200 p-3 rounded-md text-sm font-mono whitespace-pre-wrap border">
|
||||
{children}
|
||||
</code>
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-4 border-blue-300 pl-4 py-2 mb-4 italic bg-blue-50 text-gray-700 rounded-r-md">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800 underline"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{readme}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="w-[100%] h-[10rem] bg-white rounded-[10px] shadow-[0px_2px_2px_0_rgba(0,0,0,0.2)] p-[1.2rem]">
|
||||
<div className="w-full h-full flex flex-row items-start justify-start gap-[1.2rem]">
|
||||
<svg
|
||||
className="w-16 h-16 text-[#2288ee]"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M8 4C8 2.34315 9.34315 1 11 1C12.6569 1 14 2.34315 14 4C14 4.35064 13.9398 4.68722 13.8293 5H18C18.5523 5 19 5.44772 19 6V10.1707C19.3128 10.0602 19.6494 10 20 10C21.6569 10 23 11.3431 23 13C23 14.6569 21.6569 16 20 16C19.6494 16 19.3128 15.9398 19 15.8293V20C19 20.5523 18.5523 21 18 21H4C3.44772 21 3 20.5523 3 20V6C3 5.44772 3.44772 5 4 5H8.17071C8.06015 4.68722 8 4.35064 8 4Z"></path>
|
||||
</svg>
|
||||
<div
|
||||
className="w-[100%] h-[9rem] bg-white rounded-[10px] shadow-[0px_0px_4px_0_rgba(0,0,0,0.2)] p-[1rem] cursor-pointer hover:shadow-[0px_2px_8px_0_rgba(0,0,0,0.15)] transition-shadow duration-200"
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
<div className="w-full h-full flex flex-col justify-between">
|
||||
{/* 上部分:插件信息 */}
|
||||
<div className="flex flex-row items-start justify-start gap-[1.2rem]">
|
||||
<img src={cardVO.iconURL} alt="plugin icon" className="w-16 h-16" />
|
||||
|
||||
<div className="w-full h-full flex flex-col items-start justify-between gap-[0.6rem]">
|
||||
<div className="flex flex-col items-start justify-start">
|
||||
<div className="flex-1 flex flex-col items-start justify-start gap-[0.6rem]">
|
||||
<div className="flex flex-col items-start justify-start">
|
||||
<div className="text-[0.7rem] text-[#666]">
|
||||
{cardVO.author} /{' '}
|
||||
</div>
|
||||
<div className="text-[0.7rem] text-[#666]">{cardVO.pluginId}</div>
|
||||
<div className="flex flex-row items-center justify-start gap-[0.4rem]">
|
||||
<div className="text-[1.2rem] text-black">{cardVO.name}</div>
|
||||
<div className="text-[1.2rem] text-black">{cardVO.label}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -43,42 +36,40 @@ export default function PluginMarketCardComponent({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex flex-row items-start justify-between gap-[0.6rem]">
|
||||
<div className="flex h-full flex-row items-center justify-center gap-[0.4rem]">
|
||||
<div className="flex h-full flex-row items-start justify-center gap-[0.4rem]">
|
||||
{cardVO.githubURL && (
|
||||
<svg
|
||||
className="w-[1.2rem] h-[1.2rem] text-[#ffcd27]"
|
||||
className="w-[1.4rem] h-[1.4rem] text-black cursor-pointer hover:text-gray-600"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M12.0006 18.26L4.94715 22.2082L6.52248 14.2799L0.587891 8.7918L8.61493 7.84006L12.0006 0.5L15.3862 7.84006L23.4132 8.7918L17.4787 14.2799L19.054 22.2082L12.0006 18.26Z"></path>
|
||||
</svg>
|
||||
<div className="text-base text-[#ffcd27] font-medium">
|
||||
{t('plugins.starCount', { count: cardVO.starCount })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex h-full flex-row items-center justify-center gap-[0.4rem]">
|
||||
<svg
|
||||
className="w-[1.4rem] h-[1.4rem] text-black cursor-pointer"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
onClick={() => window.open(cardVO.githubURL, '_blank')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.open(cardVO.githubURL, '_blank');
|
||||
}}
|
||||
>
|
||||
<path d="M12.001 2C6.47598 2 2.00098 6.475 2.00098 12C2.00098 16.425 4.86348 20.1625 8.83848 21.4875C9.33848 21.575 9.52598 21.275 9.52598 21.0125C9.52598 20.775 9.51348 19.9875 9.51348 19.15C7.00098 19.6125 6.35098 18.5375 6.15098 17.975C6.03848 17.6875 5.55098 16.8 5.12598 16.5625C4.77598 16.375 4.27598 15.9125 5.11348 15.9C5.90098 15.8875 6.46348 16.625 6.65098 16.925C7.55098 18.4375 8.98848 18.0125 9.56348 17.75C9.65098 17.1 9.91348 16.6625 10.201 16.4125C7.97598 16.1625 5.65098 15.3 5.65098 11.475C5.65098 10.3875 6.03848 9.4875 6.67598 8.7875C6.57598 8.5375 6.22598 7.5125 6.77598 6.1375C6.77598 6.1375 7.61348 5.875 9.52598 7.1625C10.326 6.9375 11.176 6.825 12.026 6.825C12.876 6.825 13.726 6.9375 14.526 7.1625C16.4385 5.8625 17.276 6.1375 17.276 6.1375C17.826 7.5125 17.476 8.5375 17.376 8.7875C18.0135 9.4875 18.401 10.375 18.401 11.475C18.401 15.3125 16.0635 16.1625 13.8385 16.4125C14.201 16.725 14.5135 17.325 14.5135 18.2625C14.5135 19.6 14.501 20.675 14.501 21.0125C14.501 21.275 14.6885 21.5875 15.1885 21.4875C19.259 20.1133 21.9999 16.2963 22.001 12C22.001 6.475 17.526 2 12.001 2Z"></path>
|
||||
</svg>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
handleInstallClick(cardVO.githubURL);
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{t('plugins.install')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 下部分:下载量 */}
|
||||
<div className="w-full flex flex-row items-center justify-start gap-[0.4rem] px-[0.4rem]">
|
||||
<svg
|
||||
className="w-[1.2rem] h-[1.2rem] text-[#2563eb]"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7,10 12,15 17,10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
<div className="text-sm text-[#2563eb] font-medium">
|
||||
{cardVO.installCount.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<T> {
|
||||
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 {
|
||||
|
||||
@@ -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<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
}
|
||||
|
||||
@@ -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<unknown>;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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<string, any>; // 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;
|
||||
}
|
||||
|
||||
@@ -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<AsyncTaskCreatedResp> {
|
||||
return this.post('/api/v1/plugins/install/marketplace', {
|
||||
plugin_author: author,
|
||||
plugin_name: name,
|
||||
plugin_version: version,
|
||||
});
|
||||
}
|
||||
|
||||
public removePlugin(
|
||||
author: string,
|
||||
name: string,
|
||||
|
||||
@@ -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<MarketPluginResponse> {
|
||||
return this.post(`/api/v1/market/plugins`, {
|
||||
page,
|
||||
page_size,
|
||||
query,
|
||||
sort_by,
|
||||
sort_order,
|
||||
sort_by?: string,
|
||||
sort_order?: string,
|
||||
): Promise<ApiRespMarketplacePlugins> {
|
||||
return this.get<ApiRespMarketplacePlugins>('/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<ApiRespMarketplacePlugins> {
|
||||
return this.post<ApiRespMarketplacePlugins>(
|
||||
'/api/v1/marketplace/plugins/search',
|
||||
{
|
||||
query,
|
||||
page,
|
||||
page_size,
|
||||
sort_by,
|
||||
sort_order,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public getPluginDetail(
|
||||
author: string,
|
||||
pluginName: string,
|
||||
): Promise<ApiRespMarketplacePluginDetail> {
|
||||
return this.get<ApiRespMarketplacePluginDetail>(
|
||||
`/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}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
```
|
||||
|
||||
### 向后兼容(不推荐)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'パイプライン',
|
||||
|
||||
@@ -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: '流水线',
|
||||
|
||||
Reference in New Issue
Block a user