07-TailwindCSS打造优雅的对话UI组件

42 阅读7分钟

TailwindCSS打造优雅的对话UI组件

前言

TailwindCSS的原子化CSS理念让UI开发变得高效而优雅。本文将详细介绍如何使用TailwindCSS构建现代化的AI对话界面组件。

适合读者: 前端开发者、UI工程师、设计师


一、TailwindCSS配置

1.1 安装和初始化

# 安装TailwindCSS
npm install -D tailwindcss postcss autoprefixer

# 初始化配置
npx tailwindcss init -p

1.2 自定义配置

// tailwind.config.ts
import type { Config } from 'tailwindcss'

const config: Config = {
  content: [
    './pages/**/*.{js,ts,jsx,tsx,mdx}',
    './components/**/*.{js,ts,jsx,tsx,mdx}',
    './app/**/*.{js,ts,jsx,tsx,mdx}',
  ],
  theme: {
    extend: {
      colors: {
        // 自定义颜色系统
        gray: {
          1: '#ffffff',
          2: '#fafafa',
          3: '#f5f5f5',
          4: '#e5e5e5',
          5: '#d4d4d4',
          6: '#a3a3a3',
          7: '#737373',
          8: '#525252',
          9: '#404040',
          10: '#262626',
        },
        blue: {
          1: '#eff6ff',
          2: '#dbeafe',
          3: '#bfdbfe',
          4: '#93c5fd',
          5: '#60a5fa',
          6: '#3b82f6',
          7: '#2563eb',
          8: '#1d4ed8',
          9: '#1e40af',
          10: '#1e3a8a',
        },
      },
      animation: {
        'bounce-slow': 'bounce 1.5s infinite',
        'pulse-slow': 'pulse 2s infinite',
      },
    },
  },
  plugins: [],
}

export default config

1.3 全局样式

/* app/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
  * {
    @apply border-border;
  }
  
  body {
    @apply bg-gray-2 text-gray-10;
  }
}

@layer components {
  /* 自定义组件样式 */
  .btn-primary {
    @apply px-4 py-2 bg-blue-6 text-white rounded-lg hover:bg-blue-7 transition-colors;
  }
  
  .input-base {
    @apply px-3 py-2 border border-gray-4 rounded-lg focus:outline-none focus:border-blue-6 transition-colors;
  }
}

二、消息气泡组件

2.1 基础消息气泡

// components/MessageBubble.tsx
interface MessageBubbleProps {
  content: string
  isUser: boolean
  timestamp?: string
}

export function MessageBubble({ content, isUser, timestamp }: MessageBubbleProps) {
  return (
    <div className={`mb-6 flex ${isUser ? 'justify-end' : 'justify-start'}`}>
      <div
        className={`flex space-x-3 max-w-[80%] ${
          isUser ? 'flex-row-reverse space-x-reverse' : ''
        }`}
      >
        {/* 头像 */}
        <div
          className={`
            w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0
            ${isUser ? 'bg-blue-6' : 'bg-gray-4'}
          `}
        >
          {isUser ? (
            <span className="text-white text-sm">👤</span>
          ) : (
            <span className="text-gray-7 text-sm">🤖</span>
          )}
        </div>

        {/* 消息内容 */}
        <div className="flex flex-col space-y-1">
          <div
            className={`
              px-4 py-3 rounded-2xl
              ${
                isUser
                  ? 'bg-blue-6 text-white rounded-br-md'
                  : 'bg-white border border-gray-4 text-gray-10 rounded-bl-md'
              }
            `}
          >
            <p className="text-sm whitespace-pre-wrap break-words">
              {content}
            </p>
          </div>
          
          {/* 时间戳 */}
          {timestamp && (
            <span className={`text-xs text-gray-6 ${isUser ? 'text-right' : 'text-left'}`}>
              {new Date(timestamp).toLocaleTimeString('zh-CN', {
                hour: '2-digit',
                minute: '2-digit'
              })}
            </span>
          )}
        </div>
      </div>
    </div>
  )
}

