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}>
|
||||
|
||||
@@ -1165,6 +1165,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",
|
||||
|
||||
@@ -1150,6 +1150,8 @@
|
||||
"plugin_store": {
|
||||
"title": "Магазин плагинов",
|
||||
"description": "Просматривайте реестр плагинов, устанавливайте и обновляйте плагины для текущего бэкенда.",
|
||||
"description_show_more": "Показать больше",
|
||||
"description_show_less": "Свернуть",
|
||||
"refresh": "Обновить",
|
||||
"retry": "Повторить",
|
||||
"load_failed": "Не удалось загрузить магазин плагинов",
|
||||
|
||||
@@ -1165,6 +1165,8 @@
|
||||
"plugin_store": {
|
||||
"title": "插件商店",
|
||||
"description": "浏览插件注册表,为当前后端安装或更新插件。",
|
||||
"description_show_more": "展开描述",
|
||||
"description_show_less": "收起描述",
|
||||
"refresh": "刷新",
|
||||
"retry": "重试",
|
||||
"load_failed": "插件商店加载失败",
|
||||
|
||||
@@ -1191,6 +1191,8 @@
|
||||
"plugin_store": {
|
||||
"title": "插件商店",
|
||||
"description": "瀏覽插件註冊表,為目前後端安裝或更新插件。",
|
||||
"description_show_more": "展開描述",
|
||||
"description_show_less": "收起描述",
|
||||
"refresh": "重新整理",
|
||||
"retry": "重試",
|
||||
"load_failed": "插件商店載入失敗",
|
||||
|
||||
@@ -62,6 +62,21 @@ export interface ErrorLogsResponse {
|
||||
|
||||
const stringValue = (value: unknown): string => (typeof value === 'string' ? value.trim() : '');
|
||||
|
||||
const numberValue = (value: unknown): number | undefined => {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
};
|
||||
|
||||
const positiveNumberValue = (value: unknown): number | undefined => {
|
||||
const parsed = numberValue(value);
|
||||
return parsed !== undefined && parsed > 0 ? parsed : undefined;
|
||||
};
|
||||
|
||||
const homeRecordsFromPayload = (data: Record<string, unknown>): HomeLogRecord[] =>
|
||||
Array.isArray(data.logs)
|
||||
? data.logs.filter((entry): entry is HomeLogRecord => isRecord(entry))
|
||||
: [];
|
||||
|
||||
const unixSecondsFromValue = (value: unknown): number => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
const text = stringValue(value);
|
||||
@@ -95,9 +110,7 @@ const normalizeCPALogs = (data: Record<string, unknown>): LogsResponse => {
|
||||
};
|
||||
|
||||
const normalizeHomeLogs = (data: Record<string, unknown>): LogsResponse => {
|
||||
const rawLogs = Array.isArray(data.logs)
|
||||
? data.logs.filter((entry): entry is HomeLogRecord => isRecord(entry))
|
||||
: [];
|
||||
const rawLogs = homeRecordsFromPayload(data);
|
||||
const orderedLogs = [...rawLogs].reverse();
|
||||
const lines = orderedLogs
|
||||
.map((record) => record.line)
|
||||
@@ -145,9 +158,62 @@ const normalizeLogsResponse = (data: unknown): LogsResponse => {
|
||||
return { lines: [], lineCount: 0, logBackendKind: 'unknown' };
|
||||
};
|
||||
|
||||
const fetchCompleteHomeLogs = async (
|
||||
firstPage: Record<string, unknown>,
|
||||
params: LogsQuery
|
||||
): Promise<Record<string, unknown>> => {
|
||||
const requestedLimit = positiveNumberValue(params.limit);
|
||||
const firstPageLimit = positiveNumberValue(firstPage.limit);
|
||||
const pageLimit = firstPageLimit ?? requestedLimit;
|
||||
const total = numberValue(firstPage.total);
|
||||
const firstOffset = numberValue(firstPage.offset) ?? numberValue(params.offset) ?? 0;
|
||||
const records = homeRecordsFromPayload(firstPage);
|
||||
|
||||
if (requestedLimit === undefined || pageLimit === undefined || total === undefined) {
|
||||
return firstPage;
|
||||
}
|
||||
|
||||
let nextOffset = firstOffset + records.length;
|
||||
const targetCount = Math.min(requestedLimit, Math.max(total - firstOffset, 0));
|
||||
|
||||
while (records.length < targetCount && nextOffset < total) {
|
||||
const remaining = targetCount - records.length;
|
||||
const data = await apiClient.get('/logs', {
|
||||
params: {
|
||||
...params,
|
||||
limit: Math.min(pageLimit, remaining),
|
||||
offset: nextOffset,
|
||||
},
|
||||
timeout: LOGS_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
if (!isRecord(data) || !Array.isArray(data.logs)) {
|
||||
break;
|
||||
}
|
||||
|
||||
const pageRecords = homeRecordsFromPayload(data);
|
||||
if (pageRecords.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
records.push(...pageRecords);
|
||||
nextOffset += pageRecords.length;
|
||||
}
|
||||
|
||||
return {
|
||||
...firstPage,
|
||||
logs: records,
|
||||
limit: records.length,
|
||||
offset: firstOffset,
|
||||
};
|
||||
};
|
||||
|
||||
export const logsApi = {
|
||||
async fetchLogs(params: LogsQuery = {}): Promise<LogsResponse> {
|
||||
const data = await apiClient.get('/logs', { params, timeout: LOGS_TIMEOUT_MS });
|
||||
if (isRecord(data) && Array.isArray(data.logs)) {
|
||||
return normalizeLogsResponse(await fetchCompleteHomeLogs(data, params));
|
||||
}
|
||||
return normalizeLogsResponse(data);
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user