未載入 Skill3324 tokens · 66110 ms
## 三個 UI 原型方向
1. **總覽儀表板 `?view=overview`**
以 KPI、趨勢與分類占比為主,適合第一次快速掌握整體狀態。
2. **資料表優先 `?view=table`**
直接呈現可掃描的明細表與摘要,適合重視核對、比較資料的使用者。
3. **洞察摘要 `?view=insights`**
將資料轉成敘事式重點與建議行動,適合不熟悉報表或只想知道「接下來要做什麼」的使用者。
```tsx
// app/reports/page.tsx
type View = "overview" | "table" | "insights";
const rows = [
{ channel: "自然搜尋", visits: 4280, conversions: 312, revenue: 186400 },
{ channel: "付費廣告", visits: 3150, conversions: 246, revenue: 143200 },
{ channel: "電子郵件", visits: 1760, conversions: 198, revenue: 121800 },
{ channel: "社群媒體", visits: 2340, conversions: 124, revenue: 79600 },
];
const money = new Intl.NumberFormat("zh-TW", {
style: "currency",
currency: "TWD",
maximumFractionDigits: 0,
});
const number = new Intl.NumberFormat("zh-TW");
function Overview() {
const visits = rows.reduce((sum, row) => sum + row.visits, 0);
const conversions = rows.reduce((sum, row) => sum + row.conversions, 0);
const revenue = rows.reduce((sum, row) => sum + row.revenue, 0);
return (
<>
<header>
<p style={styles.eyebrow}>本月營運總覽</p>
<h1 style={styles.title}>資料表現一眼看懂</h1>
<p style={styles.muted}>先看關鍵結果,再決定是否深入明細。</p>
</header>
<section style={styles.cardGrid}>
<Metric label="總造訪" value={number.format(visits)} note="較上月 +8.4%" />
<Metric label="轉換數" value={number.format(conversions)} note="較上月 +5.1%" />
<Metric label="營收" value={money.format(revenue)} note="較上月 +11.2%" />
</section>
<section style={styles.panel}>
<h2 style={styles.subtitle}>各管道營收</h2>
<div style={styles.barList}>
{rows.map((row) => (
<div key={row.channel}>
<div style={styles.barLabel}>
<span>{row.channel}</span>
<strong>{money.format(row.revenue)}</strong>
</div>
<div style={styles.track}>
<div
style={{
...styles.bar,
width: `${(row.revenue / 186400) * 100}%`,
}}
/>
</div>
</div>
))}
</div>
</section>
</>
);
}
function TableView() {
return (
<>
<header style={styles.splitHeader}>
<div>
<p style={styles.eyebrow}>管道明細</p>
<h1 style={styles.title}>本月資料表</h1>
</div>
<div style={styles.summary}>共 {rows.length} 筆管道資料</div>
</header>
<section style={{ ...styles.panel, overflowX: "auto" }}>
<table style={styles.table}>
<thead>
<tr>
<th style={styles.th}>管道</th>
<th style={styles.thRight}>造訪</th>
<th style={styles.thRight}>轉換</th>
<th style={styles.thRight}>轉換率</th>
<th style={styles.thRight}>營收</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.channel}>
<td style={styles.td}>{row.channel}</td>
<td style={styles.tdRight}>{number.format(row.visits)}</td>
<td style={styles.tdRight}>{number.format(row.conversions)}</td>
<td style={styles.tdRight}>
{((row.conversions / row.visits) * 100).toFixed(1)}%
</td>
<td style={styles.tdRight}>{money.format(row.revenue)}</td>
</tr>
))}
</tbody>
</table>
</section>
</>
);
}
function Insights() {
const best = [...rows].sort((a, b) => b.revenue - a.revenue)[0];
const efficient = [...rows].sort(
(a, b) => b.conversions / b.visits - a.conversions / a.visits,
)[0];
return (
<>
<header>
<p style={styles.eyebrow}>自動摘要</p>
<h1 style={styles.title}>這份資料告訴你什麼?</h1>
<p style={styles.muted}>用重點與下一步取代密集圖表。</p>
</header>
<section style={styles.insightGrid}>
<article style={styles.darkCard}>
<span style={styles.badge}>最重要發現</span>
<h2 style={styles.insightTitle}>
{best.channel}帶來最高營收,貢獻 {money.format(best.revenue)}。
</h2>
<p style={styles.darkMuted}>
可優先檢視其內容與流量來源,找出能複製到其他管道的做法。
</p>
</article>
<article style={styles.panel}>
<p style={styles.eyebrow}>效率亮點</p>
<h2 style={styles.subtitle}>{efficient.channel}的轉換率最高</h2>
<p style={styles.muted}>
每 100 次造訪約產生{" "}
{((efficient.conversions / efficient.visits) * 100).toFixed(1)} 次轉換。
</p>
</article>
<article style={styles.panel}>
<p style={styles.eyebrow}>建議下一步</p>
<ol style={styles.actions}>
<li>比較自然搜尋與電子郵件的高轉換內容。</li>
<li>確認付費廣告的獲客成本是否符合目標。</li>
<li>為社群媒體設定下一期改善基準。</li>
</ol>
</article>
</section>
</>
);
}
function Metric({
label,
value,
note,
}: {
label: string;
value: string;
note: string;
}) {
return (
<article style={styles.panel}>
<p style={styles.metricLabel}>{label}</p>
<strong style={styles.metricValue}>{value}</strong>
<p style={styles.positive}>{note}</p>
</article>
);
}
export default async function ReportsPage({
searchParams,
}: {
searchParams: Promise<{ view?: string }>;
}) {
const params = await searchParams;
const view: View =
params.view === "table" || params.view === "insights"
? params.view
: "overview";
return (
<main style={styles.page}>
<div style={styles.content}>
{view === "overview" && <Overview />}
{view === "table" && <TableView />}
{view === "insights" && <Insights />}
</div>
<nav aria-label="切換報表原型" style={styles.switcher}>
<SwitchLink view="overview" current={view} label="總覽儀表板" />
<SwitchLink view="table" current={view} label="資料表優先" />
<SwitchLink view="insights" current={view} label="洞察摘要" />
</nav>
</main>
);
}
function SwitchLink({
view,
current,
label,
}: {
view: View;
current: View;
label: string;
}) {
const active = view === current;
return (
<a
href={`/reports?view=${view}`}
aria-current={active ? "page" : undefined}
style={{
...styles.switchLink,
...(active ? styles.switchLinkActive : {}),
}}
>
{label}
</a>
);
}
const styles: Record<string, React.CSSProperties> = {
page: {
minHeight: "100vh",
background: "#f4f5f7",
color: "#18202b",
fontFamily: "Arial, sans-serif",
padding: "56px 24px 120px",
},
content: { width: "min(1040px, 100%)", margin: "0 auto" },
eyebrow: {
margin: "0 0 8px",
color: "#617083",
fontSize: 13,
fontWeight: 700,
letterSpacing: 1.2,
textTransform: "uppercase",
},
title: { margin: 0, fontSize: "clamp(32px, 5vw, 52px)", lineHeight: 1.1 },
subtitle: { margin: "0 0 12px", fontSize: 22 },
muted: { color: "#687587", lineHeight: 1.7 },
cardGrid: {
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(210px, 1fr))",
gap: 16,
margin: "36px 0 16px",
},
panel: {
padding: 24,
background: "#fff",
border: "1px solid #e1e5ea",
borderRadius: 18,
},
metricLabel: { margin: 0, color: "#687587" },
metricValue: { display: "block", margin: "12px 0", fontSize: 30 },
positive: { margin: 0, color: "#16834b", fontSize: 14 },
barList: { display: "grid", gap: 20, marginTop: 24 },
barLabel: {
display: "flex",
justifyContent: "space-between",
gap: 16,
marginBottom: 8,
},
track: { height: 10, overflow: "hidden", background: "#edf0f3", borderRadius: 99 },
bar: { height: "100%", background: "#3167e3", borderRadius: 99 },
splitHeader: {
display: "flex",
alignItems: "end",
justifyContent: "space-between",
gap: 24,
marginBottom: 28,
},
summary: {
padding: "10px 14px",
background: "#e7edff",
color: "#274ea5",
borderRadius: 99,
fontSize: 14,
},
table: { width: "100%", borderCollapse: "collapse", minWidth: 660 },
th: {
padding: "14px 12px",
borderBottom: "2px solid #dfe4ea",
textAlign: "left",
color: "#617083",
},
thRight: {
padding: "14px 12px",
borderBottom: "2px solid #dfe4ea",
textAlign: "right",
color: "#617083",
},
td: { padding: "18px 12px", borderBottom: "1px solid #edf0f3" },
tdRight: {
padding: "18px 12px",
borderBottom: "1px solid #edf0f3",
textAlign: "right",
},
insightGrid: { display: "grid", gap: 16, marginTop: 36 },
darkCard: {
padding: 32,
color: "#fff",
background: "#172033",
borderRadius: 24,
},
badge: {
display: "inline-block",
padding: "7px 10px",
color: "#bcd0ff",
background: "#293756",
borderRadius: 99,
fontSize: 13,
},
insightTitle: {
maxWidth: 700,
margin: "22px 0 12px",
fontSize: "clamp(26px, 4vw, 42px)",
lineHeight: 1.2,
},
darkMuted: { maxWidth: 700, color: "#bac4d6", lineHeight: 1.7 },
actions: { margin: "12px 0 0", paddingLeft: 22, lineHeight: 2 },
switcher: {
position: "fixed",
left: "50%",
bottom: 22,
zIndex: 10,
display: "flex",
gap: 6,
width: "max-content",
maxWidth: "calc(100% - 32px)",
padding: 6,
overflowX: "auto",
background: "#172033",
borderRadius: 16,
boxShadow: "0 12px 36px rgba(23, 32, 51, .25)",
transform: "translateX(-50%)",
},
switchLink: {
padding: "11px 15px",
color: "#bbc4d4",
borderRadius: 11,
textDecoration: "none",
whiteSpace: "nowrap",
fontSize: 14,
},
switchLinkActive: { color: "#172033", background: "#fff", fontWeight: 700 },
};
```
已載入 Skill +4643 tokens · 98409 ms
## 三個 UI 原型方向
1. **A|引導式建立**
用三個簡短問題帶使用者建立第一份報表,適合需要降低空白頁焦慮的產品。
2. **B|範本優先**
先展示常見報表範本,使用者挑一個再調整,適合已有明確使用情境的產品。
3. **C|範例資料預覽**
首次進入便看到完整示範報表,再引導替換成自己的資料,適合讓使用者先理解成果。
切換網址:
- `/reports?variant=A`
- `/reports?variant=B`
- `/reports?variant=C`
```tsx
// app/reports/page.tsx
"use client";
/**
* PROTOTYPE:
* Three first-use report variants on /reports, switchable via ?variant=.
* Throw this file away after choosing a direction.
*/
import {
Suspense,
useCallback,
useEffect,
useState,
type CSSProperties,
} from "react";
import { useRouter, useSearchParams } from "next/navigation";
const variants = [
{ key: "A", name: "引導式建立" },
{ key: "B", name: "範本優先" },
{ key: "C", name: "範例資料預覽" },
] as const;
type VariantKey = (typeof variants)[number]["key"];
const templates = [
{ name: "營運概覽", detail: "收入、訂單與轉換率" },
{ name: "行銷成效", detail: "流量、活動與獲客成本" },
{ name: "客戶健康度", detail: "活躍、留存與流失風險" },
];
const sampleRows = [
["本月收入", "NT$ 1,284,000", "+12.4%"],
["新客戶", "328", "+8.1%"],
["轉換率", "4.7%", "-0.3%"],
];
export default function ReportsPage() {
return (
<Suspense fallback={<main style={pageStyle}>載入原型⋯</main>}>
<ReportsPrototype />
</Suspense>
);
}
function ReportsPrototype() {
const searchParams = useSearchParams();
const rawVariant = searchParams.get("variant")?.toUpperCase();
const variant: VariantKey =
rawVariant === "B" || rawVariant === "C" ? rawVariant : "A";
return (
<>
{variant === "A" && <GuidedSetup />}
{variant === "B" && <TemplateGallery />}
{variant === "C" && <SampleReport />}
<PrototypeSwitcher current={variant} />
</>
);
}
function GuidedSetup() {
return (
<main style={pageStyle}>
<header style={headerStyle}>
<div>
<span style={eyebrowStyle}>報表</span>
<h1 style={titleStyle}>建立第一份資料報表</h1>
<p style={mutedStyle}>回答三個問題,我們先替你組出一個版本。</p>
</div>
<span style={badgeStyle}>步驟 1 / 3</span>
</header>
<section style={splitStyle}>
<div style={panelStyle}>
<h2>你最想追蹤什麼?</h2>
<div style={choiceGridStyle}>
{["營收成長", "行銷成效", "客戶留存", "團隊效率"].map(
(item) => (
<button key={item} style={choiceStyle}>
<strong>{item}</strong>
<span style={mutedStyle}>選擇這個目標 →</span>
</button>
),
)}
</div>
</div>
<aside style={{ ...panelStyle, background: "#111827", color: "white" }}>
<span style={{ opacity: 0.65 }}>即將產生</span>
<h2>你的管理摘要</h2>
<ul style={{ lineHeight: 2 }}>
<li>3 個核心指標</li>
<li>一張趨勢圖</li>
<li>本週值得注意的變化</li>
</ul>
</aside>
</section>
</main>
);
}
function TemplateGallery() {
const [selected, setSelected] = useState<string | null>(null);
return (
<main style={pageStyle}>
<header style={headerStyle}>
<div>
<span style={eyebrowStyle}>報表範本庫</span>
<h1 style={titleStyle}>你想從哪一份開始?</h1>
</div>
<button style={secondaryButtonStyle}>從空白建立</button>
</header>
<section style={templateLayoutStyle}>
<nav style={sidebarStyle}>
<strong>分類</strong>
{["推薦", "營運", "行銷", "客戶", "財務"].map((item) => (
<button key={item} style={navButtonStyle}>
{item}
</button>
))}
</nav>
<div style={choiceGridStyle}>
{templates.map((template, index) => (
<article key={template.name} style={panelStyle}>
<div style={previewStyle}>範本預覽 {index + 1}</div>
<h2>{template.name}</h2>
<p style={mutedStyle}>{template.detail}</p>
<button
style={primaryButtonStyle}
onClick={() => setSelected(template.name)}
>
使用這個範本
</button>
</article>
))}
</div>
</section>
{selected && <p style={stateStyle}>目前選擇:{selected}</p>}
</main>
);
}
function SampleReport() {
const [showSample, setShowSample] = useState(true);
return (
<main style={pageStyle}>
<header style={headerStyle}>
<div>
<span style={eyebrowStyle}>報表 · 示範資料</span>
<h1 style={titleStyle}>本月營運概覽</h1>
<p style={mutedStyle}>先看看完成後的樣子,再換成你的資料。</p>
</div>
<button
style={primaryButtonStyle}
onClick={() => setShowSample((value) => !value)}
>
{showSample ? "換成我的資料" : "恢復示範資料"}
</button>
</header>
<section style={metricGridStyle}>
{showSample ? (
sampleRows.map(([label, value, change]) => (
<article key={label} style={panelStyle}>
<span style={mutedStyle}>{label}</span>
<h2 style={{ fontSize: 30 }}>{value}</h2>
<strong style={{ color: change.startsWith("+") ? "#15803d" : "#b45309" }}>
{change}
</strong>
</article>
))
) : (
<article style={{ ...panelStyle, gridColumn: "1 / -1" }}>
<h2>這裡會顯示你的資料</h2>
<p style={mutedStyle}>此原型不連接 API;資料替換流程尚未實作。</p>
</article>
)}
</section>
<section style={panelStyle}>
<h2>最近六個月趨勢</h2>
<div style={chartStyle}>
{[38, 55, 46, 70, 66, 88].map((height, index) => (
<div
key={index}
style={{ ...barStyle, height: `${height}%` }}
title={`${height}`}
/>
))}
</div>
</section>
</main>
);
}
function PrototypeSwitcher({ current }: { current: VariantKey }) {
const router = useRouter();
const searchParams = useSearchParams();
const currentIndex = variants.findIndex((item) => item.key === current);
const move = useCallback(
(offset: number) => {
const next =
variants[
(currentIndex + offset + variants.length) % variants.length
];
const params = new URLSearchParams(searchParams.toString());
params.set("variant", next.key);
router.replace(`/reports?${params.toString()}`, { scroll: false });
},
[currentIndex, router, searchParams],
);
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
const target = event.target as HTMLElement;
if (
target.matches("input, textarea, select") ||
target.isContentEditable
) {
return;
}
if (event.key === "ArrowLeft") move(-1);
if (event.key === "ArrowRight") move(1);
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [move]);
if (process.env.NODE_ENV === "production") return null;
const active = variants[currentIndex];
return (
<div style={switcherStyle} aria-label="報表原型切換列">
<button style={switchButtonStyle} onClick={() => move(-1)} aria-label="上一個">
←
</button>
<span>
{active.key} · {active.name}
</span>
<button style={switchButtonStyle} onClick={() => move(1)} aria-label="下一個">
→
</button>
</div>
);
}
const pageStyle: CSSProperties = {
minHeight: "100vh",
padding: "48px clamp(20px, 6vw, 88px) 120px",
background: "#f6f7f9",
color: "#111827",
fontFamily: "Arial, sans-serif",
};
const headerStyle: CSSProperties = {
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
gap: 24,
marginBottom: 40,
};
const titleStyle: CSSProperties = {
margin: "8px 0",
fontSize: "clamp(32px, 5vw, 56px)",
lineHeight: 1.05,
};
const eyebrowStyle: CSSProperties = {
color: "#4f46e5",
fontSize: 13,
fontWeight: 700,
letterSpacing: 1.5,
textTransform: "uppercase",
};
const mutedStyle: CSSProperties = { color: "#6b7280" };
const panelStyle: CSSProperties = {
padding: 24,
border: "1px solid #e5e7eb",
borderRadius: 18,
background: "white",
};
const splitStyle: CSSProperties = {
display: "grid",
gridTemplateColumns: "minmax(0, 2fr) minmax(240px, 1fr)",
gap: 24,
};
const choiceGridStyle: CSSProperties = {
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(210px, 1fr))",
gap: 16,
};
const choiceStyle: CSSProperties = {
display: "grid",
gap: 10,
padding: 20,
textAlign: "left",
border: "1px solid #d1d5db",
borderRadius: 14,
background: "white",
cursor: "pointer",
};
const templateLayoutStyle: CSSProperties = {
display: "grid",
gridTemplateColumns: "160px minmax(0, 1fr)",
gap: 28,
};
const sidebarStyle: CSSProperties = {
display: "flex",
flexDirection: "column",
gap: 8,
};
const navButtonStyle: CSSProperties = {
padding: "10px 12px",
border: 0,
borderRadius: 8,
background: "transparent",
textAlign: "left",
cursor: "pointer",
};
const previewStyle: CSSProperties = {
height: 120,
display: "grid",
placeItems: "center",
borderRadius: 12,
background: "#eef2ff",
color: "#4f46e5",
};
const metricGridStyle: CSSProperties = {
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))",
gap: 16,
marginBottom: 24,
};
const chartStyle: CSSProperties = {
height: 240,
display: "flex",
alignItems: "flex-end",
gap: 18,
paddingTop: 24,
};
const barStyle: CSSProperties = {
flex: 1,
minWidth: 20,
borderRadius: "8px 8px 0 0",
background: "#6366f1",
};
const primaryButtonStyle: CSSProperties = {
padding: "11px 16px",
border: 0,
borderRadius: 10,
background: "#4f46e5",
color: "white",
cursor: "pointer",
};
const secondaryButtonStyle: CSSProperties = {
...primaryButtonStyle,
border: "1px solid #d1d5db",
background: "white",
color: "#111827",
};
const badgeStyle: CSSProperties = {
padding: "8px 12px",
borderRadius: 999,
background: "#e0e7ff",
color: "#4338ca",
};
const stateStyle: CSSProperties = {
marginTop: 20,
padding: 12,
borderRadius: 10,
background: "#ecfdf5",
color: "#166534",
};
const switcherStyle: CSSProperties = {
position: "fixed",
left: "50%",
bottom: 24,
zIndex: 100,
transform: "translateX(-50%)",
display: "flex",
alignItems: "center",
gap: 16,
padding: "8px 12px",
borderRadius: 999,
background: "#111827",
color: "white",
boxShadow: "0 12px 30px rgb(0 0 0 / 25%)",
};
const switchButtonStyle: CSSProperties = {
width: 36,
height: 36,
border: 0,
borderRadius: "50%",
background: "#374151",
color: "white",
cursor: "pointer",
};
```