2.2 带动画的消息气泡

// components/AnimatedMessageBubble.tsx
'use client'

import { motion } from 'framer-motion'

export function AnimatedMessageBubble({ content, isUser }: MessageBubbleProps) {
  return (
    <motion.div
      initial={{ opacity: 0, y: 20 }}
      animate={{ opacity: 1, y: 0 }}
      transition={{ duration: 0.3 }}
      className={`mb-6 flex ${isUser ? 'justify-end' : 'justify-start'}`}
    >
      <div
        className={`flex space-x-3 max-w-[80%] ${
          isUser ? 'flex-row-reverse space-x-reverse' : ''
        }`}
      >
        {/* 头像动画 */}
        <motion.div
          initial={{ scale: 0 }}
          animate={{ scale: 1 }}
          transition={{ delay: 0.1, type: 'spring', stiffness: 200 }}
          className={`
            w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0
            ${isUser ? 'bg-blue-6' : 'bg-gray-4'}
          `}
        >
          {isUser ? '👤' : '🤖'}
        </motion.div>

        {/* 消息内容动画 */}
        <motion.div
          initial={{ scale: 0.8, opacity: 0 }}
          animate={{ scale: 1, opacity: 1 }}
          transition={{ delay: 0.2 }}
          className={`
            px-4 py-3 rounded-2xl
            ${
              isUser
                ? 'bg-blue-6 text-white'
                : 'bg-white border border-gray-4 text-gray-10'
            }
          `}
        >
          <p className="text-sm whitespace-pre-wrap break-words">
            {content}
          </p>
        </motion.div>
      </div>
    </motion.div>
  )
}

2.3 Markdown渲染支持

// components/MarkdownMessage.tsx
import ReactMarkdown from 'react-markdown'
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
import { vscDarkPlus } from 'react-syntax-highlighter/dist/cjs/styles/prism'

export function MarkdownMessage({ content, isUser }: MessageBubbleProps) {
  return (
    <div className={`mb-6 flex ${isUser ? 'justify-end' : 'justify-start'}`}>
      <div
        className={`
          px-4 py-3 rounded-2xl max-w-[80%]
          ${
            isUser
              ? 'bg-blue-6 text-white'
              : 'bg-white border border-gray-4 text-gray-10'
          }
        `}
      >
        <ReactMarkdown
          className="prose prose-sm max-w-none"
          components={{
            code({ node, inline, className, children, ...props }) {
              const match = /language-(\w+)/.exec(className || '')
              return !inline && match ? (
                <SyntaxHighlighter
                  style={vscDarkPlus}
                  language={match[1]}
                  PreTag="div"
                  {...props}
                >
                  {String(children).replace(/\n$/, '')}
                </SyntaxHighlighter>
              ) : (
                <code className="bg-gray-2 px-1 py-0.5 rounded text-xs" {...props}>
                  {children}
                </code>
              )
            }
          }}
        >
          {content}
        </ReactMarkdown>
      </div>
    </div>
  )
}

三、输入框组件

3.1 基础输入框

// components/ChatInput.tsx
import { useState, useRef, useEffect } from 'react'

interface ChatInputProps {
  onSend: (message: string) => void
  disabled?: boolean
  placeholder?: string
}

