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:
Misaka_Company
2026-04-02 12:23:46 +08:00
parent 020bbcdccc
commit c3118f3970
2 changed files with 240 additions and 82 deletions

View File

@@ -7,8 +7,9 @@
* - Return selected user info * - Return selected user info
*/ */
import React, { useState, useRef } from 'react' import React, { useState, useRef, useEffect } from 'react'
import { Modal } from './ui/Modal' import { Modal } from './ui/Modal'
import { Users, Shield, UserCircle, Check } from 'lucide-react'
export interface UserInfo { export interface UserInfo {
id: number id: number
@@ -37,6 +38,12 @@ export const UserSelectionDialog: React.FC<UserSelectionDialogProps> = ({
const [selectedUserId, setSelectedUserId] = useState<number | null>(null) const [selectedUserId, setSelectedUserId] = useState<number | null>(null)
const dialogRef = useRef<HTMLDivElement>(null) const dialogRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (isOpen) {
setSelectedUserId(null)
}
}, [isOpen])
const handleConfirm = () => { const handleConfirm = () => {
if (selectedUserId === null) { if (selectedUserId === null) {
return return
@@ -59,11 +66,6 @@ export const UserSelectionDialog: React.FC<UserSelectionDialogProps> = ({
onCancel() onCancel()
} }
const userTypeStyles: Record<string, string> = {
Admin: 'bg-amber-50 text-amber-600',
User: 'bg-blue-50 text-blue-600'
}
if (!isOpen) return null if (!isOpen) return null
return ( return (
@@ -73,67 +75,158 @@ export const UserSelectionDialog: React.FC<UserSelectionDialogProps> = ({
title="选择用户" title="选择用户"
size="md" size="md"
triggerRef={triggerRef} triggerRef={triggerRef}
initialFocusSelector={users.length > 0 ? '.user-item:first-child' : undefined} initialFocusSelector={users.length > 0 ? '.user-card:first-child' : undefined}
ariaDescribedBy="user-selection-description" 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 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 id="user-selection-description" className="sr-only">
</p> </p>
<div className="flex-1 overflow-y-auto mb-4"> {/* User list */}
<div className="flex flex-col gap-2"> <div className="flex-1 overflow-y-auto mb-4 space-y-1.5 pr-1 -mr-1">
{users.map((user) => ( {users.map((user, index) => {
const isSelected = selectedUserId === user.id
const isAdmin = user.userType === 'Admin'
return (
<div <div
key={user.id} 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 ${ 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 ${
selectedUserId === user.id ? 'border-blue-500 bg-blue-50' : '' 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)} onClick={() => setSelectedUserId(user.id)}
onDoubleClick={() => handleDoubleClick(user)} onDoubleClick={() => handleDoubleClick(user)}
tabIndex={0} tabIndex={0}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setSelectedUserId(user.id) setSelectedUserId(user.id)
} }
}} }}
> >
<div className="flex items-center justify-between"> {/* Left accent bar for selected state */}
<span className="text-sm font-medium text-gray-900">{user.username}</span> <div
<span className={`absolute left-0 top-[6px] bottom-[6px] w-[3px] rounded-full transition-all duration-200 ${
className={`text-xs px-2 py-1 rounded font-medium ${userTypeStyles[user.userType]}`} isSelected
> ? 'bg-gradient-to-b from-violet-500 to-indigo-500 opacity-100 scale-y-100'
{user.userType} : 'opacity-0 scale-y-0'
</span> }`}
/>
{/* 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> </div>
{user.createTime && (
<div className="mt-1 text-xs text-gray-500"> {/* Info */}
{new Date(user.createTime).toLocaleString('zh-CN')} <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> </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> </div>
<div className="border-t border-gray-200 pt-4"> {/* Footer */}
<div className="flex justify-center gap-3"> <div className="border-t border-gray-100 pt-4">
<div className="flex justify-center gap-2.5">
<button <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} onClick={handleConfirm}
disabled={selectedUserId === null} disabled={selectedUserId === null}
> >
</button> </button>
<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} onClick={handleCancel}
> >
</button> </button>
</div> </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>
</div> </div>
</Modal> </Modal>

View File

@@ -1,14 +1,14 @@
import React, { Suspense } from 'react' import React, { Suspense, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { import {
ArrowUpCircle, ArrowUpCircle,
Database,
Download, Download,
LayoutDashboard, LayoutDashboard,
LoaderCircle, LoaderCircle,
LogOut, LogOut,
Settings, Settings,
Trash2, Trash2,
User User,
Zap
} from 'lucide-react' } from 'lucide-react'
import type { import type {
DownloadReleaseRequest, DownloadReleaseRequest,
@@ -40,6 +40,12 @@ interface AuthenticatedAppShellProps {
logoutButtonRef: React.RefObject<HTMLButtonElement | null> 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({ export function AuthenticatedAppShell({
currentUser, currentUser,
currentPage, currentPage,
@@ -56,11 +62,31 @@ export function AuthenticatedAppShell({
onLogout, onLogout,
logoutButtonRef logoutButtonRef
}: AuthenticatedAppShellProps): React.JSX.Element { }: AuthenticatedAppShellProps): React.JSX.Element {
const navItems = [ const navRef = useRef<HTMLDivElement>(null)
{ id: 'extractor' as const, label: '数据提取 (Extractor)', icon: <Download size={18} /> }, const [indicatorStyle, setIndicatorStyle] = useState({ left: 0, width: 0 })
{ id: 'cleaner' as const, label: '物料验证与清理 (Cleaner)', icon: <Trash2 size={18} /> }, const [isMeasured, setIsMeasured] = useState(false)
{ id: 'settings' as const, label: '系统设置 (Settings)', icon: <Settings size={18} /> }
] 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 = const showUpdateEntry =
!!updateStatus && !!updateStatus &&
@@ -83,77 +109,116 @@ export function AuthenticatedAppShell({
return ( return (
<div className="flex flex-col h-screen bg-slate-50 text-slate-800 font-sans overflow-hidden"> <div className="flex flex-col h-screen bg-slate-50 text-slate-800 font-sans overflow-hidden">
<header <header
className="h-16 bg-slate-900 text-slate-300 flex items-center justify-between px-4 shadow-md z-20 flex-shrink-0" className="relative h-14 flex items-center justify-between px-3 z-20 flex-shrink-0 select-none"
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties} style={
{
WebkitAppRegion: 'drag',
background: 'linear-gradient(180deg, #13161f 0%, #0f1219 100%)'
} as React.CSSProperties
}
> >
<div className="flex items-center gap-6"> {/* Top accent line */}
<div className="flex gap-2 pl-2"> <div className="absolute top-0 inset-x-0 h-px bg-gradient-to-r from-transparent via-indigo-500/30 to-transparent" />
<div className="w-3 h-3 rounded-full bg-red-500"></div>
<div className="w-3 h-3 rounded-full bg-yellow-500"></div> {/* Left: Window dots + Logo */}
<div className="w-3 h-3 rounded-full bg-green-500"></div> <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>
<div <button
className="flex flex-col cursor-pointer" className="flex items-center gap-2 group"
onClick={() => onNavigate('home')} onClick={() => onNavigate('home')}
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties} style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
> >
<div className="flex items-center gap-2 text-white font-bold text-lg"> <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">
<LayoutDashboard size={22} className="text-blue-500" /> <Zap className="w-3.5 h-3.5 text-white" />
<span>ERP Auto</span>
</div> </div>
<span className="text-xs text-slate-400 ml-7"> <div className="flex flex-col -space-y-0.5">
{__APP_VERSION__}({__GIT_HASH__}) <span className="text-[13px] font-bold text-white leading-none tracking-tight">
</span> ERP Auto
</div> </span>
<span className="text-[10px] text-slate-500 leading-none">
{__APP_VERSION__}({__GIT_HASH__})
</span>
</div>
</button>
</div> </div>
{/* Center: Navigation with sliding pill indicator */}
<nav <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} style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
> >
{navItems.map((item) => ( {/* Sliding pill */}
<button {isNavPage && isMeasured && (
key={item.id} <div
onClick={() => onNavigate(item.id)} className="absolute top-1 bottom-1 rounded-md transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)]"
className={`flex items-center gap-2 px-4 py-1.5 rounded-md text-sm font-medium transition-all ${ style={{
currentPage === item.id left: indicatorStyle.left,
? 'bg-blue-600 text-white shadow' width: indicatorStyle.width,
: 'text-slate-400 hover:text-white hover:bg-slate-700' 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)'
> }}
{item.icon} />
{item.label} )}
</button>
))} {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> </nav>
{/* Right: Status + User */}
<div <div
className="flex items-center gap-4 text-sm" className="flex items-center gap-3"
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties} style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
> >
{showUpdateEntry && ( {showUpdateEntry && (
<button <button
onClick={() => void onOpenUpdateDialog()} 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 || '查看更新'} title={updateStatus?.message || '查看更新'}
> >
{updateStatus?.phase === 'downloading' || updateStatus?.phase === 'installing' ? ( {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> <span>{updateButtonLabel}</span>
</button> </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" /> <div className="flex items-center gap-1.5 text-[11px] text-slate-500">
<span className="text-slate-300"></span> <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>
<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 <span
className="font-medium text-slate-200" className="text-[12px] font-medium text-slate-300"
title={`User Type: ${currentUser?.userType}`} title={`User Type: ${currentUser?.userType}`}
> >
{currentUser?.username} {currentUser?.username}
@@ -162,10 +227,10 @@ export function AuthenticatedAppShell({
<button <button
ref={logoutButtonRef} ref={logoutButtonRef}
onClick={() => void onLogout()} 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="退出登录" title="退出登录"
> >
<LogOut size={16} /> <LogOut size={13} />
</button> </button>
)} )}
</div> </div>