style(ui): redesign UserSelectionDialog and navigation bar
Redesign UserSelectionDialog with role-based gradient avatars, staggered card entrance animation, animated selection indicators (left accent bar + check icon), and refined footer buttons. Redesign AuthenticatedAppShell header with deep gradient background, animated sliding pill indicator for navigation tabs, compact status indicators with pulsing dot, and gradient logo icon chip. Both components share a cohesive indigo/violet accent color system. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,8 +7,9 @@
|
||||
* - Return selected user info
|
||||
*/
|
||||
|
||||
import React, { useState, useRef } from 'react'
|
||||
import React, { useState, useRef, useEffect } from 'react'
|
||||
import { Modal } from './ui/Modal'
|
||||
import { Users, Shield, UserCircle, Check } from 'lucide-react'
|
||||
|
||||
export interface UserInfo {
|
||||
id: number
|
||||
@@ -37,6 +38,12 @@ export const UserSelectionDialog: React.FC<UserSelectionDialogProps> = ({
|
||||
const [selectedUserId, setSelectedUserId] = useState<number | null>(null)
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setSelectedUserId(null)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (selectedUserId === null) {
|
||||
return
|
||||
@@ -59,11 +66,6 @@ export const UserSelectionDialog: React.FC<UserSelectionDialogProps> = ({
|
||||
onCancel()
|
||||
}
|
||||
|
||||
const userTypeStyles: Record<string, string> = {
|
||||
Admin: 'bg-amber-50 text-amber-600',
|
||||
User: 'bg-blue-50 text-blue-600'
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
@@ -73,67 +75,158 @@ export const UserSelectionDialog: React.FC<UserSelectionDialogProps> = ({
|
||||
title="选择用户"
|
||||
size="md"
|
||||
triggerRef={triggerRef}
|
||||
initialFocusSelector={users.length > 0 ? '.user-item:first-child' : undefined}
|
||||
initialFocusSelector={users.length > 0 ? '.user-card:first-child' : undefined}
|
||||
ariaDescribedBy="user-selection-description"
|
||||
>
|
||||
<style>{`
|
||||
@keyframes userCardIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px) scale(0.97);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
.user-card-anim {
|
||||
opacity: 0;
|
||||
animation: userCardIn 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div ref={dialogRef} className="max-h-[60vh] flex flex-col">
|
||||
<div className="text-sm text-gray-600 mb-4">当前登录:{currentUsername}</div>
|
||||
{/* Context header */}
|
||||
<div className="flex items-center gap-3 mb-5 pb-4 border-b border-gray-100">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-violet-500 to-indigo-600 flex items-center justify-center shadow-sm shadow-violet-200">
|
||||
<Users className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[11px] text-gray-400 tracking-wider uppercase font-medium">
|
||||
当前登录
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-gray-800 leading-tight">
|
||||
{currentUsername}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p id="user-selection-description" className="sr-only">
|
||||
从列表中选择一个用户,双击可直接确认选择
|
||||
</p>
|
||||
|
||||
<div className="flex-1 overflow-y-auto mb-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
{users.map((user) => (
|
||||
{/* User list */}
|
||||
<div className="flex-1 overflow-y-auto mb-4 space-y-1.5 pr-1 -mr-1">
|
||||
{users.map((user, index) => {
|
||||
const isSelected = selectedUserId === user.id
|
||||
const isAdmin = user.userType === 'Admin'
|
||||
|
||||
return (
|
||||
<div
|
||||
key={user.id}
|
||||
className={`user-item p-3 border border-gray-200 rounded-lg cursor-pointer transition-all hover:border-blue-500 hover:bg-green-50 ${
|
||||
selectedUserId === user.id ? 'border-blue-500 bg-blue-50' : ''
|
||||
className={`user-card user-card-anim group relative flex items-center gap-3 px-3 py-2.5 rounded-lg cursor-pointer transition-all duration-200 outline-none ${
|
||||
isSelected
|
||||
? 'bg-violet-50/80 shadow-sm ring-1 ring-violet-200'
|
||||
: 'hover:bg-gray-50 focus-visible:bg-gray-50'
|
||||
}`}
|
||||
style={{ animationDelay: `${index * 50}ms` }}
|
||||
onClick={() => setSelectedUserId(user.id)}
|
||||
onDoubleClick={() => handleDoubleClick(user)}
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
setSelectedUserId(user.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-900">{user.username}</span>
|
||||
<span
|
||||
className={`text-xs px-2 py-1 rounded font-medium ${userTypeStyles[user.userType]}`}
|
||||
>
|
||||
{user.userType}
|
||||
</span>
|
||||
{/* Left accent bar for selected state */}
|
||||
<div
|
||||
className={`absolute left-0 top-[6px] bottom-[6px] w-[3px] rounded-full transition-all duration-200 ${
|
||||
isSelected
|
||||
? 'bg-gradient-to-b from-violet-500 to-indigo-500 opacity-100 scale-y-100'
|
||||
: 'opacity-0 scale-y-0'
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* Avatar */}
|
||||
<div
|
||||
className={`w-9 h-9 rounded-full flex items-center justify-center text-[13px] font-bold text-white shrink-0 shadow-sm transition-transform duration-200 ${
|
||||
isAdmin
|
||||
? 'bg-gradient-to-br from-amber-400 to-orange-500 shadow-amber-200/50'
|
||||
: 'bg-gradient-to-br from-blue-400 to-cyan-500 shadow-blue-200/50'
|
||||
} ${isSelected ? 'scale-110' : 'group-hover:scale-105'}`}
|
||||
>
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
{user.createTime && (
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
创建于:{new Date(user.createTime).toLocaleString('zh-CN')}
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-gray-800 truncate">
|
||||
{user.username}
|
||||
</span>
|
||||
<span
|
||||
className={`inline-flex items-center gap-0.5 text-[10px] font-semibold px-1.5 py-[1px] rounded-full leading-none ${
|
||||
isAdmin
|
||||
? 'bg-amber-50 text-amber-600 ring-1 ring-amber-200/60'
|
||||
: 'bg-blue-50 text-blue-600 ring-1 ring-blue-200/60'
|
||||
}`}
|
||||
>
|
||||
{isAdmin ? (
|
||||
<Shield className="w-[10px] h-[10px]" />
|
||||
) : (
|
||||
<UserCircle className="w-[10px] h-[10px]" />
|
||||
)}
|
||||
{user.userType}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{user.createTime && (
|
||||
<div className="text-[11px] text-gray-400 mt-0.5 tabular-nums">
|
||||
{new Date(user.createTime).toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Check indicator */}
|
||||
<div
|
||||
className={`w-5 h-5 rounded-full flex items-center justify-center transition-all duration-200 shrink-0 ${
|
||||
isSelected
|
||||
? 'bg-violet-500 text-white scale-100 opacity-100'
|
||||
: 'bg-gray-100 text-transparent scale-75 opacity-0 group-hover:opacity-40 group-hover:scale-90'
|
||||
}`}
|
||||
>
|
||||
<Check className="w-3 h-3" strokeWidth={3} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 pt-4">
|
||||
<div className="flex justify-center gap-3">
|
||||
{/* Footer */}
|
||||
<div className="border-t border-gray-100 pt-4">
|
||||
<div className="flex justify-center gap-2.5">
|
||||
<button
|
||||
className="px-6 py-2 rounded-md bg-blue-600 hover:bg-blue-700 text-white font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="px-7 py-2 rounded-lg text-sm font-medium transition-all duration-200 disabled:opacity-40 disabled:cursor-not-allowed disabled:shadow-none bg-gradient-to-b from-violet-500 to-violet-600 text-white shadow-sm shadow-violet-200/50 hover:shadow-md hover:shadow-violet-200/60 hover:from-violet-600 hover:to-violet-700 active:scale-[0.97]"
|
||||
onClick={handleConfirm}
|
||||
disabled={selectedUserId === null}
|
||||
>
|
||||
确认
|
||||
</button>
|
||||
<button
|
||||
className="px-6 py-2 rounded-md bg-gray-100 hover:bg-gray-200 text-gray-700 font-medium transition-colors"
|
||||
className="px-7 py-2 rounded-lg text-sm font-medium text-gray-600 transition-all duration-200 bg-gray-50 hover:bg-gray-100 hover:text-gray-800 active:scale-[0.97]"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-center text-xs text-gray-500 mt-3">双击用户可直接选择</div>
|
||||
<div className="text-center text-[11px] text-gray-400 mt-2.5">
|
||||
双击用户可直接选择
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import React, { Suspense } from 'react'
|
||||
import React, { Suspense, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
ArrowUpCircle,
|
||||
Database,
|
||||
Download,
|
||||
LayoutDashboard,
|
||||
LoaderCircle,
|
||||
LogOut,
|
||||
Settings,
|
||||
Trash2,
|
||||
User
|
||||
User,
|
||||
Zap
|
||||
} from 'lucide-react'
|
||||
import type {
|
||||
DownloadReleaseRequest,
|
||||
@@ -40,6 +40,12 @@ interface AuthenticatedAppShellProps {
|
||||
logoutButtonRef: React.RefObject<HTMLButtonElement | null>
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{ id: 'extractor' as const, label: '数据提取', icon: Download },
|
||||
{ id: 'cleaner' as const, label: '物料清理', icon: Trash2 },
|
||||
{ id: 'settings' as const, label: '系统设置', icon: Settings }
|
||||
]
|
||||
|
||||
export function AuthenticatedAppShell({
|
||||
currentUser,
|
||||
currentPage,
|
||||
@@ -56,11 +62,31 @@ export function AuthenticatedAppShell({
|
||||
onLogout,
|
||||
logoutButtonRef
|
||||
}: AuthenticatedAppShellProps): React.JSX.Element {
|
||||
const navItems = [
|
||||
{ id: 'extractor' as const, label: '数据提取 (Extractor)', icon: <Download size={18} /> },
|
||||
{ id: 'cleaner' as const, label: '物料验证与清理 (Cleaner)', icon: <Trash2 size={18} /> },
|
||||
{ id: 'settings' as const, label: '系统设置 (Settings)', icon: <Settings size={18} /> }
|
||||
]
|
||||
const navRef = useRef<HTMLDivElement>(null)
|
||||
const [indicatorStyle, setIndicatorStyle] = useState({ left: 0, width: 0 })
|
||||
const [isMeasured, setIsMeasured] = useState(false)
|
||||
|
||||
const isNavPage = navItems.some((item) => item.id === currentPage)
|
||||
|
||||
const updateIndicator = useCallback(() => {
|
||||
const el = navRef.current?.querySelector(
|
||||
`[data-nav="${currentPage}"]`
|
||||
) as HTMLElement | null
|
||||
if (!el) return
|
||||
setIndicatorStyle({ left: el.offsetLeft, width: el.offsetWidth })
|
||||
setIsMeasured(true)
|
||||
}, [currentPage])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
updateIndicator()
|
||||
}, [updateIndicator])
|
||||
|
||||
useEffect(() => {
|
||||
if (!navRef.current) return
|
||||
const observer = new ResizeObserver(updateIndicator)
|
||||
observer.observe(navRef.current)
|
||||
return () => observer.disconnect()
|
||||
}, [updateIndicator])
|
||||
|
||||
const showUpdateEntry =
|
||||
!!updateStatus &&
|
||||
@@ -83,77 +109,116 @@ export function AuthenticatedAppShell({
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-slate-50 text-slate-800 font-sans overflow-hidden">
|
||||
<header
|
||||
className="h-16 bg-slate-900 text-slate-300 flex items-center justify-between px-4 shadow-md z-20 flex-shrink-0"
|
||||
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
|
||||
className="relative h-14 flex items-center justify-between px-3 z-20 flex-shrink-0 select-none"
|
||||
style={
|
||||
{
|
||||
WebkitAppRegion: 'drag',
|
||||
background: 'linear-gradient(180deg, #13161f 0%, #0f1219 100%)'
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex gap-2 pl-2">
|
||||
<div className="w-3 h-3 rounded-full bg-red-500"></div>
|
||||
<div className="w-3 h-3 rounded-full bg-yellow-500"></div>
|
||||
<div className="w-3 h-3 rounded-full bg-green-500"></div>
|
||||
{/* Top accent line */}
|
||||
<div className="absolute top-0 inset-x-0 h-px bg-gradient-to-r from-transparent via-indigo-500/30 to-transparent" />
|
||||
|
||||
{/* Left: Window dots + Logo */}
|
||||
<div className="flex items-center gap-5 pl-1.5">
|
||||
<div className="flex gap-[7px]">
|
||||
<div className="w-[11px] h-[11px] rounded-full bg-[#ff5f57]/80 hover:bg-[#ff5f57] transition-colors" />
|
||||
<div className="w-[11px] h-[11px] rounded-full bg-[#febc2e]/80 hover:bg-[#febc2e] transition-colors" />
|
||||
<div className="w-[11px] h-[11px] rounded-full bg-[#28c840]/80 hover:bg-[#28c840] transition-colors" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex flex-col cursor-pointer"
|
||||
<button
|
||||
className="flex items-center gap-2 group"
|
||||
onClick={() => onNavigate('home')}
|
||||
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-white font-bold text-lg">
|
||||
<LayoutDashboard size={22} className="text-blue-500" />
|
||||
<span>ERP Auto</span>
|
||||
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-indigo-500 to-blue-600 flex items-center justify-center shadow-sm shadow-indigo-500/25 transition-shadow group-hover:shadow-md group-hover:shadow-indigo-500/30">
|
||||
<Zap className="w-3.5 h-3.5 text-white" />
|
||||
</div>
|
||||
<span className="text-xs text-slate-400 ml-7">
|
||||
{__APP_VERSION__}({__GIT_HASH__})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col -space-y-0.5">
|
||||
<span className="text-[13px] font-bold text-white leading-none tracking-tight">
|
||||
ERP Auto
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-500 leading-none">
|
||||
{__APP_VERSION__}({__GIT_HASH__})
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Center: Navigation with sliding pill indicator */}
|
||||
<nav
|
||||
className="flex items-center gap-2 bg-slate-800 p-1 rounded-lg"
|
||||
ref={navRef}
|
||||
className="relative flex items-center gap-1 bg-white/[0.04] p-1 rounded-lg border border-white/[0.06]"
|
||||
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
|
||||
>
|
||||
{navItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => onNavigate(item.id)}
|
||||
className={`flex items-center gap-2 px-4 py-1.5 rounded-md text-sm font-medium transition-all ${
|
||||
currentPage === item.id
|
||||
? 'bg-blue-600 text-white shadow'
|
||||
: 'text-slate-400 hover:text-white hover:bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
{/* Sliding pill */}
|
||||
{isNavPage && isMeasured && (
|
||||
<div
|
||||
className="absolute top-1 bottom-1 rounded-md transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)]"
|
||||
style={{
|
||||
left: indicatorStyle.left,
|
||||
width: indicatorStyle.width,
|
||||
background: 'linear-gradient(135deg, #4f46e5, #6366f1)',
|
||||
boxShadow: '0 1px 8px rgba(99, 102, 241, 0.35), inset 0 1px 0 rgba(255,255,255,0.1)'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{navItems.map((item) => {
|
||||
const isActive = currentPage === item.id
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
data-nav={item.id}
|
||||
onClick={() => onNavigate(item.id)}
|
||||
className={`relative z-10 flex items-center gap-1.5 px-3.5 py-1.5 rounded-md text-[13px] font-medium transition-colors duration-200 ${
|
||||
isActive ? 'text-white' : 'text-slate-400 hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
<Icon size={15} />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Right: Status + User */}
|
||||
<div
|
||||
className="flex items-center gap-4 text-sm"
|
||||
className="flex items-center gap-3"
|
||||
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
|
||||
>
|
||||
{showUpdateEntry && (
|
||||
<button
|
||||
onClick={() => void onOpenUpdateDialog()}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-blue-500/40 bg-blue-500/10 px-3 py-1.5 text-xs font-medium text-blue-100 transition hover:bg-blue-500/20"
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-[11px] font-medium text-indigo-300 bg-indigo-500/10 border border-indigo-500/20 transition-colors hover:bg-indigo-500/20 hover:text-indigo-200"
|
||||
title={updateStatus?.message || '查看更新'}
|
||||
>
|
||||
{updateStatus?.phase === 'downloading' || updateStatus?.phase === 'installing' ? (
|
||||
<LoaderCircle size={15} className="animate-spin" />
|
||||
<LoaderCircle size={12} className="animate-spin" />
|
||||
) : (
|
||||
<ArrowUpCircle size={15} />
|
||||
<ArrowUpCircle size={12} />
|
||||
)}
|
||||
<span>{updateButtonLabel}</span>
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-xs bg-slate-800 px-3 py-1.5 rounded-full border border-slate-700">
|
||||
<Database size={14} className="text-green-500" />
|
||||
<span className="text-slate-300">数据库已连接</span>
|
||||
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-slate-500">
|
||||
<div className="relative flex items-center justify-center">
|
||||
<div className="w-[7px] h-[7px] rounded-full bg-emerald-400" />
|
||||
<div className="absolute w-[7px] h-[7px] rounded-full bg-emerald-400 animate-ping opacity-40" />
|
||||
</div>
|
||||
<span>已连接</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 bg-slate-800 px-3 py-1.5 rounded-full">
|
||||
<User size={16} className="text-slate-400" />
|
||||
|
||||
<div className="flex items-center gap-2 bg-white/[0.04] pl-1.5 pr-2.5 py-[5px] rounded-full border border-white/[0.06]">
|
||||
<div className="w-6 h-6 rounded-full bg-gradient-to-br from-slate-600 to-slate-700 flex items-center justify-center ring-1 ring-white/10">
|
||||
<User size={12} className="text-slate-300" />
|
||||
</div>
|
||||
<span
|
||||
className="font-medium text-slate-200"
|
||||
className="text-[12px] font-medium text-slate-300"
|
||||
title={`User Type: ${currentUser?.userType}`}
|
||||
>
|
||||
{currentUser?.username}
|
||||
@@ -162,10 +227,10 @@ export function AuthenticatedAppShell({
|
||||
<button
|
||||
ref={logoutButtonRef}
|
||||
onClick={() => void onLogout()}
|
||||
className="ml-2 text-slate-400 hover:text-red-400 transition-colors"
|
||||
className="text-slate-500 hover:text-red-400 transition-colors ml-0.5"
|
||||
title="退出登录"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
<LogOut size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user