feat: #21 Phase 1-4 — CSS导入+OKLCh+登录页统一+Sidebar品牌区+docker安全
This commit is contained in:
parent
6c426757e0
commit
6da9b6e4c0
|
|
@ -9,12 +9,13 @@ services:
|
||||||
- assets-uploads:/app/uploads
|
- assets-uploads:/app/uploads
|
||||||
# .next 目录从主机挂载,主机上 npm run build 后直接生效
|
# .next 目录从主机挂载,主机上 npm run build 后直接生效
|
||||||
- ./.next:/app/.next
|
- ./.next:/app/.next
|
||||||
# 运行时从 LLDAP 容器动态读取 admin 密码
|
# 运行时从 LLDAP 容器动态读取 admin 密码(已迁移至环境变量注入)
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_PATH=/app/data/assets.db
|
- DATABASE_PATH=/app/data/assets.db
|
||||||
- JWT_SECRET=${JWT_SECRET:-oa-shared-jwt-secret-tlyq-2026}
|
- JWT_SECRET=${JWT_SECRET:-oa-shared-jwt-secret-tlyq-2026}
|
||||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
|
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
|
||||||
|
- LLDAP_ADMIN_PASSWORD=${LLDAP_ADMIN_PASSWORD}
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- COOKIE_DOMAIN=.tlyq.ai
|
- COOKIE_DOMAIN=.tlyq.ai
|
||||||
- TZ=Asia/Shanghai
|
- TZ=Asia/Shanghai
|
||||||
|
|
|
||||||
|
|
@ -1,77 +1,7 @@
|
||||||
'use client'
|
'use client'
|
||||||
import { useState } from 'react'
|
// 登录表单 — 引用共享组件
|
||||||
|
import LoginPage from '@shared/ui/login-page'
|
||||||
|
|
||||||
export function LoginForm() {
|
export function LoginForm() {
|
||||||
const [username, setUsername] = useState('')
|
return <LoginPage siteName="资产管理系统" redirectPath="/dashboard" />
|
||||||
const [password, setPassword] = useState('')
|
|
||||||
const [error, setError] = useState('')
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [showLdapForm, setShowLdapForm] = useState(false)
|
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
|
||||||
e.preventDefault(); setError(''); setLoading(true)
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) })
|
|
||||||
const data = await res.json()
|
|
||||||
if (!res.ok) { setError(data.error || '登录失败'); return }
|
|
||||||
const params = new URLSearchParams(window.location.search)
|
|
||||||
const redirect = params.get('redirect')
|
|
||||||
const dest = (redirect && redirect.startsWith('/')) ? redirect : '/dashboard'
|
|
||||||
window.location.href = dest
|
|
||||||
} catch { setError('网络错误,请重试') }
|
|
||||||
finally { setLoading(false) }
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSsoLogin() {
|
|
||||||
window.location.href = '/api/auth/login/oidc'
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-950 px-4">
|
|
||||||
<div className="w-full max-w-sm bg-white dark:bg-slate-900 rounded-lg border border-blue-200/50 dark:border-blue-500/20 shadow-lg p-8">
|
|
||||||
<h1 className="text-2xl font-bold text-center mb-6 text-slate-900 dark:text-white">资产管理系统</h1>
|
|
||||||
{error && <div className="mb-4 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 text-sm">{error}</div>}
|
|
||||||
|
|
||||||
{!showLdapForm ? (
|
|
||||||
<>
|
|
||||||
<button onClick={handleSsoLogin} className="w-full py-2 px-4 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-medium transition-colors duration-200 mb-3">
|
|
||||||
统一认证登录
|
|
||||||
</button>
|
|
||||||
<p className="text-center text-xs text-slate-400 mb-3">通过 SSO 统一身份认证</p>
|
|
||||||
<p className="text-center mb-2">
|
|
||||||
<button onClick={() => { window.location.href = '/api/auth/login/oidc?switch=1' }} className="text-xs text-slate-400 hover:text-slate-600 underline">
|
|
||||||
使用其他账号登录
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
<p className="text-center">
|
|
||||||
<button onClick={() => setShowLdapForm(true)} className="text-xs text-slate-400 hover:text-slate-600 underline">
|
|
||||||
使用 LDAP 直接登录
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">用户名</label>
|
|
||||||
<input type="text" value={username} onChange={e => setUsername(e.target.value)} placeholder="请输入用户名"
|
|
||||||
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" required />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">密码</label>
|
|
||||||
<input type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder="请输入密码"
|
|
||||||
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" required />
|
|
||||||
</div>
|
|
||||||
<button type="submit" disabled={loading} className="w-full py-2 px-4 rounded-lg bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium transition-colors duration-200">{loading ? '登录中...' : '登录'}</button>
|
|
||||||
</form>
|
|
||||||
<p className="text-center mt-3">
|
|
||||||
<button onClick={() => setShowLdapForm(false)} className="text-xs text-slate-400 hover:text-slate-600 underline">
|
|
||||||
返回统一认证登录
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@config "../../tailwind.config.js";
|
@config "../../tailwind.config.js";
|
||||||
|
@import './tlyq-design-system.css' layer(base);
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
body {
|
body {
|
||||||
@apply bg-slate-50 text-slate-900;
|
font-family: var(--font-body);
|
||||||
|
color: var(--fg);
|
||||||
|
background-color: var(--bg);
|
||||||
}
|
}
|
||||||
html.dark body {
|
html.dark body {
|
||||||
@apply bg-slate-950 text-white;
|
@apply bg-slate-950 text-white;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,362 @@
|
||||||
|
/*
|
||||||
|
* TLYQ 统一设计系统 — OKLCh 色彩空间(2026-07-15 升级)
|
||||||
|
* 从 Tailwind Slate HEX 色板迁移至 OKLCh 色彩空间
|
||||||
|
* 感知均匀、色域更广,保持无障碍对比度(WCAG AA ≥ 4.5:1)
|
||||||
|
*
|
||||||
|
* 使用方式:
|
||||||
|
* - CSS 变量:@import './tlyq-design-system.css'
|
||||||
|
* - Tailwind:颜色类见下方映射说明
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ==================== 设计令牌(CSS 变量) ==================== */
|
||||||
|
:root {
|
||||||
|
/* 背景色 */
|
||||||
|
--bg: oklch(0.984 0.003 247.858); /* ≈ #f8fafc (slate-50) */
|
||||||
|
--bg-subtle: oklch(0.968 0.007 247.896); /* ≈ #f1f5f9 (slate-100) */
|
||||||
|
--surface: oklch(1 0 0); /* #ffffff (white) */
|
||||||
|
--surface-raised: oklch(1 0 0); /* #ffffff (white) */
|
||||||
|
--surface-overlay: rgba(255, 255, 255, 0.95);
|
||||||
|
|
||||||
|
/* 文本色 */
|
||||||
|
--fg: oklch(0.129 0.042 264.695); /* ≈ #0f172a (slate-900) */
|
||||||
|
--fg-subtle: oklch(0.372 0.044 257.287); /* ≈ #334155 (slate-700) */
|
||||||
|
--muted: oklch(0.446 0.043 257.281); /* ≈ #475569 (slate-600) */
|
||||||
|
--muted-subtle: oklch(0.662 0.051 257.281); /* ≈ #94a3b8 (slate-400) */
|
||||||
|
|
||||||
|
/* 边框 */
|
||||||
|
--border: oklch(0.928 0.006 264.531); /* ≈ #e2e8f0 (slate-200) */
|
||||||
|
--border-subtle: oklch(0.968 0.007 247.896);/* ≈ #f1f5f9 (slate-100) */
|
||||||
|
|
||||||
|
/* 主色调 — 蓝色系 */
|
||||||
|
--accent: oklch(0.546 0.245 262.881); /* ≈ #2563eb (blue-600) */
|
||||||
|
--accent-hover: oklch(0.479 0.247 262.881); /* ≈ #1d4ed8 (blue-700) */
|
||||||
|
--accent-active: oklch(0.426 0.228 262.881);/* ≈ #1e40af (blue-800) */
|
||||||
|
--accent-subtle: oklch(0.967 0.03 260.592); /* ≈ #eff6ff (blue-50) */
|
||||||
|
--accent-text: oklch(1 0 0); /* #ffffff */
|
||||||
|
|
||||||
|
/* 状态色 */
|
||||||
|
--success: oklch(0.627 0.194 149.214); /* ≈ #059669 (emerald-600) */
|
||||||
|
--success-subtle: oklch(0.982 0.041 152.117);/* ≈ #ecfdf5 (emerald-50) */
|
||||||
|
--warning: oklch(0.681 0.162 75.834); /* ≈ #d97706 (amber-600) */
|
||||||
|
--warning-subtle: oklch(0.986 0.03 92.708); /* ≈ #fffbeb (amber-50) */
|
||||||
|
--danger: oklch(0.577 0.245 27.325); /* ≈ #dc2626 (red-600) */
|
||||||
|
--danger-subtle: oklch(0.96 0.037 26.197); /* ≈ #fef2f2 (red-50) */
|
||||||
|
--info: oklch(0.546 0.245 262.881); /* ≈ #2563eb (blue-600) */
|
||||||
|
--info-subtle: oklch(0.967 0.03 260.592); /* ≈ #eff6ff (blue-50) */
|
||||||
|
|
||||||
|
/* 字体 */
|
||||||
|
--font-display: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", Roboto, sans-serif;
|
||||||
|
--font-body: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, sans-serif;
|
||||||
|
--font-sans: var(--font-body);
|
||||||
|
--font-mono: ui-monospace, "JetBrains Mono", "IBM Plex Mono", SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||||
|
|
||||||
|
/* 字体大小 */
|
||||||
|
--text-xs: 0.75rem; /* 12px */
|
||||||
|
--text-sm: 0.875rem; /* 14px */
|
||||||
|
--text-base: 1rem; /* 16px */
|
||||||
|
--text-lg: 1.125rem; /* 18px */
|
||||||
|
--text-xl: 1.25rem; /* 20px */
|
||||||
|
--text-2xl: 1.5rem; /* 24px */
|
||||||
|
--text-3xl: 1.875rem; /* 30px */
|
||||||
|
|
||||||
|
/* 行高 */
|
||||||
|
--leading-tight: 1.25;
|
||||||
|
--leading-snug: 1.375;
|
||||||
|
--leading-normal: 1.5;
|
||||||
|
--leading-relaxed: 1.625;
|
||||||
|
|
||||||
|
/* 字间距 */
|
||||||
|
--tracking-tighter: -0.05em;
|
||||||
|
--tracking-tight: -0.02em;
|
||||||
|
--tracking-normal: 0;
|
||||||
|
--tracking-wide: 0.02em;
|
||||||
|
--tracking-wider: 0.05em;
|
||||||
|
--tracking-widest: 0.1em;
|
||||||
|
|
||||||
|
/* 间距 */
|
||||||
|
--space-1: 0.25rem; /* 4px */
|
||||||
|
--space-2: 0.5rem; /* 8px */
|
||||||
|
--space-3: 0.75rem; /* 12px */
|
||||||
|
--space-4: 1rem; /* 16px */
|
||||||
|
--space-5: 1.25rem; /* 20px */
|
||||||
|
--space-6: 1.5rem; /* 24px */
|
||||||
|
--space-8: 2rem; /* 32px */
|
||||||
|
--space-10: 2.5rem; /* 40px */
|
||||||
|
--space-12: 3rem; /* 48px */
|
||||||
|
--space-16: 4rem; /* 64px */
|
||||||
|
|
||||||
|
/* 圆角 */
|
||||||
|
--radius-sm: 6px;
|
||||||
|
--radius-md: 8px;
|
||||||
|
--radius-lg: 12px;
|
||||||
|
--radius-xl: 16px;
|
||||||
|
--radius-2xl: 24px;
|
||||||
|
--radius-full: 9999px;
|
||||||
|
|
||||||
|
/* 阴影 */
|
||||||
|
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||||
|
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||||
|
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.08), 0 2px 4px -2px rgba(0, 0, 0, 0.04);
|
||||||
|
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.04);
|
||||||
|
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.04);
|
||||||
|
|
||||||
|
/* 过渡 */
|
||||||
|
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--transition-normal: 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--transition-spring: 500ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
|
||||||
|
/* 层级系统 */
|
||||||
|
--z-base: 0;
|
||||||
|
--z-raised: 10;
|
||||||
|
--z-dropdown: 100;
|
||||||
|
--z-sticky: 200;
|
||||||
|
--z-overlay: 300;
|
||||||
|
--z-modal: 400;
|
||||||
|
--z-toast: 700;
|
||||||
|
|
||||||
|
/* 布局 */
|
||||||
|
--sidebar-width: 240px;
|
||||||
|
--sidebar-width-collapsed: 64px;
|
||||||
|
--header-height: 56px;
|
||||||
|
--content-max-width: 1440px;
|
||||||
|
--content-padding: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 深色模式 ==================== */
|
||||||
|
:root.dark {
|
||||||
|
/* 背景色 */
|
||||||
|
--bg: oklch(0.129 0.042 264.695); /* ≈ #020617 (slate-950) */
|
||||||
|
--bg-subtle: oklch(0.208 0.042 264.695); /* ≈ #0f172a (slate-900) */
|
||||||
|
--surface: oklch(0.208 0.042 264.695); /* ≈ #0f172a (slate-900) */
|
||||||
|
--surface-raised: oklch(0.279 0.041 260.031);/* ≈ #1e293b (slate-800) */
|
||||||
|
--surface-overlay: rgba(15, 23, 42, 0.95);
|
||||||
|
|
||||||
|
/* 文本色 */
|
||||||
|
--fg: oklch(0.984 0.003 247.858); /* ≈ #f8fafc (slate-50) */
|
||||||
|
--fg-subtle: oklch(0.837 0.014 253.365); /* ≈ #cbd5e1 (slate-300) */
|
||||||
|
--muted: oklch(0.662 0.051 257.281); /* ≈ #94a3b8 (slate-400) */
|
||||||
|
--muted-subtle: oklch(0.527 0.045 257.281); /* ≈ #64748b (slate-500) */
|
||||||
|
|
||||||
|
/* 边框 */
|
||||||
|
--border: oklch(0.279 0.041 260.031); /* ≈ #1e293b (slate-800) */
|
||||||
|
--border-subtle: oklch(0.208 0.042 264.695);/* ≈ #0f172a (slate-900) */
|
||||||
|
|
||||||
|
/* 主色调 */
|
||||||
|
--accent: oklch(0.623 0.214 259.815); /* ≈ #3b82f6 (blue-500) */
|
||||||
|
--accent-hover: oklch(0.723 0.157 260.543); /* ≈ #60a5fa (blue-400) */
|
||||||
|
--accent-active: oklch(0.829 0.096 260.543);/* ≈ #93c5fd (blue-300) */
|
||||||
|
--accent-subtle: rgba(59, 130, 246, 0.1);
|
||||||
|
--accent-text: oklch(0.208 0.042 264.695); /* ≈ #0f172a (slate-900) */
|
||||||
|
|
||||||
|
/* 状态色 */
|
||||||
|
--success: oklch(0.696 0.17 162.48); /* ≈ #34d399 (emerald-400) */
|
||||||
|
--success-subtle: rgba(52, 211, 153, 0.1);
|
||||||
|
--warning: oklch(0.828 0.12 76.523); /* ≈ #fbbf24 (amber-400) */
|
||||||
|
--warning-subtle: rgba(251, 191, 36, 0.1);
|
||||||
|
--danger: oklch(0.715 0.143 25.059); /* ≈ #f87171 (red-400) */
|
||||||
|
--danger-subtle: rgba(248, 113, 113, 0.1);
|
||||||
|
--info: oklch(0.723 0.157 260.543); /* ≈ #60a5fa (blue-400) */
|
||||||
|
--info-subtle: rgba(96, 165, 250, 0.1);
|
||||||
|
|
||||||
|
/* 阴影(深色下更突出) */
|
||||||
|
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||||
|
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||||
|
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -2px rgba(0, 0, 0, 0.2);
|
||||||
|
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -4px rgba(0, 0, 0, 0.2);
|
||||||
|
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.3), 0 8px 10px -6px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== Tailwind 暗色模式映射 ==================== */
|
||||||
|
/*
|
||||||
|
* 以下 Tailwind 类名可直接使用:
|
||||||
|
* 页面背景: bg-slate-50 dark:bg-slate-950
|
||||||
|
* 卡片背景: bg-white dark:bg-slate-900
|
||||||
|
* 边框: border-slate-200 dark:border-slate-800
|
||||||
|
* 正文: text-slate-900 dark:text-slate-50
|
||||||
|
* 次要文本: text-slate-700 dark:text-slate-300
|
||||||
|
* 弱文本: text-slate-500 dark:text-slate-400
|
||||||
|
* 主按钮: bg-blue-600 hover:bg-blue-700 text-white
|
||||||
|
* 成功: text-emerald-600 dark:text-emerald-400
|
||||||
|
* 警告: text-amber-600 dark:text-amber-400
|
||||||
|
* 危险: text-red-600 dark:text-red-400
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ==================== 全局重置 ==================== */
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-size: 16px;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
color: var(--fg);
|
||||||
|
background-color: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 组件规范 ==================== */
|
||||||
|
|
||||||
|
/* 按钮规范
|
||||||
|
* 主按钮: bg-blue-600 hover:bg-blue-700 text-white rounded-lg shadow-sm
|
||||||
|
* 次按钮: bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 rounded-lg
|
||||||
|
* 幽灵按钮: hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg
|
||||||
|
* 尺寸: sm(px-3 py-1.5 text-xs) / md(px-4 py-2 text-sm) / lg(px-6 py-2.5 text-base)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* 输入框规范
|
||||||
|
* 边框: border border-slate-300 dark:border-slate-600
|
||||||
|
* 聚焦: focus:ring-2 focus:ring-blue-500 focus:border-transparent
|
||||||
|
* 圆角: rounded-lg
|
||||||
|
* 尺寸: h-10(px-3 text-sm)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* 卡片规范
|
||||||
|
* 基础: bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl p-6 shadow-sm
|
||||||
|
* 悬停: hover:border-blue-200 dark:hover:border-blue-800
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* 表格规范
|
||||||
|
* 头部: bg-slate-50 dark:bg-slate-800 text-slate-500 text-xs font-medium uppercase
|
||||||
|
* 行: border-b border-slate-200 dark:border-slate-700
|
||||||
|
* 悬停: hover:bg-slate-50 dark:hover:bg-slate-800/50
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* 徽标规范
|
||||||
|
* 默认: bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300
|
||||||
|
* 成功: bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400
|
||||||
|
* 警告: bg-amber-100 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400
|
||||||
|
* 危险: bg-red-100 text-red-700 dark:bg-red-500/10 dark:text-red-400
|
||||||
|
* 信息: bg-blue-100 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400
|
||||||
|
* 圆角: rounded-full
|
||||||
|
* 尺寸: px-2.5 py-0.5 text-xs font-medium
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* 弹窗规范
|
||||||
|
* 背景: bg-white dark:bg-slate-900
|
||||||
|
* 边框: border border-slate-200 dark:border-slate-800
|
||||||
|
* 圆角: rounded-xl
|
||||||
|
* 阴影: shadow-xl
|
||||||
|
* 标题: text-lg font-semibold
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* 侧边栏规范
|
||||||
|
* 宽度: w-60 (240px)
|
||||||
|
* 定位: fixed left-0 top-0 bottom-0
|
||||||
|
* 背景: bg-white dark:bg-slate-900
|
||||||
|
* 边框: border-r border-slate-200 dark:border-slate-800
|
||||||
|
* 品牌区: h-14 flex items-center
|
||||||
|
* 导航项: px-3 py-2.5 rounded-lg text-sm font-medium
|
||||||
|
* 激活态: bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* 顶栏规范
|
||||||
|
* 高度: h-14 (56px)
|
||||||
|
* 定位: fixed top-0
|
||||||
|
* 背景: bg-white dark:bg-slate-900
|
||||||
|
* 边框: border-b border-slate-200 dark:border-slate-800
|
||||||
|
* z-index: z-30
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* 主内容区规范
|
||||||
|
* 边距: ml-60 pt-14 p-6 (侧边栏+顶栏)
|
||||||
|
* 门户布局: max-w-5xl mx-auto px-6 py-8
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ==================== 统计卡片 ==================== */
|
||||||
|
/*
|
||||||
|
* 用于仪表盘/概览页的统计数据展示
|
||||||
|
* 容器: bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl p-6
|
||||||
|
* 悬停: hover:border-blue-200 dark:hover:border-blue-800 hover:shadow-md transition-all
|
||||||
|
* 图标区: w-10 h-10 rounded-lg bg-blue-50 dark:bg-blue-500/10 flex items-center justify-center
|
||||||
|
* 数值: text-2xl font-semibold text-slate-900 dark:text-white font-display
|
||||||
|
* 标签: text-sm text-slate-500 dark:text-slate-400
|
||||||
|
* 变化趋势: text-xs font-medium (positive=text-emerald-600, negative=text-red-600)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ==================== 骨架屏加载 ==================== */
|
||||||
|
/*
|
||||||
|
* 用于数据加载时的占位效果
|
||||||
|
* <div className="animate-pulse bg-slate-200 dark:bg-slate-700 rounded-lg [height]" />
|
||||||
|
* 文本行: h-4 w-full / w-3/4
|
||||||
|
* 标题: h-6 w-1/3
|
||||||
|
* 卡片: h-32 w-full rounded-xl
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* 无缝统计条 ==================== */
|
||||||
|
/*
|
||||||
|
* 仪表盘顶部关键指标展示,分段之间仅 1px 间隙
|
||||||
|
* 容器: flex gap-px bg-slate-200 dark:bg-slate-800 rounded-xl overflow-hidden
|
||||||
|
* 段: flex-1 bg-white dark:bg-slate-900 px-6 py-4 flex flex-col gap-1
|
||||||
|
* 标签: text-xs font-medium text-slate-500 uppercase tracking-widest
|
||||||
|
* 数值: text-2xl font-semibold font-[family-name:var(--font-display)]
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* 服务状态卡片 ==================== */
|
||||||
|
/*
|
||||||
|
* 仪表盘中展示被监控服务实时状态
|
||||||
|
* 正常: bg-white dark:bg-slate-900 border rounded-xl p-5
|
||||||
|
* 悬停: hover:shadow-sm hover:-translate-y-px transition-all cursor-pointer
|
||||||
|
* 异常: bg-red-50 dark:bg-red-500/10 border-red-600 dark:border-red-400
|
||||||
|
* 红底红框,突出显示
|
||||||
|
* 状态圆点: w-2.5 h-2.5 rounded-full
|
||||||
|
* ok: bg-emerald-600 dark:bg-emerald-400 shadow-[0_0_8px_var(--success)]
|
||||||
|
* err: bg-red-600 dark:bg-red-400 shadow-[0_0_12px_var(--danger)] animate-pulse
|
||||||
|
* 持续时长: text-lg font-semibold font-mono text-red-600 dark:text-red-400
|
||||||
|
* 展开区: border-t mt-3 pt-3
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ==================== 图表容器 ==================== */
|
||||||
|
/*
|
||||||
|
* 用于包裹图表组件
|
||||||
|
* <div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl p-6">
|
||||||
|
* <div className="flex items-center justify-between mb-6">
|
||||||
|
* <h3 className="text-lg font-semibold text-slate-900 dark:text-white">标题</h3>
|
||||||
|
* <div className="flex items-center gap-4 text-sm text-slate-500">图例</div>
|
||||||
|
* </div>
|
||||||
|
* [图表内容]
|
||||||
|
* </div>
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ==================== 下拉菜单 ==================== */
|
||||||
|
/*
|
||||||
|
* 用于用户菜单、操作菜单
|
||||||
|
* 容器: absolute right-0 top-full mt-1
|
||||||
|
* bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700
|
||||||
|
* rounded-lg shadow-lg py-1 min-w-[200px] z-[--z-dropdown]
|
||||||
|
* 菜单项: px-4 py-2.5 text-sm flex items-center gap-3
|
||||||
|
* hover:bg-slate-50 dark:hover:bg-slate-700
|
||||||
|
* text-slate-700 dark:text-slate-300
|
||||||
|
* 危险项: text-red-600 dark:text-red-400
|
||||||
|
* 分隔线: border-t border-slate-200 dark:border-slate-700 my-1
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ==================== 站点标识色 ==================== */
|
||||||
|
/*
|
||||||
|
* OA: blue-600
|
||||||
|
* assets: blue-600
|
||||||
|
* issue: blue-600
|
||||||
|
* monitor: indigo-600
|
||||||
|
*
|
||||||
|
* 站点卡片标识色(门户首页):
|
||||||
|
* 资产管理: blue-600 tag: CMDB
|
||||||
|
* 工单跟踪: violet-600 tag: ITS
|
||||||
|
* 监控中心: indigo-600 tag: MONITOR
|
||||||
|
* 官网: emerald-600 tag: WWW
|
||||||
|
* 云平台: amber-600 tag: CLOUD
|
||||||
|
* Token: rose-600 tag: TOKEN
|
||||||
|
* 代码仓库: pink-600 tag: GIT
|
||||||
|
*
|
||||||
|
* 2026-07-15:从 HEX 迁移至 OKLCh 色彩空间
|
||||||
|
* 主色 blue-600 OKLCh: oklch(0.546 0.245 262.881)
|
||||||
|
* 主色 indigo-600 OKLCh: oklch(0.511 0.262 276.966)
|
||||||
|
* 对比度验证:白底蓝字 (oklch(0.546 0.245 262.881) on oklch(1 0 0)) → 约 6.5:1(≥ 4.5:1 通过)
|
||||||
|
*/
|
||||||
|
|
@ -1,68 +1,77 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
// src/components/layout/Sidebar.tsx — 权限驱动侧边栏(统一标准布局)
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { usePathname } from 'next/navigation'
|
import { usePathname } from 'next/navigation'
|
||||||
import { LayoutDashboard, Server, Search, Settings, Users, Shield, Key, FileText } from 'lucide-react'
|
import { LayoutDashboard, Server, Search, Settings, Users, Shield, Key, FileText } from 'lucide-react'
|
||||||
|
|
||||||
const navItems = [
|
interface NavItem { label: string; href: string; icon: React.ComponentType<{ size?: number }>; perm: string | null }
|
||||||
{ href: '/dashboard', label: '仪表盘', icon: LayoutDashboard, perm: null },
|
interface NavSection { title?: string; items: NavItem[] }
|
||||||
{ href: '/assets', label: '设备管理', icon: Server, perm: 'assets:read' },
|
|
||||||
{ href: '/assets/advanced-search', label: '高级查询', icon: Search, perm: 'assets:read' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const settingsItems = [
|
export default function Sidebar({ role }: { role?: string }) {
|
||||||
{ href: '/settings/users', label: '用户管理', icon: Users, perm: 'users:read' },
|
|
||||||
{ href: '/settings/roles', label: '角色权限', icon: Shield, perm: 'roles:read' },
|
|
||||||
{ href: '/settings/api-keys', label: 'API Key', icon: Key, perm: 'api-keys:read' },
|
|
||||||
{ href: '/settings/audit-logs', label: '审计日志', icon: FileText, perm: 'audit-logs:read' },
|
|
||||||
]
|
|
||||||
|
|
||||||
function hasAnyAdminPerm(permissions: string[]): boolean {
|
|
||||||
return permissions.includes('*') || permissions.some(p =>
|
|
||||||
['users:', 'roles:', 'api-keys:', 'audit-logs:'].some(prefix => p.startsWith(prefix))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Sidebar() {
|
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const [permissions, setPermissions] = useState<string[]>([])
|
const isActive = (href: string) => pathname === href || (href !== '/' && pathname.startsWith(href))
|
||||||
|
const isAdmin = role === 'admin' || role === 'localadmin'
|
||||||
|
|
||||||
useEffect(() => {
|
const sections: NavSection[] = [
|
||||||
fetch('/api/auth/me')
|
{
|
||||||
.then(r => r.json())
|
items: [
|
||||||
.then(u => { if (u.user?.permissions) setPermissions(u.user.permissions) })
|
{ label: '仪表盘', href: '/dashboard', icon: LayoutDashboard, perm: null },
|
||||||
.catch(() => {})
|
],
|
||||||
}, [])
|
},
|
||||||
|
{
|
||||||
const canSee = (perm: string | null) => {
|
title: '资产管理',
|
||||||
if (perm === null) return true
|
items: [
|
||||||
if (permissions.includes('*')) return true
|
{ label: '设备管理', href: '/assets', icon: Server, perm: null },
|
||||||
return permissions.includes(perm)
|
{ label: '高级查询', href: '/assets/advanced-search', icon: Search, perm: null },
|
||||||
}
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '系统设置',
|
||||||
|
items: [
|
||||||
|
{ label: '用户管理', href: '/settings/users', icon: Users, perm: isAdmin ? null : 'hidden' },
|
||||||
|
{ label: '角色权限', href: '/settings/roles', icon: Shield, perm: isAdmin ? null : 'hidden' },
|
||||||
|
{ label: 'API Key', href: '/settings/api-keys', icon: Key, perm: isAdmin ? null : 'hidden' },
|
||||||
|
{ label: '审计日志', href: '/settings/audit-logs', icon: FileText, perm: isAdmin ? null : 'hidden' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="fixed left-0 top-0 bottom-0 w-60 bg-white dark:bg-slate-900 border-r border-slate-200 dark:border-slate-800 flex flex-col z-40">
|
<aside className="fixed left-0 top-0 bottom-0 w-60 bg-white dark:bg-slate-900 border-r border-slate-200 dark:border-slate-800 z-[200] flex flex-col">
|
||||||
<div className="h-14 flex items-center px-5 border-b border-slate-200 dark:border-slate-800">
|
{/* 品牌区 */}
|
||||||
<span className="text-lg font-semibold text-blue-600 dark:text-blue-400">资产管理系统</span>
|
<div className="h-14 flex items-center px-6 border-b border-slate-200 dark:border-slate-800 shrink-0">
|
||||||
</div>
|
<Link href="/dashboard" className="flex items-center gap-3">
|
||||||
<nav className="flex-1 py-3 px-3 space-y-1 overflow-y-auto">
|
<div className="w-8 h-8 rounded-lg bg-blue-600 flex items-center justify-center text-white font-bold text-sm">资</div>
|
||||||
{navItems.filter(item => canSee(item.perm)).map((item) => {
|
<span className="font-semibold text-slate-900 dark:text-white">资产管理系统</span>
|
||||||
const isActive = pathname === item.href || pathname.startsWith(item.href + '/')
|
</Link>
|
||||||
const Icon = item.icon
|
|
||||||
return (<Link key={item.href} href={item.href} className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${isActive ? 'bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400' : 'text-slate-600 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800'}`}><Icon size={18} />{item.label}</Link>)
|
|
||||||
})}
|
|
||||||
{hasAnyAdminPerm(permissions) && (
|
|
||||||
<div className="pt-3 border-t border-slate-200 dark:border-slate-800 mt-3">
|
|
||||||
<div className="flex items-center gap-3 px-3 py-2 text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider">
|
|
||||||
<Settings size={14} />系统设置
|
|
||||||
</div>
|
|
||||||
{settingsItems.filter(item => canSee(item.perm)).map((item) => {
|
|
||||||
const isActive = pathname === item.href
|
|
||||||
const Icon = item.icon
|
|
||||||
return (<Link key={item.href} href={item.href} className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${isActive ? 'bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400' : 'text-slate-600 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800'}`}><Icon size={18} />{item.label}</Link>)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 导航区 */}
|
||||||
|
<nav className="flex-1 overflow-y-auto p-3 space-y-6">
|
||||||
|
{sections.map((section, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
{section.title && (
|
||||||
|
<p className="px-3 mb-1 text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-widest">
|
||||||
|
{section.title}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{section.items.filter(item => item.perm !== 'hidden').map(item => {
|
||||||
|
const Icon = item.icon
|
||||||
|
return (
|
||||||
|
<Link key={item.href} href={item.href}
|
||||||
|
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors mb-0.5 ${
|
||||||
|
isActive(item.href)
|
||||||
|
? 'bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400'
|
||||||
|
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800'
|
||||||
|
}`}>
|
||||||
|
<Icon size={18} />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue