mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-06-16 21:03:58 +08:00
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:
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 ── */}
|
||||
|
||||
@@ -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[] =>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}} 个页面",
|
||||
|
||||
@@ -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}} 個頁面",
|
||||
|
||||
@@ -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 /> },
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user