export function ChatInput({ onSend, disabled, placeholder }: ChatInputProps) {
  const [value, setValue] = useState('')
  const textareaRef = useRef<HTMLTextAreaElement>(null)

  // 自动调整高度
  useEffect(() => {
    if (textareaRef.current) {
      textareaRef.current.style.height = 'auto'
      textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px'
    }
  }, [value])

  const handleSend = () => {
    if (value.trim() && !disabled) {
      onSend(value)
      setValue('')
    }
  }

  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === 'Enter' && !e.shiftKey) {
      e.preventDefault()
      handleSend()
    }
  }

  return (
    <div className="max-w-3xl mx-auto">
      <div className="flex items-end space-x-3 bg-gray-2 rounded-2xl p-3 border border-gray-4 focus-within:border-blue-6 transition-colors">
        <textarea
          ref={textareaRef}
          value={value}
          onChange={(e) => setValue(e.target.value)}
          onKeyDown={handleKeyDown}
          placeholder={placeholder || '在这里输入消息...'}
          disabled={disabled}
          rows={1}
          className="
            flex-1 bg-transparent resize-none outline-none 
            text-sm text-gray-10 placeholder-gray-6
            max-h-32 overflow-y-auto
            disabled:opacity-50 disabled:cursor-not-allowed
          "
        />
        
        <button
          onClick={handleSend}
          disabled={!value.trim() || disabled}
          className="
            p-2.5 bg-blue-6 text-white rounded-xl 
            hover:bg-blue-7 
            disabled:opacity-50 disabled:cursor-not-allowed 
            transition-colors flex-shrink-0
          "
        >
          <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
          </svg>
        </button>
      </div>
      
      <p className="text-xs text-gray-6 text-center mt-2">
        按 Enter 发送,Shift + Enter 换行
      </p>
    </div>
  )
}

3.2 带附件上传的输入框

// components/AdvancedChatInput.tsx
import { useState, useRef } from 'react'

export function AdvancedChatInput({ onSend, disabled }: ChatInputProps) {
  const [value, setValue] = useState('')
  const [files, setFiles] = useState<File[]>([])
  const fileInputRef = useRef<HTMLInputElement>(null)

  const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
    if (e.target.files) {
      setFiles(Array.from(e.target.files))
    }
  }

  const removeFile = (index: number) => {
    setFiles(files.filter((_, i) => i !== index))
  }

  return (
    <div className="max-w-3xl mx-auto">
      {/* 文件预览 */}
      {files.length > 0 && (
        <div className="mb-2 flex flex-wrap gap-2">
          {files.map((file, index) => (
            <div
              key={index}
              className="flex items-center space-x-2 px-3 py-2 bg-gray-2 rounded-lg border border-gray-4"
            >
              <span className="text-sm text-gray-10 truncate max-w-[200px]">
                {file.name}
              </span>
              <button
                onClick={() => removeFile(index)}
                className="text-gray-6 hover:text-red-6"
              >
                ✕
              </button>
            </div>
          ))}
        </div>
      )}

      {/* 输入框 */}
      <div className="flex items-end space-x-3 bg-gray-2 rounded-2xl p-3 border border-gray-4 focus-within:border-blue-6">
        {/* 附件按钮 */}
        <button
          onClick={() => fileInputRef.current?.click()}
          className="p-2 text-gray-6 hover:text-blue-6 transition-colors"
        >
          📎
        </button>
        
        <input
          ref={fileInputRef}
          type="file"
          multiple
          onChange={handleFileSelect}
          className="hidden"
        />

        <textarea
          value={value}
          onChange={(e) => setValue(e.target.value)}
          placeholder="在这里输入消息..."
          disabled={disabled}
          rows={1}
          className="flex-1 bg-transparent resize-none outline-none text-sm text-gray-10 placeholder-gray-6 max-h-32"
        />

        <button
          onClick={() => onSend(value)}
          disabled={!value.trim() || disabled}
          className="p-2.5 bg-blue-6 text-white rounded-xl hover:bg-blue-7 disabled:opacity-50 transition-colors"
        >
          ➤
        </button>
      </div>
    </div>
  )
}

四、加载和状态组件

4.1 思考指示器

