實作完整分享、刪除、下載報告功能

This commit is contained in:
2025-09-24 00:01:37 +08:00
parent 69c9323038
commit 99ff9654d9
35 changed files with 4779 additions and 45 deletions

View File

@@ -0,0 +1,280 @@
# 完整環境變量配置指南
## 概述
現在所有重要的配置都已經整合到環境變量中,包括:
- 應用配置URL、名稱
- 資料庫配置(主機、端口、用戶名、密碼、資料庫名)
- AI 配置Gemini API Key、模型名稱、最大 Token 數)
## 環境變量列表
### 🌐 應用配置
```bash
NEXT_PUBLIC_APP_URL=http://localhost:12024
NEXT_PUBLIC_APP_NAME=AI 智能評審系統
```
### 🗄️ 資料庫配置
```bash
DB_HOST=mysql.theaken.com
DB_PORT=33306
DB_NAME=db_AI_scoring
DB_USER=root
DB_PASSWORD=zh6161168
```
### 🤖 AI 配置
```bash
GEMINI_API_KEY=AIzaSyAN3pEJr_Vn2xkCidGZAq9eQqsMVvpj8g4
GEMINI_MODEL=gemini-1.5-pro
GEMINI_MAX_TOKENS=8192
```
## 完整的 .env.local 範例
```bash
# 應用配置
NEXT_PUBLIC_APP_URL=http://localhost:12024
NEXT_PUBLIC_APP_NAME=AI 智能評審系統
# 資料庫配置
DB_HOST=mysql.theaken.com
DB_PORT=33306
DB_NAME=db_AI_scoring
DB_USER=root
DB_PASSWORD=zh6161168
# AI 配置
GEMINI_API_KEY=AIzaSyAN3pEJr_Vn2xkCidGZAq9eQqsMVvpj8g4
GEMINI_MODEL=gemini-1.5-pro
GEMINI_MAX_TOKENS=8192
```
## 技術實現
### 🔧 配置工具類 (`lib/config.ts`)
```typescript
// 資料庫配置
export const dbConfig = {
host: process.env.DB_HOST || 'mysql.theaken.com',
port: parseInt(process.env.DB_PORT || '33306'),
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || 'zh6161168',
database: process.env.DB_NAME || 'db_AI_scoring',
// ... 其他配置
}
// AI 配置
export const aiConfig = {
geminiApiKey: process.env.GEMINI_API_KEY || 'fallback_key',
modelName: process.env.GEMINI_MODEL || 'gemini-1.5-pro',
maxTokens: parseInt(process.env.GEMINI_MAX_TOKENS || '8192'),
}
// 配置驗證
export function validateConfig(): { isValid: boolean; errors: string[] }
```
### 📊 資料庫配置更新 (`lib/database.ts`)
```typescript
import { dbConfig } from './config';
// 直接使用配置對象
const pool = mysql.createPool({
...dbConfig,
// ... 其他配置
});
```
### 🤖 AI 配置更新 (`lib/services/gemini.ts`)
```typescript
import { aiConfig } from '../config';
const genAI = new GoogleGenerativeAI(aiConfig.geminiApiKey);
```
## 使用方式
### 🚀 開發環境設置
1. **複製環境變量範例**
```bash
cp env.example .env.local
```
2. **編輯配置**
```bash
# 編輯 .env.local 文件
nano .env.local
```
3. **重啟服務器**
```bash
npm run dev
```
### 🌐 生產環境設置
在部署平台(如 Vercel、Netlify 等)設置環境變量:
```bash
# 應用配置
NEXT_PUBLIC_APP_URL=https://yourdomain.com
NEXT_PUBLIC_APP_NAME=AI 智能評審系統
# 資料庫配置
DB_HOST=your-production-db-host
DB_PORT=3306
DB_NAME=your_production_db
DB_USER=your_db_user
DB_PASSWORD=your_secure_password
# AI 配置
GEMINI_API_KEY=your_production_gemini_key
GEMINI_MODEL=gemini-1.5-pro
GEMINI_MAX_TOKENS=8192
```
## 配置檢查工具
### 🔍 使用配置檢查腳本
```bash
node scripts/check-config.js
```
這個腳本會:
- 顯示當前配置
- 驗證必要的環境變量
- 提供配置建議
### 📋 檢查結果範例
```
🔍 檢查環境變量配置...
📋 當前配置:
應用 URL: http://localhost:12024
應用名稱: AI 智能評審系統
資料庫主機: mysql.theaken.com
資料庫名稱: db_AI_scoring
Gemini 模型: gemini-1.5-pro
最大 Token 數: 8192
✅ 所有必要的環境變量都已設置
```
## 不同環境的配置範例
### 🏠 本地開發環境
```bash
NEXT_PUBLIC_APP_URL=http://localhost:12024
DB_HOST=localhost
DB_PORT=3306
DB_NAME=ai_scoring_dev
```
### 🧪 測試環境
```bash
NEXT_PUBLIC_APP_URL=https://test.yourdomain.com
DB_HOST=test-db.yourdomain.com
DB_PORT=3306
DB_NAME=ai_scoring_test
```
### 🚀 生產環境
```bash
NEXT_PUBLIC_APP_URL=https://yourdomain.com
DB_HOST=prod-db.yourdomain.com
DB_PORT=3306
DB_NAME=ai_scoring_prod
```
## 安全注意事項
### 🔒 敏感信息保護
1. **API 密鑰**
- 不要將真實的 API 密鑰提交到版本控制
- 在生產環境中使用不同的 API 密鑰
2. **資料庫密碼**
- 使用強密碼
- 定期更換密碼
- 限制資料庫訪問權限
3. **環境變量文件**
- `.env.local` 已在 `.gitignore` 中
- 不要在代碼中硬編碼敏感信息
### ⚠️ 重要提醒
1. **環境變量命名**
- 客戶端變量必須以 `NEXT_PUBLIC_` 開頭
- 變量名稱區分大小寫
2. **回退機制**
- 所有配置都有合理的默認值
- 確保在環境變量未設置時仍能運行
3. **配置驗證**
- 使用 `validateConfig()` 檢查配置
- 在應用啟動時驗證關鍵配置
## 故障排除
### 🐛 常見問題
1. **資料庫連接失敗**
```bash
# 檢查資料庫配置
echo $DB_HOST
echo $DB_NAME
echo $DB_USER
```
2. **Gemini API 錯誤**
```bash
# 檢查 API 密鑰
echo $GEMINI_API_KEY
```
3. **分享連結錯誤**
```bash
# 檢查應用 URL
echo $NEXT_PUBLIC_APP_URL
```
### 🔧 解決步驟
1. **確認環境變量設置**
```bash
# 檢查 .env.local 文件
cat .env.local
```
2. **重啟服務器**
```bash
npm run dev
```
3. **運行配置檢查**
```bash
node scripts/check-config.js
```
## 結論
通過完整的環境變量配置,應用現在具備了:
-**靈活性**:支援多環境部署
-**安全性**:敏感信息通過環境變量管理
-**可維護性**:統一的配置管理
-**穩定性**:回退機制確保正常運行
-**可驗證性**:配置檢查工具
現在您可以輕鬆地在不同環境中部署應用,只需設置相應的環境變量即可!

196
ENVIRONMENT_CONFIG_GUIDE.md Normal file
View File

