Skip to content

Commit 356ecd7

Browse files
committed
v1.8.0: log page incremental refresh, plugin system UI, i18n updates
- Log page: incremental refresh, cursor-based pagination, auto-scroll - Plugin system: plugin store page, plugin management UI, install/enable/disable - Auth store: plugin auth provider support - i18n: English and Chinese translations for plugin and log features - UI: Sheet component, scroll lock utility, new icons
1 parent d0f789f commit 356ecd7

27 files changed

Lines changed: 5544 additions & 46 deletions

src/components/layout/MainLayout.tsx

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@ import {
1818
IconSidebarDashboard,
1919
IconSidebarLogs,
2020
IconSidebarOauth,
21+
IconSidebarPlugins,
2122
IconSidebarProviders,
2223
IconSidebarQuota,
24+
IconSidebarStore,
2325
IconSidebarSystem,
2426
IconSidebarUsage,
2527
} from '@/components/ui/icons';
@@ -34,6 +36,14 @@ import {
3436
import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh';
3537
import { LANGUAGE_LABEL_KEYS, LANGUAGE_ORDER } from '@/utils/constants';
3638
import { isSupportedLanguage } from '@/utils/language';
39+
// v7:插件资源条目(用于在侧边栏展开插件提供的菜单)与支持检测
40+
import { pluginsApi } from '@/services/api';
41+
import {
42+
collectPluginResourceEntries,
43+
PLUGIN_RESOURCES_REFRESH_EVENT,
44+
resolvePluginAssetURL,
45+
type PluginResourceEntry,
46+
} from '@/features/plugins/pluginResources';
3747
import type { Theme } from '@/types';
3848

3949
const sidebarIcons: Record<string, ReactNode> = {
@@ -43,11 +53,37 @@ const sidebarIcons: Record<string, ReactNode> = {
4353
oauth: <IconSidebarOauth size={18} />,
4454
quota: <IconSidebarQuota size={18} />,
4555
usage: <IconSidebarUsage size={18} />,
56+
// v7:插件管理 + 插件商店入口图标
57+
plugins: <IconSidebarPlugins size={18} />,
58+
pluginStore: <IconSidebarStore size={18} />,
4659
config: <IconSidebarConfig size={18} />,
4760
logs: <IconSidebarLogs size={18} />,
4861
system: <IconSidebarSystem size={18} />,
4962
};
5063

64+
// v7:插件资源条目的侧边栏图标——logo 加载失败时回退到通用插头图标
65+
function PluginSidebarIcon({ src }: { src: string }) {
66+
const [failed, setFailed] = useState(false);
67+
const showImage = Boolean(src) && !failed;
68+
69+
return showImage ? (
70+
<img
71+
src={src}
72+
alt=""
73+
onError={() => setFailed(true)}
74+
style={{ width: 18, height: 18, objectFit: 'cover', borderRadius: 4 }}
75+
/>
76+
) : (
77+
<IconSidebarPlugins size={18} />
78+
);
79+
}
80+
81+
// 把插件 logo 转成完整 URL 后塞给 PluginSidebarIcon
82+
function pluginResourceLogo(entry: PluginResourceEntry, apiBase: string) {
83+
const src = resolvePluginAssetURL(entry.pluginLogo, apiBase);
84+
return <PluginSidebarIcon src={src} />;
85+
}
86+
5187
// Header action icons - smaller size for header buttons
5288
const headerIconProps: SVGProps<SVGSVGElement> = {
5389
width: 16,
@@ -209,6 +245,8 @@ export function MainLayout() {
209245

210246
const apiBase = useAuthStore((state) => state.apiBase);
211247
const connectionStatus = useAuthStore((state) => state.connectionStatus);
248+
// v7:是否显示插件入口;连接成功后由下面的 effect 探测 /plugins 接口决定
249+
const supportsPlugin = useAuthStore((state) => state.supportsPlugin);
212250
const logout = useAuthStore((state) => state.logout);
213251

214252
const config = useConfigStore((state) => state.config);
@@ -225,6 +263,9 @@ export function MainLayout() {
225263
const [languageMenuOpen, setLanguageMenuOpen] = useState(false);
226264
const [themeMenuOpen, setThemeMenuOpen] = useState(false);
227265
const [brandExpanded, setBrandExpanded] = useState(true);
266+
// v7:插件资源条目(来自 enabled 插件的 menus),由 PluginsPage 在
267+
// 启用/禁用/安装/删除后通过 PLUGIN_RESOURCES_REFRESH_EVENT 派发刷新
268+
const [pluginResourceEntries, setPluginResourceEntries] = useState<PluginResourceEntry[]>([]);
228269
const contentRef = useRef<HTMLDivElement | null>(null);
229270
const languageMenuRef = useRef<HTMLDivElement | null>(null);
230271
const themeMenuRef = useRef<HTMLDivElement | null>(null);
@@ -409,6 +450,67 @@ export function MainLayout() {
409450
});
410451
}, [fetchConfig]);
411452

453+
// v7:连接成功后探测一次 /plugins 接口,决定是否启用插件入口;
454+
// 后端不支持时返回 404/405,我们视为 supportsPlugin=false,避免空页面
455+
useEffect(() => {
456+
if (connectionStatus !== 'connected') return;
457+
let cancelled = false;
458+
459+
const detect = async () => {
460+
try {
461+
await pluginsApi.list();
462+
if (!cancelled) {
463+
window.dispatchEvent(
464+
new CustomEvent('server-plugin-support-update', { detail: { supportsPlugin: true } })
465+
);
466+
}
467+
} catch (error: unknown) {
468+
if (cancelled) return;
469+
const status =
470+
typeof error === 'object' && error !== null && 'status' in error
471+
? (error as { status?: unknown }).status
472+
: undefined;
473+
// 仅 404/405 视为"后端无此接口";其它错误(401/500)保持当前状态由后续重试决定
474+
const supported = status !== 404 && status !== 405;
475+
window.dispatchEvent(
476+
new CustomEvent('server-plugin-support-update', { detail: { supportsPlugin: supported } })
477+
);
478+
}
479+
};
480+
481+
void detect();
482+
return () => {
483+
cancelled = true;
484+
};
485+
}, [connectionStatus]);
486+
487+
// v7:拉取/刷新插件资源条目;PluginsPage 任何变更后都会派发事件触发重建
488+
useEffect(() => {
489+
if (!supportsPlugin) {
490+
setPluginResourceEntries([]);
491+
return;
492+
}
493+
494+
let cancelled = false;
495+
const load = async () => {
496+
try {
497+
const resp = await pluginsApi.list();
498+
if (cancelled) return;
499+
setPluginResourceEntries(collectPluginResourceEntries(resp.plugins));
500+
} catch {
501+
if (cancelled) return;
502+
setPluginResourceEntries([]);
503+
}
504+
};
505+
506+
void load();
507+
window.addEventListener(PLUGIN_RESOURCES_REFRESH_EVENT, load);
508+
return () => {
509+
cancelled = true;
510+
window.removeEventListener(PLUGIN_RESOURCES_REFRESH_EVENT, load);
511+
};
512+
}, [supportsPlugin]);
513+
412514
const statusClass =
413515
connectionStatus === 'connected'
414516
? 'success'
@@ -426,6 +528,19 @@ export function MainLayout() {
426528
{ path: '/oauth', label: t('nav.oauth', { defaultValue: 'OAuth' }), icon: sidebarIcons.oauth },
427529
{ path: '/quota', label: t('nav.quota_management'), icon: sidebarIcons.quota },
428530
{ path: '/usage', label: t('nav.usage_stats'), icon: sidebarIcons.usage },
531+
// v7:仅当后端支持插件 API 时才显示入口;顺序保持在 logs 之前
532+
...(supportsPlugin
533+
? [
534+
{ path: '/plugins', label: t('nav.plugins'), icon: sidebarIcons.plugins },
535+
{ path: '/plugin-store', label: t('nav.plugin_store'), icon: sidebarIcons.pluginStore },
536+
]
537+
: []),
538+
// v7:展开的插件资源(每个 effective 插件的每个 menu),点击直达 iframe 页面
539+
...pluginResourceEntries.map((entry) => ({
540+
path: entry.route,
541+
label: entry.label,
542+
icon: pluginResourceLogo(entry, apiBase),
543+
})),
429544
...(config?.loggingToFile
430545
? [{ path: '/logs', label: t('nav.logs'), icon: sidebarIcons.logs }]
431546
: []),
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
@use '../../../styles/mixins' as *;
2+
@use '../../../styles/variables' as *;
3+
4+
.overlay {
5+
position: fixed;
6+
inset: 0;
7+
background: rgba(0, 0, 0, 0.5);
8+
opacity: 0;
9+
transition: opacity 200ms ease;
10+
z-index: 2000;
11+
display: flex;
12+
justify-content: flex-end;
13+
14+
&.entering {
15+
opacity: 1;
16+
}
17+
18+
&.exiting {
19+
opacity: 0;
20+
}
21+
}
22+
23+
.content {
24+
position: relative;
25+
background: var(--bg-primary);
26+
border-left: 1px solid var(--border-color);
27+
box-shadow: var(--floating-shadow);
28+
transform: translateX(100%);
29+
transition: transform 280ms cubic-bezier(0.32, 0.72, 0, 1);
30+
display: flex;
31+
flex-direction: column;
32+
height: 100%;
33+
outline: none;
34+
35+
&.entering {
36+
transform: translateX(0);
37+
}
38+
39+
&.exiting {
40+
transform: translateX(100%);
41+
}
42+
}
43+
44+
.sizeMd {
45+
width: min(640px, 100vw);
46+
}
47+
48+
.sizeLg {
49+
width: min(720px, 100vw);
50+
}
51+
52+
.sizeXl {
53+
width: min(960px, 100vw);
54+
}
55+
56+
.header {
57+
flex-shrink: 0;
58+
padding: 20px 56px 20px 24px;
59+
border-bottom: 1px solid var(--border-color);
60+
display: flex;
61+
flex-direction: column;
62+
gap: 4px;
63+
}
64+
65+
.eyebrow {
66+
font-size: 11px;
67+
font-weight: 500;
68+
text-transform: uppercase;
69+
letter-spacing: 0.05em;
70+
color: var(--muted-foreground);
71+
}
72+
73+
.title {
74+
font-size: 18px;
75+
font-weight: 600;
76+
color: var(--text-primary);
77+
margin: 0;
78+
}
79+
80+
.description {
81+
font-size: 13px;
82+
color: var(--muted-foreground);
83+
line-height: 1.5;
84+
margin: 0;
85+
}
86+
87+
.body {
88+
flex: 1;
89+
overflow-y: auto;
90+
padding: 24px;
91+
}
92+
93+
.footer {
94+
flex-shrink: 0;
95+
padding: 16px 24px;
96+
border-top: 1px solid var(--border-color);
97+
display: flex;
98+
gap: 12px;
99+
justify-content: flex-end;
100+
align-items: center;
101+
flex-wrap: wrap;
102+
}
103+
104+
.closeBtn {
105+
position: absolute;
106+
top: 14px;
107+
right: 14px;
108+
width: 32px;
109+
height: 32px;
110+
display: flex;
111+
align-items: center;
112+
justify-content: center;
113+
border: none;
114+
background: transparent;
115+
color: var(--text-secondary);
116+
border-radius: var(--radius-md);
117+
cursor: pointer;
118+
transition: background-color $transition-fast, color $transition-fast;
119+
120+
&:hover {
121+
background: var(--bg-tertiary);
122+
color: var(--text-primary);
123+
}
124+
125+
&:focus-visible {
126+
outline: 2px solid var(--primary-color);
127+
outline-offset: 2px;
128+
}
129+
}
130+
131+
@media (max-width: 640px) {
132+
.content {
133+
width: 100vw !important;
134+
border-left: none;
135+
}
136+
137+
.header {
138+
padding: 16px 52px 16px 16px;
139+
}
140+
141+
.body {
142+
padding: 16px;
143+
}
144+
145+
.footer {
146+
padding: 12px 16px;
147+
}
148+
}

0 commit comments

Comments
 (0)