// components/ThinkingIndicator.tsx
export function ThinkingIndicator() {
  return (
    <div className="mb-6 flex justify-start">
      <div className="flex space-x-3 max-w-[80%]">
        <div className="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 bg-gray-4">
          <span className="text-sm">🤖</span>
        </div>
        
        <div className="px-4 py-3 rounded-2xl bg-white border border-gray-4">
          <div className="flex space-x-2">
            <div 
              className="w-2 h-2 bg-gray-5 rounded-full animate-bounce" 
              style={{ animationDelay: '0ms' }} 
            />
            <div 
              className="w-2 h-2 bg-gray-5 rounded-full animate-bounce" 
              style={{ animationDelay: '150ms' }} 
            />
            <div 
              className="w-2 h-2 bg-gray-5 rounded-full animate-bounce" 
              style={{ animationDelay: '300ms' }} 
            />
          </div>
        </div>
      </div>
    </div>
  )
}

4.2 骨架屏

// components/MessageSkeleton.tsx
export function MessageSkeleton() {
  return (
    <div className="mb-6 flex justify-start">
      <div className="flex space-x-3 max-w-[80%]">
        {/* 头像骨架 */}
        <div className="w-8 h-8 rounded-full bg-gray-3 animate-pulse" />
        
        {/* 消息骨架 */}
        <div className="space-y-2">
          <div className="h-4 w-48 bg-gray-3 rounded animate-pulse" />
          <div className="h-4 w-32 bg-gray-3 rounded animate-pulse" />
        </div>
      </div>
    </div>
  )
}

4.3 空状态

// components/EmptyState.tsx
export function EmptyState() {
  return (
    <div className="h-full flex flex-col items-center justify-center text-center px-4">
      <div className="w-16 h-16 bg-blue-1 rounded-2xl flex items-center justify-center mb-4">
        <span className="text-3xl">💬</span>
      </div>
      
      <h2 className="text-2xl font-semibold text-gray-10 mb-2">
        今天有什么可以帮到你?
      </h2>
      
      <p className="text-gray-6 mb-6 max-w-md">
        点击"新建对话"开始与 AI 助手交流,或选择左侧的历史对话继续聊天
      </p>
      
      <button className="flex items-center space-x-2 px-6 py-3 bg-blue-6 text-white rounded-lg hover:bg-blue-7 transition-colors">
        <span></span>
        <span>新建对话</span>
      </button>
    </div>
  )
}

五、侧边栏组件

5.1 对话列表

// components/ConversationList.tsx
interface Conversation {
  id: string
  title: string
  updated_at: string
}

interface ConversationListProps {
  conversations: Conversation[]
  currentId?: string
  onSelect: (id: string) => void
  onDelete: (id: string) => void
}

export function ConversationList({
  conversations,
  currentId,
  onSelect,
  onDelete
}: ConversationListProps) {
  return (
    <div className="flex-1 overflow-y-auto p-2">
      {conversations.length === 0 ? (
        <div className="text-center text-gray-6 text-sm py-8">
          暂无对话记录
        </div>
      ) : (
        conversations.map((conv) => (
          <div
            key={conv.id}
            className={`
              group relative px-3 py-2.5 mb-1 rounded-lg cursor-pointer transition-colors
              ${
                currentId === conv.id
                  ? 'bg-blue-1 text-blue-7'
                  : 'hover:bg-gray-2 text-gray-10'
              }
            `}
            onClick={() => onSelect(conv.id)}
          >
            <div className="flex items-center justify-between">
              <div className="flex items-center space-x-2 flex-1 min-w-0">
                <span className="text-sm">💬</span>
                <span className="text-sm truncate">{conv.title}</span>
              </div>
              
              {/* 删除按钮 */}
              <button
                onClick={(e) => {
                  e.stopPropagation()
                  onDelete(conv.id)
                }}
                className="
                  opacity-0 group-hover:opacity-100 
                  p-1 hover:bg-red-1 rounded 
                  transition-opacity
                "
              >
                <span className="text-red-6 text-xs">🗑️</span>
              </button>
            </div>
            
            {/* 时间戳 */}
            <div className="text-xs text-gray-6 mt-1">
              {new Date(conv.updated_at).toLocaleDateString('zh-CN')}
            </div>
          </div>
        ))
      )}
    </div>
  )
}

