iT邦幫忙

2026 iThome 鐵人賽

DAY 14
0
Build on Google AI

用 Google AI 生態系 30 天從零打造一個全棧 AI SaaS 服務系列 第 14 篇

Day 14 -【元件化重構】將 Stitch 原型全面移植至 Next.js 15 + Tailwind CSS + shadcn/ui:打造生產級模組化 UI

  • 分享至 

  • xImage
  •  

在昨天的 [Day 13] 中,我們透過 Google Stitch 體驗了自然語言生成 UI 的震撼,僅花了幾分鐘就催生出 OmniVibe AI 具備深色科技感的現代化 Dashboard 原型。

然而,AI 原型工具導出的代碼往往是一大串寫在單一檔案裡的「巨型 Monolithic 結構(Div 湯)」,充斥著寫死的寫死數據(Hardcoded Values)、缺乏 TypeScript 型別定義,且未進行組件解耦與無障礙(a11y)優化。

今天,我們將進行真實工程落地的關鍵一步 —— 將 Google Stitch 導出的靜態原型代碼,拆解、重構並移植到 Next.js 15 + Tailwind CSS + shadcn/ui 架構中,打造可維護、可擴充且具備生產級品質的前端組件樹!


🏗️ 為什麼需要重構?從「原型」到「生產級 UI」的鴻溝

AI 介面生成工具處理的是「視覺展示」,而全棧開發者處理的是「狀態與架構」。直接拿原生的原型代碼硬套,會帶來三大痛點:

  1. 代碼重複與維護災難:相同風格的按鈕、卡片或 Badge 散落各處,修改配色需改動數十個地方。
  2. 缺乏 Client / Server Component 邊界劃分:在 Next.js 15 App Router 中,若不妥善拆分元件,容易導致整頁被迫標註為 'use client',喪失 SSR 與伺服器渲染優勢。
  3. 缺少狀態驅動(State Driven):拖曳檔案的過渡狀態、內容生成時的 Loading 骨架屏(Skeleton)、流式傳輸(Streaming)的動態渲染均無法直接從原型獲得。
graph TD
    subgraph 原型階段 (Stitch Export)
        A[Monolithic monolithic.tsx / 數百行單檔]
    end

    subgraph 生產級組件樹 (Next.js 15 + shadcn/ui)
        A --> B[app/dashboard/page.tsx - Server Component]
        B --> C[AppSidebar.tsx - 側邊欄]
        B --> D[TopNavbar.tsx - 頂部導航]
        B --> E[Workspace - 工作區]
        
        E --> F[DropzoneArea.tsx - 檔案拖曳與狀態]
        E --> G[TemplatePicker.tsx - 提煉風格選擇器]
        E --> H[ResultViewer.tsx - 結構化產出與 Tab 切換]
    end


🛠️ 第一步:環境準備與 shadcn/ui 元件庫初始化

shadcn/ui 採用「代碼所有權(Code Ownership)」模式,將元件原始碼直接拷貝至專案的 src/components/ui 目錄下,完美契合客製化需求。

我們先安裝 OmniVibe AI 儀表板所需的基礎原語(Primitives):

npx shadcn@latest init -d
npx shadcn@latest add button card tabs progress badge tooltip dropdown-menu avatar


🧩 第二步:核心組件拆解與實作

我們將 Stitch 原型拆分為四大核心元件,並將數據邏輯解耦。

1. 拖曳上傳與檔案選擇區 (src/components/dashboard/DropzoneArea.tsx)

將 Stitch 生成的純靜態 HTML 轉化為支援 Drag-and-Drop、檔案格式過濾與預覽狀態的 Client Component:

// src/components/dashboard/DropzoneArea.tsx
'use client';

import React, { useState, useRef } from 'react';
import { UploadCloud, File, X, Film, Music, FileText } from 'lucide-react';
import { Button } from '@/components/ui/button';

interface DropzoneAreaProps {
  onFileSelect: (file: File | null) => void;
  selectedFile: File | null;
}

