polish(ui): design language uplift - press feedback, brand gradient, header glass, route icon chips, empty states

- button: unified press scale (active:scale-[0.97]), primary gets vertical
  brand gradient + tighter shadow, secondary gains subtle hover shadow
- input/select/textarea: consistent focus border + 1.5px brand ring
- empty states: icon in circular tinted badge with soft ring, larger rhythm
- header: translucent frosted-glass surface (color-mix + backdrop blur),
  per-route lucide icon chip beside page title (desktop), subtitle hidden
  on phones to declutter
- sidebar: active nav icon tinted brand via .nav-active > svg; user card
  avatar gets brand gradient + ring
- dark theme: text-3 contrast raised (#68707e -> #747c8a)
- verified live (headless): h1/chips/glass render on dashboard/timeline/
  assessment/resume; tsc + vite build green; temp instrumentation reverted
This commit is contained in:
root
2026-10-17 22:33:39 +08:00
parent 8cdf8b44fa
commit a5e976fee6
24 changed files with 138 additions and 103 deletions
+27 -5
View File
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { Search, Sun, Moon, Plus, Menu } from 'lucide-react';
import { Search, Sun, Moon, Plus, Menu, CalendarDays, Rows3, GraduationCap, ChartColumn, Shapes, SlidersHorizontal, User, ScrollText, ClipboardCheck, ShieldCheck } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { Button, Input } from '@/components/ui';
const TITLES: Record<string, { title: string; sub?: string }> = {
@@ -24,6 +25,20 @@ export function Header({ onMenu }: { onMenu?: () => void }) {
const meta = TITLES[pathname] ?? { title: '' };
const ROUTE_ICONS: Record<string, LucideIcon> = {
'/timeline': CalendarDays,
'/records': Rows3,
'/grades': GraduationCap,
'/dashboard': ChartColumn,
'/types': Shapes,
'/taxonomy': SlidersHorizontal,
'/profile': User,
'/resume': ScrollText,
'/assessment': ClipboardCheck,
'/admin': ShieldCheck,
};
const RouteIcon = ROUTE_ICONS[pathname];
const toggleTheme = () => {
const next = !dark;
setDark(next);
@@ -33,14 +48,21 @@ export function Header({ onMenu }: { onMenu?: () => void }) {
};
return (
<header className="no-print flex h-14 shrink-0 items-center gap-2 border-b border-line bg-surface px-3 sm:gap-4 sm:px-5">
<header className="no-print header-glass sticky top-0 z-20 flex h-14 shrink-0 items-center gap-2.5 border-b border-line px-3 sm:gap-4 sm:px-5">
<Button variant="ghost" size="icon" className="lg:hidden" onClick={onMenu} aria-label="打开导航">
<Menu size={17} />
</Button>
<div className="min-w-0">
<h1 className="truncate text-[15px] font-semibold leading-tight text-ink-1">{meta.title}</h1>
{meta.sub && <p className="truncate text-[11px] leading-tight text-ink-3">{meta.sub}</p>}
<div className="flex min-w-0 items-center gap-2.5">
{RouteIcon && (
<span className="hidden size-8 shrink-0 items-center justify-center rounded-lg bg-brand-subtle text-brand-text sm:flex" aria-hidden>
<RouteIcon size={16} strokeWidth={2} />
</span>
)}
<div className="min-w-0">
<h1 className="truncate text-[15px] font-semibold leading-tight text-ink-1">{meta.title}</h1>
{meta.sub && <p className="hidden truncate text-[11px] leading-tight text-ink-3 sm:block sm:mt-[1px]">{meta.sub}</p>}
</div>
</div>
<div className="relative ml-auto hidden w-[240px] md:block lg:w-[280px]">
+2 -2
View File
@@ -109,10 +109,10 @@ export function Sidebar({ open, onClose }: { open: boolean; onClose: () => void
{/* 左下角:账号区 */}
<div className="border-t border-line px-3 py-3">
{getToken() && me ? (
<div className="rounded-lg bg-surface-2 px-3 py-2.5">
<div className="rounded-lg border border-line bg-surface-2 px-3 py-2.5 shadow-card">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<div className="flex size-6 shrink-0 items-center justify-center rounded-full bg-brand text-[10px] font-bold text-white">
<div className="flex size-6 shrink-0 items-center justify-center rounded-full bg-gradient-to-b from-[#4359e8] to-[#2a3ab8] text-[10px] font-bold text-white shadow-card">
{me.username.charAt(0).toUpperCase()}
</div>
<div className="min-w-0">
+3 -3
View File
@@ -3,12 +3,12 @@ import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-md font-medium transition-colors select-none disabled:pointer-events-none disabled:opacity-45',
'inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-md font-medium select-none transition-all duration-150 active:scale-[0.97] disabled:pointer-events-none disabled:opacity-45',
{
variants: {
variant: {
primary: 'bg-brand text-white hover:bg-brand-hover shadow-card',
secondary: 'bg-surface text-ink-1 border border-line hover:bg-surface-3 hover:border-line-strong',
primary: 'bg-gradient-to-b from-[#3d51e0] to-brand text-white shadow-[0_1px_2px_rgba(48,86,211,.25)] hover:brightness-110 active:brightness-95',
secondary: 'bg-surface text-ink-1 border border-line shadow-card hover:bg-surface-3 hover:border-line-strong hover:shadow-[0_2px_6px_rgba(0,0,0,.06)]',
ghost: 'text-ink-2 hover:bg-surface-3 hover:text-ink-1',
subtle: 'bg-brand-subtle text-brand-text hover:brightness-[0.97]',
danger: 'bg-danger text-white hover:brightness-110',
+6 -3
View File
@@ -8,9 +8,12 @@ export function Empty({ icon, title, description, action }: {
action?: ReactNode;
}) {
return (
<div className="flex flex-col items-center justify-center gap-2 py-16 text-center">
<div className="text-ink-3">{icon ?? <Inbox size={36} strokeWidth={1.5} />}</div>
<div className="text-[13px] font-medium text-ink-2">{title}</div>
<div className="flex flex-col items-center justify-center gap-3 py-16 text-center">
<div className="relative flex size-14 items-center justify-center rounded-full border border-line bg-surface-2 text-ink-3 shadow-card">
<div className="absolute inset-0 rounded-full ring-1 ring-brand/5" aria-hidden />
{icon ?? <Inbox size={26} strokeWidth={1.5} />}
</div>
<div className="mt-1 text-[13px] font-medium text-ink-2">{title}</div>
{description && <div className="max-w-[360px] text-[12px] text-ink-3">{description}</div>}
{action && <div className="mt-2">{action}</div>}
</div>
+1 -1
View File
@@ -1,7 +1,7 @@
import { forwardRef, type InputHTMLAttributes, type TextareaHTMLAttributes } from 'react';
import { cn } from '@/lib/utils';
const base = 'w-full rounded-md border border-line bg-surface px-2.5 text-[13px] text-ink-1 placeholder:text-ink-3 transition-colors hover:border-line-strong focus:border-brand focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed';
const base = 'w-full rounded-md border border-line bg-surface px-2.5 text-[13px] text-ink-1 placeholder:text-ink-3 transition-colors hover:border-line-strong focus:border-brand focus:ring-[1.5px] focus:ring-brand/15 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed';
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(function Input(
{ className, ...props }, ref
+1 -1
View File
@@ -12,7 +12,7 @@ export const Select = forwardRef<HTMLSelectElement, SelectHTMLAttributes<HTMLSel
ref={ref}
className={cn(
'w-full h-8 appearance-none rounded-md border border-line bg-surface pl-2.5 pr-7 text-[13px] text-ink-1',
'transition-colors hover:border-line-strong focus:border-brand focus:outline-none disabled:opacity-50',
'transition-colors hover:border-line-strong focus:border-brand focus:ring-[1.5px] focus:ring-brand/15 focus:outline-none disabled:opacity-50',
className
)}
{...props}
+11 -1
View File
@@ -64,7 +64,7 @@
--border-strong: #363b47;
--text-1: #e9ebf0;
--text-2: #a0a8b8;
--text-3: #68707e;
--text-3: #747c8a;
--brand: #6d8bfa;
--brand-hover: #8aa2fb;
--brand-subtle: #1a2144;
@@ -145,10 +145,20 @@ input[type='number']::-webkit-outer-spin-button,
input[type='number']::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
input[type='number'] { -moz-appearance: textfield; appearance: textfield; }
/* 页头毛玻璃(滚动透出内容时更轻盈) */
.header-glass {
background: color-mix(in srgb, var(--surface) 86%, transparent);
backdrop-filter: blur(10px) saturate(1.4);
-webkit-backdrop-filter: blur(10px) saturate(1.4);
}
/* 卡片悬停微抬 */
.hover-lift { transition: transform .2s cubic-bezier(.16,1,.3,1), box-shadow .2s ease; }
.hover-lift:hover { transform: translateY(-1px); box-shadow: 0 4px 16px rgba(0,0,0,.08), 0 1px 4px rgba(0,0,0,.04); }
/* 侧边栏激活图标着色(nav-active 由 NavLink isActive 挂载) */
.nav-active > svg { color: var(--brand-text); }
/* 侧边栏激活指示条 */
.nav-active::before {
content: '';