5.2 可折叠侧边栏

// components/Sidebar.tsx
'use client'

import { useState } from 'react'

export function Sidebar({ children }: { children: React.ReactNode }) {
  const [isOpen, setIsOpen] = useState(true)

  return (
    <>
      {/* 侧边栏 */}
      <div
        className={`
          ${isOpen ? 'w-64' : 'w-0'}
          transition-all duration-300 
          bg-white border-r border-gray-4 
          flex flex-col overflow-hidden
        `}
      >
        {children}
      </div>

      {/* 切换按钮 */}
      <button
        onClick={() => setIsOpen(!isOpen)}
        className="
          fixed left-4 top-4 z-50
          lg:hidden
          p-2 bg-white border border-gray-4 rounded-lg
          hover:bg-gray-2 transition-colors
        "
      >
        {isOpen ? '✕' : '☰'}
      </button>
    </>
  )
}

六、通知和提示组件

6.1 Toast通知

// components/Toast.tsx
'use client'

import { createContext, useContext, useState } from 'react'

type ToastType = 'success' | 'error' | 'info' | 'warning'

interface Toast {
  id: string
  type: ToastType
  message: string
}

const ToastContext = createContext<{
  showToast: (type: ToastType, message: string) => void
} | null>(null)

export function ToastProvider({ children }: { children: React.ReactNode }) {
  const [toasts, setToasts] = useState<Toast[]>([])

  const showToast = (type: ToastType, message: string) => {
    const id = Date.now().toString()
    setToasts(prev => [...prev, { id, type, message }])
    
    setTimeout(() => {
      setToasts(prev => prev.filter(t => t.id !== id))
    }, 3000)
  }

  return (
    <ToastContext.Provider value={{ showToast }}>
      {children}
      
      {/* Toast容器 */}
      <div className="fixed top-4 right-4 z-50 space-y-2">
        {toasts.map(toast => (
          <div
            key={toast.id}
            className={`
              px-4 py-3 rounded-lg shadow-lg
              animate-in slide-in-from-right
              ${
                toast.type === 'success' ? 'bg-green-6 text-white' :
                toast.type === 'error' ? 'bg-red-6 text-white' :
                toast.type === 'warning' ? 'bg-yellow-6 text-white' :
                'bg-blue-6 text-white'
              }
            `}
          >
            {toast.message}
          </div>
        ))}
      </div>
    </ToastContext.Provider>
  )
}

export function useToast() {
  const context = useContext(ToastContext)
  if (!context) throw new Error('useToast must be used within ToastProvider')
  return context
}

6.2 确认对话框

// components/ConfirmDialog.tsx
interface ConfirmDialogProps {
  open: boolean
  title: string
  message: string
  onConfirm: () => void
  onCancel: () => void
}

export function ConfirmDialog({
  open,
  title,
  message,
  onConfirm,
  onCancel
}: ConfirmDialogProps) {
  if (!open) return null

  return (
    <div 
      className="fixed inset-0 flex items-center justify-center z-50 bg-black/50"
      onClick={onCancel}
    >
      <div 
        className="bg-white rounded-2xl p-6 max-w-sm w-full mx-4 shadow-2xl"
        onClick={(e) => e.stopPropagation()}
      >
        <h3 className="text-lg font-semibold text-gray-10 mb-2">
          {title}
        </h3>
        
        <p className="text-gray-7 text-sm mb-6">
          {message}
        </p>
        
        <div className="flex justify-end space-x-3">
          <button
            onClick={onCancel}
            className="px-4 py-2 text-gray-10 hover:bg-gray-2 rounded-lg transition-colors"
          >
            取消
          </button>
          
          <button
            onClick={onConfirm}
            className="px-4 py-2 bg-red-6 text-white rounded-lg hover:bg-red-7 transition-colors"
          >
            确认
          </button>
        </div>
      </div>
    </div>
  )
}

