在昨天的 [Day 13] 中,我們透過 Google Stitch 體驗了自然語言生成 UI 的震撼,僅花了幾分鐘就催生出 OmniVibe AI 具備深色科技感的現代化 Dashboard 原型。
然而,AI 原型工具導出的代碼往往是一大串寫在單一檔案裡的「巨型 Monolithic 結構(Div 湯)」,充斥著寫死的寫死數據(Hardcoded Values)、缺乏 TypeScript 型別定義,且未進行組件解耦與無障礙(a11y)優化。
今天,我們將進行真實工程落地的關鍵一步 —— 將 Google Stitch 導出的靜態原型代碼,拆解、重構並移植到 Next.js 15 + Tailwind CSS + shadcn/ui 架構中,打造可維護、可擴充且具備生產級品質的前端組件樹!
AI 介面生成工具處理的是「視覺展示」,而全棧開發者處理的是「狀態與架構」。直接拿原生的原型代碼硬套,會帶來三大痛點:
'use client',喪失 SSR 與伺服器渲染優勢。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 採用「代碼所有權(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 原型拆分為四大核心元件,並將數據邏輯解耦。
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>
);
}
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>
);
}
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 原型 到 生產級程式碼 的質變:
DropzoneArea、ResultViewer 等模組化組件。擁有了優雅的 UI 介面與精緻的狀態控制後,下一步就是打通數據串流的最後一公里 —— 「如何讓使用者上傳的大檔案(百 MB 影音/PDF)無縫串接至 Google AI File API 進行大腦處理?」
👉 明天(Day 15),我們將進入【檔案管線與串流篇】:實戰 Server Actions + Google AI File API 多模態檔案處理管線!看我們如何用代碼輕鬆處理大檔案上傳與異步狀態監控!
我們明天見!🔥