export function DropzoneArea({ onFileSelect, selectedFile }: DropzoneAreaProps) {
  const [isDragging, setIsDragging] = useState(false);
  const fileInputRef = useRef<HTMLInputElement>(null);

  const handleDragOver = (e: React.DragEvent) => {
    e.preventDefault();
    setIsDragging(true);
  };

  const handleDragLeave = () => setIsDragging(false);

  const handleDrop = (e: React.DragEvent) => {
    e.preventDefault();
    setIsDragging(false);
    if (e.dataTransfer.files && e.dataTransfer.files[0]) {
      onFileSelect(e.dataTransfer.files[0]);
    }
  };

  const getFileIcon = (file: File) => {
    if (file.type.startsWith('video/')) return <Film className="w-8 h-8 text-indigo-400" />;
    if (file.type.startsWith('audio/')) return <Music className="w-8 h-8 text-violet-400" />;
    return <FileText className="w-8 h-8 text-emerald-400" />;
  };

  return (
    <div
      onDragOver={handleDragOver}
      onDragLeave={handleDragLeave}
      onDrop={handleDrop}
      onClick={() => !selectedFile && fileInputRef.current?.click()}
      className={`relative border-2 border-dashed rounded-xl p-8 text-center transition-all cursor-pointer ${
        isDragging
          ? 'border-indigo-500 bg-indigo-500/10 scale-[1.01]'
          : selectedFile
          ? 'border-slate-700 bg-slate-900/50'
          : 'border-slate-800 bg-slate-950/40 hover:border-slate-700 hover:bg-slate-900/30'
      }`}
    >
      <input
        type="file"
        ref={fileInputRef}
        onChange={(e) => e.target.files?.[0] && onFileSelect(e.target.files[0])}
        accept="video/mp4,audio/mp3,application/pdf"
        className="hidden"
      />

      {selectedFile ? (
        <div className="flex items-center justify-between p-4 bg-slate-900 rounded-lg border border-slate-800">
          <div className="flex items-center space-x-4">
            {getFileIcon(selectedFile)}
            <div className="text-left">
              <p className="text-sm font-medium text-slate-200 truncate max-w-[200px]">
                {selectedFile.name}
              </p>
              <p className="text-xs text-slate-500">
                {(selectedFile.size / (1024 * 1024)).toFixed(2)} MB
              </p>
            </div>
          </div>
          <Button
            type="button"
            variant="ghost"
            size="icon"
            onClick={(e) => {
              e.stopPropagation();
              onFileSelect(null);
            }}
            className="text-slate-400 hover:text-slate-100"
          >
            <X className="w-5 h-5" />
          </Button>
        </div>
      ) : (
        <div className="flex flex-col items-center space-y-3">
          <div className="p-4 rounded-full bg-slate-900 border border-slate-800 text-indigo-400">
            <UploadCloud className="w-8 h-8" />
          </div>
          <div>
            <p className="text-sm font-medium text-slate-300">
              拖曳音訊、影片或 PDF 至此,或 <span className="text-indigo-400 underline">點擊瀏覽檔案</span>
            </p>
            <p className="text-xs text-slate-500 mt-1">支援 MP4, MP3, PDF(單檔最大 100MB)</p>
          </div>
        </div>
      )}
    </div>
  );
}


2. 成果展示與分頁檢視區 (src/components/dashboard/ResultViewer.tsx)

利用 shadcn/ui 的 Tabs 原語,封裝具備一鍵複製、 Markdown 高亮與動態切換功能的結果展示面板:

// src/components/dashboard/ResultViewer.tsx
'use client';

import React, { useState } from 'react';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import { Copy, Check, Sparkles, FileText, Share2 } from 'lucide-react';

interface ResultViewerProps {
  isLoading: boolean;
  data: {
    takeaways?: string[];
    socialPosts?: string;
    script?: string;
  } | null;
}