七、响应式设计技巧

7.1 断点系统

// Tailwind默认断点
const breakpoints = {
  sm: '640px',   // 手机横屏
  md: '768px',   // 平板
  lg: '1024px',  // 笔记本
  xl: '1280px',  // 桌面
  '2xl': '1536px' // 大屏
}

// 使用示例
<div className="
  w-full        /* 默认全宽 */
  sm:w-1/2      /* 640px以上半宽 */
  md:w-1/3      /* 768px以上1/3宽 */
  lg:w-1/4      /* 1024px以上1/4宽 */
">
  响应式内容
</div>

7.2 移动端优化

// components/MobileOptimized.tsx
export function MobileOptimized() {
  return (
    <div className="
      p-4           /* 移动端padding */
      md:p-6        /* 平板padding */
      lg:p-8        /* 桌面padding */
      
      text-sm       /* 移动端字体 */
      md:text-base  /* 平板字体 */
      
      space-y-4     /* 移动端间距 */
      md:space-y-6  /* 平板间距 */
    ">
      <h1 className="
        text-xl       /* 移动端标题 */
        md:text-2xl   /* 平板标题 */
        lg:text-3xl   /* 桌面标题 */
      ">
        响应式标题
      </h1>
    </div>
  )
}

八、暗黑模式支持

8.1 配置暗黑模式

// tailwind.config.ts
export default {
  darkMode: 'class', // 使用class策略
  // ...
}

8.2 暗黑模式组件

// components/DarkModeToggle.tsx
'use client'

import { useEffect, useState } from 'react'

export function DarkModeToggle() {
  const [isDark, setIsDark] = useState(false)

  useEffect(() => {
    const isDarkMode = localStorage.getItem('darkMode') === 'true'
    setIsDark(isDarkMode)
    document.documentElement.classList.toggle('dark', isDarkMode)
  }, [])

  const toggle = () => {
    const newValue = !isDark
    setIsDark(newValue)
    localStorage.setItem('darkMode', String(newValue))
    document.documentElement.classList.toggle('dark', newValue)
  }

  return (
    <button
      onClick={toggle}
      className="p-2 rounded-lg hover:bg-gray-2 dark:hover:bg-gray-8 transition-colors"
    >
      {isDark ? '🌙' : '☀️'}
    </button>
  )
}

8.3 暗黑模式样式

// 使用dark:前缀
<div className="
  bg-white dark:bg-gray-9
  text-gray-10 dark:text-gray-1
  border-gray-4 dark:border-gray-7
">
  支持暗黑模式的内容
</div>

九、性能优化

9.1 PurgeCSS优化

// tailwind.config.ts
export default {
  content: [
    './app/**/*.{js,ts,jsx,tsx}',
    './components/**/*.{js,ts,jsx,tsx}',
  ],
  // 自动移除未使用的CSS
}

9.2 JIT模式

// Tailwind 3.0+ 默认启用JIT
// 按需生成CSS,大幅减小文件体积

十、总结

TailwindCSS构建UI组件的核心要点:

原子化CSS - 快速构建UI
响应式设计 - 移动端优先
暗黑模式 - 提升用户体验
自定义配置 - 符合设计系统
性能优化 - JIT模式按需生成

下一篇预告: 《TypeScript类型安全:前端与后端的契约设计》


作者简介: 资深开发者,创业者。专注于视频通讯技术领域。国内首本Flutter著作《Flutter技术入门与实战》作者,另著有《Dart语言实战》及《WebRTC音视频开发》等书籍。多年从事视频会议、远程教育等技术研发,对于Android、iOS以及跨平台开发技术有比较深入的研究和应用,作为主要程序员开发了多个应用项目,涉及医疗、交通、银行等领域。

学习资料:

欢迎交流: 如有问题欢迎在评论区讨论 🚀