mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-06-16 21:03:58 +08:00
Compare commits
2 Commits
@@ -363,6 +363,43 @@
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.cardDescBlock {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.cardDescExpanded {
|
||||
display: block;
|
||||
overflow: visible;
|
||||
-webkit-line-clamp: initial;
|
||||
}
|
||||
|
||||
.cardDescToggle {
|
||||
align-self: flex-start;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--primary-color);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
line-height: 1.4;
|
||||
|
||||
&:hover {
|
||||
color: var(--primary-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--primary-color) 45%, transparent);
|
||||
outline-offset: 3px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.cardMeta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
@@ -37,6 +37,8 @@ const getErrorDetailMessage = (error: unknown): string => {
|
||||
return typeof message === 'string' ? message.trim() : '';
|
||||
};
|
||||
|
||||
const DESCRIPTION_COLLAPSED_LINES = 2;
|
||||
|
||||
const getStoreEntryTitle = (entry: PluginStoreEntry) => entry.name || entry.id;
|
||||
|
||||
function StoreCardLogo({ src }: { src: string }) {
|
||||
@@ -66,6 +68,9 @@ export function PluginStorePage() {
|
||||
const [statusFilter, setStatusFilter] = useState<StoreStatusFilter>('all');
|
||||
const [installingID, setInstallingID] = useState('');
|
||||
const [restartRequiredIDs, setRestartRequiredIDs] = useState<string[]>([]);
|
||||
const [expandedDescriptionIDs, setExpandedDescriptionIDs] = useState<string[]>([]);
|
||||
const [overflowingDescriptionIDs, setOverflowingDescriptionIDs] = useState<string[]>([]);
|
||||
const descriptionRefs = useRef<Record<string, HTMLParagraphElement | null>>({});
|
||||
|
||||
const connected = connectionStatus === 'connected';
|
||||
|
||||
@@ -168,6 +173,68 @@ export function PluginStorePage() {
|
||||
|
||||
const hasActiveFilters = Boolean(filter.trim()) || statusFilter !== 'all';
|
||||
|
||||
const expandedDescriptionIDSet = useMemo(
|
||||
() => new Set(expandedDescriptionIDs),
|
||||
[expandedDescriptionIDs]
|
||||
);
|
||||
const overflowingDescriptionIDSet = useMemo(
|
||||
() => new Set(overflowingDescriptionIDs),
|
||||
[overflowingDescriptionIDs]
|
||||
);
|
||||
|
||||
const registerDescriptionRef = useCallback((id: string, node: HTMLParagraphElement | null) => {
|
||||
if (node) {
|
||||
descriptionRefs.current[id] = node;
|
||||
} else {
|
||||
delete descriptionRefs.current[id];
|
||||
}
|
||||
}, []);
|
||||
|
||||
const measureDescriptionOverflow = useCallback(() => {
|
||||
const nextIDs = Object.entries(descriptionRefs.current)
|
||||
.filter(([, node]) => {
|
||||
if (!node) return false;
|
||||
const computed = window.getComputedStyle(node);
|
||||
const lineHeight = Number.parseFloat(computed.lineHeight);
|
||||
if (!Number.isFinite(lineHeight) || lineHeight <= 0) {
|
||||
return node.scrollHeight > node.clientHeight + 1;
|
||||
}
|
||||
return node.scrollHeight > lineHeight * DESCRIPTION_COLLAPSED_LINES + 1;
|
||||
})
|
||||
.map(([id]) => id);
|
||||
|
||||
setOverflowingDescriptionIDs((current) => {
|
||||
if (current.length === nextIDs.length && current.every((id) => nextIDs.includes(id))) {
|
||||
return current;
|
||||
}
|
||||
return nextIDs;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const frame = window.requestAnimationFrame(measureDescriptionOverflow);
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
};
|
||||
}, [measureDescriptionOverflow, visiblePlugins]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
window.requestAnimationFrame(measureDescriptionOverflow);
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, [measureDescriptionOverflow]);
|
||||
|
||||
const toggleDescription = useCallback((id: string) => {
|
||||
setExpandedDescriptionIDs((current) =>
|
||||
current.includes(id) ? current.filter((currentID) => currentID !== id) : [...current, id]
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleInstall = (entry: PluginStoreEntry) => {
|
||||
const isUpdate = entry.installed && entry.updateAvailable;
|
||||
const title = getStoreEntryTitle(entry);
|
||||
@@ -223,6 +290,9 @@ export function PluginStorePage() {
|
||||
? `v${entry.version}`
|
||||
: '';
|
||||
const metaItems = [versionText, entry.author, entry.license].filter(Boolean);
|
||||
const isDescriptionExpanded = expandedDescriptionIDSet.has(entry.id);
|
||||
const isDescriptionOverflowing = overflowingDescriptionIDSet.has(entry.id);
|
||||
const descriptionID = `plugin-store-desc-${entry.id}`;
|
||||
|
||||
return (
|
||||
<article key={entry.id} className={styles.card}>
|
||||
@@ -246,7 +316,34 @@ export function PluginStorePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{entry.description ? <p className={styles.cardDesc}>{entry.description}</p> : null}
|
||||
{entry.description ? (
|
||||
<div className={styles.cardDescBlock}>
|
||||
<p
|
||||
id={descriptionID}
|
||||
ref={(node) => registerDescriptionRef(entry.id, node)}
|
||||
className={`${styles.cardDesc} ${
|
||||
isDescriptionExpanded ? styles.cardDescExpanded : ''
|
||||
}`}
|
||||
>
|
||||
{entry.description}
|
||||
</p>
|
||||
{isDescriptionOverflowing ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.cardDescToggle}
|
||||
onClick={() => toggleDescription(entry.id)}
|
||||
aria-expanded={isDescriptionExpanded}
|
||||
aria-controls={descriptionID}
|
||||
>
|
||||
{t(
|
||||
isDescriptionExpanded
|
||||
? 'plugin_store.description_show_less'
|
||||
: 'plugin_store.description_show_more'
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{metaItems.length > 0 ? (
|
||||
<div className={styles.cardMeta}>
|
||||
|
||||
@@ -64,6 +64,12 @@ function PluginCardLogo({ src }: { src: string }) {
|
||||
const hasStatus = (error: unknown, status: number) =>
|
||||
isRecord(error) && error.status === status;
|
||||
|
||||
const hasRestartRequired = (value: unknown) =>
|
||||
isRecord(value) && value.restart_required === true;
|
||||
|
||||
const hasRestartRequiredError = (error: unknown) =>
|
||||
isRecord(error) && (hasRestartRequired(error.details) || hasRestartRequired(error.data));
|
||||
|
||||
const normalizeFieldType = (field: PluginConfigField) => field.type.trim().toLowerCase();
|
||||
|
||||
const stringifyArrayItem = (value: unknown): string => {
|
||||
@@ -235,6 +241,7 @@ export function PluginsPage() {
|
||||
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<PluginListResponse | null>(null);
|
||||
const [filter, setFilter] = useState('');
|
||||
@@ -244,6 +251,7 @@ export function PluginsPage() {
|
||||
const [editingConfig, setEditingConfig] = useState<PluginConfigObject>({});
|
||||
const [draft, setDraft] = useState<PluginConfigDraft | null>(null);
|
||||
const [mutatingID, setMutatingID] = useState('');
|
||||
const [deletingID, setDeletingID] = useState('');
|
||||
const [openingConfigID, setOpeningConfigID] = useState('');
|
||||
const configRequestSeq = useRef(0);
|
||||
|
||||
@@ -326,7 +334,7 @@ export function PluginsPage() {
|
||||
);
|
||||
|
||||
const openConfigSheet = async (plugin: PluginListEntry) => {
|
||||
if (openingConfigID || mutatingID) return;
|
||||
if (openingConfigID || mutatingID || deletingID) return;
|
||||
|
||||
const requestSeq = configRequestSeq.current + 1;
|
||||
configRequestSeq.current = requestSeq;
|
||||
@@ -364,7 +372,7 @@ export function PluginsPage() {
|
||||
};
|
||||
|
||||
const closeConfigSheet = () => {
|
||||
if (mutatingID || openingConfigID) return;
|
||||
if (mutatingID || openingConfigID || deletingID) return;
|
||||
setEditingPlugin(null);
|
||||
setEditingConfig({});
|
||||
setDraft(null);
|
||||
@@ -375,6 +383,7 @@ export function PluginsPage() {
|
||||
};
|
||||
|
||||
const handleTogglePlugin = async (plugin: PluginListEntry, enabled: boolean) => {
|
||||
if (deletingID) return;
|
||||
setMutatingID(plugin.id);
|
||||
try {
|
||||
await pluginsApi.updateEnabled(plugin.id, enabled);
|
||||
@@ -395,8 +404,51 @@ export function PluginsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePlugin = (plugin: PluginListEntry) => {
|
||||
if (!connected || mutatingID || openingConfigID || deletingID) return;
|
||||
|
||||
const name = getPluginTitle(plugin);
|
||||
showConfirmation({
|
||||
title: t('plugin_management.delete_confirm_title'),
|
||||
message: t('plugin_management.delete_confirm_message', { name, id: plugin.id }),
|
||||
variant: 'danger',
|
||||
confirmText: t('plugin_management.delete_plugin'),
|
||||
onConfirm: async () => {
|
||||
setDeletingID(plugin.id);
|
||||
setMutatingID(plugin.id);
|
||||
try {
|
||||
const result = await pluginsApi.deletePlugin(plugin.id);
|
||||
clearConfigCache();
|
||||
if (editingPlugin?.id === plugin.id) {
|
||||
setEditingPlugin(null);
|
||||
setEditingConfig({});
|
||||
setDraft(null);
|
||||
}
|
||||
await loadPluginsAfterMutation(false);
|
||||
notifyPluginResourcesChanged();
|
||||
showNotification(t('plugin_management.delete_success'), 'success');
|
||||
if (result.restartRequired) {
|
||||
showNotification(t('plugin_management.delete_restart_required'), 'warning');
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const restartRequired = hasRestartRequiredError(err);
|
||||
const fallback = restartRequired
|
||||
? t('plugin_management.delete_restart_required')
|
||||
: t('plugin_management.delete_failed');
|
||||
showNotification(
|
||||
`${t('plugin_management.delete_failed')}: ${getErrorMessage(err, fallback)}`,
|
||||
restartRequired ? 'warning' : 'error'
|
||||
);
|
||||
} finally {
|
||||
setDeletingID('');
|
||||
setMutatingID('');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveConfig = async () => {
|
||||
if (!editingPlugin || !draft || openingConfigID || mutatingID) return;
|
||||
if (!editingPlugin || !draft || openingConfigID || mutatingID || deletingID) return;
|
||||
const { nextConfig, errors } = buildConfigPayload(
|
||||
draft,
|
||||
editingPlugin.configFields,
|
||||
@@ -707,7 +759,7 @@ export function PluginsPage() {
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={loadPlugins}
|
||||
disabled={!connected || loading || Boolean(mutatingID)}
|
||||
disabled={!connected || loading || Boolean(mutatingID || deletingID)}
|
||||
loading={loading}
|
||||
>
|
||||
<IconRefreshCw size={16} />
|
||||
@@ -749,7 +801,8 @@ export function PluginsPage() {
|
||||
const logo = resolvePluginAsset(plugin.logo || plugin.metadata?.logo || '');
|
||||
const github = plugin.metadata?.githubRepository.trim();
|
||||
const openingConfig = openingConfigID === plugin.id;
|
||||
const actionBusy = Boolean(mutatingID || openingConfigID);
|
||||
const deletingPlugin = deletingID === plugin.id;
|
||||
const actionBusy = Boolean(mutatingID || openingConfigID || deletingID);
|
||||
const version = plugin.metadata?.version;
|
||||
const author = plugin.metadata?.author;
|
||||
|
||||
@@ -836,6 +889,18 @@ export function PluginsPage() {
|
||||
<IconSettings size={14} />
|
||||
{t('plugin_management.edit_config')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => handleDeletePlugin(plugin)}
|
||||
disabled={!connected || actionBusy}
|
||||
loading={deletingPlugin}
|
||||
title={t('plugin_management.delete_plugin')}
|
||||
aria-label={t('plugin_management.delete_plugin')}
|
||||
>
|
||||
<IconTrash2 size={14} />
|
||||
{t('plugin_management.delete_plugin')}
|
||||
</Button>
|
||||
{github ? (
|
||||
<a
|
||||
className={styles.iconLink}
|
||||
|
||||
@@ -1149,6 +1149,12 @@
|
||||
"array_item_placeholder": "Enter array item",
|
||||
"add_array_item": "Add array item",
|
||||
"remove_array_item": "Remove array item",
|
||||
"delete_plugin": "Delete",
|
||||
"delete_confirm_title": "Delete plugin",
|
||||
"delete_confirm_message": "Delete {{name}} ({{id}})? This removes the local plugin file and saved config.",
|
||||
"delete_success": "Plugin deleted",
|
||||
"delete_failed": "Failed to delete plugin",
|
||||
"delete_restart_required": "The loaded plugin cannot be removed until the backend restarts.",
|
||||
"toggle_success": "Plugin status updated",
|
||||
"toggle_failed": "Failed to update plugin status",
|
||||
"save_success": "Plugin config saved",
|
||||
@@ -1165,6 +1171,8 @@
|
||||
"plugin_store": {
|
||||
"title": "Plugin Store",
|
||||
"description": "Browse the plugin registry, then install or update plugins for the current backend.",
|
||||
"description_show_more": "Show more",
|
||||
"description_show_less": "Show less",
|
||||
"refresh": "Refresh",
|
||||
"retry": "Retry",
|
||||
"load_failed": "Failed to load the plugin store",
|
||||
|
||||
@@ -1098,6 +1098,8 @@
|
||||
"description": "Просматривайте найденные и зарегистрированные плагины, управляйте переключателями экземпляров, полями конфигурации и ссылками на ресурсы.",
|
||||
"refresh": "Обновить",
|
||||
"load_failed": "Не удалось загрузить плагины",
|
||||
"config_load_failed": "Не удалось прочитать конфигурацию плагина",
|
||||
"config_not_found": "Не удалось прочитать конфигурацию плагина: backend не нашёл этот плагин.",
|
||||
"unsupported_backend": "Текущий backend не предоставляет API управления плагинами. Используйте более новую сборку backend с эндпоинтами управления плагинами и перезапустите сервис.",
|
||||
"global_status": "Глобальный статус",
|
||||
"global_enabled": "Включено",
|
||||
@@ -1134,6 +1136,12 @@
|
||||
"array_item_placeholder": "Введите элемент массива",
|
||||
"add_array_item": "Добавить элемент массива",
|
||||
"remove_array_item": "Удалить элемент массива",
|
||||
"delete_plugin": "Удалить",
|
||||
"delete_confirm_title": "Удалить плагин",
|
||||
"delete_confirm_message": "Удалить {{name}} ({{id}})? Будут удалены локальный файл плагина и сохранённая конфигурация.",
|
||||
"delete_success": "Плагин удалён",
|
||||
"delete_failed": "Не удалось удалить плагин",
|
||||
"delete_restart_required": "Загруженный плагин можно удалить только после перезапуска backend.",
|
||||
"toggle_success": "Статус плагина обновлён",
|
||||
"toggle_failed": "Не удалось обновить статус плагина",
|
||||
"save_success": "Конфигурация плагина сохранена",
|
||||
@@ -1150,6 +1158,8 @@
|
||||
"plugin_store": {
|
||||
"title": "Магазин плагинов",
|
||||
"description": "Просматривайте реестр плагинов, устанавливайте и обновляйте плагины для текущего бэкенда.",
|
||||
"description_show_more": "Показать больше",
|
||||
"description_show_less": "Свернуть",
|
||||
"refresh": "Обновить",
|
||||
"retry": "Повторить",
|
||||
"load_failed": "Не удалось загрузить магазин плагинов",
|
||||
|
||||
@@ -1149,6 +1149,12 @@
|
||||
"array_item_placeholder": "输入数组项",
|
||||
"add_array_item": "添加数组项",
|
||||
"remove_array_item": "删除数组项",
|
||||
"delete_plugin": "删除",
|
||||
"delete_confirm_title": "删除插件",
|
||||
"delete_confirm_message": "确定删除 {{name}}({{id}})吗?这会移除本地插件文件和已保存配置。",
|
||||
"delete_success": "插件已删除",
|
||||
"delete_failed": "插件删除失败",
|
||||
"delete_restart_required": "已加载的插件需要重启后端后才能移除。",
|
||||
"toggle_success": "插件状态已更新",
|
||||
"toggle_failed": "插件状态更新失败",
|
||||
"save_success": "插件配置已保存",
|
||||
@@ -1165,6 +1171,8 @@
|
||||
"plugin_store": {
|
||||
"title": "插件商店",
|
||||
"description": "浏览插件注册表,为当前后端安装或更新插件。",
|
||||
"description_show_more": "展开描述",
|
||||
"description_show_less": "收起描述",
|
||||
"refresh": "刷新",
|
||||
"retry": "重试",
|
||||
"load_failed": "插件商店加载失败",
|
||||
|
||||
@@ -1175,6 +1175,12 @@
|
||||
"array_item_placeholder": "輸入陣列項目",
|
||||
"add_array_item": "新增陣列項目",
|
||||
"remove_array_item": "刪除陣列項目",
|
||||
"delete_plugin": "刪除",
|
||||
"delete_confirm_title": "刪除插件",
|
||||
"delete_confirm_message": "確定刪除 {{name}}({{id}})嗎?這會移除本機插件檔案和已儲存設定。",
|
||||
"delete_success": "插件已刪除",
|
||||
"delete_failed": "插件刪除失敗",
|
||||
"delete_restart_required": "已載入的插件需要重新啟動後端後才能移除。",
|
||||
"toggle_success": "插件狀態已更新",
|
||||
"toggle_failed": "插件狀態更新失敗",
|
||||
"save_success": "插件設定已儲存",
|
||||
@@ -1191,6 +1197,8 @@
|
||||
"plugin_store": {
|
||||
"title": "插件商店",
|
||||
"description": "瀏覽插件註冊表,為目前後端安裝或更新插件。",
|
||||
"description_show_more": "展開描述",
|
||||
"description_show_less": "收起描述",
|
||||
"refresh": "重新整理",
|
||||
"retry": "重試",
|
||||
"load_failed": "插件商店載入失敗",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { isRecord } from '@/utils/helpers';
|
||||
import type {
|
||||
PluginConfigField,
|
||||
PluginConfigObject,
|
||||
PluginDeleteResult,
|
||||
PluginListEntry,
|
||||
PluginListResponse,
|
||||
PluginMetadata,
|
||||
@@ -118,6 +119,18 @@ const normalizePluginList = (value: unknown): PluginListResponse => {
|
||||
const normalizePluginConfig = (value: unknown): PluginConfigObject =>
|
||||
isRecord(value) ? { ...value } : {};
|
||||
|
||||
const normalizeDeleteResult = (value: unknown): PluginDeleteResult => {
|
||||
const source = isRecord(value) ? value : {};
|
||||
return {
|
||||
status: asString(source.status).trim(),
|
||||
id: asString(source.id).trim(),
|
||||
path: asString(source.path).trim(),
|
||||
fileDeleted: asBoolean(source.file_deleted),
|
||||
configuredRemoved: asBoolean(source.configured_removed),
|
||||
restartRequired: asBoolean(source.restart_required),
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeStoreEntry = (value: unknown): PluginStoreEntry | null => {
|
||||
if (!isRecord(value)) return null;
|
||||
const id = asString(value.id).trim();
|
||||
@@ -183,6 +196,11 @@ export const pluginsApi = {
|
||||
updateEnabled: (id: string, enabled: boolean) =>
|
||||
apiClient.patch(`/plugins/${encodeURIComponent(id)}/enabled`, { enabled }),
|
||||
|
||||
async deletePlugin(id: string): Promise<PluginDeleteResult> {
|
||||
const data = await apiClient.delete(`/plugins/${encodeURIComponent(id)}`);
|
||||
return normalizeDeleteResult(data);
|
||||
},
|
||||
|
||||
async getConfig(id: string): Promise<PluginConfigObject> {
|
||||
const data = await apiClient.get(`/plugins/${encodeURIComponent(id)}/config`);
|
||||
return normalizePluginConfig(data);
|
||||
|
||||
@@ -51,6 +51,15 @@ export interface PluginListResponse {
|
||||
plugins: PluginListEntry[];
|
||||
}
|
||||
|
||||
export interface PluginDeleteResult {
|
||||
status: string;
|
||||
id: string;
|
||||
path: string;
|
||||
fileDeleted: boolean;
|
||||
configuredRemoved: boolean;
|
||||
restartRequired: boolean;
|
||||
}
|
||||
|
||||
export interface PluginStoreEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
Reference in New Issue
Block a user