export function ResultViewer({ isLoading, data }: ResultViewerProps) {
  const [copied, setCopied] = useState(false);

  const handleCopy = (text: string) => {
    navigator.clipboard.writeText(text);
    setCopied(true);
    setTimeout(() => setCopied(false), 2000);
  };

  if (isLoading) {
    return (
      <div className="h-full min-h-[400px] flex flex-col items-center justify-center border border-slate-800 rounded-xl bg-slate-950/40 p-6 space-y-4">
        <Sparkles className="w-8 h-8 text-indigo-400 animate-spin" />
        <p className="text-sm text-slate-400 animate-pulse">
          Gemini 1.5 正在吞吐多模態內容並提取精華...
        </p>
      </div>
    );
  }

  if (!data) {
    return (
      <div className="h-full min-h-[400px] flex flex-col items-center justify-center border border-slate-800 rounded-xl bg-slate-950/40 p-6 text-center">
        <FileText className="w-12 h-12 text-slate-700 mb-3" />
        <p className="text-sm font-medium text-slate-400">尚未產生分析內容</p>
        <p className="text-xs text-slate-600 mt-1">上傳資產並點擊「開始 AI 內容提煉」以預覽成果</p>
      </div>
    );
  }

  return (
    <div className="border border-slate-800 rounded-xl bg-slate-950/60 p-5 space-y-4">
      <Tabs defaultValue="takeaways" className="w-full">
        <div className="flex items-center justify-between border-b border-slate-800 pb-3">
          <TabsList className="bg-slate-900 border border-slate-800">
            <TabsTrigger value="takeaways">核心洞見</TabsTrigger>
            <TabsTrigger value="posts">爆款社群文</TabsTrigger>
            <TabsTrigger value="script">短影音腳本</TabsTrigger>
          </TabsList>

          <Button
            variant="outline"
            size="sm"
            onClick={() => handleCopy(JSON.stringify(data, null, 2))}
            className="border-slate-800 bg-slate-900 text-xs hover:bg-slate-800"
          >
            {copied ? <Check className="w-3.5 h-3.5 mr-1 text-emerald-400" /> : <Copy className="w-3.5 h-3.5 mr-1" />}
            {copied ? '已複製' : '複製內容'}
          </Button>
        </div>

        <TabsContent value="takeaways" className="mt-4 space-y-3">
          <ul className="space-y-2">
            {data.takeaways?.map((item, idx) => (
              <li key={idx} className="p-3 bg-slate-900/50 border border-slate-800/80 rounded-lg text-sm text-slate-300 flex items-start">
                <span className="w-2 h-2 rounded-full bg-indigo-500 mt-1.5 mr-3 flex-shrink-0" />
                {item}
              </li>
            ))}
          </ul>
        </TabsContent>

        <TabsContent value="posts" className="mt-4">
          <div className="p-4 bg-slate-900/50 border border-slate-800 rounded-lg text-sm text-slate-200 whitespace-pre-wrap font-sans leading-relaxed">
            {data.socialPosts}
          </div>
        </TabsContent>

        <TabsContent value="script" className="mt-4">
          <div className="p-4 bg-slate-900/50 border border-slate-800 rounded-lg text-sm text-slate-200 whitespace-pre-wrap font-mono leading-relaxed">
            {data.script}
          </div>
        </TabsContent>
      </Tabs>
    </div>
  );
}


🏛️ 第三步:整合 Dashboard 主頁面 (src/app/dashboard/page.tsx)

現在我們將重構後的 UI 組件裝配至 Next.js 15 App Router 的主頁面中,保持乾淨的層次劃分:

// src/app/dashboard/page.tsx
'use client';

import React, { useState } from 'react';
import { DropzoneArea } from '@/components/dashboard/DropzoneArea';
import { ResultViewer } from '@/components/dashboard/ResultViewer';
import { Button } from '@/components/ui/button';
import { Sparkles, Zap } from 'lucide-react';

