feat: add Plugin Store page for browsing and managing plugins

- Implemented PluginStorePage component for displaying available plugins.
- Added functionality to install and update plugins with user confirmation.
- Integrated plugin store API for fetching plugin data and handling installations.
- Enhanced PluginsPage to navigate to the new Plugin Store.
- Updated localization files for new plugin store strings in English, Russian, and Chinese.
- Added new types for plugin store entries and responses in TypeScript.
- Improved UI components and styles for better user experience in the plugin store.
This commit is contained in:
LTbinglingfeng
2026-06-13 01:21:36 +08:00
Unverified
parent c7051aeb68
commit 9fae287148
14 changed files with 1419 additions and 2 deletions
+8
View File
@@ -22,6 +22,7 @@ import {
IconSidebarPlugins,
IconSidebarProviders,
IconSidebarQuota,
IconSidebarStore,
IconSidebarSystem,
IconChevronDown,
} from '@/components/ui/icons';
@@ -50,6 +51,7 @@ const sidebarIcons: Record<string, ReactNode> = {
oauth: <IconSidebarOauth size={18} />,
quota: <IconSidebarQuota size={18} />,
plugins: <IconSidebarPlugins size={18} />,
pluginStore: <IconSidebarStore size={18} />,
config: <IconSidebarConfig size={18} />,
logs: <IconSidebarLogs size={18} />,
system: <IconSidebarSystem size={18} />,
@@ -584,6 +586,12 @@ export function MainLayout() {
metaKey: 'nav_meta.plugins',
icon: sidebarIcons.plugins,
},
{
path: '/plugin-store',
labelKey: 'nav.plugin_store',
metaKey: 'nav_meta.plugin_store',
icon: sidebarIcons.pluginStore,
},
{
path: '/system',
labelKey: 'nav.system_info',
+12
View File
@@ -485,6 +485,18 @@ export function IconSidebarPlugins({ size = 20, ...props }: IconProps) {
);
}
export function IconSidebarStore({ size = 20, ...props }: IconProps) {
return (
<svg {...sidebarSvgProps} width={size} height={size} {...props}>
<path d="m2 7 4.41-4.41A2 2 0 0 1 7.83 2h8.34a2 2 0 0 1 1.42.59L22 7" />
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" />
<path d="M15 22v-4a2 2 0 0 0-2-2h-2a2 2 0 0 0-2 2v4" />
<path d="M2 7h20" />
<path d="M22 7v3a2 2 0 0 1-2 2a2.7 2.7 0 0 1-1.59-.63.7.7 0 0 0-.82 0A2.7 2.7 0 0 1 16 12a2.7 2.7 0 0 1-1.59-.63.7.7 0 0 0-.82 0A2.7 2.7 0 0 1 12 12a2.7 2.7 0 0 1-1.59-.63.7.7 0 0 0-.82 0A2.7 2.7 0 0 1 8 12a2.7 2.7 0 0 1-1.59-.63.7.7 0 0 0-.82 0A2.7 2.7 0 0 1 4 12a2 2 0 0 1-2-2V7" />
</svg>
);
}
export function IconSidebarProviders({ size = 20, ...props }: IconProps) {
return (
<svg {...sidebarSvgProps} width={size} height={size} {...props}>
@@ -0,0 +1,561 @@
@use '../../styles/variables' as *;
@use '../../styles/mixins' as *;
// ─── Page Container ─────────────────────────────────────
.page {
display: flex;
flex-direction: column;
gap: $spacing-lg;
width: 100%;
}
// ─── Header ─────────────────────────────────────────────
.pageHeader {
display: flex;
flex-direction: column;
gap: $spacing-sm;
}
.title {
margin: 0;
color: var(--text-primary);
font-size: 28px;
font-weight: 700;
line-height: 1.2;
}
.description {
margin: 0;
color: var(--text-secondary);
font-size: 14px;
line-height: 1.5;
}
// ─── Alert Boxes ────────────────────────────────────────
.errorBox,
.warningBox {
padding: $spacing-md;
border-radius: $radius-md;
font-size: 14px;
line-height: 1.5;
}
.errorBox {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-md;
border: 1px solid var(--danger-color);
background: rgba($error-color, 0.1);
color: var(--danger-color);
span {
min-width: 0;
overflow-wrap: anywhere;
}
:global(.btn) {
flex-shrink: 0;
}
@include mobile {
flex-direction: column;
align-items: stretch;
}
}
.warningBox {
border: 1px solid color-mix(in srgb, var(--warning-color, #c65746) 42%, var(--border-color));
background: color-mix(in srgb, var(--warning-color, #c65746) 9%, var(--bg-secondary));
color: var(--text-primary);
}
// ─── Status Bar ─────────────────────────────────────────
.statusBar {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: 10px;
border: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent);
background: color-mix(in srgb, var(--bg-secondary) 60%, transparent);
flex-wrap: wrap;
}
.statusPill {
display: inline-flex;
min-width: 0;
max-width: 100%;
align-items: center;
gap: 6px;
padding: 5px 12px;
border-radius: $radius-full;
border: 1px solid color-mix(in srgb, var(--border-color) 50%, transparent);
background: color-mix(in srgb, var(--bg-primary) 56%, transparent);
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.statusDot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.statusDotOn {
background: $success-color;
box-shadow: 0 0 6px rgba($success-color, 0.5);
}
.statusDotOff {
background: var(--text-tertiary);
}
.statusLabel {
color: var(--text-secondary);
}
.statusValue {
color: var(--text-primary);
font-weight: 700;
}
.statusPathValue {
display: block;
min-width: 0;
max-width: min(360px, 58vw);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.statusDivider {
width: 1px;
height: 20px;
background: color-mix(in srgb, var(--border-color) 60%, transparent);
flex-shrink: 0;
@include mobile {
display: none;
}
}
// ─── Toolbar ────────────────────────────────────────────
.toolbar {
display: flex;
align-items: center;
gap: $spacing-sm;
:global(.form-group) {
flex: 1;
max-width: 480px;
margin: 0;
}
:global(.input) {
padding-right: 36px;
}
:global(.btn > span) {
display: inline-flex;
align-items: center;
gap: 8px;
}
@include mobile {
flex-direction: column;
align-items: stretch;
:global(.form-group) {
max-width: none;
}
}
}
// ─── Status Filter Chips ────────────────────────────────
.filterChips {
display: flex;
align-items: center;
gap: $spacing-sm;
flex-wrap: wrap;
}
.filterChip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 12px;
border: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
border-radius: $radius-full;
background: var(--bg-primary);
color: var(--text-secondary);
font-size: 12px;
font-weight: 600;
line-height: 1.4;
cursor: pointer;
transition:
border-color $transition-fast,
background-color $transition-fast,
color $transition-fast;
&:hover {
border-color: var(--primary-color);
color: var(--text-primary);
}
}
.filterChipActive {
border-color: var(--primary-color);
background: color-mix(in srgb, var(--primary-color) 12%, var(--bg-primary));
color: var(--text-primary);
}
.filterChipCount {
display: inline-flex;
min-width: 18px;
align-items: center;
justify-content: center;
padding: 0 5px;
border-radius: $radius-full;
background: color-mix(in srgb, var(--border-color) 45%, transparent);
color: var(--text-secondary);
font-size: 11px;
font-weight: 700;
}
// ─── Card Grid ──────────────────────────────────────────
.cardGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: $spacing-md;
@include mobile {
grid-template-columns: 1fr;
}
}
// ─── Plugin Card ────────────────────────────────────────
.card {
display: flex;
flex-direction: column;
gap: $spacing-sm;
padding: $spacing-md;
border: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
border-radius: $radius-lg;
background: var(--bg-primary);
transition:
border-color $transition-fast,
background-color $transition-fast;
&:hover {
border-color: color-mix(in srgb, var(--primary-color) 45%, var(--border-color));
background: var(--bg-hover);
}
}
.cardHeader {
display: flex;
align-items: flex-start;
gap: $spacing-sm;
}
.logoBox {
display: inline-flex;
width: 40px;
height: 40px;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: 10px;
border: 1px solid color-mix(in srgb, var(--border-color) 50%, transparent);
background: color-mix(in srgb, var(--bg-tertiary) 60%, transparent);
color: var(--text-secondary);
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.cardTitleBlock {
display: flex;
flex: 1;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.cardTitle {
margin: 0;
color: var(--text-primary);
font-size: 15px;
font-weight: 650;
line-height: 1.3;
@include text-ellipsis;
}
.cardId {
color: var(--text-tertiary);
font-family: $font-mono;
font-size: 12px;
@include text-ellipsis;
}
.cardBadges {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 5px;
flex-shrink: 0;
}
.badge,
.badgeSuccess,
.badgeWarning {
display: inline-flex;
min-height: 22px;
align-items: center;
border-radius: 6px;
padding: 2px 8px;
font-size: 11px;
font-weight: 600;
line-height: 1.25;
}
.badge {
background: color-mix(in srgb, var(--bg-secondary) 82%, transparent);
border: 1px solid color-mix(in srgb, var(--border-color) 50%, transparent);
color: var(--text-secondary);
}
.badgeSuccess {
background: rgba($success-color, 0.1);
border: 1px solid rgba($success-color, 0.2);
color: var(--success-color);
}
.badgeWarning {
background: rgba($warning-color, 0.1);
border: 1px solid rgba($warning-color, 0.2);
color: var(--warning-color);
}
.cardDesc {
display: -webkit-box;
margin: 0;
color: var(--text-secondary);
font-size: 13px;
line-height: 1.5;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.cardMeta {
display: flex;
align-items: center;
gap: $spacing-sm;
flex-wrap: wrap;
}
.metaItem {
display: inline-flex;
min-width: 0;
max-width: 100%;
align-items: center;
gap: $spacing-sm;
color: var(--text-tertiary);
font-size: 12px;
white-space: nowrap;
strong {
color: var(--text-secondary);
font-weight: 600;
}
}
.metaDot {
width: 3px;
height: 3px;
border-radius: 50%;
background: color-mix(in srgb, var(--text-tertiary) 50%, transparent);
flex-shrink: 0;
}
.tagRow {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.tag {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: $radius-full;
background: color-mix(in srgb, var(--bg-secondary) 82%, transparent);
border: 1px solid color-mix(in srgb, var(--border-color) 40%, transparent);
color: var(--text-tertiary);
font-size: 11px;
font-weight: 600;
line-height: 1.4;
}
.cardFooter {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-sm;
margin-top: auto;
padding-top: $spacing-sm;
}
.cardActions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: $spacing-sm;
:global(.btn > span) {
display: inline-flex;
align-items: center;
gap: 6px;
}
}
.cardLinks {
display: flex;
align-items: center;
gap: $spacing-sm;
flex-shrink: 0;
}
.iconLink {
display: inline-flex;
width: 30px;
min-height: 30px;
flex: 0 0 auto;
align-items: center;
justify-content: center;
border: 1px solid var(--border-color);
border-radius: $radius-md;
background: var(--bg-primary);
color: var(--text-primary);
text-decoration: none;
transition:
border-color $transition-fast,
background-color $transition-fast,
color $transition-fast;
&:hover {
border-color: var(--primary-color);
background: var(--bg-hover);
color: var(--primary-color);
}
}
// ─── Skeleton ───────────────────────────────────────────
.skeletonCard {
display: flex;
flex-direction: column;
gap: $spacing-md;
padding: $spacing-md;
border: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
border-radius: $radius-lg;
background: var(--bg-primary);
}
.skeletonHeader {
display: flex;
align-items: center;
gap: $spacing-md;
}
.skeletonAvatar {
width: 40px;
height: 40px;
border-radius: 10px;
background: linear-gradient(
90deg,
color-mix(in srgb, var(--bg-secondary) 86%, transparent) 25%,
color-mix(in srgb, var(--bg-hover) 70%, transparent) 37%,
color-mix(in srgb, var(--bg-secondary) 86%, transparent) 63%
);
background-size: 400% 100%;
animation: skeletonPulse 1.35s ease-in-out infinite;
flex-shrink: 0;
}
.skeletonText {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
}
.skeletonLine {
height: 14px;
border-radius: 4px;
background: linear-gradient(
90deg,
color-mix(in srgb, var(--bg-secondary) 86%, transparent) 25%,
color-mix(in srgb, var(--bg-hover) 70%, transparent) 37%,
color-mix(in srgb, var(--bg-secondary) 86%, transparent) 63%
);
background-size: 400% 100%;
animation: skeletonPulse 1.35s ease-in-out infinite;
&:first-child {
width: 45%;
}
&:last-child {
width: 70%;
height: 10px;
}
}
.skeletonBody {
height: 56px;
border-radius: $radius-md;
background: linear-gradient(
90deg,
color-mix(in srgb, var(--bg-secondary) 86%, transparent) 25%,
color-mix(in srgb, var(--bg-hover) 70%, transparent) 37%,
color-mix(in srgb, var(--bg-secondary) 86%, transparent) 63%
);
background-size: 400% 100%;
animation: skeletonPulse 1.35s ease-in-out infinite;
}
// ─── Animation ──────────────────────────────────────────
@keyframes skeletonPulse {
0% {
background-position: 100% 0;
}
100% {
background-position: 0 0;
}
}
// ─── Mobile Overrides ───────────────────────────────────
@include mobile {
.page {
gap: $spacing-md;
}
}
+501
View File
@@ -0,0 +1,501 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/Button';
import { EmptyState } from '@/components/ui/EmptyState';
import { Input } from '@/components/ui/Input';
import {
IconDownload,
IconExternalLink,
IconGithub,
IconPlug,
IconRefreshCw,
IconSearch,
IconSettings,
} from '@/components/ui/icons';
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { pluginStoreApi } from '@/services/api';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import type { PluginStoreEntry, PluginStoreResponse } from '@/types';
import { buildRepositoryURL, resolvePluginAssetURL } from './pluginResources';
import styles from './PluginStorePage.module.scss';
type StoreStatusFilter = 'all' | 'installed' | 'notInstalled' | 'updates';
interface StoreLoadError {
kind: 'unsupported' | 'registry' | 'generic';
message: string;
}
const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value);
const getErrorMessage = (error: unknown, fallback: string) =>
error instanceof Error ? error.message : typeof error === 'string' ? error : fallback;
const getErrorStatus = (error: unknown): number | undefined =>
isRecord(error) && typeof error.status === 'number' ? error.status : undefined;
const getErrorDetailMessage = (error: unknown): string => {
if (!isRecord(error) || !isRecord(error.details)) return '';
const message = error.details.message;
return typeof message === 'string' ? message.trim() : '';
};
const getStoreEntryTitle = (entry: PluginStoreEntry) => entry.name || entry.id;
function StoreCardLogo({ src }: { src: string }) {
const [failed, setFailed] = useState(false);
const showImage = Boolean(src) && !failed;
return showImage ? (
<img src={src} alt="" onError={() => setFailed(true)} />
) : (
<IconPlug size={18} />
);
}
export function PluginStorePage() {
const { t } = useTranslation();
const navigate = useNavigate();
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const apiBase = useAuthStore((state) => state.apiBase);
const clearConfigCache = useConfigStore((state) => state.clearCache);
const showNotification = useNotificationStore((state) => state.showNotification);
const showConfirmation = useNotificationStore((state) => state.showConfirmation);
const [data, setData] = useState<PluginStoreResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<StoreLoadError | null>(null);
const [filter, setFilter] = useState('');
const [statusFilter, setStatusFilter] = useState<StoreStatusFilter>('all');
const [installingID, setInstallingID] = useState('');
const [restartRequiredIDs, setRestartRequiredIDs] = useState<string[]>([]);
const connected = connectionStatus === 'connected';
const loadStore = useCallback(async () => {
if (!connected) {
setLoading(false);
setError({ kind: 'generic', message: t('notification.connection_required') });
return;
}
setLoading(true);
setError(null);
try {
const store = await pluginStoreApi.list();
setData(store);
} catch (err: unknown) {
const status = getErrorStatus(err);
if (status === 404) {
setError({ kind: 'unsupported', message: t('plugin_store.unsupported_backend') });
} else if (status === 502) {
const detail = getErrorDetailMessage(err);
setError({
kind: 'registry',
message: detail
? `${t('plugin_store.registry_failed')}: ${detail}`
: t('plugin_store.registry_failed'),
});
} else {
setError({
kind: 'generic',
message: getErrorMessage(err, t('plugin_store.load_failed')),
});
}
} finally {
setLoading(false);
}
}, [connected, t]);
useHeaderRefresh(loadStore, connected);
useEffect(() => {
void loadStore();
}, [loadStore]);
const stats = useMemo(() => {
const plugins = data?.plugins ?? [];
const installed = plugins.filter((plugin) => plugin.installed).length;
return {
total: plugins.length,
installed,
notInstalled: plugins.length - installed,
updates: plugins.filter((plugin) => plugin.installed && plugin.updateAvailable).length,
};
}, [data?.plugins]);
const visiblePlugins = useMemo(() => {
const plugins = data?.plugins ?? [];
const byStatus = plugins.filter((plugin) => {
if (statusFilter === 'installed') return plugin.installed;
if (statusFilter === 'notInstalled') return !plugin.installed;
if (statusFilter === 'updates') return plugin.installed && plugin.updateAvailable;
return true;
});
const query = filter.trim().toLowerCase();
if (!query) return byStatus;
return byStatus.filter((plugin) => {
const haystack = [
plugin.id,
plugin.name,
plugin.description,
plugin.author,
plugin.repository,
plugin.license,
...plugin.tags,
]
.filter(Boolean)
.join(' ')
.toLowerCase();
return haystack.includes(query);
});
}, [data?.plugins, filter, statusFilter]);
const statusFilters: Array<{ key: StoreStatusFilter; label: string; count: number }> = [
{ key: 'all', label: t('plugin_store.filter_all'), count: stats.total },
{ key: 'installed', label: t('plugin_store.filter_installed'), count: stats.installed },
{
key: 'notInstalled',
label: t('plugin_store.filter_not_installed'),
count: stats.notInstalled,
},
{ key: 'updates', label: t('plugin_store.filter_updates'), count: stats.updates },
];
const restartNames = restartRequiredIDs.map((id) => {
const entry = data?.plugins.find((plugin) => plugin.id === id);
return entry ? getStoreEntryTitle(entry) : id;
});
const hasActiveFilters = Boolean(filter.trim()) || statusFilter !== 'all';
const handleInstall = (entry: PluginStoreEntry) => {
const isUpdate = entry.installed && entry.updateAvailable;
const title = getStoreEntryTitle(entry);
const target = entry.version ? `${title} v${entry.version}` : title;
const failedKey = isUpdate ? 'plugin_store.update_failed' : 'plugin_store.install_failed';
showConfirmation({
title: isUpdate
? t('plugin_store.update_confirm_title')
: t('plugin_store.install_confirm_title'),
message: isUpdate
? t('plugin_store.update_confirm_message', { target })
: t('plugin_store.install_confirm_message', { target }),
confirmText: isUpdate ? t('plugin_store.update') : t('plugin_store.install'),
variant: 'primary',
onConfirm: async () => {
setInstallingID(entry.id);
try {
const result = await pluginStoreApi.install(entry.id);
showNotification(
isUpdate ? t('plugin_store.update_success') : t('plugin_store.install_success'),
'success'
);
if (result.restartRequired) {
setRestartRequiredIDs((current) =>
current.includes(entry.id) ? current : [...current, entry.id]
);
showNotification(t('plugin_store.restart_required_notice'), 'warning');
}
clearConfigCache();
await loadStore();
} catch (err: unknown) {
showNotification(`${t(failedKey)}: ${getErrorMessage(err, t(failedKey))}`, 'error');
throw err;
} finally {
setInstallingID('');
}
},
});
};
const renderCard = (entry: PluginStoreEntry) => {
const logo = resolvePluginAssetURL(entry.logo, apiBase);
const repositoryURL = buildRepositoryURL(entry.repository);
const homepageURL = /^https?:\/\//i.test(entry.homepage) ? entry.homepage : '';
const isUpdate = entry.installed && entry.updateAvailable;
const versionText =
isUpdate && entry.installedVersion && entry.version
? t('plugin_store.version_arrow', { from: entry.installedVersion, to: entry.version })
: entry.installed && entry.installedVersion
? `v${entry.installedVersion}`
: entry.version
? `v${entry.version}`
: '';
const metaItems = [versionText, entry.author, entry.license].filter(Boolean);
return (
<article key={entry.id} className={styles.card}>
<div className={styles.cardHeader}>
<div className={styles.logoBox} aria-hidden="true">
<StoreCardLogo src={logo} />
</div>
<div className={styles.cardTitleBlock}>
<h2 className={styles.cardTitle}>{getStoreEntryTitle(entry)}</h2>
<span className={styles.cardId}>{entry.id}</span>
</div>
<div className={styles.cardBadges}>
{isUpdate ? (
<span className={styles.badgeWarning}>{t('plugin_store.badge_update')}</span>
) : entry.installed ? (
<span className={styles.badgeSuccess}>{t('plugin_store.badge_installed')}</span>
) : null}
{entry.installed && entry.effectiveEnabled ? (
<span className={styles.badge}>{t('plugin_store.badge_effective')}</span>
) : null}
</div>
</div>
{entry.description ? <p className={styles.cardDesc}>{entry.description}</p> : null}
{metaItems.length > 0 ? (
<div className={styles.cardMeta}>
{metaItems.map((item, index) => (
<span key={`${entry.id}-meta-${index}`} className={styles.metaItem}>
{index > 0 ? <span className={styles.metaDot} aria-hidden="true" /> : null}
{index === 0 && versionText ? <strong>{item}</strong> : item}
</span>
))}
</div>
) : null}
{entry.tags.length > 0 ? (
<div className={styles.tagRow}>
{entry.tags.map((tag) => (
<span key={`${entry.id}-tag-${tag}`} className={styles.tag}>
{tag}
</span>
))}
</div>
) : null}
<div className={styles.cardFooter}>
<div className={styles.cardActions}>
{!entry.installed ? (
<Button
size="sm"
onClick={() => handleInstall(entry)}
disabled={!connected || Boolean(installingID)}
>
<IconDownload size={14} />
{t('plugin_store.install')}
</Button>
) : (
<>
{entry.updateAvailable ? (
<Button
size="sm"
onClick={() => handleInstall(entry)}
disabled={!connected || Boolean(installingID)}
>
<IconRefreshCw size={14} />
{t('plugin_store.update')}
</Button>
) : null}
<Button variant="secondary" size="sm" onClick={() => navigate('/plugins')}>
<IconSettings size={14} />
{t('plugin_store.manage')}
</Button>
</>
)}
</div>
<div className={styles.cardLinks}>
{repositoryURL ? (
<a
className={styles.iconLink}
href={repositoryURL}
target="_blank"
rel="noreferrer"
title={t('plugin_store.open_repository')}
aria-label={t('plugin_store.open_repository')}
>
<IconGithub size={14} />
</a>
) : null}
{homepageURL ? (
<a
className={styles.iconLink}
href={homepageURL}
target="_blank"
rel="noreferrer"
title={t('plugin_store.open_homepage')}
aria-label={t('plugin_store.open_homepage')}
>
<IconExternalLink size={14} />
</a>
) : null}
</div>
</div>
</article>
);
};
return (
<div className={styles.page}>
{/* ── Page Header ── */}
<div className={styles.pageHeader}>
<h1 className={styles.title}>{t('plugin_store.title')}</h1>
<p className={styles.description}>{t('plugin_store.description')}</p>
</div>
{/* ── Alerts ── */}
{error ? (
<div className={styles.errorBox}>
<span>{error.message}</span>
{error.kind !== 'unsupported' ? (
<Button variant="secondary" size="sm" onClick={loadStore} disabled={loading}>
{t('plugin_store.retry')}
</Button>
) : null}
</div>
) : null}
{data && !data.pluginsEnabled ? (
<div className={styles.warningBox}>{t('plugin_store.global_disabled_hint')}</div>
) : null}
{restartNames.length > 0 ? (
<div className={styles.warningBox}>
{t('plugin_store.restart_required_banner', { plugins: restartNames.join(', ') })}
</div>
) : null}
{/* ── Status Bar ── */}
{data ? (
<div className={styles.statusBar}>
<div className={styles.statusPill}>
<span
className={`${styles.statusDot} ${
data.pluginsEnabled ? styles.statusDotOn : styles.statusDotOff
}`}
/>
<span className={styles.statusLabel}>{t('plugin_store.global_status')}</span>
<span className={styles.statusValue}>
{data.pluginsEnabled
? t('plugin_store.global_enabled')
: t('plugin_store.global_disabled')}
</span>
</div>
<span className={styles.statusDivider} />
<div className={styles.statusPill}>
<span className={styles.statusLabel}>{t('plugin_store.plugins_dir')}</span>
<span
className={`${styles.statusValue} ${styles.statusPathValue}`}
title={data.pluginsDir || 'plugins'}
>
{data.pluginsDir || 'plugins'}
</span>
</div>
<span className={styles.statusDivider} />
<div className={styles.statusPill}>
<span className={styles.statusLabel}>{t('plugin_store.stat_available')}</span>
<span className={styles.statusValue}>{stats.total}</span>
</div>
</div>
) : null}
{/* ── Toolbar ── */}
<div className={styles.toolbar}>
<Input
type="search"
value={filter}
onChange={(event) => setFilter(event.target.value)}
placeholder={t('plugin_store.search_placeholder')}
aria-label={t('plugin_store.search_label')}
rightElement={<IconSearch size={16} />}
/>
<Button
variant="secondary"
size="sm"
onClick={loadStore}
disabled={!connected || loading}
loading={loading}
>
<IconRefreshCw size={16} />
{t('plugin_store.refresh')}
</Button>
</div>
{/* ── Status Filter Chips ── */}
<div className={styles.filterChips} role="group" aria-label={t('plugin_store.filter_label')}>
{statusFilters.map((item) => (
<button
key={item.key}
type="button"
className={`${styles.filterChip} ${
statusFilter === item.key ? styles.filterChipActive : ''
}`}
onClick={() => setStatusFilter(item.key)}
aria-pressed={statusFilter === item.key}
>
{item.label}
<span className={styles.filterChipCount}>{item.count}</span>
</button>
))}
</div>
{/* ── Plugin Cards ── */}
{loading ? (
<div className={styles.cardGrid}>
{Array.from({ length: 6 }, (_, index) => (
<div key={index} className={styles.skeletonCard}>
<div className={styles.skeletonHeader}>
<div className={styles.skeletonAvatar} />
<div className={styles.skeletonText}>
<div className={styles.skeletonLine} />
<div className={styles.skeletonLine} />
</div>
</div>
<div className={styles.skeletonBody} />
</div>
))}
</div>
) : visiblePlugins.length === 0 ? (
!error ? (
stats.total === 0 ? (
<EmptyState
title={t('plugin_store.no_plugins')}
description={t('plugin_store.no_plugins_desc')}
action={
<Button variant="secondary" size="sm" onClick={loadStore} disabled={!connected}>
<IconRefreshCw size={16} />
{t('plugin_store.refresh')}
</Button>
}
/>
) : (
<EmptyState
title={t('plugin_store.no_matches')}
description={t('plugin_store.no_matches_desc')}
action={
hasActiveFilters ? (
<Button
variant="secondary"
size="sm"
onClick={() => {
setFilter('');
setStatusFilter('all');
}}
>
{t('plugin_store.clear_filters')}
</Button>
) : undefined
}
/>
)
) : null
) : (
<div className={styles.cardGrid}>{visiblePlugins.map((entry) => renderCard(entry))}</div>
)}
</div>
);
}
+7
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState, type ChangeEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/Button';
import { EmptyState } from '@/components/ui/EmptyState';
import { Input } from '@/components/ui/Input';
@@ -13,6 +14,7 @@ import {
IconRefreshCw,
IconSearch,
IconSettings,
IconSidebarStore,
IconTrash2,
} from '@/components/ui/icons';
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
@@ -235,6 +237,7 @@ const buildConfigPayload = (
export function PluginsPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const apiBase = useAuthStore((state) => state.apiBase);
const fetchConfig = useConfigStore((state) => state.fetchConfig);
@@ -673,6 +676,10 @@ export function PluginsPage() {
<IconRefreshCw size={16} />
{t('plugin_management.refresh')}
</Button>
<Button variant="secondary" size="sm" onClick={() => navigate('/plugin-store')}>
<IconSidebarStore size={16} />
{t('plugin_store.title')}
</Button>
</div>
{/* ── Plugin List ── */}
+8
View File
@@ -27,6 +27,14 @@ export const resolvePluginAssetURL = (value: string, apiBase: string) => {
return base ? `${base}${trimmed}` : trimmed;
};
// Registry entries usually carry an "owner/repo" slug rather than a full URL.
export const buildRepositoryURL = (repository: string) => {
const trimmed = repository.trim();
if (!trimmed) return '';
if (/^https?:\/\//i.test(trimmed)) return trimmed;
return `https://github.com/${trimmed.replace(/^\/+/, '')}`;
};
export const collectPluginResourceEntries = (
plugins: PluginListEntry[]
): PluginResourceEntry[] =>
+48
View File
@@ -117,6 +117,7 @@
"oauth": "OAuth Login",
"quota_management": "Quota Management",
"plugins": "Plugins",
"plugin_store": "Plugin Store",
"config_management": "Config Panel",
"logs": "Logs Viewer",
"system_info": "Management Center Info"
@@ -135,6 +136,7 @@
"oauth": "OAuth authorization",
"quota_management": "API keys & limits",
"plugins": "Plugin toggles & config",
"plugin_store": "Discover & install plugins",
"logs": "Request tracing",
"config_management": "Gateway configuration",
"system_info": "Runtime & diagnostics"
@@ -1149,6 +1151,52 @@
"expected_object": "Enter a JSON object",
"invalid_enum": "Choose one of the declared enum values"
},
"plugin_store": {
"title": "Plugin Store",
"description": "Browse the plugin registry, then install or update plugins for the current backend.",
"refresh": "Refresh",
"retry": "Retry",
"load_failed": "Failed to load the plugin store",
"unsupported_backend": "The current backend does not expose the plugin store API. Use a newer backend build that includes plugin store endpoints, then restart the service.",
"registry_failed": "Failed to reach the plugin registry",
"global_status": "Global status",
"global_enabled": "Enabled",
"global_disabled": "Disabled",
"global_disabled_hint": "plugins.enabled is false, so installed plugins will not become effective.",
"plugins_dir": "Plugin directory",
"stat_available": "Available",
"search_placeholder": "Search plugin ID, name, author, or tag...",
"search_label": "Search plugin store",
"filter_label": "Filter by install status",
"filter_all": "All",
"filter_installed": "Installed",
"filter_not_installed": "Not installed",
"filter_updates": "Updates",
"badge_installed": "Installed",
"badge_update": "Update available",
"badge_effective": "Effective",
"version_arrow": "v{{from}} → v{{to}}",
"install": "Install",
"update": "Update",
"manage": "Manage",
"install_confirm_title": "Install plugin",
"install_confirm_message": "Download {{target}} from the plugin registry and install it into the local plugin directory?",
"update_confirm_title": "Update plugin",
"update_confirm_message": "Download and install the latest version of {{target}}?",
"install_success": "Plugin installed",
"update_success": "Plugin updated",
"install_failed": "Failed to install plugin",
"update_failed": "Failed to update plugin",
"restart_required_notice": "Restart the service to load the new plugin version",
"restart_required_banner": "Restart the service to apply the new version of: {{plugins}}",
"open_repository": "Open repository",
"open_homepage": "Open homepage",
"no_plugins": "Registry is empty",
"no_plugins_desc": "The plugin registry did not return any plugins.",
"no_matches": "No matching plugins",
"no_matches_desc": "No plugins match the current search or filter.",
"clear_filters": "Clear filters"
},
"plugin_resource": {
"title": "Plugin Page",
"page_count": "{{count}} pages",
+48
View File
@@ -117,6 +117,7 @@
"oauth": "OAuth вход",
"quota_management": "Управление квотами",
"plugins": "Плагины",
"plugin_store": "Магазин плагинов",
"config_management": "Панель конфигурации",
"logs": "Просмотр логов",
"system_info": "Информация системы"
@@ -134,6 +135,7 @@
"oauth": "Авторизация OAuth",
"quota_management": "API ключи и лимиты",
"plugins": "Переключатели и ресурсы плагинов",
"plugin_store": "Поиск и установка плагинов",
"logs": "Трассировка запросов",
"config_management": "Базовая конфигурация шлюза",
"system_info": "Состояние и диагностика"
@@ -1136,6 +1138,52 @@
"expected_object": "Введите JSON-объект",
"invalid_enum": "Выберите одно из объявленных значений enum"
},
"plugin_store": {
"title": "Магазин плагинов",
"description": "Просматривайте реестр плагинов, устанавливайте и обновляйте плагины для текущего бэкенда.",
"refresh": "Обновить",
"retry": "Повторить",
"load_failed": "Не удалось загрузить магазин плагинов",
"unsupported_backend": "Текущий бэкенд не предоставляет API магазина плагинов. Используйте новую сборку бэкенда с поддержкой магазина плагинов и перезапустите службу.",
"registry_failed": "Не удалось обратиться к реестру плагинов",
"global_status": "Глобальный статус",
"global_enabled": "Включено",
"global_disabled": "Отключено",
"global_disabled_hint": "Параметр plugins.enabled выключен, поэтому установленные плагины не будут работать.",
"plugins_dir": "Каталог плагинов",
"stat_available": "Доступно",
"search_placeholder": "Поиск по ID, названию, автору или тегу...",
"search_label": "Поиск в магазине плагинов",
"filter_label": "Фильтр по статусу установки",
"filter_all": "Все",
"filter_installed": "Установленные",
"filter_not_installed": "Не установленные",
"filter_updates": "Обновления",
"badge_installed": "Установлен",
"badge_update": "Доступно обновление",
"badge_effective": "Активен",
"version_arrow": "v{{from}} → v{{to}}",
"install": "Установить",
"update": "Обновить",
"manage": "Управлять",
"install_confirm_title": "Установка плагина",
"install_confirm_message": "Скачать {{target}} из реестра плагинов и установить в локальный каталог плагинов?",
"update_confirm_title": "Обновление плагина",
"update_confirm_message": "Скачать и установить последнюю версию {{target}}?",
"install_success": "Плагин установлен",
"update_success": "Плагин обновлён",
"install_failed": "Не удалось установить плагин",
"update_failed": "Не удалось обновить плагин",
"restart_required_notice": "Перезапустите службу, чтобы загрузить новую версию плагина",
"restart_required_banner": "Перезапустите службу, чтобы применить новую версию: {{plugins}}",
"open_repository": "Открыть репозиторий",
"open_homepage": "Открыть сайт",
"no_plugins": "Реестр пуст",
"no_plugins_desc": "Реестр плагинов не вернул ни одного плагина.",
"no_matches": "Нет подходящих плагинов",
"no_matches_desc": "Нет плагинов, соответствующих текущему поиску или фильтру.",
"clear_filters": "Сбросить фильтры"
},
"system_info": {
"title": "Информация о центре управления",
"about_title": "CLI Proxy API Management Center",
+48
View File
@@ -117,6 +117,7 @@
"oauth": "OAuth 登录",
"quota_management": "配额管理",
"plugins": "插件管理",
"plugin_store": "插件商店",
"config_management": "配置面板",
"logs": "日志查看",
"system_info": "中心信息"
@@ -135,6 +136,7 @@
"oauth": "OAuth 授权登录",
"quota_management": "API Key 与限额",
"plugins": "插件启停与配置",
"plugin_store": "发现并安装插件",
"logs": "请求追踪与排查",
"config_management": "网关基础配置",
"system_info": "运行与诊断信息"
@@ -1149,6 +1151,52 @@
"expected_object": "请输入 JSON 对象",
"invalid_enum": "请选择声明的枚举值"
},
"plugin_store": {
"title": "插件商店",
"description": "浏览插件注册表,为当前后端安装或更新插件。",
"refresh": "刷新",
"retry": "重试",
"load_failed": "插件商店加载失败",
"unsupported_backend": "当前后端未暴露插件商店 API。请使用包含插件商店接口的新后端构建,并重启服务。",
"registry_failed": "插件注册表请求失败",
"global_status": "全局状态",
"global_enabled": "已启用",
"global_disabled": "已停用",
"global_disabled_hint": "当前 plugins.enabled 为 false,已安装的插件不会生效。",
"plugins_dir": "插件目录",
"stat_available": "可用插件",
"search_placeholder": "搜索插件 ID、名称、作者或标签...",
"search_label": "搜索插件商店",
"filter_label": "按安装状态筛选",
"filter_all": "全部",
"filter_installed": "已安装",
"filter_not_installed": "未安装",
"filter_updates": "可更新",
"badge_installed": "已安装",
"badge_update": "可更新",
"badge_effective": "生效中",
"version_arrow": "v{{from}} → v{{to}}",
"install": "安装",
"update": "更新",
"manage": "管理",
"install_confirm_title": "安装插件",
"install_confirm_message": "将从插件注册表下载 {{target}} 并安装到本地插件目录,是否继续?",
"update_confirm_title": "更新插件",
"update_confirm_message": "将下载并安装 {{target}} 的最新版本,是否继续?",
"install_success": "插件安装成功",
"update_success": "插件更新成功",
"install_failed": "插件安装失败",
"update_failed": "插件更新失败",
"restart_required_notice": "需要重启服务才能加载新的插件版本",
"restart_required_banner": "以下插件需重启服务后才能应用新版本:{{plugins}}",
"open_repository": "打开仓库",
"open_homepage": "打开主页",
"no_plugins": "注册表为空",
"no_plugins_desc": "插件注册表未返回任何插件。",
"no_matches": "没有匹配的插件",
"no_matches_desc": "当前搜索或筛选条件下没有插件。",
"clear_filters": "清除筛选"
},
"plugin_resource": {
"title": "插件页面",
"page_count": "{{count}} 个页面",
+48
View File
@@ -117,6 +117,7 @@
"oauth": "OAuth 登入",
"quota_management": "配額管理",
"plugins": "插件管理",
"plugin_store": "插件商店",
"config_management": "設定面板",
"logs": "記錄檢視",
"system_info": "中心資訊"
@@ -135,6 +136,7 @@
"oauth": "OAuth 授權登入",
"quota_management": "API Key 與限額",
"plugins": "插件啟停與設定",
"plugin_store": "探索並安裝插件",
"logs": "請求追蹤與排查",
"config_management": "閘道基礎設定",
"system_info": "運行與診斷資訊"
@@ -1175,6 +1177,52 @@
"expected_object": "請輸入 JSON 物件",
"invalid_enum": "請選擇宣告的枚舉值"
},
"plugin_store": {
"title": "插件商店",
"description": "瀏覽插件註冊表,為目前後端安裝或更新插件。",
"refresh": "重新整理",
"retry": "重試",
"load_failed": "插件商店載入失敗",
"unsupported_backend": "目前後端未提供插件商店 API。請使用包含插件商店介面的新後端建置,並重新啟動服務。",
"registry_failed": "插件註冊表請求失敗",
"global_status": "全域狀態",
"global_enabled": "已啟用",
"global_disabled": "已停用",
"global_disabled_hint": "目前 plugins.enabled 為 false,已安裝的插件不會生效。",
"plugins_dir": "插件目錄",
"stat_available": "可用插件",
"search_placeholder": "搜尋插件 ID、名稱、作者或標籤...",
"search_label": "搜尋插件商店",
"filter_label": "依安裝狀態篩選",
"filter_all": "全部",
"filter_installed": "已安裝",
"filter_not_installed": "未安裝",
"filter_updates": "可更新",
"badge_installed": "已安裝",
"badge_update": "可更新",
"badge_effective": "生效中",
"version_arrow": "v{{from}} → v{{to}}",
"install": "安裝",
"update": "更新",
"manage": "管理",
"install_confirm_title": "安裝插件",
"install_confirm_message": "將從插件註冊表下載 {{target}} 並安裝到本機插件目錄,是否繼續?",
"update_confirm_title": "更新插件",
"update_confirm_message": "將下載並安裝 {{target}} 的最新版本,是否繼續?",
"install_success": "插件安裝成功",
"update_success": "插件更新成功",
"install_failed": "插件安裝失敗",
"update_failed": "插件更新失敗",
"restart_required_notice": "需要重新啟動服務才能載入新的插件版本",
"restart_required_banner": "以下插件需重新啟動服務後才能套用新版本:{{plugins}}",
"open_repository": "開啟儲存庫",
"open_homepage": "開啟首頁",
"no_plugins": "註冊表為空",
"no_plugins_desc": "插件註冊表未回傳任何插件。",
"no_matches": "沒有符合的插件",
"no_matches_desc": "目前搜尋或篩選條件下沒有插件。",
"clear_filters": "清除篩選"
},
"plugin_resource": {
"title": "插件頁面",
"page_count": "{{count}} 個頁面",
+2
View File
@@ -8,6 +8,7 @@ import { OAuthPage } from '@/pages/OAuthPage';
import { QuotaPage } from '@/pages/QuotaPage';
import { PluginResourcePage } from '@/features/plugins/PluginResourcePage';
import { PluginsPage } from '@/features/plugins/PluginsPage';
import { PluginStorePage } from '@/features/plugins/PluginStorePage';
import { ConfigPage } from '@/pages/ConfigPage';
import { LogsPage } from '@/pages/LogsPage';
import { SystemPage } from '@/pages/SystemPage';
@@ -26,6 +27,7 @@ const mainRoutes = [
{ path: '/quota', element: <QuotaPage /> },
{ path: '/plugin-pages/:pluginId/:menuIndex', element: <PluginResourcePage /> },
{ path: '/plugins', element: <PluginsPage /> },
{ path: '/plugin-store', element: <PluginStorePage /> },
{ path: '/plugins/*', element: <Navigate to="/plugins" replace /> },
{ path: '/config', element: <ConfigPage /> },
{ path: '/logs', element: <LogsPage /> },
+71
View File
@@ -5,6 +5,9 @@ import type {
PluginListResponse,
PluginMetadata,
PluginMenu,
PluginStoreEntry,
PluginStoreInstallResult,
PluginStoreResponse,
} from '@/types';
const isRecord = (value: unknown): value is Record<string, unknown> =>
@@ -113,6 +116,62 @@ const normalizePluginList = (value: unknown): PluginListResponse => {
};
};
const normalizeStoreEntry = (value: unknown): PluginStoreEntry | null => {
if (!isRecord(value)) return null;
const id = asString(value.id).trim();
if (!id) return null;
const tags = Array.isArray(value.tags)
? value.tags.map((item) => asString(item).trim()).filter(Boolean)
: [];
return {
id,
name: asString(value.name).trim(),
description: asString(value.description).trim(),
author: asString(value.author).trim(),
version: asString(value.version).trim(),
repository: asString(value.repository).trim(),
logo: asString(value.logo).trim(),
homepage: asString(value.homepage).trim(),
license: asString(value.license).trim(),
tags,
installed: asBoolean(value.installed),
installedVersion: asString(value.installed_version).trim(),
path: asString(value.path).trim(),
configured: asBoolean(value.configured),
registered: asBoolean(value.registered),
enabled: asBoolean(value.enabled),
effectiveEnabled: asBoolean(value.effective_enabled),
updateAvailable: asBoolean(value.update_available),
};
};
const normalizeStoreList = (value: unknown): PluginStoreResponse => {
const source = isRecord(value) ? value : {};
const plugins = Array.isArray(source.plugins)
? source.plugins.map((item) => normalizeStoreEntry(item)).filter(Boolean) as PluginStoreEntry[]
: [];
return {
pluginsEnabled: asBoolean(source.plugins_enabled),
pluginsDir: asString(source.plugins_dir).trim() || 'plugins',
plugins,
};
};
const normalizeInstallResult = (value: unknown): PluginStoreInstallResult => {
const source = isRecord(value) ? value : {};
return {
status: asString(source.status).trim(),
id: asString(source.id).trim(),
version: asString(source.version).trim(),
path: asString(source.path).trim(),
pluginsEnabled: asBoolean(source.plugins_enabled),
restartRequired: asBoolean(source.restart_required),
};
};
export const pluginsApi = {
async list(): Promise<PluginListResponse> {
const data = await apiClient.get('/plugins');
@@ -128,3 +187,15 @@ export const pluginsApi = {
patchConfig: (id: string, patch: Record<string, unknown>) =>
apiClient.patch(`/plugins/${encodeURIComponent(id)}/config`, patch),
};
export const pluginStoreApi = {
async list(): Promise<PluginStoreResponse> {
const data = await apiClient.get('/plugin-store');
return normalizeStoreList(data);
},
async install(id: string): Promise<PluginStoreInstallResult> {
const data = await apiClient.post(`/plugin-store/${encodeURIComponent(id)}/install`);
return normalizeInstallResult(data);
},
};
+21 -2
View File
@@ -597,14 +597,20 @@ textarea {
.empty-icon {
width: 42px;
height: 42px;
flex: 0 0 42px;
border-radius: $radius-full;
border: 2px solid var(--border-color);
display: grid;
place-items: center;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 0;
color: var(--text-secondary);
svg {
display: block;
width: 20px;
height: 20px;
flex-shrink: 0;
}
}
@@ -617,6 +623,19 @@ textarea {
color: var(--text-secondary);
margin-top: 4px;
}
.empty-action .btn > span {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
line-height: 1;
}
.empty-action .btn svg {
display: block;
flex-shrink: 0;
}
}
.header-input-list {
+36
View File
@@ -48,3 +48,39 @@ export interface PluginListResponse {
pluginsDir: string;
plugins: PluginListEntry[];
}
export interface PluginStoreEntry {
id: string;
name: string;
description: string;
author: string;
version: string;
repository: string;
logo: string;
homepage: string;
license: string;
tags: string[];
installed: boolean;
installedVersion: string;
path: string;
configured: boolean;
registered: boolean;
enabled: boolean;
effectiveEnabled: boolean;
updateAvailable: boolean;
}
export interface PluginStoreResponse {
pluginsEnabled: boolean;
pluginsDir: string;
plugins: PluginStoreEntry[];
}
export interface PluginStoreInstallResult {
status: string;
id: string;
version: string;
path: string;
pluginsEnabled: boolean;
restartRequired: boolean;
}