Skip to content

Commit f4a2d44

Browse files
committed
feat(chat): 新增全屏对话布局
1 parent 7dcce90 commit f4a2d44

8 files changed

Lines changed: 198 additions & 35 deletions

File tree

web/src/app/layout/AppShell.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,14 +78,15 @@ const apiKeyMenuItems: MenuItem[] = [
7878
* audience = "all" — 所有登录用户可见,按当前角色挂分组
7979
*/
8080
function pluginPagePath(pluginName: string, pagePath: string) {
81+
// airgate-playground 走全屏 /chat 独立布局,不挂在 AppShell 里
8182
if (pluginName === 'airgate-playground' && pagePath === '/playground') {
82-
return '/plugins/playground';
83+
return '/chat';
8384
}
8485
return `/plugins/${pluginName}${pagePath}`;
8586
}
8687

8788
function isPlaygroundPluginPath(path: string) {
88-
return path === '/plugins/playground' || path.includes('/plugins/airgate-playground/');
89+
return path === '/chat' || path === '/plugins/playground' || path.includes('/plugins/airgate-playground/');
8990
}
9091

9192
function usePluginMenuItems(isAdmin: boolean): {

web/src/app/layout/ChatShell.tsx

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { type ReactNode, useEffect } from 'react';
2+
import { Link } from '@tanstack/react-router';
3+
import { useTranslation } from 'react-i18next';
4+
import { useAuth } from '../providers/AuthProvider';
5+
import { useTheme } from '../providers/ThemeProvider';
6+
import { useSiteSettings, defaultLogoUrl } from '../providers/SiteSettingsProvider';
7+
import {
8+
ArrowLeft,
9+
LogOut,
10+
Languages,
11+
Sun,
12+
Moon,
13+
ShieldCheck,
14+
} from 'lucide-react';
15+
16+
interface ChatShellProps {
17+
children: ReactNode;
18+
}
19+
20+
/**
21+
* 全屏沉浸式布局:仅一条窄顶栏(返回控制台 + 用户/主题/语言/退出),
22+
* 主区高度填满视口,不限制宽度、不加内边距。供 /chat 等需要最大化使用空间的页面挂载。
23+
*/
24+
export function ChatShell({ children }: ChatShellProps) {
25+
const { user, logout, isAPIKeySession } = useAuth();
26+
const { t, i18n } = useTranslation();
27+
const { theme, toggleTheme } = useTheme();
28+
const site = useSiteSettings();
29+
const isAdmin = user?.role === 'admin';
30+
31+
useEffect(() => {
32+
document.title = site.site_name || 'AirGate';
33+
}, [site.site_name]);
34+
35+
const toggleLanguage = () => {
36+
const nextLang = i18n.language === 'zh' ? 'en' : 'zh';
37+
i18n.changeLanguage(nextLang);
38+
localStorage.setItem('lang', nextLang);
39+
};
40+
41+
return (
42+
<div className="flex flex-col h-screen" style={{ height: '100dvh' }}>
43+
<header className="flex items-center justify-between h-12 px-3 md:px-4 border-b border-border bg-bg shrink-0">
44+
<div className="flex items-center gap-2 min-w-0">
45+
<Link
46+
to="/"
47+
className="flex items-center gap-1.5 h-8 px-2 rounded-[10px] text-text-tertiary hover:text-text hover:bg-bg-hover transition-colors"
48+
title={t('nav.back_to_console')}
49+
>
50+
<ArrowLeft className="w-3.5 h-3.5 shrink-0" />
51+
<span className="text-[12px] font-medium hidden sm:inline">
52+
{t('nav.back_to_console')}
53+
</span>
54+
</Link>
55+
<div className="w-px h-4 bg-border mx-1" />
56+
<img
57+
src={site.site_logo || defaultLogoUrl}
58+
alt=""
59+
className="w-6 h-6 rounded-sm flex-shrink-0 object-cover"
60+
/>
61+
<span className="text-[13px] font-semibold text-text tracking-tight truncate">
62+
{site.site_name || 'AirGate'}
63+
</span>
64+
</div>
65+
66+
<div className="flex items-center gap-1">
67+
<button
68+
onClick={toggleLanguage}
69+
className="flex items-center justify-center h-8 px-2 rounded-[10px] text-text-tertiary hover:text-text-secondary hover:bg-bg-hover transition-colors gap-1.5"
70+
title={i18n.language === 'zh' ? 'Switch to English' : '切换为中文'}
71+
>
72+
<Languages className="w-3.5 h-3.5" />
73+
<span className="text-[10px] font-mono uppercase hidden sm:inline">
74+
{i18n.language === 'zh' ? 'EN' : '中文'}
75+
</span>
76+
</button>
77+
<button
78+
onClick={toggleTheme}
79+
className="flex items-center justify-center w-8 h-8 rounded-[10px] text-text-tertiary hover:text-text-secondary hover:bg-bg-hover transition-colors"
80+
title={theme === 'dark' ? '切换亮色模式' : '切换暗色模式'}
81+
>
82+
{theme === 'dark' ? <Sun className="w-3.5 h-3.5" /> : <Moon className="w-3.5 h-3.5" />}
83+
</button>
84+
85+
<div className="w-px h-5 bg-border mx-1.5" />
86+
87+
<div className="flex items-center gap-2 pl-1">
88+
{!isAPIKeySession && (
89+
<div className="hidden md:block text-right">
90+
<p className="text-xs font-medium text-text leading-tight">
91+
{user?.username || user?.email?.split('@')[0]}
92+
</p>
93+
<p className="text-[10px] text-text-tertiary leading-tight">
94+
{user?.email}
95+
</p>
96+
</div>
97+
)}
98+
{isAdmin ? (
99+
<div className="flex items-center justify-center w-7 h-7 rounded-full bg-primary-subtle text-primary shrink-0">
100+
<ShieldCheck className="w-3.5 h-3.5" />
101+
</div>
102+
) : (
103+
<div className="flex items-center justify-center w-7 h-7 rounded-full bg-primary-subtle text-[11px] font-bold text-primary shrink-0">
104+
{(user?.username || user?.email || 'U').charAt(0).toUpperCase()}
105+
</div>
106+
)}
107+
<button
108+
onClick={logout}
109+
className="flex items-center justify-center w-7 h-7 rounded-[10px] text-text-tertiary hover:text-danger hover:bg-danger-subtle transition-all"
110+
title={t('common.logout')}
111+
>
112+
<LogOut className="w-3.5 h-3.5" />
113+
</button>
114+
</div>
115+
</div>
116+
</header>
117+
118+
<main className="flex-1 min-h-0 overflow-hidden">
119+
{children}
120+
</main>
121+
</div>
122+
);
123+
}

web/src/app/router.tsx

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
} from '@tanstack/react-router';
88
import { Suspense, lazy } from 'react';
99
import { AppShell } from './layout/AppShell';
10+
import { ChatShell } from './layout/ChatShell';
1011
import { useAuth } from './providers/AuthProvider';
1112
import { ErrorBoundary } from './providers/ErrorBoundary';
1213
import { getToken } from '../shared/api/client';
@@ -189,16 +190,39 @@ const profileRoute = createRoute({ getParentRoute: () => authLayout, path: '/pro
189190
const userKeysRoute = createRoute({ getParentRoute: () => authLayout, path: '/keys', component: UserKeysPage });
190191
const userUsageRoute = createRoute({ getParentRoute: () => authLayout, path: '/usage', component: UserUsagePage });
191192

192-
const playgroundPluginRoute = createRoute({
193-
getParentRoute: () => authLayout,
194-
path: '/plugins/playground',
193+
// /chat: 全屏沉浸式 AI 对话页(airgate-playground 插件),独立布局不挂 AppShell。
194+
// 仍要求登录 + 安装完成;走 ChatShell 极简顶栏。
195+
const chatRoute = createRoute({
196+
getParentRoute: () => rootRoute,
197+
path: '/chat',
198+
beforeLoad: async () => {
199+
const needs = await checkSetup();
200+
if (needs) {
201+
throw redirect({ to: '/setup' });
202+
}
203+
if (!getToken()) {
204+
throw redirect({ to: '/home' });
205+
}
206+
},
195207
component: () => (
196-
<Suspense fallback={null}>
197-
<PluginPage pluginNameOverride="airgate-playground" subPathOverride="/playground" />
198-
</Suspense>
208+
<ChatShell>
209+
<Suspense fallback={null}>
210+
<PluginPage pluginNameOverride="airgate-playground" subPathOverride="/playground" />
211+
</Suspense>
212+
</ChatShell>
199213
),
200214
});
201215

216+
// 旧路径 /plugins/playground 重定向到 /chat,避免历史书签 / 链接失效。
217+
const playgroundLegacyRoute = createRoute({
218+
getParentRoute: () => rootRoute,
219+
path: '/plugins/playground',
220+
beforeLoad: () => {
221+
throw redirect({ to: '/chat' });
222+
},
223+
component: () => null,
224+
});
225+
202226
// 插件页面路由(catch-all)
203227
const pluginRoute = createRoute({
204228
getParentRoute: () => authLayout,
@@ -216,6 +240,8 @@ const routeTree = rootRoute.addChildren([
216240
homeRoute,
217241
loginRoute,
218242
docsRoute,
243+
chatRoute,
244+
playgroundLegacyRoute,
219245
authLayout.addChildren([
220246
dashboardRoute,
221247
adminLayout.addChildren([
@@ -231,7 +257,6 @@ const routeTree = rootRoute.addChildren([
231257
profileRoute,
232258
userKeysRoute,
233259
userUsageRoute,
234-
playgroundPluginRoute,
235260
pluginRoute,
236261
]),
237262
]);

web/src/i18n/en.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,8 @@
121121
"user": "User",
122122
"docs": "Docs",
123123
"contact": "Contact",
124-
"status": "Status"
124+
"status": "Status",
125+
"back_to_console": "Back to Console"
125126
},
126127

127128
"auth": {

web/src/i18n/zh.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,8 @@
121121
"user": "用户",
122122
"docs": "文档",
123123
"contact": "联系我们",
124-
"status": "服务状态"
124+
"status": "服务状态",
125+
"back_to_console": "返回控制台"
125126
},
126127

127128
"auth": {

web/src/pages/DashboardPage.tsx

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
} from 'lucide-react';
1212
import { Card } from '../shared/components/Card';
1313
import { Alert } from '../shared/components/Alert';
14+
import { Select } from '../shared/components/Input';
1415
import { Tabs } from '../shared/components/Tabs';
1516
import { dashboardApi } from '../shared/api/dashboard';
1617
import { usersApi } from '../shared/api/users';
@@ -106,28 +107,28 @@ export default function DashboardPage() {
106107
<div className="flex items-center gap-3">
107108
<div className="flex items-center gap-2">
108109
<span className="text-xs text-text-tertiary">{t('dashboard.filter_user')}</span>
109-
<select
110-
className="text-xs rounded-md border border-border-subtle bg-bg-elevated px-3 py-1.5 text-text-secondary max-w-[180px]"
111-
value={selectedUserId ?? ''}
110+
<Select
111+
className="text-xs rounded-md px-3 py-1.5 max-w-[180px]"
112+
value={selectedUserId ? String(selectedUserId) : ''}
112113
onChange={(e) => setSelectedUserId(e.target.value ? Number(e.target.value) : undefined)}
113-
>
114-
<option value="">{t('dashboard.all_users')}</option>
115-
{(usersData?.list ?? []).map((u) => (
116-
<option key={u.id} value={u.id}>{u.email}</option>
117-
))}
118-
</select>
114+
options={[
115+
{ value: '', label: t('dashboard.all_users') },
116+
...(usersData?.list ?? []).map((u) => ({ value: String(u.id), label: u.email })),
117+
]}
118+
/>
119119
</div>
120120
<div className="flex items-center gap-2">
121121
<span className="text-xs text-text-tertiary">{t('dashboard.granularity')}</span>
122-
<select
123-
className="text-xs rounded-md border border-border-subtle bg-bg-elevated px-3 py-1.5 text-text-secondary"
122+
<Select
123+
className="text-xs rounded-md px-3 py-1.5"
124124
value={range === 'today' ? 'hour' : granularity}
125125
onChange={(e) => setGranularity(e.target.value as Granularity)}
126126
disabled={range === 'today'}
127-
>
128-
<option value="day">{t('dashboard.granularity_day')}</option>
129-
<option value="hour">{t('dashboard.granularity_hour')}</option>
130-
</select>
127+
options={[
128+
{ value: 'day', label: t('dashboard.granularity_day') },
129+
{ value: 'hour', label: t('dashboard.granularity_hour') },
130+
]}
131+
/>
131132
</div>
132133
</div>
133134
</div>

web/src/shared/components/Table.tsx

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useMemo, type ReactNode, type CSSProperties } from 'react';
22
import { ChevronLeft, ChevronRight } from 'lucide-react';
33
import { EmptyState } from './EmptyState';
4+
import { Select } from './Input';
45
import { useIsMobile } from '../hooks/useMediaQuery';
56

67
export interface Column<T> {
@@ -155,15 +156,12 @@ export function Table<T extends Record<string, any>>({
155156
{total} 条 · 第 {page}/{totalPages}
156157
</span>
157158
{onPageSizeChange && (
158-
<select
159-
value={pageSize}
159+
<Select
160+
value={String(pageSize)}
160161
onChange={(e) => { onPageSizeChange(Number(e.target.value)); onPageChange(1); }}
161-
className="text-xs text-text-secondary bg-transparent border border-glass-border rounded px-1.5 py-0.5 cursor-pointer hover:border-primary transition-colors outline-none"
162-
>
163-
{pageSizeOptions.map((s) => (
164-
<option key={s} value={s}>{s} 条/页</option>
165-
))}
166-
</select>
162+
className="text-xs rounded px-1.5 py-0.5"
163+
options={pageSizeOptions.map((s) => ({ value: String(s), label: `${s} 条/页` }))}
164+
/>
167165
)}
168166
</div>
169167
<div className="flex items-center gap-1">

web/vite.config.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,20 @@ export default defineConfig({
129129
// OpenAI 兼容接口(含 WebSocket)
130130
'/v1': { target: BACKEND, ws: true },
131131
'/responses': { target: BACKEND, ws: true },
132-
'/chat': { target: BACKEND, ws: true },
132+
// /chat 既是 SPA 全屏对话页(GET /chat),也是 OpenAI 兼容裸路径(POST
133+
// /chat/completions)的兜底代理。bypass:纯 /chat 与 /chat/ 让 vite 走
134+
// SPA fallback;其余 /chat/<sub> 才转给后端,避免刷新页面时被代理到 core
135+
// 拿不到 SPA 而白屏。
136+
'/chat': {
137+
target: BACKEND,
138+
ws: true,
139+
bypass: (req) => {
140+
if (req.url === '/chat' || req.url === '/chat/') {
141+
return req.url;
142+
}
143+
return null;
144+
},
145+
},
133146
'/messages': { target: BACKEND, ws: true },
134147
'/models': { target: BACKEND, ws: true },
135148
},

0 commit comments

Comments
 (0)