export default function DashboardPage() {
  const [selectedFile, setSelectedFile] = useState<File | null>(null);
  const [isLoading, setIsLoading] = useState(false);
  const [resultData, setResultData] = useState<any>(null);

  const handleStartDistill = async () => {
    if (!selectedFile) return;

    setIsLoading(true);
    // 模擬呼叫 Day 07 - Day 12 打造的後端 Gemini API
    setTimeout(() => {
      setResultData({
        takeaways: [
          '長上下文大模型(Long-context LLM)正在全面取代傳統繁瑣的 RAG 切片架構。',
          '原生多模態音訊與影片理解能大幅減少語音轉文字(STT)的資訊失真。',
          '結合 Context Caching 可以直接降低 75% 的重複輸入成本。',
        ],
        socialPosts: `🚀 不再手刻幾百行 API!\n今天帶大家用 Next.js 15 + Google Gemini 1.5 打造可可提煉長影音與 PDF 的 AI SaaS。\n\n🔥 核心賣點:\n- 吞吐 45 分鐘影片無需裁切\n- 結構化輸出消除白屏\n- 實戰上下文快取省 75% 費用\n\n#BuildOnGoogleAI #VibeCoding #Nextjs15`,
        script: `[00:00 - 00:05] (畫面: 開頭極速切換畫面) 講者: 「你還在用舊方法切片 RAG 嗎?」\n[00:05 - 00:20] (畫面: 展示 OmniVibe AI 拖曳影音與極速產出成果)\n[00:20 - 00:30] (畫面: 呼籲點擊贊助與試用連結)`,
      });
      setIsLoading(false);
    }, 2500);
  };

  return (
    <div className="min-h-screen bg-[#090D16] text-slate-100 p-6 md:p-10">
      <div className="max-w-7xl mx-auto space-y-8">
        {/* Header */}
        <div className="flex flex-col md:flex-row md:items-center justify-between gap-4 border-b border-slate-800 pb-6">
          <div>
            <h1 className="text-2xl font-bold tracking-tight text-white flex items-center gap-2">
              OmniVibe AI 控制台 <Zap className="w-5 h-5 text-indigo-400 fill-indigo-400" />
            </h1>
            <p className="text-sm text-slate-400 mt-1">
              將長篇影片、 Podcast 音訊或 PDF 論文一鍵提煉為多通道爆款內容
            </p>
          </div>
        </div>

        {/* 雙欄主工作區 */}
        <div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
          {/* 左欄:內容輸入區 */}
          <div className="lg:col-span-6 space-y-6">
            <div className="bg-slate-950/40 border border-slate-800 p-6 rounded-xl space-y-4">
              <h2 className="text-base font-semibold text-slate-200">1. 上傳多模態來源資產</h2>
              <DropzoneArea onFileSelect={setSelectedFile} selectedFile={selectedFile} />
            </div>

            <Button
              onClick={handleStartDistill}
              disabled={!selectedFile || isLoading}
              className="w-full h-12 bg-gradient-to-r from-indigo-600 to-violet-600 hover:from-indigo-500 hover:to-violet-500 text-white font-medium shadow-lg shadow-indigo-500/20 rounded-xl"
            >
              {isLoading ? (
                <Sparkles className="w-5 h-5 mr-2 animate-spin" />
              ) : (
                <Sparkles className="w-5 h-5 mr-2" />
              )}
              {isLoading ? 'AI 提煉進行中...' : '開始 AI 內容提煉 (⌘ + Enter)'}
            </Button>
          </div>

          {/* 右欄:成果展示區 */}
          <div className="lg:col-span-6">
            <ResultViewer isLoading={isLoading} data={resultData} />
          </div>
        </div>
      </div>
    </div>
  );
}


🎯 總結與明日預告

今天我們成功完成從 AI 原型 到 生產級程式碼 的質變:

  1. 擺脫單檔巨集:將 Stitch 產出的單一原型解耦為 DropzoneArea、ResultViewer 等模組化組件。
  2. 導入 shadcn/ui 原語:賦予元件極致的無障礙支援、過渡動畫與一致的 Design Tokens。
  3. 完成狀態連接:讓 UI 具備 Drag-and-Drop、檔案狀態預覽、Loading 骨架屏與一鍵複製等生產級細節。

擁有了優雅的 UI 介面與精緻的狀態控制後,下一步就是打通數據串流的最後一公里 —— 「如何讓使用者上傳的大檔案(百 MB 影音/PDF)無縫串接至 Google AI File API 進行大腦處理?」

👉 明天(Day 15),我們將進入【檔案管線與串流篇】:實戰 Server Actions + Google AI File API 多模態檔案處理管線!看我們如何用代碼輕鬆處理大檔案上傳與異步狀態監控!

我們明天見!🔥


上一篇
Day 13 -【Google Stitch】AI 介面生成實戰:用自然語言疾速打造 OmniVibe AI 現代化 Dashboard 前端原型
下一篇
Day 15 -【檔案管線】Server Actions + Google AI File API 多模態檔案處理管線:巨型影音與 PDF 異步上傳與狀態監控實戰
系列文
用 Google AI 生態系 30 天從零打造一個全棧 AI SaaS 服務 共 18 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言