From 3f932c55d6940266d95059fd3508e5a205ed482f Mon Sep 17 00:00:00 2001 From: moonrailgun Date: Wed, 23 Sep 2026 02:11:29 +0800 Subject: [PATCH] feat(admin-next): add collapsible icon-only sidebar --- .../admin-next/src/client/components.test.tsx | 13 +++++++++ server/admin-next/src/client/components.tsx | 29 +++++++++++++++++-- server/admin-next/src/client/i18n.tsx | 4 +++ server/admin-next/src/client/styles.css | 20 +++++++++++-- 4 files changed, 61 insertions(+), 5 deletions(-) diff --git a/server/admin-next/src/client/components.test.tsx b/server/admin-next/src/client/components.test.tsx index 3752242a..b984901a 100644 --- a/server/admin-next/src/client/components.test.tsx +++ b/server/admin-next/src/client/components.test.tsx @@ -64,3 +64,16 @@ test('keeps inactive navigation neutral and groups user actions in a dropdown', assert.match(styles, /grid-template-columns:\s*24px 1fr/); assert.match(styles, /column-gap:\s*12px/); }); + +test('collapses the sidebar to icons on desktop', () => { + const components = readFileSync(`${__dirname}/components.tsx`, 'utf8'); + const styles = readFileSync(`${__dirname}/styles.css`, 'utf8'); + + assert.match(components, /className="icon-button sidebar-toggle"/); + assert.match(components, /aria-expanded=\{!collapsed\}/); + assert.match(styles, /\.sidebar-collapsed \.sidebar \{ width: 68px; \}/); + assert.match( + styles, + /\.sidebar-collapsed \.nav button span[^}]*display: none/ + ); +}); diff --git a/server/admin-next/src/client/components.tsx b/server/admin-next/src/client/components.tsx index 58ea43d9..d79ebfaa 100644 --- a/server/admin-next/src/client/components.tsx +++ b/server/admin-next/src/client/components.tsx @@ -223,6 +223,8 @@ const sections: { label: string; routes: { id: RouteId; icon: IconName }[] }[] = }, ]; +const SIDEBAR_STORAGE_KEY = 'tailchat:admin-next:sidebar'; + export function AppShell({ route, username, @@ -237,6 +239,9 @@ export function AppShell({ }>) { const { t, language, setLanguage } = useI18n(); const [drawer, setDrawer] = useState(false); + const [collapsed, setCollapsed] = useState( + () => window.localStorage.getItem(SIDEBAR_STORAGE_KEY) === 'collapsed' + ); const [palette, setPalette] = useState(false); const [query, setQuery] = useState(''); useEffect(() => { @@ -265,8 +270,16 @@ export function AppShell({ setDrawer(false); setPalette(false); }; + const toggleSidebar = () => { + const next = !collapsed; + window.localStorage.setItem( + SIDEBAR_STORAGE_KEY, + next ? 'collapsed' : 'expanded' + ); + setCollapsed(next); + }; return ( -
+
{drawer && ( go(item.id)} > @@ -300,7 +314,18 @@ export function AppShell({
))} -
{t('app.footer')}
+
+ {t('app.footer')} + } + /> +
diff --git a/server/admin-next/src/client/i18n.tsx b/server/admin-next/src/client/i18n.tsx index 1cb03b51..5dcc638a 100644 --- a/server/admin-next/src/client/i18n.tsx +++ b/server/admin-next/src/client/i18n.tsx @@ -54,6 +54,8 @@ const commonZh: Record = { 'auth.expired': '登录已过期,请重新登录', 'auth.logout': '退出登录', 'shell.menu': '打开导航', + 'shell.collapse': '收起侧边栏', + 'shell.expand': '展开侧边栏', 'shell.command': '快速跳转', 'shell.commandHint': '按 ⌘K 快速跳转', 'shell.commandPlaceholder': '搜索页面…', @@ -224,6 +226,8 @@ const commonEn: Record = { 'auth.expired': 'Your session expired. Sign in again.', 'auth.logout': 'Sign out', 'shell.menu': 'Open navigation', + 'shell.collapse': 'Collapse sidebar', + 'shell.expand': 'Expand sidebar', 'shell.command': 'Quick navigation', 'shell.commandHint': 'Press ⌘K to jump', 'shell.commandPlaceholder': 'Search pages…', diff --git a/server/admin-next/src/client/styles.css b/server/admin-next/src/client/styles.css index 8caf1c41..55435b89 100644 --- a/server/admin-next/src/client/styles.css +++ b/server/admin-next/src/client/styles.css @@ -71,7 +71,7 @@ code, kbd, pre { font-family: "SFMono-Regular", Consolas, monospace; } .icon-button:hover { background: var(--surface); } .app-shell { min-height: 100vh; } -.sidebar { position: fixed; inset: 0 auto 0 0; width: 260px; z-index: 30; display: flex; flex-direction: column; background: #0e1118; border-right: 1px solid var(--border-soft); } +.sidebar { position: fixed; inset: 0 auto 0 0; width: 260px; z-index: 30; display: flex; flex-direction: column; background: #0e1118; border-right: 1px solid var(--border-soft); transition: width .2s ease; } .brand { height: 76px; padding: 0 22px; display: flex; align-items: center; gap: 12px; border-bottom: 1px solid var(--border-soft); } .brand img { width: 38px; height: 38px; border-radius: 10px; } .brand div { min-width: 0; display: flex; flex-direction: column; } @@ -84,8 +84,10 @@ code, kbd, pre { font-family: "SFMono-Regular", Consolas, monospace; } .sidebar .nav .arco-btn-text:not(.active) { color: #9aa4b5; } .sidebar .nav .arco-btn-text:not(.active):hover { color: var(--text); background: rgba(255,255,255,.035); } .nav button.active { background: var(--primary-soft); color: #72b8ff; } -.sidebar-footer { padding: 14px 22px; border-top: 1px solid var(--border-soft); color: #596476; font-size: 11px; } -.workspace { min-height: 100vh; margin-left: 260px; } +.sidebar-footer { padding: 8px 8px 8px 22px; display: flex; align-items: center; justify-content: space-between; gap: 8px; border-top: 1px solid var(--border-soft); color: #596476; font-size: 11px; } +.sidebar-toggle { flex: 0 0 auto; color: #697488; } +.sidebar-toggle .icon { transform: rotate(180deg); transition: transform .2s ease; } +.workspace { min-height: 100vh; margin-left: 260px; transition: margin-left .2s ease; } .topbar { height: 62px; position: sticky; top: 0; z-index: 20; display: flex; align-items: center; padding: 0 28px; background: rgba(11, 14, 20, .88); border-bottom: 1px solid var(--border-soft); backdrop-filter: blur(14px); } .command-trigger { height: 36px; min-width: 236px; padding: 0 10px; display: flex; align-items: center; gap: 9px; color: var(--muted); background: var(--panel); border: 1px solid var(--border); border-radius: 8px; cursor: pointer; } .command-trigger span { flex: 1; text-align: left; font-size: 12px; } @@ -262,6 +264,18 @@ label > span { color: #a7b0bf; font-size: 12px; font-weight: 600; } .login-panel footer { margin-top: 28px; color: #596476; text-align: center; font-size: 10px; } .drawer-backdrop { display: none; } +@media (min-width: 941px) { + .sidebar-collapsed .sidebar { width: 68px; } + .sidebar-collapsed .workspace { margin-left: 68px; } + .sidebar-collapsed .brand { padding: 0; justify-content: center; } + .sidebar-collapsed .brand div, .sidebar-collapsed .nav-label, .sidebar-collapsed .nav button span, .sidebar-collapsed .sidebar-footer span { display: none; } + .sidebar-collapsed .nav { padding: 14px 10px 24px; } + .sidebar-collapsed .nav-section + .nav-section { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border-soft); } + .sidebar-collapsed .nav button { padding: 0; justify-content: center; } + .sidebar-collapsed .sidebar-footer { padding: 8px; justify-content: center; } + .sidebar-collapsed .sidebar-toggle .icon { transform: none; } +} + @media (max-width: 1120px) { .kpi-grid { grid-template-columns: repeat(2, 1fr); } .network-detail-grid { grid-template-columns: 1fr; }