diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 8d2bb9c..99f007a 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -21,12 +21,24 @@ import { Settings, Plus, ChevronLeft, - ChevronRight + ChevronRight, + TrendingUp, + TrendingDown, + Minus, + Target, + BookOpen, + ChevronDown, + ChevronUp, + Shield, + EyeOff, + HelpCircle } from "lucide-react" import { Input } from "@/components/ui/input" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import HeaderMusicControl from "@/components/header-music-control" import IpDisplay from "@/components/ip-display" +import RadarChart from "@/components/radar-chart" +import { categories, categorizeWishMultiple, type Wish } from "@/lib/categorization" interface WishData { id: number @@ -46,6 +58,15 @@ interface WishData { updated_at: string } +interface CategoryData { + name: string + count: number + percentage: number + color: string + keywords: string[] + description?: string +} + interface AdminStats { totalWishes: number publicWishes: number @@ -53,6 +74,16 @@ interface AdminStats { totalLikes: number categories: { [key: string]: number } recentWishes: number + categoryDetails: CategoryData[] + recentTrends: { + thisWeek: number + lastWeek: number + growth: number + growthLabel: string + growthIcon: "up" | "down" | "flat" + growthColor: string + } + topKeywords: { word: string; count: number }[] } export default function AdminPage() { @@ -64,11 +95,147 @@ export default function AdminPage() { const [statusFilter, setStatusFilter] = useState("all") const [visibilityFilter, setVisibilityFilter] = useState("all") const [isExporting, setIsExporting] = useState(false) + const [showCategoryGuide, setShowCategoryGuide] = useState(false) + const [showPrivacyDetails, setShowPrivacyDetails] = useState(false) // 分頁狀態 const [currentPage, setCurrentPage] = useState(1) const itemsPerPage = 10 + // 分析許願內容(包含所有數據,包括私密的) + const analyzeWishes = (wishList: WishData[]): AdminStats => { + const totalWishes = wishList.length + const publicWishes = wishList.filter((wish) => wish.is_public !== false).length + const privateWishes = wishList.filter((wish) => wish.is_public === false).length + const totalLikes = wishList.reduce((sum, wish) => sum + wish.like_count, 0) + + const categoryStats: { [key: string]: number } = {} + const keywordCount: { [key: string]: number } = {} + + // 初始化分類統計 + categories.forEach((cat) => { + categoryStats[cat.name] = 0 + }) + categoryStats["其他問題"] = 0 + + // 分析每個許願(多標籤統計)- 包含所有數據 + wishList.forEach((wish) => { + // 轉換數據格式以匹配 categorization.ts 的 Wish 接口 + const convertedWish: Wish = { + id: wish.id.toString(), + title: wish.title, + currentPain: wish.current_pain, + expectedSolution: wish.expected_solution, + expectedEffect: wish.expected_effect || "", + createdAt: wish.created_at, + isPublic: wish.is_public, + email: wish.email, + images: wish.images, + like_count: wish.like_count || 0, + } + + const wishCategories = categorizeWishMultiple(convertedWish) + + wishCategories.forEach((category) => { + categoryStats[category.name]++ + + // 統計關鍵字 + if (category.keywords) { + const fullText = + `${wish.title} ${wish.current_pain} ${wish.expected_solution} ${wish.expected_effect}`.toLowerCase() + category.keywords.forEach((keyword: string) => { + if (fullText.includes(keyword.toLowerCase())) { + keywordCount[keyword] = (keywordCount[keyword] || 0) + 1 + } + }) + } + }) + }) + + // 計算百分比和準備數據 + const categoryDetails: CategoryData[] = categories.map((cat) => ({ + name: cat.name, + count: categoryStats[cat.name] || 0, + percentage: totalWishes > 0 ? Math.round(((categoryStats[cat.name] || 0) / totalWishes) * 100) : 0, + color: cat.color, + keywords: cat.keywords, + description: cat.description, + })) + + // 改進的趨勢計算 + const now = new Date() + const oneWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000) + const twoWeeksAgo = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000) + + const thisWeek = wishList.filter((wish) => new Date(wish.created_at) >= oneWeekAgo).length + const lastWeek = wishList.filter((wish) => { + const date = new Date(wish.created_at) + return date >= twoWeeksAgo && date < oneWeekAgo + }).length + + // 改進的成長趨勢計算 + let growth = 0 + let growthLabel = "持平" + let growthIcon: "up" | "down" | "flat" = "flat" + let growthColor = "#6B7280" + + if (lastWeek === 0 && thisWeek > 0) { + // 上週沒有,本週有 → 全新開始 + growth = 100 + growthLabel = "開始增長" + growthIcon = "up" + growthColor = "#10B981" + } else if (lastWeek === 0 && thisWeek === 0) { + // 兩週都沒有 + growth = 0 + growthLabel = "尚無數據" + growthIcon = "flat" + growthColor = "#6B7280" + } else if (lastWeek > 0) { + // 正常計算成長率 + growth = Math.round(((thisWeek - lastWeek) / lastWeek) * 100) + + if (growth > 0) { + growthLabel = "持續增長" + growthIcon = "up" + growthColor = "#10B981" + } else if (growth < 0) { + growthLabel = "有所下降" + growthIcon = "down" + growthColor = "#EF4444" + } else { + growthLabel = "保持穩定" + growthIcon = "flat" + growthColor = "#6B7280" + } + } + + // 取得熱門關鍵字 + const topKeywords = Object.entries(keywordCount) + .sort(([, a], [, b]) => b - a) + .slice(0, 15) + .map(([word, count]) => ({ word, count })) + + return { + totalWishes, + publicWishes, + privateWishes, + totalLikes, + categories: categoryStats, + recentWishes: thisWeek, + categoryDetails, + recentTrends: { + thisWeek, + lastWeek, + growth, + growthLabel, + growthIcon, + growthColor, + }, + topKeywords, + } + } + // 獲取所有數據 const fetchData = async () => { try { @@ -79,16 +246,13 @@ export default function AdminPage() { const wishesResult = await wishesResponse.json() if (wishesResult.success) { - setWishes(wishesResult.data) - setFilteredWishes(wishesResult.data) - } - - // 獲取統計數據 - const statsResponse = await fetch('/api/admin/stats') - const statsResult = await statsResponse.json() - - if (statsResult.success) { - setStats(statsResult.data) + const wishesData = wishesResult.data + setWishes(wishesData) + setFilteredWishes(wishesData) + + // 使用本地分析函數生成詳細統計 + const detailedStats = analyzeWishes(wishesData) + setStats(detailedStats) } } catch (error) { @@ -537,59 +701,338 @@ export default function AdminPage() { - - - - - 數據分析 - - - 困擾案例統計與分析 - + {/* 隱私說明卡片 */} + + +
+
+
+ +
+
+ 數據隱私說明 + + 本分析包含所有提交的案例,包括選擇保持私密的困擾 + +
+
+ +
- -
- {/* 類別分布 */} -
-

- - 問題類別分布 -

-
- {stats && Object.entries(stats.categories).map(([category, count]) => ( -
- {category} - - {count} +
+ +
+
+

+ + 公開案例 ({stats?.publicWishes || 0} 個) +

+

+ 這些案例會顯示在「聆聽心聲」頁面,供其他人查看和產生共鳴 +

+
+
+

+ + 私密案例 ({stats?.privateWishes || 0} 個) +

+

+ 這些案例保持匿名且私密,僅用於統計分析,幫助了解整體趨勢 +

+
+
+
+
+ + + {/* 統計概覽 */} +
+ + +
+ +
+
{stats?.totalWishes || 0}
+
總案例數
+
+ 公開 {stats?.publicWishes || 0} + 私密 {stats?.privateWishes || 0} +
+
+
+ + + +
+ +
+
{stats?.recentTrends?.thisWeek || 0}
+
本週新增
+
+
+ + + +
+ +
+
+ {stats?.categoryDetails?.filter((c) => c.count > 0).length || 0} +
+
問題領域
+
+
+ + + +
+ {stats?.recentTrends?.growthIcon === "up" ? ( + + ) : stats?.recentTrends?.growthIcon === "down" ? ( + + ) : ( + + )} +
+
+ {stats?.recentTrends?.growth && stats.recentTrends.growth > 0 ? "+" : ""} + {stats?.recentTrends?.growth || 0}% +
+
+ {stats?.recentTrends?.growthLabel || "持平"} +
+
上週: {stats?.recentTrends?.lastWeek || 0} 個
+
+
+
+ + {/* 分類指南 */} + + +
+
+
+ +
+
+ 問題分類說明 + + 了解我們如何分類和分析各種職場困擾 + +
+
+ +
+
+ + {showCategoryGuide && ( + +
+ {categories.map((category, index) => ( +
+
+
{category.icon}
+
+
+

{category.name}

+
+
+

{category.description}

+
+
+
+
常見關鍵字:
+
+ {category.keywords.slice(0, 6).map((keyword, idx) => ( + + {keyword} + + ))} + {category.keywords.length > 6 && ( + + +{category.keywords.length - 6} + + )} +
+
+
+ ))} +
+
+ )} +
+ + {/* 手機版:垂直佈局,桌面版:並排佈局 */} +
+ {/* 雷達圖 */} + + + +
+ +
+ 問題分布圖譜 +
+ + 各類職場困擾的完整案例分布(包含私密數據) + +
+ +
+ {stats?.categoryDetails && } +
+
+
+ + {/* 分類詳細統計 */} + + + +
+ +
+ 完整案例統計 + + 含私密數據 + +
+ + 每個領域的所有案例數量(包含公開和私密案例) + {stats?.categoryDetails?.filter((cat) => cat.count > 0).length && ( + + 共 {stats.categoryDetails.filter((cat) => cat.count > 0).length} 個活躍分類 + {stats.categoryDetails.filter((cat) => cat.count > 0).length > 4 && ",可滾動查看全部"} + + )} + +
+ +
+ {stats?.categoryDetails + ?.filter((cat) => cat.count > 0) + .sort((a, b) => b.count - a.count) + .map((category, index) => ( +
+
+
+ {categories.find((cat) => cat.name === category.name)?.icon || "❓"} +
+
+
+ {category.name} +
+ {index < 3 && ( + + TOP {index + 1} + + )} +
+
{category.count} 個案例
+ {category.description && ( +
{category.description}
+ )} +
+
+ + {category.percentage}%
))} -
- {/* 時間分布 */} -
-

- - 創建時間分布 -

-
-
-
{stats?.recentWishes}
-
最近7天新增
-
-
-
{stats?.totalWishes}
-
總案例數
+ {/* 滾動提示 */} + {stats?.categoryDetails?.filter((cat) => cat.count > 0).length && stats.categoryDetails.filter((cat) => cat.count > 0).length > 4 && ( +
+
+
+ 向下滾動查看更多分類 +
+ )} + + +
+ + {/* 熱門關鍵字 */} + {stats?.topKeywords && stats.topKeywords.length > 0 && ( + + + +
+ +
+ 最常見的問題關鍵字 +
+ + 在所有案例中最常出現的詞彙,反映團隊面臨的核心挑戰 + +
+ +
+ {stats.topKeywords.map((keyword, index) => ( + + {keyword.word} ({keyword.count}) + + ))}
-
- - + + + )}
diff --git a/env.mysql.example b/env.mysql.example index e7edc60..9eb0567 100644 --- a/env.mysql.example +++ b/env.mysql.example @@ -2,6 +2,7 @@ # 請將此檔案複製為 .env.local 並確保資料庫連接正常 # MySQL 資料庫連接 +DATABASE_TYPE=mysql DATABASE_URL="mysql://wish_pool:Aa123456@mysql.theaken.com:33306/db_wish_pool?schema=public" # 應用程式配置 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e988ba3..dcbd7f8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -176,6 +176,9 @@ importers: vaul: specifier: ^0.9.6 version: 0.9.9(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + xlsx: + specifier: ^0.18.5 + version: 0.18.5 zod: specifier: ^3.24.1 version: 3.25.76 @@ -1403,6 +1406,10 @@ packages: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} + adler-32@1.3.1: + resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==} + engines: {node: '>=0.8'} + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} @@ -1529,6 +1536,10 @@ packages: caniuse-lite@1.0.30001727: resolution: {integrity: sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==} + cfb@1.2.2: + resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==} + engines: {node: '>=0.8'} + chalk@5.4.1: resolution: {integrity: sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} @@ -1583,6 +1594,10 @@ packages: code-block-writer@12.0.0: resolution: {integrity: sha512-q4dMFMlXtKR3XNBHyMHt/3pwYNA69EDk00lloMOaaUMKPUXBw6lpXtbu3MMVG6/uOihGnRDOlkyqsONEUj60+w==} + codepage@1.15.0: + resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==} + engines: {node: '>=0.8'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1644,6 +1659,11 @@ packages: typescript: optional: true + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1922,6 +1942,10 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} + frac@1.1.2: + resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==} + engines: {node: '>=0.8'} + fraction.js@4.3.7: resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} @@ -2826,6 +2850,10 @@ packages: resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} engines: {node: '>= 0.6'} + ssf@0.11.2: + resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==} + engines: {node: '>=0.8'} + statuses@2.0.1: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} @@ -3062,6 +3090,14 @@ packages: engines: {node: '>= 8'} hasBin: true + wmf@1.0.2: + resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==} + engines: {node: '>=0.8'} + + word@0.3.0: + resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==} + engines: {node: '>=0.8'} + wrap-ansi@6.2.0: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} @@ -3089,6 +3125,11 @@ packages: utf-8-validate: optional: true + xlsx@0.18.5: + resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==} + engines: {node: '>=0.8'} + hasBin: true + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -4393,6 +4434,8 @@ snapshots: mime-types: 3.0.1 negotiator: 1.0.0 + adler-32@1.3.1: {} + agent-base@7.1.4: {} ajv@6.12.6: @@ -4530,6 +4573,11 @@ snapshots: caniuse-lite@1.0.30001727: {} + cfb@1.2.2: + dependencies: + adler-32: 1.3.1 + crc-32: 1.2.2 + chalk@5.4.1: {} chokidar@3.6.0: @@ -4590,6 +4638,8 @@ snapshots: code-block-writer@12.0.0: {} + codepage@1.15.0: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -4640,6 +4690,8 @@ snapshots: optionalDependencies: typescript: 5.8.3 + crc-32@1.2.2: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -4903,6 +4955,8 @@ snapshots: forwarded@0.2.0: {} + frac@1.1.2: {} + fraction.js@4.3.7: {} fresh@2.0.0: {} @@ -5829,6 +5883,10 @@ snapshots: sqlstring@2.3.3: {} + ssf@0.11.2: + dependencies: + frac: 1.1.2 + statuses@2.0.1: {} statuses@2.0.2: {} @@ -6077,6 +6135,10 @@ snapshots: dependencies: isexe: 2.0.0 + wmf@1.0.2: {} + + word@0.3.0: {} + wrap-ansi@6.2.0: dependencies: ansi-styles: 4.3.0 @@ -6099,6 +6161,16 @@ snapshots: ws@8.18.3: {} + xlsx@0.18.5: + dependencies: + adler-32: 1.3.1 + cfb: 1.2.2 + codepage: 1.15.0 + crc-32: 1.2.2 + ssf: 0.11.2 + wmf: 1.0.2 + word: 0.3.0 + y18n@5.0.8: {} yallist@3.1.1: {}