實作完整分享、刪除、下載報告功能
This commit is contained in:
280
COMPLETE_ENV_CONFIG_GUIDE.md
Normal file
280
COMPLETE_ENV_CONFIG_GUIDE.md
Normal 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
196
ENVIRONMENT_CONFIG_GUIDE.md
Normal 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。
|
162
ENV_CONFIG_IMPLEMENTATION.md
Normal file
162
ENV_CONFIG_IMPLEMENTATION.md
Normal 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
196
ENV_SETUP_COMPLETE.md
Normal 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 配置
|
||||||
|
- ✅ **安全的環境變量**:敏感信息保護
|
||||||
|
- ✅ **多環境支援**:開發、測試、生產
|
||||||
|
- ✅ **配置驗證工具**:確保配置正確
|
||||||
|
- ✅ **分享功能優化**:使用正確的域名
|
||||||
|
|
||||||
|
現在您可以輕鬆地在不同環境中部署應用,只需設置相應的環境變量即可!🚀
|
115
HOVER_EFFECTS_OPTIMIZATION.md
Normal file
115
HOVER_EFFECTS_OPTIMIZATION.md
Normal 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
135
SHARE_FEATURE_SUMMARY.md
Normal 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
115
SHARE_MODAL_FIXES.md
Normal 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**:在新視窗中開啟分享頁面
|
||||||
|
|
||||||
|
## 結論
|
||||||
|
|
||||||
|
分享視窗現在已經完全優化,解決了跑版問題,並添加了郵件分享功能。設計更加響應式,用戶體驗更加流暢,特別是在手機端的使用體驗得到了顯著改善。
|
114
app/api/evaluation/[id]/delete/route.ts
Normal file
114
app/api/evaluation/[id]/delete/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
236
app/api/evaluation/[id]/download/route.ts
Normal file
236
app/api/evaluation/[id]/download/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@@ -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 />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
@@ -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>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<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" />
|
<Trash2 className="h-4 w-4" />
|
||||||
|
)}
|
||||||
</Button>
|
</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>
|
||||||
|
@@ -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 () => {
|
||||||
|
try {
|
||||||
|
// 顯示載入提示
|
||||||
toast({
|
toast({
|
||||||
title: "報告下載中",
|
title: "報告下載中",
|
||||||
description: "評審報告 PDF 正在生成,請稍候...",
|
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
296
components/share-modal.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
141
components/ui/alert-dialog.tsx
Normal file
141
components/ui/alert-dialog.tsx
Normal 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
122
components/ui/dialog.tsx
Normal 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
35
components/ui/toaster.tsx
Normal 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
15
env.example
Normal 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
89
lib/config.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
@@ -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({
|
||||||
|
@@ -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;
|
||||||
|
504
lib/utils/html-pdf-generator.ts
Normal file
504
lib/utils/html-pdf-generator.ts
Normal 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);
|
||||||
|
}
|
319
lib/utils/pdf-generator-chinese.ts
Normal file
319
lib/utils/pdf-generator-chinese.ts
Normal 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
269
lib/utils/pdf-generator.ts
Normal 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);
|
||||||
|
}
|
@@ -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
1131
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
60
scripts/check-config.js
Normal file
60
scripts/check-config.js
Normal 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');
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user