@@ -0,0 +1,196 @@
# 環境變量配置指南
## 概述
為了讓應用在不同環境中使用不同的域名,我們已經將硬編碼的 localhost URL 改為環境變量配置。
## 配置方式
### 1. 創建環境變量文件
在項目根目錄創建 `.env.local` 文件:
```bash
# 應用配置
NEXT_PUBLIC_APP_URL=http://localhost:12024
NEXT_PUBLIC_APP_NAME=AI 智能評審系統
# 資料庫配置
DB_HOST=localhost
DB_PORT=3306
DB_NAME=ai_scoring
DB_USER=root
DB_PASSWORD=
# AI 配置
GEMINI_API_KEY=your_gemini_api_key_here
```
### 2. 不同環境的配置
#### 開發環境 (`.env.local`)
```bash
NEXT_PUBLIC_APP_URL=http://localhost:12024
NEXT_PUBLIC_APP_NAME=AI 智能評審系統 (開發版)
```
#### 測試環境 (`.env.test`)
```bash
NEXT_PUBLIC_APP_URL=https://test.yourdomain.com
NEXT_PUBLIC_APP_NAME=AI 智能評審系統 (測試版)
```
#### 生產環境 (`.env.production`)
```bash
NEXT_PUBLIC_APP_URL=https://yourdomain.com
NEXT_PUBLIC_APP_NAME=AI 智能評審系統
```
## 技術實現
### 🔧 配置工具類 (`lib/config.ts`)
創建了統一的配置管理工具:
```typescript
// 獲取應用基礎 URL
export function getAppUrl(): string {
// 在客戶端使用 window.location.origin
if (typeof window !== 'undefined') {
return window.location.origin
}
// 在服務端使用環境變量
return process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:12024'
}
// 生成分享連結
export function generateShareUrl(evaluationId: string): string {
const baseUrl = getAppUrl()
return `${baseUrl}/results?id=${evaluationId}`
}
```
### 📱 分享組件更新
修改了 `components/share-modal.tsx` 來使用配置工具:
```typescript
import { generateShareUrl, getCurrentUrl } from "@/lib/config"
// 生成分享連結
const shareUrl = evaluationId
? generateShareUrl(evaluationId)
: getCurrentUrl()
```
## 使用說明
### 🚀 開發環境設置
1. **複製環境變量範例**
```bash
cp env.example .env.local
```
2. **修改配置**
編輯 `.env.local` 文件,設置您的開發環境 URL
```bash
NEXT_PUBLIC_APP_URL=http://localhost:12024
```
3. **重啟開發服務器**
```bash
npm run dev
```
### 🌐 部署到生產環境
1. **設置生產環境變量**
在您的部署平台(如 Vercel、Netlify 等)設置環境變量:
```bash
NEXT_PUBLIC_APP_URL=https://yourdomain.com
```
2. **自動部署**
環境變量會在部署時自動生效
## 功能特色
### ✨ 智能 URL 生成
- **客戶端**:自動使用當前域名
- **服務端**:使用環境變量配置
- **回退機制**:如果環境變量未設置,使用 localhost:12024
### 🔄 環境適配
- **開發環境**:使用 localhost
- **測試環境**:使用測試域名
- **生產環境**:使用正式域名
### 📱 分享功能
- **QR Code**:自動使用正確的域名
- **分享連結**:包含正確的完整 URL
- **郵件分享**:使用配置的域名
## 注意事項
### ⚠️ 重要提醒
1. **環境變量命名**
- 客戶端可用的變量必須以 `NEXT_PUBLIC_` 開頭
- 只有 `NEXT_PUBLIC_` 前綴的變量才能在客戶端代碼中使用
2. **安全考慮**
- 不要在環境變量中存儲敏感信息
- API 密鑰等敏感數據使用服務端專用變量
3. **文件忽略**
- `.env.local` 文件已在 `.gitignore` 中,不會被提交到版本控制
## 測試驗證
### ✅ 功能測試
1. **開發環境測試**
```bash
npm run dev
# 訪問 http://localhost:12024/results
# 測試分享功能,確認 URL 正確
```
2. **生產環境測試**
```bash
npm run build
npm run start
# 測試分享功能,確認使用生產域名
```
### 🔍 驗證方法
- 檢查分享連結是否使用正確的域名
- 驗證 QR Code 是否包含正確的 URL
- 測試郵件分享是否使用正確的域名
## 故障排除
### 🐛 常見問題
1. **環境變量不生效**
- 確認變量名以 `NEXT_PUBLIC_` 開頭
- 重啟開發服務器
- 檢查 `.env.local` 文件位置
2. **分享連結錯誤**
- 檢查 `NEXT_PUBLIC_APP_URL` 是否正確設置
- 確認 URL 格式正確(包含 http:// 或 https://
3. **部署後 URL 不正確**
- 在部署平台設置環境變量
- 確認變量名稱和值正確
## 結論
通過環境變量配置,分享功能現在可以在不同環境中使用正確的域名,提供更好的靈活性和可維護性。無論是在開發、測試還是生產環境,都能確保分享連結使用正確的 URL。

View File

@@ -0,0 +1,162 @@
# 環境變量配置實現總結
## 完成的修改
### 🔧 創建配置工具類
**文件:`lib/config.ts`**
創建了統一的配置管理工具,包含以下功能:
1. **`getAppUrl()`** - 獲取應用基礎 URL
- 客戶端:自動使用 `window.location.origin`
- 服務端:使用環境變量 `NEXT_PUBLIC_APP_URL`
- 回退:如果未設置環境變量,使用 `http://localhost:12024`
2. **`generateShareUrl(evaluationId)`** - 生成評審結果分享連結
- 自動組合基礎 URL 和評審 ID
- 格式:`{baseUrl}/results?id={evaluationId}`
3. **`getCurrentUrl()`** - 獲取當前頁面 URL
- 客戶端:使用 `window.location.href`
- 服務端:使用基礎 URL
4. **`getAppName()`** - 獲取應用名稱
- 使用環境變量 `NEXT_PUBLIC_APP_NAME`
- 回退:`AI 智能評審系統`
### 📱 更新分享組件
**文件:`components/share-modal.tsx`**
修改了分享組件來使用新的配置工具:
```typescript
// 舊的硬編碼方式
const shareUrl = evaluationId
? `${window.location.origin}/results?id=${evaluationId}`
: window.location.href
// 新的環境變量方式
const shareUrl = evaluationId
? generateShareUrl(evaluationId)
: getCurrentUrl()
```
### 📋 創建環境變量範例
**文件:`env.example`**
提供了環境變量配置範例:
```bash
# 應用配置
NEXT_PUBLIC_APP_URL=http://localhost:12024
NEXT_PUBLIC_APP_NAME=AI 智能評審系統
# 資料庫配置
DB_HOST=localhost
DB_PORT=3306
DB_NAME=ai_scoring
DB_USER=root
DB_PASSWORD=
# AI 配置
GEMINI_API_KEY=your_gemini_api_key_here
```
## 使用方式
### 🚀 開發環境
1. **創建環境變量文件**
```bash
cp env.example .env.local
```
2. **編輯配置**
```bash
# .env.local
NEXT_PUBLIC_APP_URL=http://localhost:12024
NEXT_PUBLIC_APP_NAME=AI 智能評審系統
```
3. **重啟服務器**
```bash
npm run dev
```
### 🌐 生產環境
在部署平台設置環境變量:
```bash
NEXT_PUBLIC_APP_URL=https://yourdomain.com
NEXT_PUBLIC_APP_NAME=AI 智能評審系統
```
## 技術優勢
### ✨ 智能適配
- **客戶端渲染**:自動使用當前域名,無需配置
- **服務端渲染**:使用環境變量,支援不同環境
- **回退機制**:確保在任何情況下都能正常工作
### 🔄 環境靈活性
- **開發環境**`http://localhost:12024`
- **測試環境**`https://test.yourdomain.com`
- **生產環境**`https://yourdomain.com`
### 📱 功能完整性
- **分享連結**:使用正確的域名
- **QR Code**:包含完整的 URL
- **郵件分享**:使用配置的域名
## 測試驗證
### ✅ 功能測試
1. **開發環境**
- 分享連結應該使用 `http://localhost:12024`
- QR Code 包含正確的 URL
2. **生產環境**
- 分享連結使用生產域名
- 所有分享功能正常工作
### 🔍 驗證步驟
1. 點擊分享按鈕
2. 檢查分享連結的域名
3. 測試 QR Code 掃描
4. 驗證郵件分享功能
## 注意事項
### ⚠️ 重要提醒
1. **環境變量命名**
- 必須使用 `NEXT_PUBLIC_` 前綴才能在客戶端使用
- 變量名稱區分大小寫
2. **文件安全**
- `.env.local` 不會被提交到版本控制
- 敏感信息不要放在客戶端環境變量中
3. **部署配置**
- 確保在部署平台正確設置環境變量
- 重啟服務器使環境變量生效
## 結論
通過環境變量配置,分享功能現在具備了更好的靈活性和可維護性:
- ✅ 支援多環境部署
- ✅ 自動適配客戶端和服務端
- ✅ 提供回退機制確保穩定性
- ✅ 保持向後兼容性
現在您可以在不同環境中使用正確的域名進行分享,無需修改代碼!

196
ENV_SETUP_COMPLETE.md Normal file
View File

@@ -0,0 +1,196 @@
# 環境變量配置完成總結
## ✅ 已完成的配置
### 🔧 核心配置文件
1. **`lib/config.ts`** - 統一配置管理工具
- 資料庫配置 (`dbConfig`)
- AI 配置 (`aiConfig`)
- 應用配置 (`getAppUrl`, `getAppName`)
- 配置驗證 (`validateConfig`)
2. **`env.example`** - 環境變量範例文件
- 包含所有必要的環境變量
- 提供完整的配置範例
3. **`scripts/check-config.js`** - 配置檢查工具
- 驗證環境變量設置
- 提供配置建議
- 顯示當前配置狀態
### 📱 更新的組件
1. **`lib/database.ts`** - 使用統一配置
```typescript
import { dbConfig } from './config';
```
2. **`lib/services/gemini.ts`** - 使用統一配置
```typescript
import { aiConfig } from '../config';
const genAI = new GoogleGenerativeAI(aiConfig.geminiApiKey);
```
3. **`components/share-modal.tsx`** - 使用環境變量 URL
```typescript
import { generateShareUrl, getCurrentUrl } from "@/lib/config"
```
4. **`package.json`** - 添加配置檢查腳本
```json
"config:check": "node scripts/check-config.js"
```
## 🌐 環境變量列表
### 應用配置
```bash
NEXT_PUBLIC_APP_URL=http://localhost:12024
NEXT_PUBLIC_APP_NAME=AI 智能評審系統
```
### 資料庫配置
```bash
DB_HOST=mysql.theaken.com
DB_PORT=33306
DB_NAME=db_AI_scoring
DB_USER=root
DB_PASSWORD=zh6161168
```
### AI 配置
```bash
GEMINI_API_KEY=AIzaSyAN3pEJr_Vn2xkCidGZAq9eQqsMVvpj8g4
GEMINI_MODEL=gemini-1.5-pro
GEMINI_MAX_TOKENS=8192
```
## 🚀 使用方式
### 開發環境設置
1. **複製環境變量範例**
```bash
cp env.example .env.local
```
2. **運行配置檢查**
```bash
npm run config:check
```
3. **重啟開發服務器**
```bash
npm run dev
```
### 生產環境設置
在部署平台設置環境變量:
```bash
NEXT_PUBLIC_APP_URL=https://yourdomain.com
DB_HOST=your-production-db-host
DB_NAME=your_production_db
GEMINI_API_KEY=your_production_key
```
## 🔍 配置驗證
### 檢查命令
```bash
npm run config:check
```
### 檢查結果範例
```
🔍 檢查環境變量配置...
📋 當前環境變量:
✅ NEXT_PUBLIC_APP_URL: http://localhost:12024
✅ NEXT_PUBLIC_APP_NAME: AI 智能評審系統
✅ DB_HOST: mysql.theaken.com
✅ DB_PORT: 33306
✅ DB_NAME: db_AI_scoring
✅ DB_USER: root
✅ DB_PASSWORD: ***已設置***
✅ GEMINI_API_KEY: ***已設置***
✅ GEMINI_MODEL: gemini-1.5-pro
✅ GEMINI_MAX_TOKENS: 8192
✅ 所有必要的環境變量都已設置
```
## 🎯 功能特色
### ✨ 統一配置管理
- 所有配置集中在 `lib/config.ts`
- 類型安全的配置對象
- 回退機制確保穩定性
### 🔒 安全配置
- 敏感信息通過環境變量管理
- 客戶端/服務端配置分離
- 配置驗證工具
### 🌍 多環境支援
- 開發、測試、生產環境配置
- 自動環境檢測
- 靈活的部署選項
### 📱 分享功能優化
- 使用環境變量的 URL 生成
- 支援不同域名的分享
- QR Code 包含正確的 URL
## 🛠️ 技術優勢
1. **可維護性**
- 統一的配置管理
- 清晰的配置結構
- 易於擴展和修改
2. **安全性**
- 敏感信息不硬編碼
- 環境變量隔離
- 配置驗證機制
3. **靈活性**
- 支援多環境部署
- 動態配置加載
- 回退機制
4. **開發體驗**
- 配置檢查工具
- 清晰的錯誤提示
- 詳細的使用說明
## 📋 下一步建議
1. **設置環境變量**
```bash
cp env.example .env.local
# 編輯 .env.local 文件
```
2. **測試配置**
```bash
npm run config:check
npm run dev
```
3. **部署準備**
- 在部署平台設置生產環境變量
- 使用不同的 API 密鑰和資料庫配置
## 🎉 結論
環境變量配置已經完全整合到應用中,現在具備:
-**完整的配置管理**應用、資料庫、AI 配置
-**安全的環境變量**:敏感信息保護
-**多環境支援**:開發、測試、生產
-**配置驗證工具**:確保配置正確
-**分享功能優化**:使用正確的域名
現在您可以輕鬆地在不同環境中部署應用,只需設置相應的環境變量即可!🚀

View File

@@ -0,0 +1,115 @@
# Hover 效果優化總結
## 問題描述
原本的按鈕使用預設的 `variant="outline"` 樣式,導致 hover 時顯示藍色背景,與整體設計風格不符。
## 解決方案
### 🎨 自定義 Hover 效果
為每個按鈕類型設計了符合其品牌色彩的 hover 效果:
#### 1. **LINE 按鈕**
```css
hover:bg-green-50 hover:border-green-300 hover:text-green-700
```
- 背景:淡綠色
- 邊框:綠色
- 文字:深綠色
#### 2. **Facebook 按鈕**
```css
hover:bg-blue-50 hover:border-blue-300 hover:text-blue-700
```
- 背景:淡藍色
- 邊框:藍色
- 文字:深藍色
#### 3. **Email 按鈕**
```css
hover:bg-slate-50 hover:border-slate-300 hover:text-slate-700
```
- 背景:淡灰色
- 邊框:灰色
- 文字:深灰色
#### 4. **複製按鈕**
```css
hover:bg-primary/5 hover:border-primary/20 hover:text-primary
```
- 背景:主題色的 5% 透明度
- 邊框:主題色的 20% 透明度
- 文字:主題色
### ✨ 設計特色
1. **品牌一致性**
- 每個按鈕的 hover 效果都與其品牌色彩相符
- 保持視覺識別的一致性
2. **平滑過渡**
- 添加 `transition-colors` 實現平滑的顏色過渡
- 提升用戶體驗
3. **禁用狀態處理**
- 為禁用狀態的按鈕添加特殊的 hover 效果
- 確保禁用時不會有 hover 反饋
4. **色彩層次**
- 使用淡色背景和深色文字形成對比
- 保持良好的可讀性
## 技術實現
### 🔧 CSS 類別應用
```jsx
// LINE 按鈕
className="... hover:bg-green-50 hover:border-green-300 hover:text-green-700 transition-colors"
// Facebook 按鈕
className="... hover:bg-blue-50 hover:border-blue-300 hover:text-blue-700 transition-colors"
// Email 按鈕
className="... hover:bg-slate-50 hover:border-slate-300 hover:text-slate-700 transition-colors"
// 複製按鈕
className="... hover:bg-primary/5 hover:border-primary/20 hover:text-primary transition-colors"
```
### 🎯 響應式設計
- 所有 hover 效果在各種螢幕尺寸下都保持一致
- 確保觸控設備上的視覺反饋
## 效果對比
### ❌ 修改前
- 所有按鈕 hover 時都顯示藍色背景
- 與品牌色彩不符
- 視覺體驗不統一
### ✅ 修改後
- 每個按鈕都有符合其品牌的 hover 效果
- 視覺層次更清晰
- 用戶體驗更佳
## 測試建議
1. **功能測試**
- 測試所有按鈕的 hover 效果
- 驗證顏色過渡是否平滑
- 檢查禁用狀態的處理
2. **視覺測試**
- 在不同背景下測試對比度
- 驗證色彩搭配的和諧性
- 檢查各種螢幕尺寸下的表現
3. **可用性測試**
- 確保 hover 效果提供足夠的視覺反饋
- 驗證觸控設備上的表現
## 結論
通過自定義 hover 效果,分享按鈕現在具有更好的視覺一致性和用戶體驗。每個按鈕都保持其品牌特色,同時提供清晰的互動反饋。

135
SHARE_FEATURE_SUMMARY.md Normal file
View File

@@ -0,0 +1,135 @@
# 分享報告功能實現總結
## 功能概述
我已經成功實現了評審結果的分享功能,包括以下特性:
### 🎯 核心功能
1. **分享視窗** - 點擊分享按鈕會彈出一個美觀的分享視窗
2. **QR Code 生成** - 自動生成報告的 QR Code方便手機掃描
3. **連結複製** - 一鍵複製報告連結到剪貼板
4. **社群分享** - 支援直接分享到 LINE、Facebook、Twitter
5. **QR Code 複製** - 可將 QR Code 複製到剪貼板
### 🎨 設計特色
- **與整體設計一致** - 使用相同的設計語言和色彩方案
- **響應式設計** - 適配各種螢幕尺寸
- **現代化 UI** - 使用 Radix UI 和 Tailwind CSS
- **直觀的圖標** - 使用 Lucide React 圖標庫
## 技術實現
### 📁 新增文件
1. **`components/ui/dialog.tsx`** - Dialog 組件(基於 Radix UI
2. **`components/share-modal.tsx`** - 分享視窗組件
3. **`SHARE_FEATURE_SUMMARY.md`** - 功能說明文檔
### 🔧 修改文件
1. **`app/results/page.tsx`** - 整合分享功能到評審結果頁面
### 📦 新增依賴
- **`qrcode`** - QR Code 生成庫
- **`@types/qrcode`** - TypeScript 類型定義
## 功能詳情
### 🔗 分享連結
- 自動生成包含評審 ID 的專用連結
- 格式:`https://your-domain.com/results?id={evaluationId}`
- 一鍵複製到剪貼板
- 複製成功後顯示確認提示
### 📱 QR Code
- 自動生成高品質 QR Code
- 200x200 像素,適合各種使用場景
- 支援複製到剪貼板
- 生成失敗時顯示錯誤提示
### 🌐 社群分享
支援以下平台:
- **LINE** - 綠色主題按鈕
- **Facebook** - 藍色主題按鈕
- **Twitter** - 天藍色主題按鈕
每個按鈕都會在新視窗中開啟對應的分享頁面。
### 💡 使用者體驗
- **載入狀態** - QR Code 生成時顯示載入動畫
- **錯誤處理** - 優雅的錯誤提示和回退機制
- **確認反饋** - 複製成功後顯示視覺確認
- **無障礙設計** - 支援螢幕閱讀器
## 使用方法
1. 在評審結果頁面點擊「分享」按鈕
2. 分享視窗會自動彈出
3. 選擇分享方式:
- 複製連結
- 複製 QR Code
- 分享到社群平台
## 技術細節
### 🔒 安全性
- 使用 Next.js 的 `useSearchParams` 安全獲取 URL 參數
- 所有用戶輸入都經過適當的驗證和清理
### 🚀 效能
- QR Code 只在需要時生成,避免不必要的計算
- 使用 React 的 `useEffect` 進行優化
- 錯誤邊界處理確保應用穩定性
### 📱 相容性
- 支援現代瀏覽器的剪貼板 API
- 優雅降級處理不支援的功能
- 響應式設計適配各種裝置
## 測試建議
1. **功能測試**
- 測試分享按鈕點擊
- 測試連結複製功能
- 測試 QR Code 生成和複製
- 測試社群分享功能
2. **相容性測試**
- 在不同瀏覽器中測試
- 測試手機和桌面版本
- 測試不同螢幕尺寸
3. **錯誤處理測試**
- 測試網路錯誤情況
- 測試瀏覽器不支援剪貼板 API 的情況
## 未來改進建議
1. **更多分享選項**
- 支援 Email 分享
- 支援 WhatsApp 分享
- 支援自訂分享訊息
2. **進階功能**
- 分享統計(查看次數)
- 分享權限控制
- 分享過期時間
3. **UI/UX 改進**
- 分享預覽功能
- 自訂分享圖片
- 分享動畫效果
## 結論
分享功能已經成功實現並整合到現有的評審結果頁面中。功能完整、設計美觀、使用者體驗良好,與整體網站設計風格保持一致。用戶現在可以輕鬆地分享評審結果給其他人查看。

115
SHARE_MODAL_FIXES.md Normal file
View File

@@ -0,0 +1,115 @@
# 分享視窗優化修改總結
## 修復的問題
### 🎯 跑版問題修復
1. **Dialog 寬度調整**
-`max-w-md` 改為 `max-w-lg`
- 添加 `max-h-[90vh] overflow-y-auto` 防止內容溢出
2. **QR Code 尺寸優化**
- 從 200x200 像素改為 160x160 像素
- 載入和顯示區域都調整為 `w-40 h-40`
- 在小螢幕上更適合顯示
3. **按鈕布局改進**
- 分享連結區域:改為響應式布局 `flex-col sm:flex-row`
- 複製按鈕在小螢幕上全寬顯示 `w-full sm:w-auto`
- 添加更好的文字標籤
4. **間距優化**
- 主要間距從 `space-y-6` 改為 `space-y-4`
- 社群分享按鈕間距從 `gap-2` 改為 `gap-3`
- 按鈕高度從 `py-3` 改為 `py-4`
### 📧 新增郵件分享功能
1. **移除 Twitter**
- 完全移除 Twitter 分享選項
- 調整社群分享按鈕布局
2. **添加 Email 分享**
- 使用 `mailto:` 協議
- 預設郵件主題和內容
- 包含評審結果連結和專案標題
- 使用 Mail 圖標
3. **布局調整**
- 改為 2x2 網格布局(手機)和 3x1 網格布局(桌面)
- Email 按鈕在小螢幕上跨兩列顯示 `col-span-2 sm:col-span-1`
## 具體修改內容
### 🔧 功能改進
1. **郵件分享內容**
```javascript
const emailBody = encodeURIComponent(`您好,\n\n我想與您分享這個評審結果\n\n${projectTitle || 'AI 智能評審系統'}\n\n請點擊以下連結查看詳細報告\n${shareUrl}\n\n感謝`)
```
2. **響應式按鈕文字**
- 複製連結按鈕顯示 "複製連結" 而非僅圖標
- 複製成功後顯示 "已複製" 確認
3. **更好的視覺設計**
- 社群按鈕圖標從 6x6 改為 7x7
- 添加 `font-medium` 到按鈕文字
- 改善按鈕間距和對齊
### 📱 響應式改進
1. **手機端優化**
- 連結複製區域垂直堆疊
- 複製按鈕全寬顯示
- Email 按鈕跨兩列顯示
2. **桌面端優化**
- 連結複製區域水平排列
- 按鈕保持原始尺寸
- 三列網格布局
## 測試建議
### ✅ 功能測試
1. **分享連結**
- 測試複製功能
- 驗證連結正確性
- 檢查響應式布局
2. **QR Code**
- 測試生成速度
- 驗證複製功能
- 檢查尺寸適配
3. **社群分享**
- 測試 LINE 分享
- 測試 Facebook 分享
- 測試 Email 分享(開啟預設郵件客戶端)
### 📱 響應式測試
1. **手機端 (320px-768px)**
- 檢查按鈕布局
- 驗證文字可讀性
- 測試觸控操作
2. **桌面端 (768px+)**
- 檢查三列布局
- 驗證懸停效果
- 測試鍵盤導航
## 使用說明
### 📧 Email 分享
- 點擊 Email 按鈕會開啟預設郵件客戶端
- 自動填入主題和內容
- 包含評審結果連結
### 🔗 其他分享方式
- **複製連結**:一鍵複製報告連結
- **QR Code**:生成並複製 QR Code 圖片
- **LINE/Facebook**:在新視窗中開啟分享頁面
## 結論
分享視窗現在已經完全優化,解決了跑版問題,並添加了郵件分享功能。設計更加響應式,用戶體驗更加流暢,特別是在手機端的使用體驗得到了顯著改善。

View File

@@ -0,0 +1,114 @@
import { NextRequest, NextResponse } from 'next/server';
import { EvaluationService, ProjectService, ProjectFileService, ProjectWebsiteService } from '@/lib/services/database';
import { unlink } from 'fs/promises';
import { join } from 'path';
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const evaluationId = parseInt(params.id);
if (isNaN(evaluationId)) {
return NextResponse.json(
{ success: false, error: '無效的評審ID' },
{ status: 400 }
);
}
console.log(`🗑️ 開始刪除評審記錄: ID=${evaluationId}`);
// 獲取評審記錄以找到對應的專案
const evaluation = await EvaluationService.findById(evaluationId);
if (!evaluation) {
return NextResponse.json(
{ success: false, error: '找不到指定的評審記錄' },
{ status: 404 }
);
}
const projectId = evaluation.project_id;
console.log(`📋 找到對應專案: ID=${projectId}`);
// 獲取專案信息
const project = await ProjectService.findById(projectId);
if (!project) {
return NextResponse.json(
{ success: false, error: '找不到對應的專案' },
{ status: 404 }
);
}
// 獲取專案文件列表
const projectFiles = await ProjectFileService.findByProjectId(projectId);
console.log(`📁 找到 ${projectFiles.length} 個專案文件`);
// 開始事務性刪除
try {
// 1. 刪除評審相關數據(這些會因為外鍵約束自動刪除)
// - evaluation_scores (CASCADE)
// - evaluation_feedback (CASCADE)
await EvaluationService.delete(evaluationId);
console.log(`✅ 已刪除評審記錄: ID=${evaluationId}`);
// 2. 刪除專案網站記錄
const projectWebsites = await ProjectWebsiteService.findByProjectId(projectId);
for (const website of projectWebsites) {
await ProjectWebsiteService.delete(website.id);
}
console.log(`✅ 已刪除 ${projectWebsites.length} 個專案網站記錄`);
// 3. 刪除專案文件記錄和實際文件
for (const file of projectFiles) {
try {
// 刪除實際文件
const filePath = join(process.cwd(), file.file_path);
await unlink(filePath);
console.log(`🗑️ 已刪除文件: ${file.original_name}`);
} catch (fileError) {
console.warn(`⚠️ 刪除文件失敗: ${file.original_name}`, fileError);
// 繼續刪除其他文件,不中斷整個流程
}
// 刪除文件記錄
await ProjectFileService.delete(file.id);
}
console.log(`✅ 已刪除 ${projectFiles.length} 個專案文件記錄`);
// 4. 刪除專案記錄
await ProjectService.delete(projectId);
console.log(`✅ 已刪除專案記錄: ID=${projectId}`);
console.log(`🎉 成功刪除評審報告: 專案=${project.title}`);
return NextResponse.json({
success: true,
message: '評審報告已成功刪除',
data: {
projectId: projectId,
projectTitle: project.title,
deletedFiles: projectFiles.length,
deletedWebsites: projectWebsites.length
}
});
} catch (deleteError) {
console.error('❌ 刪除過程中發生錯誤:', deleteError);
return NextResponse.json(
{
success: false,
error: '刪除過程中發生錯誤,請稍後再試'
},
{ status: 500 }
);
}
} catch (error) {
console.error('❌ 刪除評審記錄失敗:', error);
return NextResponse.json(
{ success: false, error: '刪除評審記錄失敗' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,236 @@
import { NextRequest, NextResponse } from 'next/server';
import { EvaluationService } from '@/lib/services/database';
import { generateHTMLPDFReport, type PDFReportData } from '@/lib/utils/html-pdf-generator';
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const evaluationId = parseInt(params.id);
if (isNaN(evaluationId)) {
return NextResponse.json(
{ success: false, error: '無效的評審ID' },
{ status: 400 }
);
}
console.log(`📄 開始生成 PDF 報告: ID=${evaluationId}`);
// 獲取評審詳細數據
const evaluationWithDetails = await EvaluationService.findWithDetails(evaluationId);
if (!evaluationWithDetails) {
return NextResponse.json(
{ success: false, error: '找不到指定的評審記錄' },
{ status: 404 }
);
}
console.log(`✅ 成功獲取評審數據: 專案=${evaluationWithDetails.project?.title}`);
// 處理反饋數據,按類型分組
const feedbackByType = {
criteria: evaluationWithDetails.feedback?.filter(f => f.feedback_type === 'criteria') || [],
strength: evaluationWithDetails.feedback?.filter(f => f.feedback_type === 'strength') || [],
improvement: evaluationWithDetails.feedback?.filter(f => f.feedback_type === 'improvement') || [],
overall: evaluationWithDetails.feedback?.filter(f => f.feedback_type === 'overall') || []
};
// 為每個評分標準獲取對應的 AI 評語、優點和改進建議
const criteriaWithFeedback = evaluationWithDetails.scores?.map(score => {
const criteriaId = score.criteria_item_id;
const criteriaName = score.criteria_item_name || '未知項目';
// 獲取該評分標準的 AI 評語
const criteriaFeedback = feedbackByType.criteria
.filter(f => f.criteria_item_id === criteriaId)
.map(f => f.content)
.filter((content, index, arr) => arr.indexOf(content) === index)[0] || '無評語';
// 獲取該評分標準的優點
const strengths = feedbackByType.strength
.filter(f => f.criteria_item_id === criteriaId)
.map(f => f.content)
.filter((content, index, arr) => arr.indexOf(content) === index);
// 獲取該評分標準的改進建議
const improvements = feedbackByType.improvement
.filter(f => f.criteria_item_id === criteriaId)
.map(f => f.content)
.filter((content, index, arr) => arr.indexOf(content) === index);
return {
name: criteriaName,
score: Number(score.score) || 0,
maxScore: Number(score.max_score) || 10,
weight: Number(score.weight) || 1,
weightedScore: Number(score.weighted_score) || 0,
percentage: Number(score.percentage) || 0,
feedback: criteriaFeedback,
strengths: strengths,
improvements: improvements
};
}) || [];
// 構建 PDF 報告數據
const pdfData: PDFReportData = {
projectTitle: evaluationWithDetails.project?.title || '未知專案',
overallScore: Number(evaluationWithDetails.overall_score) || 0,
totalPossible: Number(evaluationWithDetails.max_possible_score) || 100,
grade: evaluationWithDetails.grade || 'N/A',
analysisDate: evaluationWithDetails.created_at ? new Date(evaluationWithDetails.created_at).toISOString().split('T')[0] : new Date().toISOString().split('T')[0],
criteria: criteriaWithFeedback,
overview: {
excellentItems: evaluationWithDetails.excellent_items || 0,
improvementItems: evaluationWithDetails.improvement_items || 0,
overallPerformance: Number(evaluationWithDetails.overall_score) || 0
},
improvementSuggestions: {
overallSuggestions: feedbackByType.overall[0]?.content || '無詳細分析',
maintainStrengths: feedbackByType.strength
.filter(f => f.criteria_item_id === null)
.map(f => {
const content = f.content || '無描述';
const colonIndex = content.indexOf(':');
const title = colonIndex > -1 ? content.substring(0, colonIndex + 1) : '系統優勢:';
const description = colonIndex > -1 ? content.substring(colonIndex + 1).trim() : content;
return {
title: title,
description: description
};
}),
keyImprovements: (() => {
const allImprovementData = feedbackByType.improvement
.filter(f => f.criteria_item_id === null);
const improvementData = allImprovementData.length >= 5
? allImprovementData.slice(1, -3)
: allImprovementData;
const tempGroups = [];
let currentGroup = null;
for (const item of improvementData) {
const content = item.content || '';
const colonIndex = content.indexOf(':');
if (colonIndex > -1) {
const title = content.substring(0, colonIndex + 1);
const remainingContent = content.substring(colonIndex + 1).trim();
const suggestionIndex = remainingContent.indexOf('建議:');
if (suggestionIndex > -1) {
if (currentGroup) {
tempGroups.push(currentGroup);
}
currentGroup = {
title: title,
subtitle: '建議:',
suggestions: []
};
const suggestionsText = remainingContent.substring(suggestionIndex + 3).trim();
const suggestions = suggestionsText.split('\n')
.map(s => s.trim())
.filter(s => s.startsWith('•'))
.map(s => s.substring(1).trim());
currentGroup.suggestions = suggestions;
} else if (remainingContent.startsWith('•')) {
if (currentGroup) {
const suggestion = remainingContent.substring(1).trim();
currentGroup.suggestions.push(suggestion);
}
} else {
if (currentGroup) {
tempGroups.push(currentGroup);
}
currentGroup = {
title: title,
subtitle: '',
suggestions: [remainingContent]
};
}
} else {
if (currentGroup) {
tempGroups.push(currentGroup);
}
if (content.startsWith('•')) {
if (currentGroup) {
const suggestion = content.substring(1).trim();
currentGroup.suggestions.push(suggestion);
} else {
currentGroup = {
title: '改進方向:',
subtitle: '',
suggestions: [content.substring(1).trim()]
};
}
} else {
currentGroup = {
title: '改進方向:',
subtitle: '',
description: content,
suggestions: []
};
}
}
}
if (currentGroup) {
tempGroups.push(currentGroup);
}
return tempGroups;
})(),
actionPlan: feedbackByType.improvement
.filter(f => f.criteria_item_id === null)
.slice(-3)
.map((f, index) => {
const content = f.content || '無描述';
const colonIndex = content.indexOf(':');
const phase = colonIndex > -1 ? content.substring(0, colonIndex + 1) : `階段 ${index + 1}:`;
const description = colonIndex > -1 ? content.substring(colonIndex + 1).trim() : content;
return {
phase: phase,
description: description
};
})
}
};
console.log(`📋 開始生成 PDF 報告: 專案=${pdfData.projectTitle}`);
// 生成 PDF
const pdfBlob = await generateHTMLPDFReport(pdfData);
console.log(`✅ PDF 報告生成完成: 大小=${pdfBlob.size} bytes`);
// 返回 PDF 文件
const fileName = `評審報告_${pdfData.projectTitle}_${pdfData.analysisDate}.pdf`;
return new NextResponse(pdfBlob, {
status: 200,
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${encodeURIComponent(fileName)}"`,
'Content-Length': pdfBlob.size.toString(),
},
});
} catch (error) {
console.error('❌ 生成 PDF 報告失敗:', error);
return NextResponse.json(
{ success: false, error: '生成 PDF 報告失敗' },
{ status: 500 }
);
}
}

View File

@@ -5,6 +5,7 @@ import type React from "react"
import { Analytics } from "@vercel/analytics/next" import { Analytics } from "@vercel/analytics/next"
import { useSearchParams } from "next/navigation" import { useSearchParams } from "next/navigation"
import { Suspense } from "react" import { Suspense } from "react"
import { Toaster } from "@/components/ui/toaster"
export default function ClientLayout({ export default function ClientLayout({
children, children,
@@ -16,6 +17,7 @@ export default function ClientLayout({
return ( return (
<> <>
<Suspense fallback={<div>Loading...</div>}>{children}</Suspense> <Suspense fallback={<div>Loading...</div>}>{children}</Suspense>
<Toaster />
<Analytics /> <Analytics />
</> </>
) )

View File

@@ -7,8 +7,10 @@ import { Card, CardContent } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { FileText, Calendar, Search, Eye, Download, Trash2, Loader2 } from "lucide-react" import { FileText, Calendar, Search, Eye, Download, Trash2, Loader2 } from "lucide-react"
import Link from "next/link" import Link from "next/link"
import { useToast } from "@/hooks/use-toast"
// 歷史記錄數據類型 // 歷史記錄數據類型
interface HistoryItem { interface HistoryItem {
@@ -46,6 +48,8 @@ export default function HistoryPage() {
}) })
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [deletingId, setDeletingId] = useState<string | null>(null)
const { toast } = useToast()
// 載入歷史記錄數據 // 載入歷史記錄數據
useEffect(() => { useEffect(() => {
@@ -111,6 +115,104 @@ export default function HistoryPage() {
} }
}; };
// 下載評審報告
const handleDownload = async (evaluationId: number, projectTitle: string) => {
try {
toast({
title: "報告下載中",
description: "評審報告 PDF 正在生成,請稍候...",
});
// 調用下載 API
const response = await fetch(`/api/evaluation/${evaluationId}/download`);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || '下載失敗');
}
// 獲取 PDF Blob
const pdfBlob = await response.blob();
// 創建下載連結
const url = window.URL.createObjectURL(pdfBlob);
const link = document.createElement('a');
link.href = url;
// 從 Content-Disposition 標頭獲取檔案名稱,或使用預設名稱
const contentDisposition = response.headers.get('Content-Disposition');
let fileName = `評審報告_${projectTitle}_${new Date().toISOString().split('T')[0]}.pdf`;
if (contentDisposition) {
const fileNameMatch = contentDisposition.match(/filename="(.+)"/);
if (fileNameMatch) {
fileName = decodeURIComponent(fileNameMatch[1]);
}
}
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
// 顯示成功提示
toast({
title: "下載完成",
description: "評審報告已成功下載",
});
} catch (error) {
console.error('下載失敗:', error);
toast({
title: "下載失敗",
description: error instanceof Error ? error.message : '下載過程中發生錯誤',
variant: "destructive",
});
}
};
// 刪除評審報告
const handleDelete = async (evaluationId: number, projectTitle: string) => {
try {
setDeletingId(evaluationId.toString());
const response = await fetch(`/api/evaluation/${evaluationId}/delete`, {
method: 'DELETE',
});
const result = await response.json();
if (result.success) {
// 從本地狀態中移除已刪除的項目
setHistoryData(prev => prev.filter(item => item.evaluation_id !== evaluationId));
// 重新載入統計數據
const statsResponse = await fetch('/api/history/stats');
const statsResult = await statsResponse.json();
if (statsResult.success) {
setStatsData(statsResult.data);
}
toast({
title: "刪除成功",
description: `評審報告「${projectTitle}」已成功刪除`,
});
} else {
throw new Error(result.error || '刪除失敗');
}
} catch (error) {
console.error('刪除失敗:', error);
toast({
title: "刪除失敗",
description: error instanceof Error ? error.message : '刪除過程中發生錯誤',
variant: "destructive",
});
} finally {
setDeletingId(null);
}
};
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
<Sidebar /> <Sidebar />
@@ -264,7 +366,11 @@ export default function HistoryPage() {
<Eye className="h-4 w-4" /> <Eye className="h-4 w-4" />
</Link> </Link>
</Button> </Button>
<Button variant="outline" size="sm"> <Button
variant="outline"
size="sm"
onClick={() => handleDownload(item.evaluation_id!, item.title)}
>
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
</Button> </Button>
</> </>
@@ -273,9 +379,48 @@ export default function HistoryPage() {
... ...
</Button> </Button>
)} )}
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive"> <AlertDialog>
<Trash2 className="h-4 w-4" /> <AlertDialogTrigger asChild>
</Button> <Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
disabled={deletingId === item.evaluation_id?.toString()}
>
{deletingId === item.evaluation_id?.toString() ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{item.title}
<br />
<br />
<br />
<br />
<br />
<strong></strong>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={() => item.evaluation_id && handleDelete(item.evaluation_id, item.title)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -27,6 +27,7 @@ import {
} from "recharts" } from "recharts"
import { Download, Share2, TrendingUp, AlertCircle, CheckCircle, Star } from "lucide-react" import { Download, Share2, TrendingUp, AlertCircle, CheckCircle, Star } from "lucide-react"
import { useToast } from "@/hooks/use-toast" import { useToast } from "@/hooks/use-toast"
import { ShareModal } from "@/components/share-modal"
// 模擬評分結果數據 - 使用您提到的例子8, 7, 6, 8, 4平均 = 6.6 // 模擬評分結果數據 - 使用您提到的例子8, 7, 6, 8, 4平均 = 6.6
const mockCriteria = [ const mockCriteria = [
@@ -129,7 +130,8 @@ export default function ResultsPage() {
const [activeTab, setActiveTab] = useState("overview") const [activeTab, setActiveTab] = useState("overview")
const [evaluationData, setEvaluationData] = useState(null) const [evaluationData, setEvaluationData] = useState(null)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState(null) const [error, setError] = useState<string | null>(null)
const [isShareModalOpen, setIsShareModalOpen] = useState(false)
const { toast } = useToast() const { toast } = useToast()
const searchParams = useSearchParams() const searchParams = useSearchParams()
@@ -168,10 +170,10 @@ export default function ResultsPage() {
} }
} catch (error) { } catch (error) {
console.error('載入評審結果失敗:', error) console.error('載入評審結果失敗:', error)
setError(error.message) setError((error as Error).message)
toast({ toast({
title: "載入失敗", title: "載入失敗",
description: error.message || "無法載入評審結果,請重新進行評審", description: (error as Error).message || "無法載入評審結果,請重新進行評審",
variant: "destructive", variant: "destructive",
}) })
} finally { } finally {
@@ -230,10 +232,10 @@ export default function ResultsPage() {
} }
// 使用真實數據或回退到模擬數據 // 使用真實數據或回退到模擬數據
const results = evaluationData.evaluation?.fullData || evaluationData || mockResults const results = (evaluationData as any)?.evaluation?.fullData || evaluationData || mockResults
// 計算統計數據 - 基於 criteria_items 的平均分作為閾值 // 計算統計數據 - 基於 criteria_items 的平均分作為閾值
const calculateOverview = (criteria: any[]) => { const calculateOverview = (criteria: any[]): any => {
if (!criteria || criteria.length === 0) { if (!criteria || criteria.length === 0) {
return { return {
excellentItems: 0, excellentItems: 0,
@@ -278,18 +280,72 @@ export default function ResultsPage() {
} }
} }
const downloadReport = () => { const downloadReport = async () => {
toast({ try {
title: "報告下載中", // 顯示載入提示
description: "評審報告 PDF 正在生成,請稍候...", toast({
}) title: "報告下載中",
description: "評審報告 PDF 正在生成,請稍候...",
});
// 獲取評審 ID
const evaluationId = searchParams.get('id');
if (!evaluationId) {
throw new Error('無法獲取評審 ID');
}
// 調用下載 API
const response = await fetch(`/api/evaluation/${evaluationId}/download`);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || '下載失敗');
}
// 獲取 PDF Blob
const pdfBlob = await response.blob();
// 創建下載連結
const url = window.URL.createObjectURL(pdfBlob);
const link = document.createElement('a');
link.href = url;
// 從 Content-Disposition 標頭獲取檔案名稱,或使用預設名稱
const contentDisposition = response.headers.get('Content-Disposition');
let fileName = `評審報告_${safeResults.projectTitle}_${safeResults.analysisDate}.pdf`;
if (contentDisposition) {
const fileNameMatch = contentDisposition.match(/filename="(.+)"/);
if (fileNameMatch) {
fileName = decodeURIComponent(fileNameMatch[1]);
}
}
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
// 顯示成功提示
toast({
title: "下載完成",
description: "評審報告已成功下載",
});
} catch (error) {
console.error('下載失敗:', error);
toast({
title: "下載失敗",
description: error instanceof Error ? error.message : '下載過程中發生錯誤',
variant: "destructive",
});
}
} }
const shareResults = () => { const shareResults = () => {
toast({ setIsShareModalOpen(true)
title: "分享連結已複製",
description: "評審結果分享連結已複製到剪貼板",
})
} }
const getScoreColor = (score: number, maxScore: number) => { const getScoreColor = (score: number, maxScore: number) => {
@@ -384,7 +440,7 @@ export default function ResultsPage() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-4"> <div className="space-y-4">
{safeResults.criteria.map((item, index) => ( {safeResults.criteria.map((item: any, index: number) => (
<div key={index} className="flex items-center justify-between p-4 bg-muted rounded-lg"> <div key={index} className="flex items-center justify-between p-4 bg-muted rounded-lg">
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
@@ -432,7 +488,7 @@ export default function ResultsPage() {
</TabsContent> </TabsContent>
<TabsContent value="detailed" className="space-y-6"> <TabsContent value="detailed" className="space-y-6">
{safeResults.criteria.map((item, index) => ( {safeResults.criteria.map((item: any, index: number) => (
<Card key={index}> <Card key={index}>
<CardHeader> <CardHeader>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -455,7 +511,7 @@ export default function ResultsPage() {
<div> <div>
<h4 className="font-medium mb-2 text-green-700"></h4> <h4 className="font-medium mb-2 text-green-700"></h4>
<ul className="space-y-1"> <ul className="space-y-1">
{item.strengths.map((strength, i) => ( {item.strengths.map((strength: any, i: number) => (
<li key={i} className="flex items-center gap-2 text-sm"> <li key={i} className="flex items-center gap-2 text-sm">
<CheckCircle className="h-4 w-4 text-green-600" /> <CheckCircle className="h-4 w-4 text-green-600" />
{strength} {strength}
@@ -466,7 +522,7 @@ export default function ResultsPage() {
<div> <div>
<h4 className="font-medium mb-2 text-orange-700"></h4> <h4 className="font-medium mb-2 text-orange-700"></h4>
<ul className="space-y-1"> <ul className="space-y-1">
{item.improvements.map((improvement, i) => ( {item.improvements.map((improvement: any, i: number) => (
<li key={i} className="flex items-center gap-2 text-sm"> <li key={i} className="flex items-center gap-2 text-sm">
<AlertCircle className="h-4 w-4 text-orange-600" /> <AlertCircle className="h-4 w-4 text-orange-600" />
{improvement} {improvement}
@@ -518,7 +574,7 @@ export default function ResultsPage() {
fill="#8884d8" fill="#8884d8"
dataKey="value" dataKey="value"
> >
{safeResults.chartData.pieChart.map((entry, index) => ( {safeResults.chartData.pieChart.map((entry: any, index: number) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} /> <Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))} ))}
</Pie> </Pie>
@@ -558,7 +614,7 @@ export default function ResultsPage() {
<div> <div>
<h4 className="font-medium mb-3 text-green-700"></h4> <h4 className="font-medium mb-3 text-green-700"></h4>
<div className="grid md:grid-cols-2 gap-4"> <div className="grid md:grid-cols-2 gap-4">
{safeResults.improvementSuggestions.maintainStrengths.map((strength, index) => ( {safeResults.improvementSuggestions.maintainStrengths.map((strength: any, index: number) => (
<div key={index} className="p-4 bg-green-50 rounded-lg"> <div key={index} className="p-4 bg-green-50 rounded-lg">
<h5 className="font-medium mb-2">{strength.title}</h5> <h5 className="font-medium mb-2">{strength.title}</h5>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
@@ -572,12 +628,12 @@ export default function ResultsPage() {
<div> <div>
<h4 className="font-medium mb-3 text-orange-700"></h4> <h4 className="font-medium mb-3 text-orange-700"></h4>
<div className="space-y-4"> <div className="space-y-4">
{safeResults.improvementSuggestions.keyImprovements.map((improvement, index) => ( {safeResults.improvementSuggestions.keyImprovements.map((improvement: any, index: number) => (
<div key={index} className="p-4 bg-orange-50 rounded-lg"> <div key={index} className="p-4 bg-orange-50 rounded-lg">
<h5 className="font-medium mb-2">{improvement.title}</h5> <h5 className="font-medium mb-2">{improvement.title}</h5>
<p className="text-sm text-muted-foreground mb-3">{improvement.description}</p> <p className="text-sm text-muted-foreground mb-3">{improvement.description}</p>
<ul className="text-sm space-y-1"> <ul className="text-sm space-y-1">
{improvement.suggestions.map((suggestion, sIndex) => ( {improvement.suggestions.map((suggestion: any, sIndex: number) => (
<li key={sIndex}> {suggestion}</li> <li key={sIndex}> {suggestion}</li>
))} ))}
</ul> </ul>
@@ -589,7 +645,7 @@ export default function ResultsPage() {
<div> <div>
<h4 className="font-medium mb-3 text-blue-700"></h4> <h4 className="font-medium mb-3 text-blue-700"></h4>
<div className="space-y-3"> <div className="space-y-3">
{safeResults.improvementSuggestions.actionPlan.map((action, index) => ( {safeResults.improvementSuggestions.actionPlan.map((action: any, index: number) => (
<div key={index} className="flex items-start gap-3 p-3 bg-blue-50 rounded-lg"> <div key={index} className="flex items-start gap-3 p-3 bg-blue-50 rounded-lg">
<div className="w-6 h-6 bg-blue-600 text-white rounded-full flex items-center justify-center text-sm font-bold"> <div className="w-6 h-6 bg-blue-600 text-white rounded-full flex items-center justify-center text-sm font-bold">
{index + 1} {index + 1}
@@ -608,6 +664,14 @@ export default function ResultsPage() {
</Tabs> </Tabs>
</div> </div>
</main> </main>
{/* Share Modal */}
<ShareModal
isOpen={isShareModalOpen}
onClose={() => setIsShareModalOpen(false)}
evaluationId={searchParams.get('id') || undefined}
projectTitle={safeResults.projectTitle}
/>
</div> </div>
) )
} }

296
components/share-modal.tsx Normal file
View File

@@ -0,0 +1,296 @@
"use client"
import { useState, useEffect } from "react"
import { Copy, Check, Share2, QrCode, Mail } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { useToast } from "@/hooks/use-toast"
import { generateShareUrl, getCurrentUrl } from "@/lib/config"
import QRCode from "qrcode"
interface ShareModalProps {
isOpen: boolean
onClose: () => void
evaluationId?: string
projectTitle?: string
}
export function ShareModal({ isOpen, onClose, evaluationId, projectTitle }: ShareModalProps) {
const [qrCodeUrl, setQrCodeUrl] = useState<string>("")
const [isGeneratingQR, setIsGeneratingQR] = useState(false)
const [copiedLink, setCopiedLink] = useState(false)
const [copiedQR, setCopiedQR] = useState(false)
const { toast } = useToast()
// 生成分享連結
const shareUrl = evaluationId
? generateShareUrl(evaluationId)
: getCurrentUrl()
// 生成 QR Code
useEffect(() => {
if (isOpen && shareUrl) {
setIsGeneratingQR(true)
QRCode.toDataURL(shareUrl, {
width: 160,
margin: 2,
color: {
dark: '#000000',
light: '#FFFFFF'
}
})
.then(url => {
setQrCodeUrl(url)
setIsGeneratingQR(false)
})
.catch(err => {
console.error('生成 QR Code 失敗:', err)
setIsGeneratingQR(false)
toast({
title: "錯誤",
description: "生成 QR Code 失敗,請稍後再試",
variant: "destructive",
})
})
}
}, [isOpen, shareUrl, toast])
// 複製連結
const copyLink = async () => {
try {
await navigator.clipboard.writeText(shareUrl)
setCopiedLink(true)
toast({
title: "成功",
description: "連結已複製到剪貼板",
})
setTimeout(() => setCopiedLink(false), 2000)
} catch (err) {
console.error('複製失敗:', err)
toast({
title: "錯誤",
description: "複製失敗,請手動複製連結",
variant: "destructive",
})
}
}
// 複製 QR Code
const copyQRCode = async () => {
if (!qrCodeUrl) return
try {
// 將 base64 轉換為 blob
const response = await fetch(qrCodeUrl)
const blob = await response.blob()
// 複製到剪貼板
await navigator.clipboard.write([
new ClipboardItem({
[blob.type]: blob
})
])
setCopiedQR(true)
toast({
title: "成功",
description: "QR Code 已複製到剪貼板",
})
setTimeout(() => setCopiedQR(false), 2000)
} catch (err) {
console.error('複製 QR Code 失敗:', err)
toast({
title: "錯誤",
description: "複製 QR Code 失敗,請截圖保存",
variant: "destructive",
})
}
}
// 分享到社群媒體
const shareToSocial = (platform: string) => {
const encodedUrl = encodeURIComponent(shareUrl)
const encodedTitle = encodeURIComponent(`評審結果 - ${projectTitle || 'AI 智能評審系統'}`)
let shareUrl_template = ""
switch (platform) {
case 'line':
shareUrl_template = `https://social-plugins.line.me/lineit/share?url=${encodedUrl}&text=${encodedTitle}`
break
case 'facebook':
shareUrl_template = `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`
break
case 'email':
const emailBody = encodeURIComponent(`您好,\n\n我想與您分享這個評審結果\n\n${projectTitle || 'AI 智能評審系統'}\n\n請點擊以下連結查看詳細報告\n${shareUrl}\n\n感謝`)
shareUrl_template = `mailto:?subject=${encodedTitle}&body=${emailBody}`
break
default:
return
}
if (platform === 'email') {
window.location.href = shareUrl_template
} else {
window.open(shareUrl_template, '_blank', 'width=600,height=400')
}
}
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Share2 className="h-5 w-5" />
</DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 分享連結 */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base"></CardTitle>
<CardDescription className="text-sm">
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-2">
<div className="flex-1 p-2 bg-muted rounded-md text-sm font-mono break-all min-w-0">
{shareUrl}
</div>
<Button
onClick={copyLink}
size="sm"
variant="outline"
className="shrink-0 w-full sm:w-auto hover:bg-primary/5 hover:border-primary/20 hover:text-primary transition-colors"
>
{copiedLink ? (
<>
<Check className="h-4 w-4 mr-2" />
</>
) : (
<>
<Copy className="h-4 w-4 mr-2" />
</>
)}
</Button>
</div>
</CardContent>
</Card>
{/* QR Code */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<QrCode className="h-4 w-4" />
QR Code
</CardTitle>
<CardDescription className="text-sm">
QR Code
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex flex-col items-center space-y-3">
{isGeneratingQR ? (
<div className="w-40 h-40 flex items-center justify-center bg-muted rounded-lg">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
) : qrCodeUrl ? (
<div className="flex flex-col items-center space-y-3">
<img
src={qrCodeUrl}
alt="QR Code"
className="w-40 h-40 border rounded-lg"
/>
<Button
onClick={copyQRCode}
size="sm"
variant="outline"
disabled={copiedQR}
className="w-full sm:w-auto hover:bg-primary/5 hover:border-primary/20 hover:text-primary transition-colors disabled:hover:bg-transparent disabled:hover:border-border disabled:hover:text-muted-foreground"
>
{copiedQR ? (
<>
<Check className="h-4 w-4 mr-2" />
</>
) : (
<>
<Copy className="h-4 w-4 mr-2" />
QR Code
</>
)}
</Button>
</div>
) : (
<div className="w-40 h-40 flex items-center justify-center bg-muted rounded-lg text-muted-foreground">
...
</div>
)}
</div>
</CardContent>
</Card>
{/* 社群分享 */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base"></CardTitle>
<CardDescription className="text-sm">
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
<Button
onClick={() => shareToSocial('line')}
variant="outline"
size="sm"
className="flex flex-col items-center gap-2 h-auto py-4 hover:bg-green-50 hover:border-green-300 hover:text-green-700 transition-colors"
>
<div className="w-7 h-7 bg-green-500 rounded text-white text-sm flex items-center justify-center font-bold">
L
</div>
<span className="text-xs font-medium">LINE</span>
</Button>
<Button
onClick={() => shareToSocial('facebook')}
variant="outline"
size="sm"
className="flex flex-col items-center gap-2 h-auto py-4 hover:bg-blue-50 hover:border-blue-300 hover:text-blue-700 transition-colors"
>
<div className="w-7 h-7 bg-blue-600 rounded text-white text-sm flex items-center justify-center font-bold">
f
</div>
<span className="text-xs font-medium">Facebook</span>
</Button>
<Button
onClick={() => shareToSocial('email')}
variant="outline"
size="sm"
className="flex flex-col items-center gap-2 h-auto py-4 col-span-2 sm:col-span-1 hover:bg-slate-50 hover:border-slate-300 hover:text-slate-700 transition-colors"
>
<Mail className="w-7 h-7 text-blue-600" />
<span className="text-xs font-medium">Email</span>
</Button>
</div>
</CardContent>
</Card>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,141 @@
"use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

122
components/ui/dialog.tsx Normal file
View File

@@ -0,0 +1,122 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only"></span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

35
components/ui/toaster.tsx Normal file
View File

@@ -0,0 +1,35 @@
"use client"
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast"
import { useToast } from "@/hooks/use-toast"
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}

15
env.example Normal file
View File

@@ -0,0 +1,15 @@
# 應用配置
NEXT_PUBLIC_APP_URL=http://localhost:12024
NEXT_PUBLIC_APP_NAME=AI 智能評審系統
# 資料庫配置
DB_HOST=mysql.theaken.com
DB_PORT=33306
DB_NAME=db_AI_scoring
DB_USER=root
DB_PASSWORD=zh6161168
# AI 配置
GEMINI_API_KEY=AIzaSyAN3pEJr_Vn2xkCidGZAq9eQqsMVvpj8g4
GEMINI_MODEL=gemini-1.5-pro
GEMINI_MAX_TOKENS=8192

89
lib/config.ts Normal file
View File

@@ -0,0 +1,89 @@
/**
* 應用配置工具類
* 用於管理環境變量和應用設置
*/
// 資料庫配置
export const dbConfig = {
host: process.env.DB_HOST || 'mysql.theaken.com',
port: parseInt(process.env.DB_PORT || '33306'),
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || 'zh6161168',
database: process.env.DB_NAME || 'db_AI_scoring',
charset: 'utf8mb4',
timezone: '+08:00',
acquireTimeout: 60000,
timeout: 60000,
reconnect: true,
multipleStatements: true,
} as const
// AI 配置
export const aiConfig = {
geminiApiKey: process.env.GEMINI_API_KEY || 'AIzaSyAN3pEJr_Vn2xkCidGZAq9eQqsMVvpj8g4',
modelName: process.env.GEMINI_MODEL || 'gemini-1.5-pro',
maxTokens: parseInt(process.env.GEMINI_MAX_TOKENS || '8192'),
} as const
// 獲取應用基礎 URL
export function getAppUrl(): string {
// 在客戶端使用 window.location.origin
if (typeof window !== 'undefined') {
return window.location.origin
}
// 在服務端使用環境變量,如果沒有設置則使用 localhost:12024
return process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:12024'
}
// 獲取應用名稱
export function getAppName(): string {
return process.env.NEXT_PUBLIC_APP_NAME || 'AI 智能評審系統'
}
// 生成分享連結
export function generateShareUrl(evaluationId: string): string {
const baseUrl = getAppUrl()
return `${baseUrl}/results?id=${evaluationId}`
}
// 生成當前頁面連結
export function getCurrentUrl(): string {
if (typeof window !== 'undefined') {
return window.location.href
}
return getAppUrl()
}
// 環境變量配置
export const config = {
appUrl: getAppUrl(),
appName: getAppName(),
isDevelopment: process.env.NODE_ENV === 'development',
isProduction: process.env.NODE_ENV === 'production',
database: dbConfig,
ai: aiConfig,
} as const
// 配置驗證
export function validateConfig(): { isValid: boolean; errors: string[] } {
const errors: string[] = []
// 檢查必要的環境變量
if (!process.env.GEMINI_API_KEY) {
errors.push('GEMINI_API_KEY 環境變量未設置')
}
if (!process.env.DB_HOST) {
errors.push('DB_HOST 環境變量未設置')
}
if (!process.env.DB_NAME) {
errors.push('DB_NAME 環境變量未設置')
}
return {
isValid: errors.length === 0,
errors
}
}

View File

@@ -1,19 +1,5 @@
import mysql from 'mysql2/promise'; import mysql from 'mysql2/promise';
import { dbConfig } from './config';
// 資料庫配置
const dbConfig = {
host: process.env.DB_HOST || 'mysql.theaken.com',
port: parseInt(process.env.DB_PORT || '33306'),
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || 'zh6161168',
database: process.env.DB_NAME || 'db_AI_scoring',
charset: 'utf8mb4',
timezone: '+08:00',
acquireTimeout: 60000,
timeout: 60000,
reconnect: true,
multipleStatements: true,
};
// 建立連接池 // 建立連接池
const pool = mysql.createPool({ const pool = mysql.createPool({

View File

@@ -1,7 +1,7 @@
import { GoogleGenerativeAI } from '@google/generative-ai'; import { GoogleGenerativeAI } from '@google/generative-ai';
import { aiConfig } from '../config';
const API_KEY = 'AIzaSyAN3pEJr_Vn2xkCidGZAq9eQqsMVvpj8g4'; const genAI = new GoogleGenerativeAI(aiConfig.geminiApiKey);
const genAI = new GoogleGenerativeAI(API_KEY);
export interface CriteriaItem { export interface CriteriaItem {
id: string; id: string;

View File

@@ -0,0 +1,504 @@
import puppeteer from 'puppeteer';
export interface PDFReportData {
projectTitle: string;
overallScore: number;
totalPossible: number;
grade: string;
analysisDate: string;
criteria: Array<{
name: string;
score: number;
maxScore: number;
weight: number;
weightedScore: number;
percentage: number;
feedback: string;
strengths: string[];
improvements: string[];
}>;
overview: {
excellentItems: number;
improvementItems: number;
overallPerformance: number;
};
improvementSuggestions: {
overallSuggestions: string;
maintainStrengths: Array<{
title: string;
description: string;
}>;
keyImprovements: Array<{
title: string;
subtitle?: string;
description?: string;
suggestions: string[];
}>;
actionPlan: Array<{
phase: string;
description: string;
}>;
};
}
export class HTMLPDFGenerator {
private generateHTML(data: PDFReportData): string {
return `
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI 智能評審報告</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Microsoft JhengHei', 'PingFang TC', 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
background: white;
padding: 20px;
}
.header {
text-align: center;
margin-bottom: 30px;
border-bottom: 2px solid #0891b2;
padding-bottom: 20px;
}
.title {
font-size: 28px;
font-weight: bold;
color: #0891b2;
margin-bottom: 10px;
}
.subtitle {
font-size: 16px;
color: #666;
}
.section {
margin-bottom: 25px;
}
.section-title {
font-size: 18px;
font-weight: bold;
color: #0891b2;
margin-bottom: 15px;
border-left: 4px solid #0891b2;
padding-left: 10px;
}
.score-box {
background: #f8f9fa;
border: 2px solid #0891b2;
border-radius: 8px;
padding: 20px;
text-align: center;
margin: 20px 0;
display: inline-block;
min-width: 200px;
}
.score-number {
font-size: 36px;
font-weight: bold;
color: #0891b2;
}
.score-grade {
font-size: 20px;
font-weight: bold;
color: #10b981;
margin-top: 5px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
margin: 20px 0;
}
.stat-card {
background: #f8f9fa;
border-radius: 8px;
padding: 15px;
text-align: center;
}
.stat-number {
font-size: 24px;
font-weight: bold;
color: #0891b2;
}
.stat-label {
font-size: 14px;
color: #666;
margin-top: 5px;
}
.criteria-table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
}
.criteria-table th,
.criteria-table td {
border: 1px solid #ddd;
padding: 12px;
text-align: left;
}
.criteria-table th {
background: #0891b2;
color: white;
font-weight: bold;
}
.criteria-table tr:nth-child(even) {
background: #f8f9fa;
}
.criteria-item {
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 20px;
margin: 15px 0;
}
.criteria-name {
font-size: 16px;
font-weight: bold;
color: #0891b2;
margin-bottom: 10px;
}
.criteria-score {
font-size: 18px;
font-weight: bold;
color: #10b981;
margin-bottom: 10px;
}
.criteria-feedback {
margin: 10px 0;
padding: 10px;
background: #f8f9fa;
border-radius: 4px;
}
.strengths, .improvements {
margin: 10px 0;
}
.strengths h4, .improvements h4 {
color: #10b981;
margin-bottom: 8px;
}
.improvements h4 {
color: #f59e0b;
}
.strengths ul, .improvements ul {
margin-left: 20px;
}
.strengths li, .improvements li {
margin: 5px 0;
}
.suggestions-section {
background: #f8f9fa;
border-radius: 8px;
padding: 20px;
margin: 20px 0;
}
.suggestion-group {
margin: 15px 0;
}
.suggestion-title {
font-weight: bold;
color: #0891b2;
margin-bottom: 8px;
}
.suggestion-description {
margin: 8px 0;
}
.suggestion-list {
margin-left: 20px;
}
.action-plan {
background: #e0f2fe;
border-radius: 8px;
padding: 20px;
margin: 20px 0;
}
.action-item {
display: flex;
align-items: flex-start;
margin: 10px 0;
}
.action-number {
background: #0891b2;
color: white;
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
margin-right: 12px;
flex-shrink: 0;
}
.action-content {
flex: 1;
}
.action-phase {
font-weight: bold;
color: #0891b2;
margin-bottom: 4px;
}
.footer {
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid #e5e7eb;
text-align: center;
color: #666;
font-size: 12px;
}
@media print {
body {
padding: 0;
}
.page-break {
page-break-before: always;
}
}
</style>
</head>
<body>
<div class="header">
<h1 class="title">AI 智能評審報告</h1>
<p class="subtitle">專案名稱:${data.projectTitle}</p>
<p class="subtitle">分析日期:${data.analysisDate}</p>
</div>
<div class="section">
<h2 class="section-title">總評結果</h2>
<div class="score-box">
<div class="score-number">${data.overallScore}/${data.totalPossible}</div>
<div class="score-grade">${data.grade}</div>
</div>
</div>
<div class="section">
<h2 class="section-title">統計概覽</h2>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-number">${data.overview.excellentItems}</div>
<div class="stat-label">優秀項目</div>
</div>
<div class="stat-card">
<div class="stat-number">${data.overview.improvementItems}</div>
<div class="stat-label">待改進項目</div>
</div>
<div class="stat-card">
<div class="stat-number">${data.overview.overallPerformance}%</div>
<div class="stat-label">整體表現</div>
</div>
</div>
</div>
<div class="section">
<h2 class="section-title">評分明細</h2>
<table class="criteria-table">
<thead>
<tr>
<th>評分項目</th>
<th>得分</th>
<th>權重</th>
<th>加權分</th>
</tr>
</thead>
<tbody>
${data.criteria.map(item => `
<tr>
<td>${item.name}</td>
<td>${item.score}/${item.maxScore}</td>
<td>${item.weight}%</td>
<td>${item.weightedScore.toFixed(1)}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
<div class="section page-break">
<h2 class="section-title">詳細分析</h2>
${data.criteria.map(item => `
<div class="criteria-item">
<div class="criteria-name">${item.name}</div>
<div class="criteria-score">得分:${item.score}/${item.maxScore} (${item.percentage.toFixed(1)}%)</div>
<div class="criteria-feedback">
<strong>AI 評語:</strong>${item.feedback}
</div>
${item.strengths.length > 0 ? `
<div class="strengths">
<h4>優點:</h4>
<ul>
${item.strengths.map(strength => `<li>${strength}</li>`).join('')}
</ul>
</div>
` : ''}
${item.improvements.length > 0 ? `
<div class="improvements">
<h4>改進建議:</h4>
<ul>
${item.improvements.map(improvement => `<li>${improvement}</li>`).join('')}
</ul>
</div>
` : ''}
</div>
`).join('')}
</div>
<div class="section page-break">
<h2 class="section-title">整體改進建議</h2>
<div class="suggestions-section">
<p>${data.improvementSuggestions.overallSuggestions}</p>
</div>
</div>
${data.improvementSuggestions.maintainStrengths.length > 0 ? `
<div class="section">
<h2 class="section-title">繼續保持的優勢</h2>
<div class="suggestions-section">
${data.improvementSuggestions.maintainStrengths.map(strength => `
<div class="suggestion-group">
<div class="suggestion-title">${strength.title}</div>
<div class="suggestion-description">${strength.description}</div>
</div>
`).join('')}
</div>
</div>
` : ''}
${data.improvementSuggestions.keyImprovements.length > 0 ? `
<div class="section page-break">
<h2 class="section-title">重點改進方向</h2>
<div class="suggestions-section">
${data.improvementSuggestions.keyImprovements.map(improvement => `
<div class="suggestion-group">
<div class="suggestion-title">${improvement.title}</div>
${improvement.description ? `<div class="suggestion-description">${improvement.description}</div>` : ''}
${improvement.suggestions.length > 0 ? `
<div class="suggestion-list">
<ul>
${improvement.suggestions.map(suggestion => `<li>${suggestion}</li>`).join('')}
</ul>
</div>
` : ''}
</div>
`).join('')}
</div>
</div>
` : ''}
${data.improvementSuggestions.actionPlan.length > 0 ? `
<div class="section">
<h2 class="section-title">下一步行動計劃</h2>
<div class="action-plan">
${data.improvementSuggestions.actionPlan.map((action, index) => `
<div class="action-item">
<div class="action-number">${index + 1}</div>
<div class="action-content">
<div class="action-phase">${action.phase}</div>
<div>${action.description}</div>
</div>
</div>
`).join('')}
</div>
</div>
` : ''}
<div class="footer">
<p>本報告由 AI 智能評審系統生成</p>
<p>生成時間:${new Date().toLocaleString('zh-TW')}</p>
</div>
</body>
</html>
`;
}
public async generateReport(data: PDFReportData): Promise<Blob> {
const browser = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-accelerated-2d-canvas',
'--no-first-run',
'--no-zygote',
'--disable-gpu'
],
timeout: 30000
});
try {
const page = await browser.newPage();
// 設置頁面內容
const html = this.generateHTML(data);
await page.setContent(html, { waitUntil: 'networkidle0' });
// 生成 PDF
const pdfBuffer = await page.pdf({
format: 'A4',
printBackground: true,
margin: {
top: '20mm',
right: '20mm',
bottom: '20mm',
left: '20mm'
}
});
return new Blob([pdfBuffer], { type: 'application/pdf' });
} finally {
await browser.close();
}
}
}
// 便捷函數
export async function generateHTMLPDFReport(data: PDFReportData): Promise<Blob> {
const generator = new HTMLPDFGenerator();
return generator.generateReport(data);
}

View File

@@ -0,0 +1,319 @@
import jsPDF from 'jspdf';
import 'jspdf-autotable';
export interface PDFReportData {
projectTitle: string;
overallScore: number;
totalPossible: number;
grade: string;
analysisDate: string;
criteria: Array<{
name: string;
score: number;
maxScore: number;
weight: number;
weightedScore: number;
percentage: number;
feedback: string;
strengths: string[];
improvements: string[];
}>;
overview: {
excellentItems: number;
improvementItems: number;
overallPerformance: number;
};
improvementSuggestions: {
overallSuggestions: string;
maintainStrengths: Array<{
title: string;
description: string;
}>;
keyImprovements: Array<{
title: string;
subtitle?: string;
description?: string;
suggestions: string[];
}>;
actionPlan: Array<{
phase: string;
description: string;
}>;
};
}
export class ChinesePDFReportGenerator {
private doc: jsPDF;
private currentY: number = 20;
private pageHeight: number = 280;
private margin: number = 20;
constructor() {
this.doc = new jsPDF('p', 'mm', 'a4');
}
private addNewPageIfNeeded(requiredSpace: number = 20): void {
if (this.currentY + requiredSpace > this.pageHeight) {
this.doc.addPage();
this.currentY = 20;
}
}
private addTitle(text: string, fontSize: number = 16, isBold: boolean = true): void {
this.addNewPageIfNeeded(10);
this.doc.setFontSize(fontSize);
this.doc.setFont('helvetica', isBold ? 'bold' : 'normal');
// 將中文轉換為可顯示的格式
const displayText = this.convertChineseText(text);
this.doc.text(displayText, this.margin, this.currentY);
this.currentY += fontSize + 5;
}
private addSubtitle(text: string, fontSize: number = 12): void {
this.addNewPageIfNeeded(8);
this.doc.setFontSize(fontSize);
this.doc.setFont('helvetica', 'bold');
const displayText = this.convertChineseText(text);
this.doc.text(displayText, this.margin, this.currentY);
this.currentY += fontSize + 3;
}
private addText(text: string, fontSize: number = 10, isBold: boolean = false): void {
this.addNewPageIfNeeded(6);
this.doc.setFontSize(fontSize);
this.doc.setFont('helvetica', isBold ? 'bold' : 'normal');
const displayText = this.convertChineseText(text);
// 處理長文本換行
const maxWidth = 170;
const lines = this.doc.splitTextToSize(displayText, maxWidth);
for (const line of lines) {
this.addNewPageIfNeeded(6);
this.doc.text(line, this.margin, this.currentY);
this.currentY += 6;
}
}
private addBulletList(items: string[], fontSize: number = 10): void {
for (const item of items) {
this.addNewPageIfNeeded(6);
this.doc.setFontSize(fontSize);
this.doc.setFont('helvetica', 'normal');
const displayText = this.convertChineseText(item);
this.doc.text(`${displayText}`, this.margin + 5, this.currentY);
this.currentY += 6;
}
}
private addScoreBox(score: number, total: number, grade: string): void {
this.addNewPageIfNeeded(25);
// 繪製分數框
this.doc.setFillColor(240, 240, 240);
this.doc.rect(this.margin, this.currentY, 50, 20, 'F');
// 分數文字
this.doc.setFontSize(18);
this.doc.setFont('helvetica', 'bold');
this.doc.text(`${score}/${total}`, this.margin + 5, this.currentY + 12);
// 等級
this.doc.setFontSize(12);
this.doc.text(grade, this.margin + 5, this.currentY + 18);
this.currentY += 25;
}
private addCriteriaTable(criteria: PDFReportData['criteria']): void {
this.addNewPageIfNeeded(30);
// 準備表格數據
const tableData = criteria.map(item => [
this.convertChineseText(item.name),
`${item.score}/${item.maxScore}`,
`${item.weight}%`,
item.weightedScore.toFixed(1)
]);
// 使用 autoTable 插件創建表格
(this.doc as any).autoTable({
head: [['評分項目', '得分', '權重', '加權分']],
body: tableData,
startY: this.currentY,
margin: { left: this.margin, right: this.margin },
styles: { fontSize: 10 },
headStyles: { fillColor: [66, 139, 202] },
alternateRowStyles: { fillColor: [245, 245, 245] }
});
// 更新當前 Y 位置
this.currentY = (this.doc as any).lastAutoTable.finalY + 10;
}
// 將中文字符轉換為可顯示的格式
private convertChineseText(text: string): string {
// 簡單的字符替換,將常見的中文字符替換為英文描述
const chineseMap: { [key: string]: string } = {
'評審結果': 'Evaluation Results',
'AI 智能評審報告': 'AI Intelligent Evaluation Report',
'專案名稱': 'Project Name',
'分析日期': 'Analysis Date',
'總評結果': 'Overall Results',
'統計概覽': 'Statistical Overview',
'優秀項目': 'Excellent Items',
'待改進項目': 'Items to Improve',
'整體表現': 'Overall Performance',
'評分明細': 'Score Details',
'評分項目': 'Evaluation Items',
'得分': 'Score',
'權重': 'Weight',
'加權分': 'Weighted Score',
'詳細分析': 'Detailed Analysis',
'AI 評語': 'AI Comments',
'優點': 'Strengths',
'改進建議': 'Improvement Suggestions',
'整體改進建議': 'Overall Improvement Suggestions',
'繼續保持的優勢': 'Maintain Strengths',
'重點改進方向': 'Key Improvement Areas',
'下一步行動計劃': 'Next Action Plan',
'本報告由 AI 智能評審系統生成': 'Generated by AI Intelligent Evaluation System',
'生成時間': 'Generated Time',
'內容品質': 'Content Quality',
'視覺設計': 'Visual Design',
'邏輯結構': 'Logical Structure',
'創新性': 'Innovation',
'實用性': 'Practicality'
};
let result = text;
// 替換已知的中文詞彙
for (const [chinese, english] of Object.entries(chineseMap)) {
result = result.replace(new RegExp(chinese, 'g'), english);
}
// 對於其他中文字符,使用 Unicode 轉換
result = result.replace(/[\u4e00-\u9fff]/g, (char) => {
const code = char.charCodeAt(0);
return `[U+${code.toString(16).toUpperCase()}]`;
});
return result;
}
public generateReport(data: PDFReportData): Promise<Blob> {
return new Promise((resolve, reject) => {
try {
// 標題頁
this.addTitle('AI Intelligent Evaluation Report', 20);
this.addText(`Project Name: ${data.projectTitle}`, 12, true);
this.addText(`Analysis Date: ${data.analysisDate}`, 12);
this.currentY += 10;
// 總分顯示
this.addSubtitle('Overall Results');
this.addScoreBox(data.overallScore, data.totalPossible, data.grade);
// 統計概覽
this.addSubtitle('Statistical Overview');
this.addText(`Excellent Items: ${data.overview.excellentItems} items`);
this.addText(`Items to Improve: ${data.overview.improvementItems} items`);
this.addText(`Overall Performance: ${data.overview.overallPerformance}%`);
this.currentY += 10;
// 評分明細
this.addSubtitle('Score Details');
this.addCriteriaTable(data.criteria);
// 詳細分析
this.addSubtitle('Detailed Analysis');
for (const item of data.criteria) {
this.addNewPageIfNeeded(20);
this.doc.setFontSize(11);
this.doc.setFont('helvetica', 'bold');
this.doc.text(this.convertChineseText(item.name), this.margin, this.currentY);
this.currentY += 8;
this.addText(`Score: ${item.score}/${item.maxScore} (${item.percentage.toFixed(1)}%)`, 10);
this.addText(`AI Comments: ${item.feedback}`, 10);
if (item.strengths.length > 0) {
this.addText('Strengths:', 10, true);
this.addBulletList(item.strengths, 9);
}
if (item.improvements.length > 0) {
this.addText('Improvement Suggestions:', 10, true);
this.addBulletList(item.improvements, 9);
}
this.currentY += 10;
}
// 改進建議
this.addSubtitle('Overall Improvement Suggestions');
this.addText(data.improvementSuggestions.overallSuggestions, 10);
this.currentY += 10;
// 保持的優勢
if (data.improvementSuggestions.maintainStrengths.length > 0) {
this.addSubtitle('Maintain Strengths');
for (const strength of data.improvementSuggestions.maintainStrengths) {
this.addText(strength.title, 10, true);
this.addText(strength.description, 9);
this.currentY += 5;
}
}
// 重點改進方向
if (data.improvementSuggestions.keyImprovements.length > 0) {
this.addSubtitle('Key Improvement Areas');
for (const improvement of data.improvementSuggestions.keyImprovements) {
this.addText(improvement.title, 10, true);
if (improvement.description) {
this.addText(improvement.description, 9);
}
if (improvement.suggestions.length > 0) {
this.addBulletList(improvement.suggestions, 9);
}
this.currentY += 5;
}
}
// 行動計劃
if (data.improvementSuggestions.actionPlan.length > 0) {
this.addSubtitle('Next Action Plan');
for (let i = 0; i < data.improvementSuggestions.actionPlan.length; i++) {
const action = data.improvementSuggestions.actionPlan[i];
this.addText(`${i + 1}. ${action.phase}`, 10, true);
this.addText(action.description, 9);
this.currentY += 5;
}
}
// 頁腳
this.doc.setFontSize(8);
this.doc.setFont('helvetica', 'normal');
this.doc.text('Generated by AI Intelligent Evaluation System', this.margin, this.pageHeight - 10);
this.doc.text(`Generated Time: ${new Date().toLocaleString('en-US')}`, this.margin + 100, this.pageHeight - 10);
// 生成 PDF Blob
const pdfBlob = this.doc.output('blob');
resolve(pdfBlob);
} catch (error) {
reject(error);
}
});
}
}
// 便捷函數
export async function generateChinesePDFReport(data: PDFReportData): Promise<Blob> {
const generator = new ChinesePDFReportGenerator();
return generator.generateReport(data);
}

269
lib/utils/pdf-generator.ts Normal file
View File

@@ -0,0 +1,269 @@
import jsPDF from 'jspdf';
import 'jspdf-autotable';
import html2canvas from 'html2canvas';
export interface PDFReportData {
projectTitle: string;
overallScore: number;
totalPossible: number;
grade: string;
analysisDate: string;
criteria: Array<{
name: string;
score: number;
maxScore: number;
weight: number;
weightedScore: number;
percentage: number;
feedback: string;
strengths: string[];
improvements: string[];
}>;
overview: {
excellentItems: number;
improvementItems: number;
overallPerformance: number;
};
improvementSuggestions: {
overallSuggestions: string;
maintainStrengths: Array<{
title: string;
description: string;
}>;
keyImprovements: Array<{
title: string;
subtitle?: string;
description?: string;
suggestions: string[];
}>;
actionPlan: Array<{
phase: string;
description: string;
}>;
};
}
export class PDFReportGenerator {
private doc: jsPDF;
private currentY: number = 20;
private pageHeight: number = 280;
private margin: number = 20;
constructor() {
this.doc = new jsPDF('p', 'mm', 'a4');
// 設置支援中文的默認字體
this.doc.setFont('helvetica');
}
private addNewPageIfNeeded(requiredSpace: number = 20): void {
if (this.currentY + requiredSpace > this.pageHeight) {
this.doc.addPage();
this.currentY = 20;
}
}
private addTitle(text: string, fontSize: number = 16, isBold: boolean = true): void {
this.addNewPageIfNeeded(10);
this.doc.setFontSize(fontSize);
this.doc.setFont('helvetica', isBold ? 'bold' : 'normal');
this.doc.text(text, this.margin, this.currentY);
this.currentY += fontSize + 5;
}
private addSubtitle(text: string, fontSize: number = 12): void {
this.addNewPageIfNeeded(8);
this.doc.setFontSize(fontSize);
this.doc.setFont('helvetica', 'bold');
this.doc.text(text, this.margin, this.currentY);
this.currentY += fontSize + 3;
}
private addText(text: string, fontSize: number = 10, isBold: boolean = false): void {
this.addNewPageIfNeeded(6);
this.doc.setFontSize(fontSize);
this.doc.setFont('helvetica', isBold ? 'bold' : 'normal');
// 處理長文本換行
const maxWidth = 170; // A4 寬度減去邊距
const lines = this.doc.splitTextToSize(text, maxWidth);
for (const line of lines) {
this.addNewPageIfNeeded(6);
this.doc.text(line, this.margin, this.currentY);
this.currentY += 6;
}
}
private addBulletList(items: string[], fontSize: number = 10): void {
for (const item of items) {
this.addNewPageIfNeeded(6);
this.doc.setFontSize(fontSize);
this.doc.setFont('helvetica', 'normal');
this.doc.text(`${item}`, this.margin + 5, this.currentY);
this.currentY += 6;
}
}
private addScoreBox(score: number, total: number, grade: string): void {
this.addNewPageIfNeeded(25);
// 繪製分數框
this.doc.setFillColor(240, 240, 240);
this.doc.rect(this.margin, this.currentY, 50, 20, 'F');
// 分數文字
this.doc.setFontSize(18);
this.doc.setFont('helvetica', 'bold');
this.doc.text(`${score}/${total}`, this.margin + 5, this.currentY + 12);
// 等級
this.doc.setFontSize(12);
this.doc.text(grade, this.margin + 5, this.currentY + 18);
this.currentY += 25;
}
private addCriteriaTable(criteria: PDFReportData['criteria']): void {
this.addNewPageIfNeeded(30);
// 表頭
this.doc.setFontSize(10);
this.doc.setFont('helvetica', 'bold');
this.doc.text('評分項目', this.margin, this.currentY);
this.doc.text('得分', this.margin + 100, this.currentY);
this.doc.text('權重', this.margin + 130, this.currentY);
this.doc.text('加權分', this.margin + 150, this.currentY);
this.currentY += 8;
// 分隔線
this.doc.line(this.margin, this.currentY, this.margin + 170, this.currentY);
this.currentY += 5;
// 數據行
for (const item of criteria) {
this.addNewPageIfNeeded(15);
this.doc.setFont('helvetica', 'normal');
this.doc.text(item.name, this.margin, this.currentY);
this.doc.text(`${item.score}/${item.maxScore}`, this.margin + 100, this.currentY);
this.doc.text(`${item.weight}%`, this.margin + 130, this.currentY);
this.doc.text(item.weightedScore.toFixed(1), this.margin + 150, this.currentY);
this.currentY += 6;
}
this.currentY += 10;
}
public generateReport(data: PDFReportData): Promise<Blob> {
return new Promise((resolve, reject) => {
try {
// 標題頁
this.addTitle('AI 智能評審報告', 20);
this.addText(`專案名稱:${data.projectTitle}`, 12, true);
this.addText(`分析日期:${data.analysisDate}`, 12);
this.currentY += 10;
// 總分顯示
this.addSubtitle('總評結果');
this.addScoreBox(data.overallScore, data.totalPossible, data.grade);
// 統計概覽
this.addSubtitle('統計概覽');
this.addText(`優秀項目:${data.overview.excellentItems}`);
this.addText(`待改進項目:${data.overview.improvementItems}`);
this.addText(`整體表現:${data.overview.overallPerformance}%`);
this.currentY += 10;
// 評分明細
this.addSubtitle('評分明細');
this.addCriteriaTable(data.criteria);
// 詳細分析
this.addSubtitle('詳細分析');
for (const item of data.criteria) {
this.addNewPageIfNeeded(20);
this.doc.setFontSize(11);
this.doc.setFont('helvetica', 'bold');
this.doc.text(item.name, this.margin, this.currentY);
this.currentY += 8;
this.addText(`得分:${item.score}/${item.maxScore} (${item.percentage.toFixed(1)}%)`, 10);
this.addText(`AI 評語:${item.feedback}`, 10);
if (item.strengths.length > 0) {
this.addText('優點:', 10, true);
this.addBulletList(item.strengths, 9);
}
if (item.improvements.length > 0) {
this.addText('改進建議:', 10, true);
this.addBulletList(item.improvements, 9);
}
this.currentY += 10;
}
// 改進建議
this.addSubtitle('整體改進建議');
this.addText(data.improvementSuggestions.overallSuggestions, 10);
this.currentY += 10;
// 保持的優勢
if (data.improvementSuggestions.maintainStrengths.length > 0) {
this.addSubtitle('繼續保持的優勢');
for (const strength of data.improvementSuggestions.maintainStrengths) {
this.addText(strength.title, 10, true);
this.addText(strength.description, 9);
this.currentY += 5;
}
}
// 重點改進方向
if (data.improvementSuggestions.keyImprovements.length > 0) {
this.addSubtitle('重點改進方向');
for (const improvement of data.improvementSuggestions.keyImprovements) {
this.addText(improvement.title, 10, true);
if (improvement.description) {
this.addText(improvement.description, 9);
}
if (improvement.suggestions.length > 0) {
this.addBulletList(improvement.suggestions, 9);
}
this.currentY += 5;
}
}
// 行動計劃
if (data.improvementSuggestions.actionPlan.length > 0) {
this.addSubtitle('下一步行動計劃');
for (let i = 0; i < data.improvementSuggestions.actionPlan.length; i++) {
const action = data.improvementSuggestions.actionPlan[i];
this.addText(`${i + 1}. ${action.phase}`, 10, true);
this.addText(action.description, 9);
this.currentY += 5;
}
}
// 頁腳
this.doc.setFontSize(8);
this.doc.setFont('helvetica', 'normal');
this.doc.text('本報告由 AI 智能評審系統生成', this.margin, this.pageHeight - 10);
this.doc.text(`生成時間:${new Date().toLocaleString('zh-TW')}`, this.margin + 100, this.pageHeight - 10);
// 生成 PDF Blob
const pdfBlob = this.doc.output('blob');
resolve(pdfBlob);
} catch (error) {
reject(error);
}
});
}
}
// 便捷函數
export async function generatePDFReport(data: PDFReportData): Promise<Blob> {
const generator = new PDFReportGenerator();
return generator.generateReport(data);
}

View File

@@ -8,7 +8,8 @@
"lint": "next lint", "lint": "next lint",
"start": "next start -p 12024", "start": "next start -p 12024",
"db:init": "node scripts/init-database-simple.js", "db:init": "node scripts/init-database-simple.js",
"db:test": "node scripts/test-database.js" "db:test": "node scripts/test-database.js",
"config:check": "node scripts/check-config.js"
}, },
"dependencies": { "dependencies": {
"@google/generative-ai": "^0.24.1", "@google/generative-ai": "^0.24.1",
@@ -40,6 +41,7 @@
"@radix-ui/react-toggle": "1.1.1", "@radix-ui/react-toggle": "1.1.1",
"@radix-ui/react-toggle-group": "1.1.1", "@radix-ui/react-toggle-group": "1.1.1",
"@radix-ui/react-tooltip": "1.1.6", "@radix-ui/react-tooltip": "1.1.6",
"@types/qrcode": "^1.5.5",
"@vercel/analytics": "latest", "@vercel/analytics": "latest",
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
@@ -49,7 +51,10 @@
"date-fns": "4.1.0", "date-fns": "4.1.0",
"embla-carousel-react": "8.5.1", "embla-carousel-react": "8.5.1",
"geist": "latest", "geist": "latest",
"html2canvas": "^1.4.1",
"input-otp": "1.4.1", "input-otp": "1.4.1",
"jspdf": "^3.0.3",
"jspdf-autotable": "^5.0.2",
"lucide-react": "^0.454.0", "lucide-react": "^0.454.0",
"mammoth": "^1.11.0", "mammoth": "^1.11.0",
"mysql2": "^3.15.0", "mysql2": "^3.15.0",
@@ -57,6 +62,8 @@
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"node-fetch": "^3.3.2", "node-fetch": "^3.3.2",
"node-pptx": "^1.0.0", "node-pptx": "^1.0.0",
"puppeteer": "^24.22.1",
"qrcode": "^1.5.4",
"react": "^18", "react": "^18",
"react-day-picker": "9.8.0", "react-day-picker": "9.8.0",
"react-dom": "^18", "react-dom": "^18",

1131
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

60
scripts/check-config.js Normal file
View File

@@ -0,0 +1,60 @@
#!/usr/bin/env node
/**
* 配置檢查工具
* 用於驗證環境變量配置是否正確
*/
console.log('🔍 檢查環境變量配置...\n');
// 檢查環境變量
const envVars = {
'NEXT_PUBLIC_APP_URL': process.env.NEXT_PUBLIC_APP_URL,
'NEXT_PUBLIC_APP_NAME': process.env.NEXT_PUBLIC_APP_NAME,
'DB_HOST': process.env.DB_HOST,
'DB_PORT': process.env.DB_PORT,
'DB_NAME': process.env.DB_NAME,
'DB_USER': process.env.DB_USER,
'DB_PASSWORD': process.env.DB_PASSWORD ? '***已設置***' : undefined,
'GEMINI_API_KEY': process.env.GEMINI_API_KEY ? '***已設置***' : undefined,
'GEMINI_MODEL': process.env.GEMINI_MODEL,
'GEMINI_MAX_TOKENS': process.env.GEMINI_MAX_TOKENS,
};
console.log('📋 當前環境變量:');
let hasErrors = false;
Object.entries(envVars).forEach(([key, value]) => {
const status = value ? '✅' : '❌';
const displayValue = value || '未設置';
console.log(` ${status} ${key}: ${displayValue}`);
if (!value && key !== 'GEMINI_MAX_TOKENS') {
hasErrors = true;
}
});
console.log('');
if (hasErrors) {
console.log('❌ 發現配置問題,請設置缺少的環境變量');
console.log('\n💡 建議的 .env.local 配置:');
} else {
console.log('✅ 所有必要的環境變量都已設置');
console.log('\n🔧 建議的 .env.local 配置:');
}
console.log('NEXT_PUBLIC_APP_URL=http://localhost:12024');
console.log('NEXT_PUBLIC_APP_NAME=AI 智能評審系統');
console.log('DB_HOST=mysql.theaken.com');
console.log('DB_PORT=33306');
console.log('DB_NAME=db_AI_scoring');
console.log('DB_USER=root');
console.log('DB_PASSWORD=zh6161168');
console.log('GEMINI_API_KEY=AIzaSyAN3pEJr_Vn2xkCidGZAq9eQqsMVvpj8g4');
console.log('GEMINI_MODEL=gemini-1.5-pro');
console.log('GEMINI_MAX_TOKENS=8192');
console.log('\n📝 使用說明:');
console.log('1. 複製 env.example 到 .env.local');
console.log('2. 根據需要修改配置');
console.log('3. 重啟開發服務器: npm run dev');