style(frontend): redesign with Miuix HyperOS design language

- Color system: Miuix dark tokens (primary #277AF7, bg #242424, surface #000)
- Components: rounded-full buttons, squircle cards (18px), glass header
- Messages: blue user bubbles, dark AI bubbles with typing animation
- Admin dashboard: stat cards with real-time service status
- Login: centered logo with gradient shadow, HyperOS input fields
- Smooth transitions and scale-on-press interactions
This commit is contained in:
Aska committed 2026-08-12 16:41:34 +08:00
1 parent 17ef73bcbe
commit bea29d3b73
9 files changed
+312 -99

No files matched your search

@@ -23,8 +23,11 @@ export default function ChatContainer() {
addMessage(userMsg)
setStreaming(true)
// Add placeholder for assistant response
addMessage({ id: crypto.randomUUID(), session_id: currentSessionId, role: 'assistant', content: '', created_at: new Date().toISOString() })
// Placeholder for assistant
addMessage({
id: crypto.randomUUID(), session_id: currentSessionId,
role: 'assistant' as const, content: '', created_at: new Date().toISOString(),
})
send({ type: 'message', content: inputText, session_id: currentSessionId, timestamp: Date.now() })
setInput('')
@@ -32,16 +35,29 @@ export default function ChatContainer() {
return (
<div className="flex flex-col h-full">
<div className="flex-1 overflow-auto px-4 py-4">
<MessageList messages={messages} />
<div ref={bottomRef} />
{/* Chat area */}
<div className="flex-1 overflow-auto">
<div className="max-w-2xl mx-auto px-4 py-6">
{messages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center space-y-2 pt-32">
<div className="w-16 h-16 rounded-[20px] bg-[#277AF7] flex items-center justify-center shadow-lg shadow-[#277AF7]/20 mb-2">
<span className="text-2xl font-bold text-white">C</span>
</div>
<p className="text-white/50 text-sm">开始新对话吧~</p>
</div>
) : (
<MessageList messages={messages} />
)}
<div ref={bottomRef} />
</div>
</div>
{/* Input bar */}
<div className="shrink-0 border-t border-[#393939] bg-[#000]">
<div className="max-w-2xl mx-auto px-4 py-3">
<ChatInput value={inputText} onChange={setInput} onSend={handleSend} disabled={isStreaming} />
</div>
</div>
<ChatInput
value={inputText}
onChange={setInput}
onSend={handleSend}
disabled={isStreaming}
/>
</div>
)
}
+20 -20
View File
@@ -9,30 +9,30 @@ export default function ChatInput({ value, onChange, onSend, disabled }: ChatInp
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
onSend()
if (!disabled && value.trim()) onSend()
}
}
return (
<div className="border-t border-gray-800 p-3 shrink-0">
<div className="max-w-3xl mx-auto flex gap-2">
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
disabled={disabled}
placeholder={disabled ? '思考中...' : '输入消息...'}
className="flex-1 bg-gray-800 rounded-lg px-4 py-2.5 text-sm outline-none focus:ring-2 focus:ring-purple-500 disabled:opacity-50"
/>
<button
onClick={onSend}
disabled={disabled || !value.trim()}
className="bg-purple-600 hover:bg-purple-500 disabled:bg-gray-700 rounded-lg px-5 py-2.5 text-sm font-medium transition-colors disabled:cursor-not-allowed"
>
发送
</button>
</div>
<div className="flex items-end gap-2 bg-[#000] rounded-[20px] border border-[#404040] focus-within:border-[#277AF7] transition-colors p-1.5">
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
disabled={disabled}
placeholder="输入消息..."
className="flex-1 bg-transparent px-3 py-1.5 text-sm text-[#F2F2F2] placeholder:text-white/30 outline-none disabled:opacity-40"
/>
<button
onClick={onSend}
disabled={disabled || !value.trim()}
className="w-9 h-9 bg-[#277AF7] hover:bg-[#3482FF] active:scale-90 disabled:bg-[#253E64] text-white rounded-full flex items-center justify-center shrink-0 transition-all duration-200"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M2 8L14 2L8 14L6.5 9.5L2 8Z" fill="currentColor" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
</div>
)
}
@@ -1,16 +1,8 @@
import type { Message } from '../../types/chat'
export default function MessageList({ messages }: { messages: Message[] }) {
if (messages.length === 0) {
return (
<div className="flex items-center justify-center h-full text-gray-500 text-sm">
开始新对话吧~
</div>
)
}
return (
<div className="space-y-4 max-w-3xl mx-auto">
<div className="space-y-3">
{messages.map((msg) => (
<MessageBubble key={msg.id} message={msg} />
))}
@@ -21,22 +13,56 @@ export default function MessageList({ messages }: { messages: Message[] }) {
function MessageBubble({ message }: { message: Message }) {
const isUser = message.role === 'user'
const isAction = message.role === 'action'
const isSystem = message.role === 'system'
const isEmpty = message.role === 'assistant' && !message.content
if (isAction) {
return <div className="text-center text-gray-500 text-sm italic py-1">{message.content}</div>
return (
<div className="flex justify-center py-1">
<span className="text-xs text-[#4788FF] bg-[#2B3B54]/80 px-3 py-1 rounded-full">
{message.content}
</span>
</div>
)
}
if (isSystem) {
return (
<div className="flex justify-center py-1">
<span className="text-xs text-[#ffdad6] bg-[#2E0603]/80 px-3 py-1 rounded-full">{message.content}</span>
</div>
)
}
return (
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[70%] rounded-2xl px-4 py-2.5 text-sm leading-relaxed ${
{/* Avatar for AI */}
{!isUser && (
<div className="w-8 h-8 rounded-xl bg-[#277AF7] flex items-center justify-center shrink-0 mr-2 mt-1">
<span className="text-xs font-bold text-white">C</span>
</div>
)}
<div className={`max-w-[75%] px-4 py-2.5 text-sm leading-relaxed ${
isUser
? 'bg-purple-600 text-white rounded-br-md'
: message.role === 'system'
? 'bg-red-900/50 text-red-300 mx-auto text-xs'
: 'bg-gray-800 text-gray-100 rounded-bl-md'
? 'bg-[#277AF7] text-white rounded-[18px] rounded-br-[6px]'
: isEmpty
? 'bg-transparent text-transparent'
: 'bg-[#000] text-[#F2F2F2] rounded-[18px] rounded-bl-[6px] border border-[#404040]'
}`}>
{message.content || (message.role === 'assistant' ? <span className="animate-pulse">...</span> : null)}
{isEmpty ? (
<span className="flex gap-1.5 py-1">
<span className="w-2 h-2 rounded-full bg-[#404040] animate-bounce" style={{animationDelay:'0ms'}} />
<span className="w-2 h-2 rounded-full bg-[#404040] animate-bounce" style={{animationDelay:'150ms'}} />
<span className="w-2 h-2 rounded-full bg-[#404040] animate-bounce" style={{animationDelay:'300ms'}} />
</span>
) : (
message.content
)}
</div>
{/* Spacer for user messages */}
{isUser && <div className="w-8 shrink-0 ml-2" />}
</div>
)
}
@@ -1,18 +1,47 @@
import { ReactNode } from 'react'
import { Link, useLocation } from 'react-router-dom'
import { useAuthStore } from '../../store/authStore'
import Header from './Header'
import Sidebar from './Sidebar'
export default function AppLayout({ children }: { children: ReactNode }) {
const { username, logout } = useAuthStore()
const location = useLocation()
return (
<div className="h-screen flex flex-col bg-gray-950 text-gray-100">
<Header username={username} onLogout={logout} />
<div className="flex-1 flex overflow-hidden">
<Sidebar />
<main className="flex-1 overflow-auto">{children}</main>
</div>
<div className="h-screen flex flex-col bg-[#242424] text-[#F2F2F2]">
{/* Header */}
<header className="h-14 shrink-0 flex items-center justify-between px-4 border-b border-[#393939] bg-[#000]">
<Link to="/chat" className="flex items-center gap-2">
<div className="w-7 h-7 rounded-lg bg-[#277AF7] flex items-center justify-center">
<span className="text-sm font-bold text-white">C</span>
</div>
<span className="font-semibold text-base">Cyrene</span>
</Link>
<nav className="flex items-center gap-1">
<Link to="/chat"
className={`px-3 py-1.5 rounded-full text-sm transition-colors ${
location.pathname.startsWith('/chat')
? 'bg-[#277AF7]/15 text-[#4788FF]'
: 'text-white/50 hover:text-white/80'
}`}
>聊天</Link>
<Link to="/admin/dashboard"
className={`px-3 py-1.5 rounded-full text-sm transition-colors ${
location.pathname.startsWith('/admin')
? 'bg-[#277AF7]/15 text-[#4788FF]'
: 'text-white/50 hover:text-white/80'
}`}
>管理</Link>
<div className="w-px h-4 bg-[#393939] mx-1" />
<span className="text-xs text-white/40">{username}</span>
<button onClick={logout}
className="px-3 py-1.5 rounded-full text-sm text-white/40 hover:text-[#F12522] transition-colors"
>退出</button>
</nav>
</header>
{/* Content */}
<main className="flex-1 overflow-hidden">{children}</main>
</div>
)
}
@@ -1,14 +0,0 @@
import { Link, useLocation } from 'react-router-dom'
export default function Header({ username, onLogout }: { username: string; onLogout: () => void }) {
return (
<header className="h-12 bg-gray-900 border-b border-gray-800 flex items-center px-4 justify-between shrink-0">
<Link to="/chat" className="font-bold text-purple-400 text-lg">Cyrene</Link>
<div className="flex items-center gap-3 text-sm text-gray-400">
<Link to="/admin/dashboard" className="hover:text-gray-200">管理</Link>
<span>{username}</span>
<button onClick={onLogout} className="hover:text-red-400">退出</button>
</div>
</header>
)
}
@@ -16,18 +16,22 @@ export default function Sidebar() {
}
return (
<aside className="w-56 bg-gray-900 border-r border-gray-800 flex flex-col shrink-0">
<aside className="w-60 bg-[#000] border-r border-[#393939] shrink-0 flex flex-col">
<div className="p-3">
<button onClick={handleNewChat}
className="w-full py-2 bg-purple-600 hover:bg-purple-500 rounded text-sm font-medium transition-colors">
className="w-full py-2.5 bg-[#277AF7] hover:bg-[#3482FF] active:scale-[0.98] text-white font-medium text-sm rounded-full transition-all duration-200"
>
+ 新对话
</button>
</div>
<div className="flex-1 overflow-auto px-2 text-sm text-gray-400">
<div className="flex-1 overflow-auto px-2 pb-2">
{currentSessionId ? (
<p className="px-2 py-1 truncate text-xs">会话: {currentSessionId.slice(0, 8)}...</p>
<div className="px-3 py-2 bg-[#242424] rounded-[14px]">
<p className="text-xs text-white/40 truncate">{currentSessionId.slice(0, 12)}...</p>
</div>
) : (
<p className="px-2 py-1 text-xs">暂无对话</p>
<p className="px-3 py-2 text-xs text-white/30 text-center">暂无对话</p>
)}
</div>
</aside>
+94 -3
View File
@@ -1,9 +1,100 @@
@import "tailwindcss";
@theme {
/* Miuix Dark Color Scheme — Xiaomi HyperOS */
--color-mx-primary: #277AF7;
--color-mx-primary-hover: #3482FF;
--color-mx-primary-container: #338FE4;
--color-mx-on-primary: #FFFFFF;
--color-mx-bg: #242424;
--color-mx-bg-elevated: #000000;
--color-mx-bg-high: #2D2D2D;
--color-mx-surface: #000000;
--color-mx-surface-variant: #242424;
--color-mx-surface-high: #2D2D2D;
--color-mx-text: #F2F2F2;
--color-mx-text-secondary: rgba(255,255,255,0.8);
--color-mx-text-dim: rgba(255,255,255,0.5);
--color-mx-text-disabled: #666666;
--color-mx-outline: #404040;
--color-mx-divider: #393939;
--color-mx-error: #F12522;
--color-mx-error-container: #2E0603;
--color-mx-tertiary: #2B3B54;
--color-mx-tertiary-text: #4788FF;
--radius-mx-card: 18px;
--radius-mx-button: 9999px;
--radius-mx-input: 14px;
--radius-mx-bubble: 18px;
--font-mx: -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", sans-serif;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--color-mx-bg);
color: var(--color-mx-text);
font-family: var(--font-mx);
-webkit-font-smoothing: antialiased;
}
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-thumb { background: #4b5563; border-radius: 3px; }
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--color-mx-outline); border-radius: 2px; }
/* Smooth focus ring */
*:focus-visible {
outline: 2px solid var(--color-mx-primary);
outline-offset: 2px;
border-radius: var(--radius-mx-button);
}
/* Glass effect for header */
.mx-glass {
background: rgba(36, 36, 36, 0.85);
backdrop-filter: blur(20px) saturate(1.5);
-webkit-backdrop-filter: blur(20px) saturate(1.5);
}
/* Smooth transitions */
.mx-transition {
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Card surface */
.mx-card {
background: var(--color-mx-surface);
border-radius: var(--radius-mx-card);
border: 0.5px solid var(--color-mx-outline);
}
/* Button base */
.mx-btn {
background: var(--color-mx-primary);
color: var(--color-mx-on-primary);
border-radius: var(--radius-mx-button);
font-weight: 500;
transition: all 0.2s ease;
}
.mx-btn:hover { background: var(--color-mx-primary-hover); }
.mx-btn:active { transform: scale(0.97); }
.mx-btn:disabled { background: #253E64; color: #677993; }
/* Input base */
.mx-input {
background: var(--color-mx-surface);
border: 0.5px solid var(--color-mx-outline);
border-radius: var(--radius-mx-input);
color: var(--color-mx-text);
outline: none;
transition: border-color 0.2s;
}
.mx-input:focus { border-color: var(--color-mx-primary); }
.mx-input::placeholder { color: var(--color-mx-text-dim); }
+44 -11
View File
@@ -6,31 +6,64 @@ export default function LoginPage() {
const [username, setUsername] = useState('admin')
const [password, setPassword] = useState('admin')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const navigate = useNavigate()
const login = useAuthStore((s) => s.login)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
await login(username, password)
navigate('/chat')
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed')
setError(err instanceof Error ? err.message : '登录失败')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-950">
<form onSubmit={handleSubmit} className="bg-gray-900 p-8 rounded-xl w-80 space-y-4 border border-gray-800">
<h1 className="text-2xl font-bold text-center text-purple-400">Cyrene</h1>
{error && <p className="text-red-400 text-sm text-center">{error}</p>}
<input type="text" value={username} onChange={e => setUsername(e.target.value)}
placeholder="用户名" className="w-full bg-gray-800 rounded px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-purple-500" />
<input type="password" value={password} onChange={e => setPassword(e.target.value)}
placeholder="密码" className="w-full bg-gray-800 rounded px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-purple-500" />
<button type="submit" className="w-full bg-purple-600 hover:bg-purple-500 py-2 rounded text-sm font-medium transition-colors">
登录
<div className="min-h-screen flex items-center justify-center bg-[#242424] p-4">
<form onSubmit={handleSubmit} className="w-full max-w-sm space-y-5">
{/* Logo area */}
<div className="text-center space-y-2 mb-8">
<div className="mx-auto w-20 h-20 rounded-[22px] bg-[#277AF7] flex items-center justify-center shadow-lg shadow-[#277AF7]/20">
<span className="text-3xl font-bold text-white">C</span>
</div>
<h1 className="text-2xl font-semibold text-[#F2F2F2]">Cyrene</h1>
<p className="text-sm text-white/50">LLM 智能体平台</p>
</div>
{/* Error */}
{error && (
<div className="bg-[#2E0603] border border-[#F12522]/30 rounded-[14px] px-4 py-3 text-sm text-[#ffdad6] text-center">
{error}
</div>
)}
{/* Inputs */}
<div className="space-y-3">
<input
type="text" value={username}
onChange={e => setUsername(e.target.value)}
placeholder="用户名"
className="w-full bg-[#000] border border-[#404040] focus:border-[#277AF7] rounded-[14px] px-4 py-3 text-[#F2F2F2] placeholder:text-white/50 outline-none transition-colors"
/>
<input
type="password" value={password}
onChange={e => setPassword(e.target.value)}
placeholder="密码"
className="w-full bg-[#000] border border-[#404040] focus:border-[#277AF7] rounded-[14px] px-4 py-3 text-[#F2F2F2] placeholder:text-white/50 outline-none transition-colors"
/>
</div>
{/* Submit */}
<button type="submit" disabled={loading}
className="w-full bg-[#277AF7] hover:bg-[#3482FF] active:scale-[0.97] disabled:bg-[#253E64] disabled:text-[#677993] text-white font-medium py-3 rounded-full transition-all duration-200"
>
{loading ? '登录中...' : '登录'}
</button>
</form>
</div>
+39 -11
View File
@@ -1,17 +1,45 @@
import { useEffect, useState } from 'react'
import { api } from '../../api/client'
export default function DashboardPage() {
const [data, setData] = useState<Record<string, any> | null>(null)
useEffect(() => {
api.admin.dashboard().then(r => setData(r.dashboard))
}, [])
return (
<div className="p-6 max-w-3xl mx-auto">
<h1 className="text-xl font-bold mb-4">管理面板</h1>
<div className="grid grid-cols-2 gap-4">
<div className="bg-gray-800 rounded-lg p-4">
<p className="text-gray-400 text-sm">服务状态</p>
<p className="text-green-400 text-lg font-bold mt-1">运行中</p>
</div>
<div className="bg-gray-800 rounded-lg p-4">
<p className="text-gray-400 text-sm">版本</p>
<p className="text-purple-400 text-lg font-bold mt-1">2.0.0-dev</p>
</div>
<div className="p-6 max-w-2xl mx-auto">
<h2 className="text-lg font-semibold mb-5">管理面板</h2>
{/* Stats grid */}
<div className="grid grid-cols-2 gap-3 mb-5">
<StatCard label="服务状态" value={data?.status || '...'} accent="text-[#4788FF]" />
<StatCard label="版本" value={data?.version || '...'} accent="text-white/80" />
<StatCard label="运行时间" value={`${data?.uptime_sec || 0}s`} accent="text-white/60" />
<StatCard label="服务数"
value={data ? Object.values(data.services || {}).filter(Boolean).length + '/3' : '...'}
accent="text-[#4788FF]" />
</div>
{/* Service status */}
<div className="bg-[#000] rounded-[18px] border border-[#404040] divide-y divide-[#393939]">
{['ai_core', 'memory', 'voice'].map(name => (
<div key={name} className="flex items-center justify-between px-4 py-3">
<span className="text-sm text-[#F2F2F2]">{name}</span>
<span className={`w-2 h-2 rounded-full ${data?.services?.[name] ? 'bg-[#4788FF]' : 'bg-[#404040]'}`} />
</div>
))}
</div>
</div>
)
}
function StatCard({ label, value, accent }: { label: string; value: string; accent: string }) {
return (
<div className="bg-[#000] border border-[#404040] rounded-[18px] px-4 py-3">
<p className="text-xs text-white/40 mb-1">{label}</p>
<p className={`text-lg font-semibold capitalize ${accent}`}>{value}</p>
</div>
)
}