Compare commits
34 Commits
da85bb8f42
...
f60570390e
| Author | SHA1 | Date |
|---|---|---|
|
|
f60570390e | |
|
|
500d70839b | |
|
|
be1126e7db | |
|
|
11e19d5e1c | |
|
|
fb89cf1a33 | |
|
|
14b55d6f7a | |
|
|
36659d3bed | |
|
|
3b61cf8ce4 | |
|
|
a6a7e53638 | |
|
|
209dcedf2c | |
|
|
a5c439bbaf | |
|
|
b348780eaa | |
|
|
aca9ec2f7a | |
|
|
5583b763bc | |
|
|
421edd0589 | |
|
|
db952f94be | |
|
|
453ecd6d1c | |
|
|
be236f7216 | |
|
|
080cbe14a6 | |
|
|
0b871a9301 | |
|
|
33b291b505 | |
|
|
4e69030bc2 | |
|
|
b8aa0214f0 | |
|
|
12488b3bdd | |
|
|
d1c5f2e31c | |
|
|
7203346134 | |
|
|
a20fa9004d | |
|
|
a39ef4ba6f | |
|
|
55db91c872 | |
|
|
e794f47909 | |
|
|
15c4bffe3d | |
|
|
5bd823ee91 | |
|
|
c1e296c860 | |
|
|
31e3fe9fa8 |
|
|
@ -0,0 +1,695 @@
|
|||
# AI生成畫面前端程式碼規格
|
||||
|
||||
## 📋 **概述**
|
||||
|
||||
本文件詳細說明DramaLing AI生成功能的前端程式碼架構、API調用、資料流程,以及如何理解和維護相關程式碼。
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ **檔案架構圖**
|
||||
|
||||
### **1. 核心檔案結構**
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── app/generate/
|
||||
│ └── page.tsx # 🎯 主分析頁面
|
||||
├── components/
|
||||
│ ├── ClickableTextV2.tsx # 🔍 可點擊詞彙組件
|
||||
│ ├── Navigation.tsx # 🧭 導航組件
|
||||
│ └── ProtectedRoute.tsx # 🔒 路由保護組件
|
||||
└── lib/services/
|
||||
└── flashcards.ts # 💾 詞卡服務層
|
||||
```
|
||||
|
||||
### **2. 依賴關係圖**
|
||||
|
||||
```
|
||||
page.tsx
|
||||
├── imports Navigation.tsx
|
||||
├── imports ProtectedRoute.tsx
|
||||
├── imports ClickableTextV2.tsx
|
||||
└── imports flashcardsService
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 **API調用架構**
|
||||
|
||||
### **1. 主分析頁面 (`/app/generate/page.tsx`)**
|
||||
|
||||
#### **調用的API端點**:
|
||||
```typescript
|
||||
POST /api/ai/analyze-sentence
|
||||
```
|
||||
|
||||
#### **調用位置**:
|
||||
```typescript
|
||||
// 第40行 - handleAnalyzeSentence函數
|
||||
const response = await fetch('http://localhost:5000/api/ai/analyze-sentence', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
inputText: textInput,
|
||||
userLevel: userLevel, // 個人化重點學習範圍
|
||||
analysisMode: 'full'
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
#### **API回傳資料格式**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"analysisId": "guid",
|
||||
"userLevel": "A2",
|
||||
"highValueCriteria": "B1-B2",
|
||||
"wordAnalysis": {
|
||||
"bonus": {
|
||||
"word": "bonus",
|
||||
"translation": "獎金",
|
||||
"definition": "額外給予的金錢",
|
||||
"partOfSpeech": "noun",
|
||||
"pronunciation": "/ˈboʊnəs/",
|
||||
"isHighValue": true,
|
||||
"difficultyLevel": "B1",
|
||||
"synonyms": ["reward", "incentive"],
|
||||
"example": "She received a year-end bonus.",
|
||||
"exampleTranslation": "她獲得了年終獎金。"
|
||||
}
|
||||
},
|
||||
"sentenceMeaning": {
|
||||
"translation": "公司提供了獎金。"
|
||||
},
|
||||
"grammarCorrection": { /*...*/ },
|
||||
"highValueWords": ["bonus", "offered"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### **2. 可點擊詞彙組件 (`/components/ClickableTextV2.tsx`)**
|
||||
|
||||
#### **調用的API端點**:
|
||||
```typescript
|
||||
POST /api/ai/query-word
|
||||
```
|
||||
|
||||
#### **調用位置有兩處**:
|
||||
|
||||
##### **位置1: handleCostConfirm函數 (第245行)**
|
||||
```typescript
|
||||
const response = await fetch('http://localhost:5000/api/ai/query-word', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
word: showCostConfirm.word,
|
||||
sentence: text,
|
||||
analysisId: null
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
##### **位置2: queryWordWithAI函數 (第303行)**
|
||||
```typescript
|
||||
const response = await fetch('http://localhost:5000/api/ai/query-word', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
word: word,
|
||||
sentence: text,
|
||||
analysisId: null
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
#### **觸發條件**:
|
||||
- 用戶點擊詞彙時,如果該詞彙不在`analysis`物件中
|
||||
- 用戶確認付費查詢詞彙時
|
||||
|
||||
### **3. 詞卡服務 (`/lib/services/flashcards.ts`)**
|
||||
|
||||
#### **調用的API端點**:
|
||||
```typescript
|
||||
POST /api/flashcards // 創建詞卡
|
||||
GET /api/flashcards // 查詢詞卡
|
||||
GET /api/cardsets // 查詢詞卡組
|
||||
```
|
||||
|
||||
#### **調用方式**:
|
||||
```typescript
|
||||
// 透過flashcardsService.createFlashcard()間接調用
|
||||
await this.makeRequest<ApiResponse<Flashcard>>('/flashcards', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 **資料流程架構**
|
||||
|
||||
### **1. 完整用戶操作流程**
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[用戶輸入句子] --> B[點擊分析按鈕]
|
||||
B --> C[調用 analyze-sentence API]
|
||||
C --> D[接收完整詞彙分析資料]
|
||||
D --> E[顯示可點擊文字]
|
||||
E --> F[用戶點擊詞彙]
|
||||
F --> G{詞彙在analysis中?}
|
||||
G -->|是| H[直接顯示Portal彈窗]
|
||||
G -->|否| I[調用 query-word API]
|
||||
I --> J[覆蓋原有資料]
|
||||
J --> K[顯示Portal彈窗]
|
||||
H --> L[點擊保存詞卡]
|
||||
K --> L
|
||||
L --> M[調用 flashcards API]
|
||||
```
|
||||
|
||||
### **2. 狀態管理流程**
|
||||
|
||||
```typescript
|
||||
// 主頁面狀態
|
||||
const [sentenceAnalysis, setSentenceAnalysis] = useState<any>(null) // 完整詞彙分析
|
||||
const [sentenceMeaning, setSentenceMeaning] = useState('') // 句子翻譯
|
||||
const [grammarCorrection, setGrammarCorrection] = useState<any>(null) // 語法修正
|
||||
const [finalText, setFinalText] = useState('') // 最終文本
|
||||
|
||||
// ClickableTextV2狀態
|
||||
const [selectedWord, setSelectedWord] = useState<string | null>(null) // 選中詞彙
|
||||
const [popupPosition, setPopupPosition] = useState({...}) // 彈窗位置
|
||||
const [mounted, setMounted] = useState(false) // Portal渲染狀態
|
||||
```
|
||||
|
||||
### **3. 資料傳遞路徑**
|
||||
|
||||
```
|
||||
API回應 → setSentenceAnalysis → analysis prop → ClickableTextV2 → Portal彈窗
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 **組件職責分析**
|
||||
|
||||
### **1. `/app/generate/page.tsx` - 主分析頁面**
|
||||
|
||||
#### **核心職責**:
|
||||
- 🎯 **句子分析觸發器** - 調用AI分析API
|
||||
- 📊 **資料狀態管理** - 管理分析結果和UI狀態
|
||||
- 🎨 **UI佈局控制** - 控制分析前/後的畫面切換
|
||||
- 🔧 **個人化設定** - 取得用戶程度設定
|
||||
|
||||
#### **關鍵函數**:
|
||||
```typescript
|
||||
handleAnalyzeSentence() // 句子分析主函數
|
||||
handleSaveWord() // 詞彙儲存函數
|
||||
handleAcceptCorrection() // 語法修正處理
|
||||
```
|
||||
|
||||
#### **API依賴**:
|
||||
- `POST /api/ai/analyze-sentence` - 句子分析
|
||||
- `flashcardsService.createFlashcard()` - 詞卡儲存
|
||||
|
||||
### **2. `/components/ClickableTextV2.tsx` - 可點擊詞彙組件**
|
||||
|
||||
#### **核心職責**:
|
||||
- 🖱️ **詞彙互動處理** - 處理詞彙點擊事件
|
||||
- 🎨 **Portal彈窗管理** - 使用React Portal渲染彈窗
|
||||
- 🔍 **詞彙資料查找** - 在analysis中查找或即時查詢
|
||||
- 💾 **詞卡儲存整合** - 提供儲存到詞卡功能
|
||||
|
||||
#### **關鍵函數**:
|
||||
```typescript
|
||||
handleWordClick() // 詞彙點擊處理
|
||||
queryWordWithAI() // 即時詞彙查詢
|
||||
getWordProperty() // 智能屬性讀取
|
||||
VocabPopup() // Portal彈窗組件
|
||||
```
|
||||
|
||||
#### **API依賴**:
|
||||
- `POST /api/ai/query-word` - 即時詞彙查詢
|
||||
|
||||
#### **⚠️ 已知問題**:
|
||||
- 使用`query-word` API覆蓋了`analyze-sentence`的完整資料
|
||||
- 導致例句和其他資料遺失
|
||||
|
||||
### **3. `/components/Navigation.tsx` - 導航組件**
|
||||
|
||||
#### **核心職責**:
|
||||
- 🧭 **頁面導航** - 提供網站主要頁面連結
|
||||
- 👤 **用戶狀態顯示** - 顯示登入狀態
|
||||
- ⚙️ **設定頁面入口** - 連結到用戶程度設定
|
||||
|
||||
#### **API依賴**:無直接API調用
|
||||
|
||||
### **4. `/lib/services/flashcards.ts` - 詞卡服務層**
|
||||
|
||||
#### **核心職責**:
|
||||
- 💾 **詞卡CRUD操作** - 創建、讀取、更新、刪除詞卡
|
||||
- 🗂️ **詞卡組管理** - 管理詞卡分類
|
||||
- 🔒 **API認證處理** - 自動添加JWT Token
|
||||
|
||||
#### **API端點封裝**:
|
||||
```typescript
|
||||
/api/flashcards // 詞卡CRUD
|
||||
/api/cardsets // 詞卡組管理
|
||||
/api/cardsets/ensure-default // 確保預設詞卡組
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 **如何分析程式碼中的API調用**
|
||||
|
||||
### **1. 搜索技巧**
|
||||
|
||||
#### **在VS Code或終端中**:
|
||||
```bash
|
||||
# 搜索API調用
|
||||
grep -r "fetch(" frontend/
|
||||
grep -r "api/" frontend/
|
||||
grep -r "localhost:5000" frontend/
|
||||
|
||||
# 搜索特定API端點
|
||||
grep -r "analyze-sentence" frontend/
|
||||
grep -r "query-word" frontend/
|
||||
grep -r "flashcards" frontend/
|
||||
```
|
||||
|
||||
#### **在瀏覽器開發者工具中**:
|
||||
1. **Network面板** - 查看實際API調用
|
||||
2. **Console面板** - 查看調試輸出
|
||||
3. **Application面板** - 查看localStorage資料
|
||||
|
||||
### **2. 程式碼閱讀要點**
|
||||
|
||||
#### **識別API調用的關鍵字**:
|
||||
```typescript
|
||||
// 直接API調用
|
||||
fetch('http://localhost:5000/api/...')
|
||||
await fetch(...)
|
||||
|
||||
// 服務層調用
|
||||
flashcardsService.createFlashcard()
|
||||
flashcardsService.getFlashcards()
|
||||
|
||||
// 其他HTTP客戶端
|
||||
axios.post(...)
|
||||
```
|
||||
|
||||
#### **找到觸發條件**:
|
||||
```typescript
|
||||
// 用戶事件觸發
|
||||
onClick={handleAnalyzeSentence}
|
||||
onClick={(e) => handleWordClick(word, e)}
|
||||
|
||||
// 狀態變化觸發
|
||||
useEffect(() => { /* API調用 */ }, [dependency])
|
||||
```
|
||||
|
||||
### **3. 資料流追蹤**
|
||||
|
||||
#### **API回應到狀態**:
|
||||
```typescript
|
||||
const result = await response.json()
|
||||
setSentenceAnalysis(result.data.WordAnalysis) // 儲存到狀態
|
||||
```
|
||||
|
||||
#### **狀態到組件**:
|
||||
```typescript
|
||||
<ClickableTextV2
|
||||
analysis={sentenceAnalysis} // 傳遞給子組件
|
||||
onSaveWord={handleSaveWord} // 回調函數
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚨 **當前架構問題分析**
|
||||
|
||||
### **1. API調用衝突問題**
|
||||
|
||||
#### **問題描述**:
|
||||
- **主頁面** 調用 `analyze-sentence` API → 取得完整詞彙資料(包含例句)
|
||||
- **詞彙組件** 調用 `query-word` API → 取得簡化資料(無例句)
|
||||
- **結果** → 好資料被壞資料覆蓋
|
||||
|
||||
#### **程式碼位置**:
|
||||
```typescript
|
||||
// ✅ 正確的API (page.tsx:40)
|
||||
POST /api/ai/analyze-sentence → 完整資料
|
||||
|
||||
// ❌ 問題的API (ClickableTextV2.tsx:245, 303)
|
||||
POST /api/ai/query-word → 簡化資料
|
||||
```
|
||||
|
||||
#### **觸發條件**:
|
||||
```typescript
|
||||
// ClickableTextV2.tsx:221
|
||||
if (wordAnalysis) {
|
||||
// 使用預存資料 ✅
|
||||
} else {
|
||||
// 調用 query-word API ❌
|
||||
await queryWordWithAI(cleanWord, position)
|
||||
}
|
||||
```
|
||||
|
||||
### **2. 資料不一致問題**
|
||||
|
||||
#### **analyze-sentence 回傳**:
|
||||
```json
|
||||
{
|
||||
"example": "She received a year-end bonus for her hard work.",
|
||||
"exampleTranslation": "她因為努力工作獲得了年終獎金。",
|
||||
"synonyms": ["reward", "incentive", "extra pay"]
|
||||
}
|
||||
```
|
||||
|
||||
#### **query-word 回傳**:
|
||||
```json
|
||||
{
|
||||
"example": null,
|
||||
"exampleTranslation": null,
|
||||
"synonyms": []
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 **UI組件架構**
|
||||
|
||||
### **1. Portal彈窗系統**
|
||||
|
||||
#### **技術實現**:
|
||||
```typescript
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
const VocabPopup = () => {
|
||||
if (!selectedWord || !analysis?.[selectedWord] || !mounted) return null
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed z-50 bg-white rounded-xl shadow-lg w-96">
|
||||
{/* 彈窗內容 */}
|
||||
</div>,
|
||||
document.body // 渲染到body,避免CSS繼承
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
#### **設計優勢**:
|
||||
- **完全脫離父級CSS繼承**
|
||||
- **響應式定位系統**
|
||||
- **詞卡風格一致性**
|
||||
|
||||
### **2. 個人化標記系統**
|
||||
|
||||
#### **詞彙分類邏輯**:
|
||||
```typescript
|
||||
const getWordClass = (word: string) => {
|
||||
const wordAnalysis = analysis?.[cleanWord]
|
||||
const isHighValue = getWordProperty(wordAnalysis, 'isHighValue')
|
||||
|
||||
if (isHighValue) {
|
||||
return "bg-green-100 border-green-400 hover:bg-green-200" // 重點學習
|
||||
} else {
|
||||
return "bg-blue-100 border-blue-300 hover:bg-blue-200" // 普通詞彙
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### **視覺效果**:
|
||||
- **重點學習詞彙** → 綠色邊框 + ⭐ 標記
|
||||
- **普通詞彙** → 藍色邊框
|
||||
- **未分析詞彙** → 灰色虛線邊框
|
||||
|
||||
---
|
||||
|
||||
## 📊 **狀態管理架構**
|
||||
|
||||
### **1. 主頁面狀態流**
|
||||
|
||||
```typescript
|
||||
// 分析階段
|
||||
[textInput] → handleAnalyzeSentence() → [sentenceAnalysis]
|
||||
↓
|
||||
[sentenceMeaning]
|
||||
↓
|
||||
[grammarCorrection]
|
||||
|
||||
// 顯示階段
|
||||
[sentenceAnalysis] → ClickableTextV2 → Portal彈窗
|
||||
```
|
||||
|
||||
### **2. 詞彙組件狀態流**
|
||||
|
||||
```typescript
|
||||
// 點擊階段
|
||||
handleWordClick() → [selectedWord] + [popupPosition]
|
||||
↓
|
||||
VocabPopup() Portal渲染
|
||||
|
||||
// 儲存階段
|
||||
handleSaveWord() → flashcardsService.createFlashcard()
|
||||
```
|
||||
|
||||
### **3. 個人化設定流**
|
||||
|
||||
```typescript
|
||||
localStorage.getItem('userEnglishLevel') → API請求 → 個人化結果
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 **關鍵技術實現**
|
||||
|
||||
### **1. Portal彈窗技術**
|
||||
|
||||
#### **為什麼使用Portal**:
|
||||
```typescript
|
||||
// ❌ 舊方式 - CSS繼承問題
|
||||
<div className="relative">
|
||||
<div className="text-lg">可點擊文字</div>
|
||||
<div className="fixed popup">彈窗</div> // 會繼承text-lg
|
||||
</div>
|
||||
|
||||
// ✅ Portal方式 - 完全隔離
|
||||
<div className="relative">
|
||||
<div className="text-lg">可點擊文字</div>
|
||||
</div>
|
||||
{createPortal(
|
||||
<div className="fixed popup">彈窗</div>, // 渲染到body,不繼承
|
||||
document.body
|
||||
)}
|
||||
```
|
||||
|
||||
### **2. 智能屬性讀取**
|
||||
|
||||
#### **解決大小寫不一致**:
|
||||
```typescript
|
||||
const getWordProperty = (wordData: any, propName: string) => {
|
||||
const variations = [
|
||||
propName, // 原始
|
||||
propName.toLowerCase(), // 小寫
|
||||
propName.charAt(0).toUpperCase() + propName.slice(1) // 首字母大寫
|
||||
];
|
||||
|
||||
for (const variation of variations) {
|
||||
if (wordData[variation] !== undefined) {
|
||||
return wordData[variation];
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### **3. 個人化重點學習範圍**
|
||||
|
||||
#### **前端整合**:
|
||||
```typescript
|
||||
// 讀取用戶程度
|
||||
const userLevel = localStorage.getItem('userEnglishLevel') || 'A2';
|
||||
|
||||
// 傳遞給API
|
||||
body: JSON.stringify({
|
||||
inputText: textInput,
|
||||
userLevel: userLevel, // 個人化參數
|
||||
analysisMode: 'full'
|
||||
})
|
||||
|
||||
// 顯示重點學習範圍
|
||||
const getTargetRange = (level: string) => {
|
||||
const ranges = {
|
||||
'A1': 'A2-B1', 'A2': 'B1-B2', 'B1': 'B2-C1',
|
||||
'B2': 'C1-C2', 'C1': 'C2', 'C2': 'C2'
|
||||
};
|
||||
return ranges[level] || 'B1-B2';
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ **開發維護指南**
|
||||
|
||||
### **1. 如何添加新的API調用**
|
||||
|
||||
#### **步驟**:
|
||||
1. **選擇調用位置** - 頁面組件或服務層
|
||||
2. **定義請求格式** - TypeScript介面
|
||||
3. **處理回應資料** - 錯誤處理和狀態更新
|
||||
4. **更新UI狀態** - 觸發重新渲染
|
||||
|
||||
#### **範例**:
|
||||
```typescript
|
||||
// 1. 定義介面
|
||||
interface NewApiRequest {
|
||||
input: string;
|
||||
options: object;
|
||||
}
|
||||
|
||||
// 2. API調用
|
||||
const callNewApi = async (data: NewApiRequest) => {
|
||||
try {
|
||||
const response = await fetch('/api/new-endpoint', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
// 3. 更新狀態
|
||||
setNewData(result.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('API調用失敗:', error);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### **2. 如何修改詞彙顯示邏輯**
|
||||
|
||||
#### **修改位置**:
|
||||
```typescript
|
||||
// 詞彙分類邏輯
|
||||
ClickableTextV2.tsx → getWordClass() 函數
|
||||
|
||||
// 彈窗內容
|
||||
ClickableTextV2.tsx → VocabPopup() 組件
|
||||
|
||||
// 屬性讀取
|
||||
ClickableTextV2.tsx → getWordProperty() 函數
|
||||
```
|
||||
|
||||
### **3. 如何添加新的詞彙屬性**
|
||||
|
||||
#### **步驟**:
|
||||
1. **後端API** - 確保API回傳新屬性
|
||||
2. **前端介面** - 更新TypeScript介面
|
||||
3. **屬性讀取** - 在`getWordProperty`中處理
|
||||
4. **UI顯示** - 在Portal彈窗中顯示
|
||||
|
||||
---
|
||||
|
||||
## 🔍 **問題診斷指南**
|
||||
|
||||
### **1. API調用問題**
|
||||
|
||||
#### **檢查步驟**:
|
||||
```typescript
|
||||
// 1. 檢查Network面板
|
||||
// 瀏覽器 → F12 → Network → 查看API調用
|
||||
|
||||
// 2. 檢查Console輸出
|
||||
console.log('API回應:', result);
|
||||
|
||||
// 3. 檢查回應格式
|
||||
console.log('詞彙資料:', result.data.WordAnalysis?.bonus);
|
||||
```
|
||||
|
||||
#### **常見問題**:
|
||||
- **API端點錯誤** - 檢查URL是否正確
|
||||
- **請求格式錯誤** - 檢查Content-Type和body
|
||||
- **認證問題** - 檢查JWT Token
|
||||
|
||||
### **2. 資料顯示問題**
|
||||
|
||||
#### **檢查步驟**:
|
||||
```typescript
|
||||
// 1. 檢查狀態
|
||||
console.log('sentenceAnalysis:', sentenceAnalysis);
|
||||
|
||||
// 2. 檢查組件接收
|
||||
console.log('analysis prop:', analysis);
|
||||
|
||||
// 3. 檢查屬性讀取
|
||||
console.log('getWordProperty結果:', getWordProperty(wordData, 'example'));
|
||||
```
|
||||
|
||||
### **3. Portal彈窗問題**
|
||||
|
||||
#### **檢查步驟**:
|
||||
```typescript
|
||||
// 1. 檢查Portal渲染條件
|
||||
console.log('selectedWord:', selectedWord);
|
||||
console.log('mounted:', mounted);
|
||||
|
||||
// 2. 檢查彈窗位置
|
||||
console.log('popupPosition:', popupPosition);
|
||||
|
||||
// 3. 檢查CSS樣式
|
||||
// 瀏覽器 → F12 → Elements → 檢查Portal元素
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 **最佳實踐建議**
|
||||
|
||||
### **1. API調用**
|
||||
- ✅ **統一使用服務層** - 避免直接在組件中調用API
|
||||
- ✅ **錯誤處理** - 每個API調用都要有try-catch
|
||||
- ✅ **loading狀態** - 提供用戶反饋
|
||||
- ✅ **快取策略** - 避免重複調用相同API
|
||||
|
||||
### **2. 狀態管理**
|
||||
- ✅ **單一資料來源** - 避免狀態重複
|
||||
- ✅ **明確的狀態型別** - 使用TypeScript介面
|
||||
- ✅ **適當的狀態粒度** - 不要過度細分或合併
|
||||
|
||||
### **3. 組件設計**
|
||||
- ✅ **職責單一** - 每個組件專注一個功能
|
||||
- ✅ **Props介面** - 明確定義組件輸入
|
||||
- ✅ **可重用性** - 組件應該可以在多處使用
|
||||
|
||||
---
|
||||
|
||||
## 📝 **未來改進方向**
|
||||
|
||||
### **1. 統一API策略**
|
||||
- 合併`analyze-sentence`和`query-word`的功能
|
||||
- 建立統一的詞彙分析端點
|
||||
- 減少API調用複雜度
|
||||
|
||||
### **2. 效能優化**
|
||||
- 實現詞彙分析結果快取
|
||||
- 減少不必要的API調用
|
||||
- 優化Portal渲染效能
|
||||
|
||||
### **3. 用戶體驗提升**
|
||||
- 添加載入動畫
|
||||
- 優化錯誤處理和用戶提示
|
||||
- 增強響應式設計
|
||||
|
||||
---
|
||||
|
||||
**文件版本**: v1.0
|
||||
**建立日期**: 2025-09-21
|
||||
**維護團隊**: DramaLing開發團隊
|
||||
|
||||
---
|
||||
|
||||
## 📞 **技術支援**
|
||||
|
||||
如需修改或擴展AI生成功能,請參考本規格文件的相關章節,並遵循最佳實踐建議進行開發。
|
||||
|
|
@ -0,0 +1,905 @@
|
|||
# AI詞彙分析生成系統規格
|
||||
|
||||
## 📋 **系統概述**
|
||||
|
||||
DramaLing 的 AI 詞彙分析生成系統是一個完整的英語學習輔助工具,提供智能句子分析、詞彙詳細解釋、語法修正建議,以及個人化的詞卡儲存功能。
|
||||
|
||||
---
|
||||
|
||||
## 🎯 **功能規格**
|
||||
|
||||
### 1. **句子分析功能**
|
||||
|
||||
#### 1.1 核心功能
|
||||
- **智能句子解析**: 使用 Gemini AI 分析英文句子結構和語義
|
||||
- **語法錯誤檢測**: 自動檢測並提供語法修正建議
|
||||
- **中文翻譯生成**: 提供自然流暢的中文翻譯
|
||||
- **重點學習範圍標記**: 根據用戶CEFR等級智能標記重點學習詞彙(用戶程度+1~2階級)
|
||||
|
||||
#### 1.2 輸入限制
|
||||
- **手動輸入**: 最大300字符
|
||||
- **截圖輸入**: 支援圖片OCR識別(預留功能)
|
||||
- **語言檢測**: 自動檢測英文內容
|
||||
|
||||
#### 1.3 輸出內容
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"analysisId": "guid",
|
||||
"inputText": "原始輸入文本",
|
||||
"userLevel": "A2|B1|B2|C1|C2",
|
||||
"highValueCriteria": "B1-B2", // 用戶的重點學習範圍
|
||||
"grammarCorrection": {
|
||||
"hasErrors": boolean,
|
||||
"originalText": "string",
|
||||
"correctedText": "string|null",
|
||||
"corrections": []
|
||||
},
|
||||
"sentenceMeaning": {
|
||||
"translation": "中文翻譯"
|
||||
},
|
||||
"finalAnalysisText": "最終分析文本",
|
||||
"wordAnalysis": {
|
||||
"詞彙": {
|
||||
"word": "string",
|
||||
"translation": "中文翻譯",
|
||||
"definition": "英文定義",
|
||||
"partOfSpeech": "詞性",
|
||||
"pronunciation": "IPA音標",
|
||||
"isHighValue": boolean, // 由CEFRLevelService判定,非AI決定
|
||||
"difficultyLevel": "CEFR等級"
|
||||
}
|
||||
},
|
||||
"highValueWords": ["重點學習詞彙數組"], // 由後端邏輯決定,非AI決定
|
||||
"phrasesDetected": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. **可點擊詞彙功能**
|
||||
|
||||
#### 2.1 詞彙互動
|
||||
- **即時彈窗**: 點擊任意詞彙顯示詳細資訊
|
||||
- **智能定位**: 彈窗自動避開屏幕邊界
|
||||
- **響應式設計**: 適配桌面端和移動端
|
||||
|
||||
#### 2.2 個人化詞彙分類標記
|
||||
根據用戶CEFR等級進行個人化標記:
|
||||
|
||||
| 用戶程度 | 重點學習範圍 | 標記詞彙 | 視覺效果 |
|
||||
|----------|--------------|----------|----------|
|
||||
| **A1** | A2-B1 | A2, B1 詞彙 | 綠色邊框 + ⭐ |
|
||||
| **A2** | B1-B2 | B1, B2 詞彙 | 綠色邊框 + ⭐ |
|
||||
| **B1** | B2-C1 | B2, C1 詞彙 | 綠色邊框 + ⭐ |
|
||||
| **B2** | C1-C2 | C1, C2 詞彙 | 綠色邊框 + ⭐ |
|
||||
| **C1** | C1-C2 | C1, C2 詞彙 | 綠色邊框 + ⭐ |
|
||||
|
||||
- **重點學習詞彙**: 綠色邊框 + ⭐ 標記(用戶程度+1~2階級)
|
||||
- **重點學習片語**: 黃色邊框 + ⭐ 標記
|
||||
- **普通詞彙**: 藍色邊框(已掌握或太難的詞彙)
|
||||
- **未分析詞彙**: 灰色虛線邊框
|
||||
|
||||
#### 2.3 詞彙詳情彈窗
|
||||
採用**詞卡風格設計**,包含:
|
||||
- **標題區**: 漸層背景,詞彙名稱 + CEFR等級標籤
|
||||
- **基本資訊**: 詞性標籤、IPA發音、播放按鈕
|
||||
- **翻譯區塊**: 綠色背景,中文翻譯
|
||||
- **定義區塊**: 灰色背景,英文定義
|
||||
- **同義詞區塊**: 紫色背景,相關同義詞
|
||||
- **儲存按鈕**: 一鍵保存到個人詞卡庫
|
||||
|
||||
### 3. **詞卡儲存系統**
|
||||
|
||||
#### 3.1 儲存功能
|
||||
- **一鍵儲存**: 從詞彙彈窗直接保存到詞卡
|
||||
- **自動分類**: 自動加入預設詞卡組
|
||||
- **去重處理**: 避免重複儲存相同詞彙
|
||||
- **即時反饋**: 儲存成功/失敗的視覺提示
|
||||
|
||||
#### 3.2 資料結構
|
||||
```json
|
||||
{
|
||||
"word": "詞彙",
|
||||
"translation": "中文翻譯",
|
||||
"definition": "英文定義",
|
||||
"pronunciation": "IPA發音",
|
||||
"partOfSpeech": "詞性",
|
||||
"example": "例句"
|
||||
}
|
||||
```
|
||||
|
||||
### 4. **個人化程度設定系統**
|
||||
|
||||
#### 4.1 用戶程度管理
|
||||
- **CEFR等級選擇**: A1-C2六個等級選擇
|
||||
- **本地儲存**: localStorage保存,未登入用戶也可使用
|
||||
- **雲端同步**: 登入用戶的程度設定同步到後端
|
||||
- **智能預設**: 未設定用戶預設為A2等級
|
||||
|
||||
#### 4.2 重點學習範圍邏輯
|
||||
```typescript
|
||||
// 個人化判定規則
|
||||
const getTargetLevelRange = (userLevel: string): string => {
|
||||
const ranges = {
|
||||
'A1': 'A2-B1', // A1用戶重點學習A2和B1詞彙
|
||||
'A2': 'B1-B2', // A2用戶重點學習B1和B2詞彙
|
||||
'B1': 'B2-C1', // B1用戶重點學習B2和C1詞彙
|
||||
'B2': 'C1-C2', // B2用戶重點學習C1和C2詞彙
|
||||
'C1': 'C1-C2', // C1用戶重點學習C1和C2詞彙
|
||||
'C2': 'C1-C2' // C2用戶維持高階詞彙
|
||||
};
|
||||
return ranges[userLevel] || 'B1-B2';
|
||||
};
|
||||
```
|
||||
|
||||
#### 4.3 視覺化學習指導
|
||||
- **程度指示器**: 顯示當前程度和重點學習範圍
|
||||
- **學習建議**: 基於程度提供個人化學習策略
|
||||
- **進度追蹤**: 詞彙掌握程度可視化
|
||||
|
||||
### 5. **快取系統**
|
||||
|
||||
#### 5.1 個人化快取
|
||||
- **基於用戶程度快取**: 不同程度用戶的分析結果分別快取
|
||||
- **快取鍵格式**: `{sentence}_{userLevel}` 確保個人化結果
|
||||
- **詞彙分析快取**: 高頻詞彙結果快取
|
||||
- **快取過期**: 自動清理過期項目
|
||||
|
||||
#### 5.2 效能優化
|
||||
- **智能快取策略**: 優先快取重點學習範圍的分析結果
|
||||
- **快取統計**: 提供快取命中率監控
|
||||
- **定期清理**: 自動清理過期快取項目
|
||||
|
||||
---
|
||||
|
||||
## 🎯 **個人化重點學習範圍系統**
|
||||
|
||||
### 1. **核心設計理念**
|
||||
|
||||
#### 1.1 問題解決
|
||||
**現有問題**:
|
||||
- A1學習者看不到A2詞彙的學習價值(對他們很重要)
|
||||
- C1學習者被B1詞彙干擾(對他們太簡單)
|
||||
- 一刀切設計不符合個別學習需求
|
||||
|
||||
**解決方案**:
|
||||
```
|
||||
新邏輯:重點學習詞彙 = 用戶當前程度 + 1~2階級
|
||||
```
|
||||
|
||||
#### 1.2 個人化效果對比
|
||||
|
||||
| 學習者程度 | 舊系統標記 | 新系統標記 | 改善效果 |
|
||||
|-----------|------------|------------|----------|
|
||||
| **A1** | B1,B2,C1,C2 | **A2,B1** | 更實用的學習目標 |
|
||||
| **A2** | B1,B2,C1,C2 | **B1,B2** | 適當的進階挑戰 |
|
||||
| **B1** | B1,B2,C1,C2 | **B2,C1** | 避免重複簡單詞彙 |
|
||||
| **B2** | B1,B2,C1,C2 | **C1,C2** | 專注高階詞彙 |
|
||||
| **C1** | B1,B2,C1,C2 | **C1,C2** | 專注高階詞彙精進 |
|
||||
|
||||
### 2. **技術實現架構**
|
||||
|
||||
#### 2.1 CEFRLevelService
|
||||
```csharp
|
||||
public static class CEFRLevelService
|
||||
{
|
||||
// 判定詞彙對特定用戶是否為重點學習
|
||||
public static bool IsHighValueForUser(string wordLevel, string userLevel)
|
||||
{
|
||||
var userIndex = GetLevelIndex(userLevel);
|
||||
var wordIndex = GetLevelIndex(wordLevel);
|
||||
|
||||
// 重點學習範圍:比用戶程度高 1-2 級
|
||||
return wordIndex >= userIndex + 1 && wordIndex <= userIndex + 2;
|
||||
}
|
||||
|
||||
// 取得用戶的目標學習等級範圍
|
||||
public static string GetTargetLevelRange(string userLevel)
|
||||
{
|
||||
var userIndex = GetLevelIndex(userLevel);
|
||||
var targetMin = Levels[Math.Min(userIndex + 1, Levels.Length - 1)];
|
||||
var targetMax = Levels[Math.Min(userIndex + 2, Levels.Length - 1)];
|
||||
return targetMin == targetMax ? targetMin : $"{targetMin}-{targetMax}";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.2 AI Prompt個人化
|
||||
```csharp
|
||||
// Gemini AI Prompt 動態生成
|
||||
private string BuildSentenceAnalysisPrompt(string inputText, string userLevel)
|
||||
{
|
||||
var targetRange = CEFRLevelService.GetTargetLevelRange(userLevel);
|
||||
|
||||
return $@"
|
||||
請分析以下英文句子:{inputText}
|
||||
學習者程度:{userLevel}
|
||||
|
||||
要求:
|
||||
1. 提供自然流暢的繁體中文翻譯
|
||||
2. **基於學習者程度({userLevel}),標記 {targetRange} 等級的詞彙為高價值**
|
||||
3. 太簡單的詞彙(≤{userLevel})不要標記為高價值
|
||||
4. 太難的詞彙(>{targetRange})謹慎標記
|
||||
|
||||
高價值判定邏輯:
|
||||
- 重點關注 {targetRange} 範圍內的詞彙
|
||||
- 提供適合當前程度的學習挑戰
|
||||
";
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.3 後處理驗證
|
||||
```csharp
|
||||
// AI結果的後處理驗證
|
||||
private SentenceAnalysisResponse PostProcessHighValueWords(
|
||||
SentenceAnalysisResponse result, string userLevel)
|
||||
{
|
||||
// 二次驗證AI的重點學習判定,確保準確性
|
||||
foreach (var wordPair in result.WordAnalysis)
|
||||
{
|
||||
var word = wordPair.Value;
|
||||
word.IsHighValue = CEFRLevelService.IsHighValueForUser(
|
||||
word.DifficultyLevel, userLevel);
|
||||
}
|
||||
|
||||
// 更新重點學習詞彙列表
|
||||
result.HighValueWords = result.WordAnalysis
|
||||
.Where(w => w.Value.IsHighValue)
|
||||
.Select(w => w.Key)
|
||||
.ToList();
|
||||
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. **用戶程度設定介面**
|
||||
|
||||
#### 3.1 設定頁面設計
|
||||
- **等級選擇器**: 6個CEFR等級的圖形化選擇
|
||||
- **程度描述**: 每個等級的能力描述和範例詞彙
|
||||
- **效果預覽**: 顯示選擇該程度的重點學習範圍
|
||||
- **學習建議**: 基於程度的個人化學習策略
|
||||
|
||||
#### 3.2 整合到分析流程
|
||||
```typescript
|
||||
// 前端API調用整合
|
||||
const handleAnalyzeSentence = async () => {
|
||||
const userLevel = localStorage.getItem('userEnglishLevel') || 'A2';
|
||||
|
||||
const response = await fetch('/api/ai/analyze-sentence', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
inputText: textInput,
|
||||
userLevel: userLevel, // 傳遞用戶程度
|
||||
analysisMode: 'full'
|
||||
})
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ **技術架構**
|
||||
|
||||
### 1. **前端架構 (Next.js + TypeScript)**
|
||||
|
||||
#### 1.1 核心組件
|
||||
```typescript
|
||||
// 主要組件
|
||||
ClickableTextV2.tsx // 可點擊文本組件(使用React Portal)
|
||||
GeneratePage.tsx // 句子分析主頁面
|
||||
FlashcardsPage.tsx // 詞卡管理頁面
|
||||
|
||||
// 輔助組件
|
||||
Navigation.tsx // 導航組件
|
||||
ProtectedRoute.tsx // 路由保護
|
||||
```
|
||||
|
||||
#### 1.2 狀態管理
|
||||
```typescript
|
||||
// 分析狀態
|
||||
const [sentenceAnalysis, setSentenceAnalysis] = useState<Record<string, WordAnalysis>>({})
|
||||
const [sentenceMeaning, setSentenceMeaning] = useState<string>('')
|
||||
const [grammarCorrection, setGrammarCorrection] = useState<GrammarCorrection | null>(null)
|
||||
|
||||
// UI狀態
|
||||
const [selectedWord, setSelectedWord] = useState<string | null>(null)
|
||||
const [popupPosition, setPopupPosition] = useState({ x: 0, y: 0, showBelow: false })
|
||||
const [isSavingWord, setIsSavingWord] = useState<boolean>(false)
|
||||
```
|
||||
|
||||
#### 1.3 API服務層
|
||||
```typescript
|
||||
// 服務介面
|
||||
flashcardsService.createFlashcard() // 詞卡創建
|
||||
flashcardsService.getFlashcards() // 詞卡查詢
|
||||
flashcardsService.deleteFlashcard() // 詞卡刪除
|
||||
|
||||
// API端點
|
||||
POST /api/ai/analyze-sentence // 句子分析
|
||||
POST /api/flashcards // 詞卡創建
|
||||
GET /api/flashcards // 詞卡查詢
|
||||
```
|
||||
|
||||
### 2. **後端架構 (.NET 8 + Entity Framework)**
|
||||
|
||||
#### 2.1 控制器層
|
||||
```csharp
|
||||
AIController.cs // AI分析相關API
|
||||
FlashcardsController.cs // 詞卡CRUD操作
|
||||
AuthController.cs // 用戶認證
|
||||
StatsController.cs // 統計資料
|
||||
```
|
||||
|
||||
#### 2.2 服務層
|
||||
```csharp
|
||||
GeminiService.cs // Gemini AI整合
|
||||
AudioCacheService.cs // 音頻快取管理
|
||||
AuthService.cs // 認證服務
|
||||
CacheCleanupService.cs // 快取清理服務
|
||||
```
|
||||
|
||||
#### 2.3 資料層
|
||||
```csharp
|
||||
// 主要實體
|
||||
User.cs // 用戶資料
|
||||
Flashcard.cs // 詞卡實體
|
||||
CardSet.cs // 詞卡組
|
||||
SentenceAnalysisCache.cs // 分析快取
|
||||
|
||||
// 資料庫上下文
|
||||
DramaLingDbContext.cs // EF DbContext
|
||||
```
|
||||
|
||||
### 3. **資料庫設計 (SQLite)**
|
||||
|
||||
#### 3.1 核心表結構
|
||||
```sql
|
||||
-- 詞卡表
|
||||
Flashcards {
|
||||
Id: GUID (PK)
|
||||
UserId: GUID (FK)
|
||||
CardSetId: GUID (FK)
|
||||
Word: VARCHAR(100)
|
||||
Translation: VARCHAR(200)
|
||||
Definition: TEXT
|
||||
PartOfSpeech: VARCHAR(50)
|
||||
Pronunciation: VARCHAR(100)
|
||||
Example: TEXT
|
||||
MasteryLevel: INT
|
||||
CreatedAt: DATETIME
|
||||
}
|
||||
|
||||
-- 分析快取表
|
||||
SentenceAnalysisCache {
|
||||
Id: GUID (PK)
|
||||
InputTextHash: VARCHAR(64) (Index)
|
||||
AnalysisResult: TEXT
|
||||
ExpiresAt: DATETIME (Index)
|
||||
AccessCount: INT
|
||||
CreatedAt: DATETIME
|
||||
}
|
||||
```
|
||||
|
||||
### 4. **AI整合架構**
|
||||
|
||||
#### 4.1 Gemini AI整合
|
||||
```csharp
|
||||
// AI分析流程
|
||||
1. 接收用戶輸入 →
|
||||
2. 檢查快取 →
|
||||
3. 調用Gemini API →
|
||||
4. 解析AI回應 →
|
||||
5. 補充本地資料 →
|
||||
6. 儲存快取 →
|
||||
7. 返回結果
|
||||
```
|
||||
|
||||
#### 4.2 回退機制
|
||||
```csharp
|
||||
// AI失敗處理
|
||||
try {
|
||||
// Gemini AI分析
|
||||
} catch {
|
||||
// 回退到本地分析
|
||||
return LocalAnalysis();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 **API規格**
|
||||
|
||||
### 1. **句子分析API**
|
||||
|
||||
#### 端點
|
||||
```
|
||||
POST /api/ai/analyze-sentence
|
||||
```
|
||||
|
||||
#### 請求格式
|
||||
```json
|
||||
{
|
||||
"inputText": "要分析的英文句子",
|
||||
"userLevel": "A2", // 用戶CEFR等級,用於個人化重點學習範圍判定
|
||||
"analysisMode": "full"
|
||||
}
|
||||
```
|
||||
|
||||
#### 回應格式
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"analysisId": "830ef2a1-83fd-4cfd-ae74-7b54350bff5e",
|
||||
"inputText": "The company offered a bonus",
|
||||
"userLevel": "A2",
|
||||
"highValueCriteria": "B1-B2", // A2用戶的重點學習範圍
|
||||
"grammarCorrection": {
|
||||
"hasErrors": false,
|
||||
"originalText": "The company offered a bonus",
|
||||
"correctedText": "",
|
||||
"corrections": []
|
||||
},
|
||||
"sentenceMeaning": {
|
||||
"translation": "公司發放了獎金。"
|
||||
},
|
||||
"finalAnalysisText": "The company offered a bonus",
|
||||
"wordAnalysis": {
|
||||
"bonus": {
|
||||
"word": "bonus",
|
||||
"translation": "獎金",
|
||||
"definition": "An extra amount of money added to a person's salary",
|
||||
"partOfSpeech": "Noun",
|
||||
"pronunciation": "/ˈbəʊnəs/",
|
||||
"isHighValue": true, // 由CEFRLevelService判定:B1屬於A2用戶的重點學習範圍
|
||||
"difficultyLevel": "B1"
|
||||
}
|
||||
},
|
||||
"highValueWords": ["offered", "bonus"], // 由CEFRLevelService判定,非AI決定
|
||||
"phrasesDetected": []
|
||||
},
|
||||
"message": "AI句子分析完成",
|
||||
"usingAI": true
|
||||
}
|
||||
```
|
||||
|
||||
### 2. **詞卡儲存API**
|
||||
|
||||
#### 端點
|
||||
```
|
||||
POST /api/flashcards
|
||||
```
|
||||
|
||||
#### 請求格式
|
||||
```json
|
||||
{
|
||||
"word": "bonus",
|
||||
"translation": "獎金、紅利",
|
||||
"definition": "An extra payment given in addition to regular salary",
|
||||
"pronunciation": "/ˈboʊnəs/",
|
||||
"partOfSpeech": "noun",
|
||||
"example": "I received a Christmas bonus this year."
|
||||
}
|
||||
```
|
||||
|
||||
#### 回應格式
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": "flashcard-id",
|
||||
"word": "bonus",
|
||||
"translation": "獎金、紅利",
|
||||
"cardSet": {
|
||||
"name": "未分類",
|
||||
"color": "bg-slate-700"
|
||||
}
|
||||
},
|
||||
"message": "詞卡創建成功"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 **UI/UX設計規格**
|
||||
|
||||
### 1. **Portal彈窗設計**
|
||||
|
||||
#### 1.1 設計原則
|
||||
- **詞卡風格一致性**: 與展示頁面的詞卡風格100%一致
|
||||
- **CSS隔離**: 使用React Portal避免樣式繼承問題
|
||||
- **響應式設計**: 適配各種屏幕尺寸
|
||||
|
||||
#### 1.2 視覺規格
|
||||
```css
|
||||
/* 彈窗容器 */
|
||||
.popup-container {
|
||||
width: 24rem; /* w-96 */
|
||||
max-width: 28rem; /* max-w-md */
|
||||
border-radius: 0.75rem; /* rounded-xl */
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); /* shadow-lg */
|
||||
}
|
||||
|
||||
/* 標題區漸層 */
|
||||
.title-section {
|
||||
background: linear-gradient(to bottom right, #dbeafe, #e0e7ff); /* from-blue-50 to-indigo-50 */
|
||||
padding: 1.25rem; /* p-5 */
|
||||
border-bottom: 1px solid #c3ddfd; /* border-blue-200 */
|
||||
}
|
||||
|
||||
/* CEFR顏色系統 */
|
||||
.cefr-a1 { background: #dcfce7; color: #166534; border: #bbf7d0; } /* 綠色 */
|
||||
.cefr-a2 { background: #dbeafe; color: #1e40af; border: #bfdbfe; } /* 藍色 */
|
||||
.cefr-b1 { background: #fef3c7; color: #a16207; border: #fde68a; } /* 黃色 */
|
||||
.cefr-b2 { background: #fed7aa; color: #c2410c; border: #fdba74; } /* 橙色 */
|
||||
.cefr-c1 { background: #fecaca; color: #dc2626; border: #fca5a5; } /* 紅色 */
|
||||
.cefr-c2 { background: #e9d5ff; color: #7c3aed; border: #c4b5fd; } /* 紫色 */
|
||||
```
|
||||
|
||||
### 2. **彩色區塊設計**
|
||||
|
||||
#### 2.1 內容區塊
|
||||
- **翻譯區塊**: 綠色系 (`bg-green-50`, `border-green-200`)
|
||||
- **定義區塊**: 灰色系 (`bg-gray-50`, `border-gray-200`)
|
||||
- **同義詞區塊**: 紫色系 (`bg-purple-50`, `border-purple-200`)
|
||||
|
||||
#### 2.2 互動元素
|
||||
- **播放按鈕**: 藍色圓形 (`bg-blue-600`, `w-8 h-8`)
|
||||
- **儲存按鈕**: 主色調 (`bg-primary`, `hover:bg-primary-hover`)
|
||||
- **關閉按鈕**: 半透明白色 (`bg-white bg-opacity-80`)
|
||||
|
||||
---
|
||||
|
||||
## 🔧 **技術實現規格**
|
||||
|
||||
### 1. **前端技術棧**
|
||||
|
||||
#### 1.1 核心技術
|
||||
```json
|
||||
{
|
||||
"framework": "Next.js 15.5.3",
|
||||
"language": "TypeScript",
|
||||
"styling": "Tailwind CSS",
|
||||
"stateManagement": "React Hooks",
|
||||
"apiClient": "Fetch API"
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.2 關鍵實現
|
||||
```typescript
|
||||
// React Portal實現
|
||||
const VocabPopup = () => {
|
||||
if (!selectedWord || !analysis?.[selectedWord] || !mounted) return null
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed z-50 bg-white rounded-xl shadow-lg w-96 max-w-md overflow-hidden">
|
||||
{/* 彈窗內容 */}
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
// 智能屬性讀取
|
||||
const getWordProperty = (wordData: any, propName: string) => {
|
||||
// 處理大小寫不一致
|
||||
const lowerProp = propName.toLowerCase()
|
||||
const upperProp = propName.charAt(0).toUpperCase() + propName.slice(1)
|
||||
|
||||
// 特殊處理AI資料缺失
|
||||
if (propName === 'synonyms') {
|
||||
return wordData?.[lowerProp] || wordData?.[upperProp] || []
|
||||
}
|
||||
|
||||
return wordData?.[lowerProp] || wordData?.[upperProp]
|
||||
}
|
||||
```
|
||||
|
||||
### 2. **後端技術棧**
|
||||
|
||||
#### 2.1 核心技術
|
||||
```json
|
||||
{
|
||||
"framework": ".NET 8.0",
|
||||
"language": "C#",
|
||||
"database": "SQLite + Entity Framework Core",
|
||||
"ai": "Google Gemini API",
|
||||
"authentication": "JWT Bearer Token"
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.2 關鍵實現
|
||||
```csharp
|
||||
// AI分析服務 - 整合個人化重點學習範圍
|
||||
[HttpPost("analyze-sentence")]
|
||||
public async Task<ActionResult> AnalyzeSentence([FromBody] AnalyzeSentenceRequest request)
|
||||
{
|
||||
// 1. 取得用戶程度
|
||||
string userLevel = request.UserLevel ?? await GetUserLevelFromAuth() ?? "A2";
|
||||
|
||||
// 2. 快取檢查(基於用戶程度)
|
||||
var cacheKey = $"{request.InputText}_{userLevel}";
|
||||
var cachedResult = await CheckCache(cacheKey);
|
||||
if (cachedResult != null) return Ok(cachedResult);
|
||||
|
||||
// 3. AI分析(傳遞用戶程度)
|
||||
var aiAnalysis = await _geminiService.AnalyzeSentenceAsync(request.InputText, userLevel);
|
||||
|
||||
// 4. 重點學習範圍判定(關鍵步驟)
|
||||
var enhancedAnalysis = PostProcessHighValueWords(aiAnalysis, userLevel);
|
||||
|
||||
// 5. 快取儲存
|
||||
await SaveToCache(cacheKey, enhancedAnalysis);
|
||||
|
||||
return Ok(new {
|
||||
Success = true,
|
||||
Data = new {
|
||||
AnalysisId = Guid.NewGuid(),
|
||||
InputText = request.InputText,
|
||||
UserLevel = userLevel,
|
||||
HighValueCriteria = CEFRLevelService.GetTargetLevelRange(userLevel),
|
||||
GrammarCorrection = enhancedAnalysis.GrammarCorrection,
|
||||
SentenceMeaning = new { Translation = enhancedAnalysis.Translation },
|
||||
FinalAnalysisText = request.InputText,
|
||||
WordAnalysis = enhancedAnalysis.WordAnalysis,
|
||||
HighValueWords = enhancedAnalysis.HighValueWords
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 重點學習範圍判定服務
|
||||
public static class CEFRLevelService
|
||||
{
|
||||
public static bool IsHighValueForUser(string wordLevel, string userLevel)
|
||||
{
|
||||
var userIndex = GetLevelIndex(userLevel);
|
||||
var wordIndex = GetLevelIndex(wordLevel);
|
||||
|
||||
// 重點學習範圍:用戶程度 + 1~2 階級
|
||||
return wordIndex >= userIndex + 1 && wordIndex <= userIndex + 2;
|
||||
}
|
||||
|
||||
public static string GetTargetLevelRange(string userLevel)
|
||||
{
|
||||
var levels = new[] { "A1", "A2", "B1", "B2", "C1", "C2" };
|
||||
var userIndex = Array.IndexOf(levels, userLevel);
|
||||
|
||||
var targetMin = levels[Math.Min(userIndex + 1, levels.Length - 1)];
|
||||
var targetMax = levels[Math.Min(userIndex + 2, levels.Length - 1)];
|
||||
|
||||
return targetMin == targetMax ? targetMin : $"{targetMin}-{targetMax}";
|
||||
}
|
||||
}
|
||||
|
||||
// 詞彙分析增強
|
||||
private Dictionary<string, object> GenerateWordAnalysisForSentence(string text)
|
||||
{
|
||||
var analysis = new Dictionary<string, object>();
|
||||
var words = text.Split(new[] { ' ', '.', ',', '!', '?' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
foreach (var word in words)
|
||||
{
|
||||
analysis[word] = new
|
||||
{
|
||||
word = word,
|
||||
translation = GetWordTranslation(word),
|
||||
definition = GetWordDefinition(word),
|
||||
partOfSpeech = GetPartOfSpeech(word),
|
||||
pronunciation = GetPronunciation(word),
|
||||
synonyms = GetSynonyms(word),
|
||||
isHighValue = IsHighValueWord(word),
|
||||
difficultyLevel = GetWordDifficulty(word)
|
||||
};
|
||||
}
|
||||
|
||||
return analysis;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. **資料庫架構**
|
||||
|
||||
#### 3.1 實體關係
|
||||
```
|
||||
User (1) ←→ (N) CardSet (1) ←→ (N) Flashcard
|
||||
User (1) ←→ (N) SentenceAnalysisCache
|
||||
User (1) ←→ (N) WordQueryUsageStats
|
||||
```
|
||||
|
||||
#### 3.2 索引策略
|
||||
```sql
|
||||
-- 效能索引
|
||||
CREATE INDEX IX_SentenceAnalysisCache_InputTextHash ON SentenceAnalysisCache(InputTextHash);
|
||||
CREATE INDEX IX_SentenceAnalysisCache_ExpiresAt ON SentenceAnalysisCache(ExpiresAt);
|
||||
CREATE INDEX IX_Flashcards_UserId_Word ON Flashcards(UserId, Word);
|
||||
CREATE INDEX IX_WordQueryUsageStats_UserId_Date ON WordQueryUsageStats(UserId, Date);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 **效能與擴展規格**
|
||||
|
||||
### 1. **效能指標**
|
||||
|
||||
#### 1.1 回應時間
|
||||
- **快取命中**: < 100ms
|
||||
- **AI分析**: < 3000ms
|
||||
- **詞卡儲存**: < 500ms
|
||||
- **彈窗顯示**: < 50ms
|
||||
|
||||
#### 1.2 併發處理
|
||||
- **最大併發用戶**: 100
|
||||
- **AI API限制**: 每分鐘60次請求
|
||||
- **資料庫連線池**: 20個連線
|
||||
|
||||
### 2. **擴展性設計**
|
||||
|
||||
#### 2.1 水平擴展
|
||||
- **無狀態設計**: 所有狀態存於資料庫
|
||||
- **API分離**: 前後端完全分離
|
||||
- **快取策略**: 支援Redis擴展
|
||||
|
||||
#### 2.2 功能擴展
|
||||
- **多語言支援**: 預留i18n架構
|
||||
- **AI模型切換**: 支援多種AI服務
|
||||
- **音頻功能**: TTS語音合成擴展
|
||||
|
||||
---
|
||||
|
||||
## 🔒 **安全性規格**
|
||||
|
||||
### 1. **身份驗證**
|
||||
- **JWT Token**: 用戶身份驗證
|
||||
- **Token過期**: 24小時自動過期
|
||||
- **保護路由**: 所有敏感API需要認證
|
||||
|
||||
### 2. **資料安全**
|
||||
- **輸入驗證**: 防止SQL注入和XSS
|
||||
- **資料加密**: 敏感資料庫內加密
|
||||
- **CORS設定**: 限制跨域請求來源
|
||||
|
||||
### 3. **API安全**
|
||||
```csharp
|
||||
[Authorize] // 需要認證
|
||||
[ValidateAntiForgeryToken] // CSRF保護
|
||||
[Rate限制] // API調用頻率限制
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📈 **監控與維護**
|
||||
|
||||
### 1. **日誌系統**
|
||||
- **結構化日誌**: 使用Serilog記錄
|
||||
- **分級記錄**: Debug/Info/Warning/Error
|
||||
- **效能監控**: API回應時間追蹤
|
||||
|
||||
### 2. **健康檢查**
|
||||
```
|
||||
GET /health // 系統健康狀態
|
||||
GET /api/ai/cache-stats // 快取統計資料
|
||||
GET /api/stats/usage // 使用統計資料
|
||||
```
|
||||
|
||||
### 3. **錯誤處理**
|
||||
- **全域例外處理**: 統一錯誤回應格式
|
||||
- **使用者友善訊息**: 技術錯誤轉換為用戶可理解訊息
|
||||
- **錯誤報告**: 自動記錄並分析系統錯誤
|
||||
|
||||
---
|
||||
|
||||
## 🚀 **部署規格**
|
||||
|
||||
### 1. **環境配置**
|
||||
```json
|
||||
{
|
||||
"development": {
|
||||
"frontend": "http://localhost:3001",
|
||||
"backend": "http://localhost:5000",
|
||||
"database": "SQLite本地檔案"
|
||||
},
|
||||
"production": {
|
||||
"frontend": "Vercel/Netlify",
|
||||
"backend": "Azure App Service",
|
||||
"database": "Azure SQL Database"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. **環境變數**
|
||||
```bash
|
||||
# AI設定
|
||||
GEMINI_API_KEY=your_gemini_api_key
|
||||
|
||||
# 資料庫
|
||||
CONNECTION_STRING=Data Source=dramaling.db
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=your_jwt_secret
|
||||
JWT_ISSUER=DramaLing.Api
|
||||
JWT_AUDIENCE=DramaLing.Frontend
|
||||
|
||||
# CORS
|
||||
ALLOWED_ORIGINS=http://localhost:3000,http://localhost:3001
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 **開發與測試規格**
|
||||
|
||||
### 1. **開發環境設置**
|
||||
```bash
|
||||
# 前端
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
|
||||
# 後端
|
||||
cd backend/DramaLing.Api
|
||||
dotnet restore
|
||||
dotnet run
|
||||
```
|
||||
|
||||
### 2. **測試策略**
|
||||
- **單元測試**: 核心業務邏輯測試
|
||||
- **整合測試**: API端點測試
|
||||
- **端到端測試**: 完整用戶流程測試
|
||||
- **效能測試**: API回應時間測試
|
||||
|
||||
### 3. **品質保證**
|
||||
```typescript
|
||||
// TypeScript嚴格模式
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true
|
||||
|
||||
// ESLint規則
|
||||
"@typescript-eslint/no-unused-vars": "error",
|
||||
"@typescript-eslint/explicit-function-return-type": "warn"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 **使用統計與分析**
|
||||
|
||||
### 1. **用戶行為追蹤**
|
||||
- **分析次數**: 每日句子分析統計
|
||||
- **詞彙點擊**: 高頻詞彙使用統計
|
||||
- **儲存行為**: 詞卡儲存成功率
|
||||
- **學習進度**: 用戶學習軌跡分析
|
||||
|
||||
### 2. **系統效能監控**
|
||||
- **API回應時間**: 分析各端點效能
|
||||
- **快取命中率**: 優化快取策略
|
||||
- **錯誤率統計**: 監控系統穩定性
|
||||
- **AI使用量**: 追蹤AI API調用成本
|
||||
|
||||
---
|
||||
|
||||
## 🔮 **未來擴展計劃**
|
||||
|
||||
### 1. **功能擴展**
|
||||
- **語音輸入**: 支援語音轉文字
|
||||
- **文法練習**: 基於分析結果生成練習題
|
||||
- **學習路徑**: 個人化學習建議
|
||||
- **社群功能**: 詞卡分享與協作
|
||||
|
||||
### 2. **技術優化**
|
||||
- **AI模型升級**: 整合更先進的語言模型
|
||||
- **快取優化**: 引入Redis提升效能
|
||||
- **微服務架構**: 將功能模組化部署
|
||||
- **實時同步**: WebSocket即時更新
|
||||
|
||||
---
|
||||
|
||||
**文件版本**: v2.0 (整合個人化重點學習範圍系統)
|
||||
**建立日期**: 2025-09-21
|
||||
**最後更新**: 2025-09-21
|
||||
**重大更新**:
|
||||
- 高價值詞彙 → 重點學習範圍概念
|
||||
- 個人化CEFR等級判定邏輯
|
||||
- CEFRLevelService技術架構
|
||||
- 用戶程度設定系統整合
|
||||
|
||||
**維護團隊**: DramaLing開發團隊
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,197 @@
|
|||
# Popup樣式一致性測試案例
|
||||
|
||||
## 測試目標
|
||||
驗證展示頁面的"詞卡風格"popup與AI生成頁面的實際詞彙popup樣式是否完全一致。
|
||||
|
||||
---
|
||||
|
||||
## 測試環境
|
||||
- **瀏覽器**: Chrome/Safari/Firefox
|
||||
- **屏幕尺寸**: 桌面端(>1024px)、平板端(768-1024px)、手機端(<768px)
|
||||
- **展示頁面**: http://localhost:3000/vocab-designs
|
||||
- **實際功能**: http://localhost:3000/generate
|
||||
|
||||
---
|
||||
|
||||
## 詳細測試案例
|
||||
|
||||
### TC-001: 視覺外觀對比
|
||||
|
||||
#### TC-001-01: 整體尺寸檢查
|
||||
**測試步驟**:
|
||||
1. 打開展示頁面,選擇"詞卡風格",點擊預覽按鈕
|
||||
2. 打開AI生成頁面,輸入"Hello world",點擊分析,點擊任意詞彙
|
||||
3. 使用瀏覽器開發者工具測量popup尺寸
|
||||
|
||||
**檢查項目**:
|
||||
- [ ] popup寬度是否相同
|
||||
- [ ] popup高度是否相似
|
||||
- [ ] 圓角半徑是否一致 (`rounded-xl`)
|
||||
- [ ] 陰影效果是否相同 (`shadow-lg`)
|
||||
|
||||
**預期結果**: 兩個popup的外觀尺寸應該完全相同
|
||||
|
||||
#### TC-001-02: 標題區對比
|
||||
**檢查項目**:
|
||||
- [ ] 漸層背景是否相同 (`bg-gradient-to-br from-blue-50 to-indigo-50`)
|
||||
- [ ] 內邊距是否一致 (`p-5`)
|
||||
- [ ] 邊框是否相同 (`border-b border-blue-200`)
|
||||
|
||||
**測試方法**: 使用瀏覽器檢查元素工具對比CSS類別
|
||||
|
||||
#### TC-001-03: 關閉按鈕檢查
|
||||
**檢查項目**:
|
||||
- [ ] 按鈕位置: 右上角
|
||||
- [ ] 按鈕尺寸: `w-6 h-6`
|
||||
- [ ] 背景色: `bg-white bg-opacity-80`
|
||||
- [ ] 懸停效果是否相同
|
||||
|
||||
### TC-002: 內容佈局對比
|
||||
|
||||
#### TC-002-01: 詞彙標題行
|
||||
**展示頁面**: `elaborate` + `[B2]`在同一行
|
||||
**實際popup**: `{word}` + `{difficultyLevel}`
|
||||
|
||||
**檢查項目**:
|
||||
- [ ] 詞彙名稱字體大小 (`text-2xl font-bold`)
|
||||
- [ ] CEFR標籤位置 (最右邊)
|
||||
- [ ] 行間距是否一致 (`mb-3`)
|
||||
|
||||
#### TC-002-02: 詞性發音行
|
||||
**展示頁面**: `[verb] /pronunciation/ ▶️` + `[B2]`
|
||||
**實際popup**: `[partOfSpeech] /pronunciation/ ▶️` + `[difficultyLevel]`
|
||||
|
||||
**檢查項目**:
|
||||
- [ ] 詞性標籤樣式 (`bg-gray-100 text-gray-700 px-3 py-1 rounded-full`)
|
||||
- [ ] 發音字體大小 (`text-base text-gray-600`)
|
||||
- [ ] 播放按鈕尺寸 (`w-8 h-8 bg-blue-600 rounded-full`)
|
||||
- [ ] 元素間距 (`gap-3`)
|
||||
|
||||
### TC-003: 彩色區塊對比
|
||||
|
||||
#### TC-003-01: 翻譯區塊
|
||||
**檢查項目**:
|
||||
- [ ] 背景色: `bg-green-50`
|
||||
- [ ] 邊框: `border border-green-200`
|
||||
- [ ] 內邊距: `p-3`
|
||||
- [ ] 標題樣式: `font-semibold text-green-900 mb-2 text-left text-sm`
|
||||
- [ ] 內容樣式: `text-green-800 font-medium text-left`
|
||||
|
||||
#### TC-003-02: 定義區塊
|
||||
**檢查項目**:
|
||||
- [ ] 背景色: `bg-gray-50`
|
||||
- [ ] 邊框: `border border-gray-200`
|
||||
- [ ] 標題: `font-semibold text-gray-900 mb-2 text-left text-sm`
|
||||
- [ ] 內容: `text-gray-700 text-left text-sm leading-relaxed`
|
||||
|
||||
#### TC-003-03: 同義詞區塊
|
||||
**檢查項目**:
|
||||
- [ ] 背景色: `bg-purple-50`
|
||||
- [ ] 邊框: `border border-purple-200`
|
||||
- [ ] 標籤樣式: `bg-white text-purple-700 px-2 py-1 rounded-full text-xs`
|
||||
|
||||
### TC-004: CEFR顏色測試
|
||||
|
||||
#### TC-004-01: 六個等級顏色檢查
|
||||
**測試數據**: A1, A2, B1, B2, C1, C2
|
||||
|
||||
**檢查項目**:
|
||||
- [ ] A1: `bg-green-100 text-green-700 border-green-200`
|
||||
- [ ] A2: `bg-blue-100 text-blue-700 border-blue-200`
|
||||
- [ ] B1: `bg-yellow-100 text-yellow-700 border-yellow-200`
|
||||
- [ ] B2: `bg-orange-100 text-orange-700 border-orange-200`
|
||||
- [ ] C1: `bg-red-100 text-red-700 border-red-200`
|
||||
- [ ] C2: `bg-purple-100 text-purple-700 border-purple-200`
|
||||
|
||||
**測試方法**:
|
||||
1. 在展示頁面修改mock數據的difficultyLevel
|
||||
2. 在實際頁面測試不同CEFR等級的詞彙
|
||||
3. 對比顏色是否完全相同
|
||||
|
||||
### TC-005: 按鈕樣式對比
|
||||
|
||||
#### TC-005-01: 保存按鈕檢查
|
||||
**檢查項目**:
|
||||
- [ ] 寬度: `w-full`
|
||||
- [ ] 背景: `bg-primary`
|
||||
- [ ] 內邊距: `py-3`
|
||||
- [ ] 圓角: `rounded-lg`
|
||||
- [ ] 字體: `font-medium`
|
||||
- [ ] 圖標尺寸: `w-4 h-4`
|
||||
|
||||
### TC-006: 響應式測試
|
||||
|
||||
#### TC-006-01: 手機端對比
|
||||
**測試步驟**:
|
||||
1. 將瀏覽器調整為手機尺寸 (375px寬度)
|
||||
2. 分別測試兩個popup
|
||||
3. 檢查是否都能完整顯示
|
||||
|
||||
**檢查項目**:
|
||||
- [ ] 寬度自動調整
|
||||
- [ ] 不會超出屏幕邊界
|
||||
- [ ] 內容不會被截掉
|
||||
- [ ] 觸控操作友好
|
||||
|
||||
---
|
||||
|
||||
## 實際差異分析
|
||||
|
||||
### 🔍 **程式碼層面的差異**
|
||||
|
||||
#### **1. CEFR顏色實現方式**
|
||||
**展示頁面** (正確):
|
||||
```typescript
|
||||
const getCEFRColor = (level: string) => {
|
||||
switch (level) {
|
||||
case 'A1': return 'bg-green-100 text-green-700 border-green-200'
|
||||
// ... 完整的6個等級
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**實際popup** (簡化版):
|
||||
```typescript
|
||||
difficulty === 'A1' || difficulty === 'A2' ? 'bg-green-100 text-green-700' :
|
||||
difficulty === 'B1' || difficulty === 'B2' ? 'bg-yellow-100 text-yellow-700' :
|
||||
// ... 只有3-4個分組
|
||||
```
|
||||
|
||||
#### **2. 容器尺寸差異**
|
||||
**展示頁面**:
|
||||
```typescript
|
||||
className="bg-white rounded-xl shadow-lg w-96 max-w-md overflow-hidden"
|
||||
// 固定寬度 w-96 = 384px
|
||||
```
|
||||
|
||||
**實際popup**:
|
||||
```typescript
|
||||
width: 'min(384px, calc(100vw - 32px))'
|
||||
// 響應式寬度
|
||||
```
|
||||
|
||||
#### **3. 可能的其他差異**
|
||||
- 字體載入狀態
|
||||
- CSS優先級問題
|
||||
- 瀏覽器快取問題
|
||||
- 假資料vs真實資料的處理差異
|
||||
|
||||
---
|
||||
|
||||
## 修正建議
|
||||
|
||||
### 高優先級修正:
|
||||
1. **統一CEFR顏色函數**: 在ClickableTextV2中實現完整的getCEFRColor
|
||||
2. **統一容器樣式**: 確保所有CSS類別完全相同
|
||||
3. **統一寬度處理**: 在保持響應式的前提下統一寬度邏輯
|
||||
|
||||
### 測試驗證:
|
||||
1. **並排對比**: 同時打開兩個頁面進行視覺對比
|
||||
2. **開發者工具**: 使用瀏覽器工具檢查computed styles
|
||||
3. **不同設備**: 在桌面端和手機端都進行測試
|
||||
|
||||
---
|
||||
|
||||
## 結論
|
||||
|
||||
我承認之前的判斷可能不準確,因為我無法實際看到瀏覽器渲染效果。通過程式碼分析,確實存在一些可能導致視覺差異的技術細節。需要進行實際的程式碼修正和測試來確保兩者完全一致。
|
||||
|
|
@ -36,465 +36,14 @@ public class AIController : ControllerBase
|
|||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AI 生成詞卡測試端點 (開發用,無需認證)
|
||||
/// </summary>
|
||||
[HttpPost("test/generate")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult> TestGenerateCards([FromBody] GenerateCardsRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 基本驗證
|
||||
if (string.IsNullOrWhiteSpace(request.InputText))
|
||||
{
|
||||
return BadRequest(new { Success = false, Error = "Input text is required" });
|
||||
}
|
||||
|
||||
if (request.InputText.Length > 5000)
|
||||
{
|
||||
return BadRequest(new { Success = false, Error = "Input text must be less than 5000 characters" });
|
||||
}
|
||||
|
||||
if (!new[] { "vocabulary", "smart" }.Contains(request.ExtractionType))
|
||||
{
|
||||
return BadRequest(new { Success = false, Error = "Invalid extraction type" });
|
||||
}
|
||||
|
||||
if (request.CardCount < 1 || request.CardCount > 20)
|
||||
{
|
||||
return BadRequest(new { Success = false, Error = "Card count must be between 1 and 20" });
|
||||
}
|
||||
|
||||
// 測試模式:直接使用模擬資料
|
||||
try
|
||||
{
|
||||
var generatedCards = await _geminiService.GenerateCardsAsync(
|
||||
request.InputText,
|
||||
request.ExtractionType,
|
||||
request.CardCount);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Success = true,
|
||||
Data = new
|
||||
{
|
||||
TaskId = Guid.NewGuid(),
|
||||
Status = "completed",
|
||||
GeneratedCards = generatedCards
|
||||
},
|
||||
Message = $"Successfully generated {generatedCards.Count} cards"
|
||||
});
|
||||
}
|
||||
catch (InvalidOperationException ex) when (ex.Message.Contains("API key"))
|
||||
{
|
||||
_logger.LogWarning("Gemini API key not configured, using mock data");
|
||||
|
||||
// 返回模擬資料
|
||||
var mockCards = GenerateMockCards(request.CardCount);
|
||||
return Ok(new
|
||||
{
|
||||
Success = true,
|
||||
Data = new
|
||||
{
|
||||
TaskId = Guid.NewGuid(),
|
||||
Status = "completed",
|
||||
GeneratedCards = mockCards
|
||||
},
|
||||
Message = $"Generated {mockCards.Count} mock cards (Test mode)"
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in AI card generation test");
|
||||
return StatusCode(500, new
|
||||
{
|
||||
Success = false,
|
||||
Error = "Failed to generate cards",
|
||||
Details = ex.Message,
|
||||
Timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AI 生成詞卡 (支援 /frontend/app/generate/page.tsx)
|
||||
/// </summary>
|
||||
[HttpPost("generate")]
|
||||
public async Task<ActionResult> GenerateCards([FromBody] GenerateCardsRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var userId = await _authService.GetUserIdFromTokenAsync(Request.Headers.Authorization);
|
||||
if (userId == null)
|
||||
return Unauthorized(new { Success = false, Error = "Invalid token" });
|
||||
|
||||
// 基本驗證
|
||||
if (string.IsNullOrWhiteSpace(request.InputText))
|
||||
{
|
||||
return BadRequest(new { Success = false, Error = "Input text is required" });
|
||||
}
|
||||
|
||||
if (request.InputText.Length > 5000)
|
||||
{
|
||||
return BadRequest(new { Success = false, Error = "Input text must be less than 5000 characters" });
|
||||
}
|
||||
|
||||
if (!new[] { "vocabulary", "smart" }.Contains(request.ExtractionType))
|
||||
{
|
||||
return BadRequest(new { Success = false, Error = "Invalid extraction type" });
|
||||
}
|
||||
|
||||
if (request.CardCount < 5 || request.CardCount > 20)
|
||||
{
|
||||
return BadRequest(new { Success = false, Error = "Card count must be between 5 and 20" });
|
||||
}
|
||||
|
||||
// 檢查每日配額 (簡化版,未來可以基於用戶訂閱狀態)
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var todayStats = await _context.DailyStats
|
||||
.FirstOrDefaultAsync(ds => ds.UserId == userId && ds.Date == today);
|
||||
|
||||
var todayApiCalls = todayStats?.AiApiCalls ?? 0;
|
||||
var maxApiCalls = 10; // 免費用戶每日限制
|
||||
|
||||
if (todayApiCalls >= maxApiCalls)
|
||||
{
|
||||
return StatusCode(429, new
|
||||
{
|
||||
Success = false,
|
||||
Error = "Daily AI generation limit exceeded"
|
||||
});
|
||||
}
|
||||
|
||||
// 建立生成任務 (簡化版,直接處理而不是非同步)
|
||||
try
|
||||
{
|
||||
var generatedCards = await _geminiService.GenerateCardsAsync(
|
||||
request.InputText,
|
||||
request.ExtractionType,
|
||||
request.CardCount);
|
||||
|
||||
if (generatedCards.Count == 0)
|
||||
{
|
||||
return StatusCode(500, new
|
||||
{
|
||||
Success = false,
|
||||
Error = "AI generated no valid cards"
|
||||
});
|
||||
}
|
||||
|
||||
// 更新每日統計
|
||||
if (todayStats == null)
|
||||
{
|
||||
todayStats = new DailyStats
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId.Value,
|
||||
Date = today
|
||||
};
|
||||
_context.DailyStats.Add(todayStats);
|
||||
}
|
||||
|
||||
todayStats.AiApiCalls++;
|
||||
todayStats.CardsGenerated += generatedCards.Count;
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Success = true,
|
||||
Data = new
|
||||
{
|
||||
TaskId = Guid.NewGuid(), // 模擬任務 ID
|
||||
Status = "completed",
|
||||
GeneratedCards = generatedCards
|
||||
},
|
||||
Message = $"Successfully generated {generatedCards.Count} cards"
|
||||
});
|
||||
}
|
||||
catch (InvalidOperationException ex) when (ex.Message.Contains("API key"))
|
||||
{
|
||||
_logger.LogWarning("Gemini API key not configured, using mock data");
|
||||
|
||||
// 返回模擬資料(開發階段)
|
||||
var mockCards = GenerateMockCards(request.CardCount);
|
||||
return Ok(new
|
||||
{
|
||||
Success = true,
|
||||
Data = new
|
||||
{
|
||||
TaskId = Guid.NewGuid(),
|
||||
Status = "completed",
|
||||
GeneratedCards = mockCards
|
||||
},
|
||||
Message = $"Generated {mockCards.Count} mock cards (Gemini API not configured)"
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in AI card generation");
|
||||
return StatusCode(500, new
|
||||
{
|
||||
Success = false,
|
||||
Error = "Failed to generate cards",
|
||||
Timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 測試版保存生成的詞卡 (無需認證)
|
||||
/// </summary>
|
||||
[HttpPost("test/save")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult> TestSaveCards([FromBody] TestSaveCardsRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 基本驗證
|
||||
if (request.SelectedCards == null || request.SelectedCards.Count == 0)
|
||||
{
|
||||
return BadRequest(new { Success = false, Error = "Selected cards are required" });
|
||||
}
|
||||
|
||||
// 創建或使用預設卡組
|
||||
var defaultCardSet = await _context.CardSets
|
||||
.FirstOrDefaultAsync(cs => cs.IsDefault);
|
||||
|
||||
if (defaultCardSet == null)
|
||||
{
|
||||
// 創建預設卡組
|
||||
defaultCardSet = new CardSet
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = Guid.NewGuid(), // 測試用戶 ID
|
||||
Name = "AI 生成詞卡",
|
||||
Description = "通過 AI 智能生成的詞卡集合",
|
||||
Color = "#3B82F6",
|
||||
IsDefault = true,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
_context.CardSets.Add(defaultCardSet);
|
||||
}
|
||||
|
||||
// 將生成的詞卡轉換為資料庫實體
|
||||
var flashcardsToSave = request.SelectedCards.Select(card => new Flashcard
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = defaultCardSet.UserId,
|
||||
CardSetId = defaultCardSet.Id,
|
||||
Word = card.Word,
|
||||
Translation = card.Translation,
|
||||
Definition = card.Definition,
|
||||
PartOfSpeech = card.PartOfSpeech,
|
||||
Pronunciation = card.Pronunciation,
|
||||
Example = card.Example,
|
||||
ExampleTranslation = card.ExampleTranslation,
|
||||
DifficultyLevel = card.DifficultyLevel,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
}).ToList();
|
||||
|
||||
_context.Flashcards.AddRange(flashcardsToSave);
|
||||
|
||||
// 更新卡組計數
|
||||
defaultCardSet.CardCount += flashcardsToSave.Count;
|
||||
defaultCardSet.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Success = true,
|
||||
Data = new
|
||||
{
|
||||
SavedCount = flashcardsToSave.Count,
|
||||
CardSetId = defaultCardSet.Id,
|
||||
CardSetName = defaultCardSet.Name,
|
||||
Cards = flashcardsToSave.Select(f => new
|
||||
{
|
||||
f.Id,
|
||||
f.Word,
|
||||
f.Translation,
|
||||
f.Definition
|
||||
})
|
||||
},
|
||||
Message = $"Successfully saved {flashcardsToSave.Count} cards to deck '{defaultCardSet.Name}'"
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error saving generated cards");
|
||||
return StatusCode(500, new
|
||||
{
|
||||
Success = false,
|
||||
Error = "Failed to save cards",
|
||||
Details = ex.Message,
|
||||
Timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存生成的詞卡
|
||||
/// </summary>
|
||||
[HttpPost("generate/{taskId}/save")]
|
||||
public async Task<ActionResult> SaveGeneratedCards(
|
||||
Guid taskId,
|
||||
[FromBody] SaveCardsRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var userId = await _authService.GetUserIdFromTokenAsync(Request.Headers.Authorization);
|
||||
if (userId == null)
|
||||
return Unauthorized(new { Success = false, Error = "Invalid token" });
|
||||
|
||||
// 基本驗證
|
||||
if (request.CardSetId == Guid.Empty)
|
||||
{
|
||||
return BadRequest(new { Success = false, Error = "Card set ID is required" });
|
||||
}
|
||||
|
||||
if (request.SelectedCards == null || request.SelectedCards.Count == 0)
|
||||
{
|
||||
return BadRequest(new { Success = false, Error = "Selected cards are required" });
|
||||
}
|
||||
|
||||
// 驗證卡組是否屬於用戶
|
||||
var cardSet = await _context.CardSets
|
||||
.FirstOrDefaultAsync(cs => cs.Id == request.CardSetId && cs.UserId == userId);
|
||||
|
||||
if (cardSet == null)
|
||||
{
|
||||
return NotFound(new { Success = false, Error = "Card set not found" });
|
||||
}
|
||||
|
||||
// 將生成的詞卡轉換為資料庫實體
|
||||
var flashcardsToSave = request.SelectedCards.Select(card => new Flashcard
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId.Value,
|
||||
CardSetId = request.CardSetId,
|
||||
Word = card.Word,
|
||||
Translation = card.Translation,
|
||||
Definition = card.Definition,
|
||||
PartOfSpeech = card.PartOfSpeech,
|
||||
Pronunciation = card.Pronunciation,
|
||||
Example = card.Example,
|
||||
ExampleTranslation = card.ExampleTranslation,
|
||||
DifficultyLevel = card.DifficultyLevel
|
||||
}).ToList();
|
||||
|
||||
_context.Flashcards.AddRange(flashcardsToSave);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Success = true,
|
||||
Data = new
|
||||
{
|
||||
SavedCount = flashcardsToSave.Count,
|
||||
Cards = flashcardsToSave.Select(f => new
|
||||
{
|
||||
f.Id,
|
||||
f.Word,
|
||||
f.Translation,
|
||||
f.Definition
|
||||
})
|
||||
},
|
||||
Message = $"Successfully saved {flashcardsToSave.Count} cards to your deck"
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error saving generated cards");
|
||||
return StatusCode(500, new
|
||||
{
|
||||
Success = false,
|
||||
Error = "Failed to save cards",
|
||||
Timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 智能檢測詞卡內容
|
||||
/// </summary>
|
||||
[HttpPost("validate-card")]
|
||||
public async Task<ActionResult> ValidateCard([FromBody] ValidateCardRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var userId = await _authService.GetUserIdFromTokenAsync(Request.Headers.Authorization);
|
||||
if (userId == null)
|
||||
return Unauthorized(new { Success = false, Error = "Invalid token" });
|
||||
|
||||
var flashcard = await _context.Flashcards
|
||||
.FirstOrDefaultAsync(f => f.Id == request.FlashcardId && f.UserId == userId);
|
||||
|
||||
if (flashcard == null)
|
||||
{
|
||||
return NotFound(new { Success = false, Error = "Flashcard not found" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var validationResult = await _geminiService.ValidateCardAsync(flashcard);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Success = true,
|
||||
Data = new
|
||||
{
|
||||
FlashcardId = request.FlashcardId,
|
||||
ValidationResult = validationResult,
|
||||
CheckedAt = DateTime.UtcNow
|
||||
},
|
||||
Message = "Card validation completed"
|
||||
});
|
||||
}
|
||||
catch (InvalidOperationException ex) when (ex.Message.Contains("API key"))
|
||||
{
|
||||
// 模擬檢測結果
|
||||
var mockResult = new ValidationResult
|
||||
{
|
||||
Issues = new List<ValidationIssue>(),
|
||||
Suggestions = new List<string> { "詞卡內容看起來正確", "建議添加更多例句" },
|
||||
OverallScore = 85,
|
||||
Confidence = 0.7
|
||||
};
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Success = true,
|
||||
Data = new
|
||||
{
|
||||
FlashcardId = request.FlashcardId,
|
||||
ValidationResult = mockResult,
|
||||
CheckedAt = DateTime.UtcNow,
|
||||
Note = "Mock validation (Gemini API not configured)"
|
||||
},
|
||||
Message = "Card validation completed (mock mode)"
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error validating card");
|
||||
return StatusCode(500, new
|
||||
{
|
||||
Success = false,
|
||||
Error = "Failed to validate card",
|
||||
Timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 句子分析API - 支援語法修正和高價值標記
|
||||
/// ✅ 句子分析API - 支援語法修正和高價值標記
|
||||
/// 🎯 前端使用:/app/generate/page.tsx (主要功能)
|
||||
/// </summary>
|
||||
[HttpPost("analyze-sentence")]
|
||||
[AllowAnonymous] // 暫時無需認證,開發階段
|
||||
|
|
@ -549,13 +98,12 @@ public class AIController : ControllerBase
|
|||
var finalText = aiAnalysis.GrammarCorrection.HasErrors ?
|
||||
aiAnalysis.GrammarCorrection.CorrectedText : request.InputText;
|
||||
|
||||
// 3. 準備AI分析響應資料(包含個人化資訊)
|
||||
// 3. 準備AI分析響應資料
|
||||
var baseResponseData = new
|
||||
{
|
||||
AnalysisId = Guid.NewGuid(),
|
||||
InputText = request.InputText,
|
||||
UserLevel = userLevel, // 新增:顯示使用的程度
|
||||
HighValueCriteria = CEFRLevelService.GetTargetLevelRange(userLevel), // 新增:顯示高價值判定範圍
|
||||
UserLevel = userLevel,
|
||||
GrammarCorrection = aiAnalysis.GrammarCorrection,
|
||||
SentenceMeaning = new
|
||||
{
|
||||
|
|
@ -563,7 +111,6 @@ public class AIController : ControllerBase
|
|||
},
|
||||
FinalAnalysisText = finalText ?? request.InputText,
|
||||
WordAnalysis = aiAnalysis.WordAnalysis,
|
||||
HighValueWords = aiAnalysis.HighValueWords,
|
||||
PhrasesDetected = new object[0] // 暫時簡化
|
||||
};
|
||||
|
||||
|
|
@ -597,7 +144,6 @@ public class AIController : ControllerBase
|
|||
},
|
||||
FinalAnalysisText = finalText,
|
||||
WordAnalysis = analysis.WordAnalysis,
|
||||
HighValueWords = analysis.HighValueWords,
|
||||
PhrasesDetected = analysis.PhrasesDetected
|
||||
};
|
||||
|
||||
|
|
@ -626,146 +172,9 @@ public class AIController : ControllerBase
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 單字點擊查詢API
|
||||
/// </summary>
|
||||
[HttpPost("query-word")]
|
||||
[AllowAnonymous] // 暫時無需認證,開發階段
|
||||
public async Task<ActionResult> QueryWord([FromBody] QueryWordRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 基本驗證
|
||||
if (string.IsNullOrWhiteSpace(request.Word))
|
||||
{
|
||||
return BadRequest(new { Success = false, Error = "Word is required" });
|
||||
}
|
||||
|
||||
// 簡化邏輯:直接調用 GeminiService 進行詞彙分析
|
||||
var wordAnalysis = await _geminiService.AnalyzeWordAsync(request.Word, request.Sentence);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Success = true,
|
||||
Data = new
|
||||
{
|
||||
Word = request.Word,
|
||||
Analysis = wordAnalysis
|
||||
},
|
||||
Message = "詞彙分析完成"
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error analyzing word: {Word}", request.Word);
|
||||
return StatusCode(500, new
|
||||
{
|
||||
Success = false,
|
||||
Error = "詞彙查詢失敗",
|
||||
Details = ex.Message,
|
||||
Timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 獲取快取統計資料
|
||||
/// </summary>
|
||||
[HttpGet("cache-stats")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult> GetCacheStats()
|
||||
{
|
||||
try
|
||||
{
|
||||
var hitCount = await _cacheService.GetCacheHitCountAsync();
|
||||
var totalCacheItems = await _context.SentenceAnalysisCache
|
||||
.Where(c => c.ExpiresAt > DateTime.UtcNow)
|
||||
.CountAsync();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Success = true,
|
||||
Data = new
|
||||
{
|
||||
TotalCacheItems = totalCacheItems,
|
||||
TotalCacheHits = hitCount,
|
||||
CacheHitRate = totalCacheItems > 0 ? (double)hitCount / totalCacheItems : 0,
|
||||
CacheSize = totalCacheItems
|
||||
},
|
||||
Message = "快取統計資料"
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting cache stats");
|
||||
return StatusCode(500, new
|
||||
{
|
||||
Success = false,
|
||||
Error = "獲取快取統計失敗",
|
||||
Timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清理過期快取
|
||||
/// </summary>
|
||||
[HttpPost("cache-cleanup")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult> CleanupCache()
|
||||
{
|
||||
try
|
||||
{
|
||||
await _cacheService.CleanExpiredCacheAsync();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Success = true,
|
||||
Message = "過期快取清理完成"
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error cleaning up cache");
|
||||
return StatusCode(500, new
|
||||
{
|
||||
Success = false,
|
||||
Error = "快取清理失敗",
|
||||
Timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 獲取使用統計
|
||||
/// </summary>
|
||||
[HttpGet("usage-stats")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult> GetUsageStats()
|
||||
{
|
||||
try
|
||||
{
|
||||
var mockUserId = Guid.Parse("00000000-0000-0000-0000-000000000001");
|
||||
var stats = await _usageService.GetUsageStatsAsync(mockUserId);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Success = true,
|
||||
Data = stats,
|
||||
Message = "使用統計資料"
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting usage stats");
|
||||
return StatusCode(500, new
|
||||
{
|
||||
Success = false,
|
||||
Error = "獲取使用統計失敗",
|
||||
Timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#region 私有輔助方法
|
||||
|
||||
|
|
@ -841,7 +250,7 @@ public class AIController : ControllerBase
|
|||
Translation = card.Translation,
|
||||
Explanation = card.Definition, // 使用 AI 生成的定義作為解釋
|
||||
WordAnalysis = GenerateWordAnalysisForSentence(text),
|
||||
HighValueWords = GetHighValueWordsForSentence(text),
|
||||
HighValueWords = new string[0], // 移除高價值詞彙判定,由前端負責
|
||||
PhrasesDetected = new[]
|
||||
{
|
||||
new
|
||||
|
|
@ -906,7 +315,7 @@ public class AIController : ControllerBase
|
|||
Translation = translation,
|
||||
Explanation = explanation,
|
||||
WordAnalysis = GenerateWordAnalysisForSentence(text),
|
||||
HighValueWords = GetHighValueWordsForSentence(text),
|
||||
HighValueWords = new string[0], // 移除高價值詞彙判定,由前端負責
|
||||
PhrasesDetected = new[]
|
||||
{
|
||||
new
|
||||
|
|
@ -1068,8 +477,6 @@ public class AIController : ControllerBase
|
|||
var cleanWord = word.Trim();
|
||||
if (string.IsNullOrEmpty(cleanWord) || cleanWord.Length < 2) continue;
|
||||
|
||||
// 判斷是否為高價值詞彙
|
||||
var isHighValue = IsHighValueWordDynamic(cleanWord);
|
||||
var difficulty = GetWordDifficulty(cleanWord);
|
||||
|
||||
analysis[cleanWord] = new
|
||||
|
|
@ -1082,10 +489,7 @@ public class AIController : ControllerBase
|
|||
synonyms = GetSynonyms(cleanWord),
|
||||
antonyms = new string[0],
|
||||
isPhrase = false,
|
||||
isHighValue = isHighValue,
|
||||
learningPriority = isHighValue ? "high" : "low",
|
||||
difficultyLevel = difficulty,
|
||||
costIncurred = isHighValue ? 0 : 1
|
||||
difficultyLevel = difficulty
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1098,29 +502,9 @@ public class AIController : ControllerBase
|
|||
private string[] GetHighValueWordsForSentence(string text)
|
||||
{
|
||||
var words = text.ToLower().Split(new[] { ' ', '.', ',', '!', '?' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
return words.Where(w => IsHighValueWordDynamic(w.Trim())).ToArray();
|
||||
return new string[0]; // 移除高價值詞彙判定,由前端負責
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 動態判斷高價值詞彙
|
||||
/// </summary>
|
||||
private bool IsHighValueWordDynamic(string word)
|
||||
{
|
||||
// B1+ 詞彙或特殊概念詞彙視為高價值
|
||||
var highValueWords = new[]
|
||||
{
|
||||
"animals", "instincts", "safe", "food", "find",
|
||||
"brought", "meeting", "agreed", "thing",
|
||||
"study", "learn", "important", "necessary",
|
||||
"beautiful", "wonderful", "amazing",
|
||||
"problem", "solution", "different", "special",
|
||||
"cut", "slack", "job", "new", "think", "should",
|
||||
"ashamed", "mistake", "apologized", "felt",
|
||||
"strong", "wind", "knocked", "tree", "park"
|
||||
};
|
||||
|
||||
return highValueWords.Contains(word.ToLower());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 獲取詞彙翻譯
|
||||
|
|
@ -1146,6 +530,14 @@ public class AIController : ControllerBase
|
|||
"since" => "因為、自從",
|
||||
"he" => "他",
|
||||
"is" => "是",
|
||||
"company" => "公司",
|
||||
"offered" => "提供了",
|
||||
"bonus" => "獎金、紅利",
|
||||
"employees" => "員工",
|
||||
"wanted" => "想要",
|
||||
"even" => "甚至",
|
||||
"more" => "更多",
|
||||
"benefits" => "福利、好處",
|
||||
"new" => "新的",
|
||||
"job" => "工作",
|
||||
"think" => "認為",
|
||||
|
|
@ -1177,6 +569,12 @@ public class AIController : ControllerBase
|
|||
{
|
||||
return word.ToLower() switch
|
||||
{
|
||||
"company" => "A commercial business organization",
|
||||
"offered" => "Past tense of offer; to present something for acceptance",
|
||||
"bonus" => "An extra payment given in addition to regular salary",
|
||||
"employees" => "People who work for a company or organization",
|
||||
"wanted" => "Past tense of want; to desire or wish for something",
|
||||
"benefits" => "Advantages or helpful features provided by an employer",
|
||||
"animals" => "Living creatures that can move and feel",
|
||||
"instincts" => "Natural behavior that animals are born with",
|
||||
"safe" => "Not in danger; protected from harm",
|
||||
|
|
@ -1193,6 +591,12 @@ public class AIController : ControllerBase
|
|||
{
|
||||
return word.ToLower() switch
|
||||
{
|
||||
"company" => "noun",
|
||||
"offered" => "verb",
|
||||
"bonus" => "noun",
|
||||
"employees" => "noun",
|
||||
"wanted" => "verb",
|
||||
"benefits" => "noun",
|
||||
"animals" => "noun",
|
||||
"use" => "verb",
|
||||
"their" => "pronoun",
|
||||
|
|
@ -1213,12 +617,21 @@ public class AIController : ControllerBase
|
|||
{
|
||||
return word.ToLower() switch
|
||||
{
|
||||
// 你的例句詞彙
|
||||
"company" => new[] { "business", "corporation", "firm" },
|
||||
"offered" => new[] { "provided", "gave", "presented" },
|
||||
"bonus" => new[] { "reward", "incentive", "extra pay" },
|
||||
"employees" => new[] { "workers", "staff", "personnel" },
|
||||
"wanted" => new[] { "desired", "wished for", "sought" },
|
||||
"benefits" => new[] { "advantages", "perks", "rewards" },
|
||||
|
||||
// 原有詞彙
|
||||
"animals" => new[] { "creatures", "beings" },
|
||||
"instincts" => new[] { "intuition", "impulse" },
|
||||
"safe" => new[] { "secure", "protected" },
|
||||
"food" => new[] { "nourishment", "sustenance" },
|
||||
"find" => new[] { "locate", "discover" },
|
||||
_ => new[] { "synonym1", "synonym2" }
|
||||
_ => new string[0] // 返回空數組而不是無意義的文字
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1229,6 +642,12 @@ public class AIController : ControllerBase
|
|||
{
|
||||
return word.ToLower() switch
|
||||
{
|
||||
"company" => "A2",
|
||||
"offered" => "B1",
|
||||
"bonus" => "B2",
|
||||
"employees" => "B1",
|
||||
"wanted" => "A1",
|
||||
"benefits" => "B2",
|
||||
"animals" => "A2",
|
||||
"instincts" => "B2",
|
||||
"safe" => "A1",
|
||||
|
|
@ -1242,52 +661,80 @@ public class AIController : ControllerBase
|
|||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 生成模擬資料 (開發階段使用)
|
||||
/// 取得有學習價值的例句
|
||||
/// </summary>
|
||||
private List<GeneratedCard> GenerateMockCards(int count)
|
||||
private string GetQualityExampleSentence(string word)
|
||||
{
|
||||
var mockCards = new List<GeneratedCard>
|
||||
return word.ToLower() switch
|
||||
{
|
||||
new() {
|
||||
Word = "accomplish",
|
||||
PartOfSpeech = "verb",
|
||||
Pronunciation = "/əˈkʌmplɪʃ/",
|
||||
Translation = "完成、達成",
|
||||
Definition = "To finish something successfully or to achieve something",
|
||||
Synonyms = new() { "achieve", "complete" },
|
||||
Example = "She accomplished her goal of learning English.",
|
||||
ExampleTranslation = "她達成了學習英語的目標。",
|
||||
DifficultyLevel = "B1"
|
||||
},
|
||||
new() {
|
||||
Word = "negotiate",
|
||||
PartOfSpeech = "verb",
|
||||
Pronunciation = "/nɪˈɡəʊʃieɪt/",
|
||||
Translation = "協商、談判",
|
||||
Definition = "To discuss something with someone in order to reach an agreement",
|
||||
Synonyms = new() { "bargain", "discuss" },
|
||||
Example = "We need to negotiate a better deal.",
|
||||
ExampleTranslation = "我們需要協商一個更好的交易。",
|
||||
DifficultyLevel = "B2"
|
||||
},
|
||||
new() {
|
||||
Word = "perspective",
|
||||
PartOfSpeech = "noun",
|
||||
Pronunciation = "/pərˈspektɪv/",
|
||||
Translation = "觀點、看法",
|
||||
Definition = "A particular way of considering something",
|
||||
Synonyms = new() { "viewpoint", "opinion" },
|
||||
Example = "From my perspective, this is the best solution.",
|
||||
ExampleTranslation = "從我的觀點來看,這是最好的解決方案。",
|
||||
DifficultyLevel = "B2"
|
||||
}
|
||||
};
|
||||
// 商業職場詞彙
|
||||
"company" => "The tech company is hiring new software engineers.",
|
||||
"offered" => "She offered valuable advice during the meeting.",
|
||||
"bonus" => "Employees received a year-end bonus for excellent performance.",
|
||||
"employees" => "The company's employees work from home twice a week.",
|
||||
"benefits" => "Health insurance is one of the most important job benefits.",
|
||||
|
||||
return mockCards.Take(Math.Min(count, mockCards.Count)).ToList();
|
||||
// 動作動詞
|
||||
"wanted" => "He wanted to improve his English speaking skills.",
|
||||
|
||||
// 連接詞和修飾詞
|
||||
"even" => "Even experienced programmers make mistakes sometimes.",
|
||||
"more" => "We need more time to complete this project.",
|
||||
"but" => "The weather was cold, but we still went hiking.",
|
||||
|
||||
// 冠詞和基礎詞
|
||||
"the" => "The book on the table belongs to Sarah.",
|
||||
"a" => "She bought a new laptop for her studies.",
|
||||
|
||||
// 其他常見詞彙
|
||||
"brought" => "The new policy brought significant changes to our workflow.",
|
||||
"meeting" => "Our team meeting is scheduled for 3 PM tomorrow.",
|
||||
"agreed" => "All stakeholders agreed on the proposed budget.",
|
||||
|
||||
_ => $"Learning {word} is important for English proficiency."
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 取得例句的中文翻譯
|
||||
/// </summary>
|
||||
private string GetQualityExampleTranslation(string word)
|
||||
{
|
||||
return word.ToLower() switch
|
||||
{
|
||||
// 商業職場詞彙
|
||||
"company" => "這家科技公司正在招聘新的軟體工程師。",
|
||||
"offered" => "她在會議中提供了寶貴的建議。",
|
||||
"bonus" => "員工因優異的表現獲得年終獎金。",
|
||||
"employees" => "公司員工每週在家工作兩天。",
|
||||
"benefits" => "健康保險是最重要的工作福利之一。",
|
||||
|
||||
// 動作動詞
|
||||
"wanted" => "他想要提升自己的英語口說能力。",
|
||||
|
||||
// 連接詞和修飾詞
|
||||
"even" => "即使是有經驗的程式設計師有時也會犯錯。",
|
||||
"more" => "我們需要更多時間來完成這個專案。",
|
||||
"but" => "天氣很冷,但我們還是去爬山了。",
|
||||
|
||||
// 冠詞和基礎詞
|
||||
"the" => "桌上的書是莎拉的。",
|
||||
"a" => "她為了學習買了一台新筆電。",
|
||||
|
||||
// 其他常見詞彙
|
||||
"brought" => "新政策為我們的工作流程帶來了重大變化。",
|
||||
"meeting" => "我們的團隊會議安排在明天下午3點。",
|
||||
"agreed" => "所有利害關係人都同意提議的預算。",
|
||||
|
||||
_ => $"學習 {word} 對英語能力很重要。"
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
}
|
||||
|
||||
// Request DTOs
|
||||
|
|
@ -1304,16 +751,7 @@ public class SaveCardsRequest
|
|||
public List<GeneratedCard> SelectedCards { get; set; } = new();
|
||||
}
|
||||
|
||||
public class ValidateCardRequest
|
||||
{
|
||||
public Guid FlashcardId { get; set; }
|
||||
public Guid? ErrorReportId { get; set; }
|
||||
}
|
||||
|
||||
public class TestSaveCardsRequest
|
||||
{
|
||||
public List<GeneratedCard> SelectedCards { get; set; } = new();
|
||||
}
|
||||
|
||||
// 新增的API請求/響應 DTOs
|
||||
public class AnalyzeSentenceRequest
|
||||
|
|
@ -1324,12 +762,6 @@ public class AnalyzeSentenceRequest
|
|||
public string AnalysisMode { get; set; } = "full";
|
||||
}
|
||||
|
||||
public class QueryWordRequest
|
||||
{
|
||||
public string Word { get; set; } = string.Empty;
|
||||
public string Sentence { get; set; } = string.Empty;
|
||||
public Guid? AnalysisId { get; set; }
|
||||
}
|
||||
|
||||
public class GrammarCorrectionResult
|
||||
{
|
||||
|
|
|
|||
|
|
@ -322,6 +322,112 @@ public class FlashcardsController : ControllerBase
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("batch")]
|
||||
public async Task<ActionResult> BatchCreateFlashcards([FromBody] BatchCreateFlashcardsRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var userId = GetUserId();
|
||||
|
||||
if (request.Cards == null || !request.Cards.Any())
|
||||
return BadRequest(new { Success = false, Error = "No cards provided" });
|
||||
|
||||
if (request.Cards.Count > 50)
|
||||
return BadRequest(new { Success = false, Error = "Maximum 50 cards per batch" });
|
||||
|
||||
// 確定要使用的卡組ID
|
||||
Guid cardSetId;
|
||||
if (request.CardSetId.HasValue)
|
||||
{
|
||||
var cardSet = await _context.CardSets
|
||||
.FirstOrDefaultAsync(cs => cs.Id == request.CardSetId.Value && cs.UserId == userId);
|
||||
|
||||
if (cardSet == null)
|
||||
return NotFound(new { Success = false, Error = "Card set not found" });
|
||||
|
||||
cardSetId = request.CardSetId.Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
cardSetId = await GetOrCreateDefaultCardSetAsync(userId);
|
||||
}
|
||||
|
||||
var savedCards = new List<object>();
|
||||
var errors = new List<string>();
|
||||
|
||||
using var transaction = await _context.Database.BeginTransactionAsync();
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var cardRequest in request.Cards)
|
||||
{
|
||||
try
|
||||
{
|
||||
var flashcard = new Flashcard
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
CardSetId = cardSetId,
|
||||
Word = cardRequest.Word.Trim(),
|
||||
Translation = cardRequest.Translation.Trim(),
|
||||
Definition = cardRequest.Definition.Trim(),
|
||||
PartOfSpeech = cardRequest.PartOfSpeech?.Trim(),
|
||||
Pronunciation = cardRequest.Pronunciation?.Trim(),
|
||||
Example = cardRequest.Example?.Trim(),
|
||||
ExampleTranslation = cardRequest.ExampleTranslation?.Trim()
|
||||
};
|
||||
|
||||
_context.Flashcards.Add(flashcard);
|
||||
savedCards.Add(new
|
||||
{
|
||||
Id = flashcard.Id,
|
||||
Word = flashcard.Word,
|
||||
Translation = flashcard.Translation
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"Failed to save card '{cardRequest.Word}': {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
await transaction.CommitAsync();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Success = true,
|
||||
Data = new
|
||||
{
|
||||
SavedCards = savedCards,
|
||||
SavedCount = savedCards.Count,
|
||||
ErrorCount = errors.Count,
|
||||
Errors = errors
|
||||
},
|
||||
Message = $"Successfully saved {savedCards.Count} flashcards"
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return Unauthorized(new { Success = false, Error = "Unauthorized" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new
|
||||
{
|
||||
Success = false,
|
||||
Error = "Failed to create flashcards",
|
||||
Timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DTOs
|
||||
|
|
@ -347,4 +453,10 @@ public class UpdateFlashcardRequest
|
|||
public string? Example { get; set; }
|
||||
public string? ExampleTranslation { get; set; }
|
||||
public bool? IsFavorite { get; set; }
|
||||
}
|
||||
|
||||
public class BatchCreateFlashcardsRequest
|
||||
{
|
||||
public Guid? CardSetId { get; set; }
|
||||
public List<CreateFlashcardRequest> Cards { get; set; } = new();
|
||||
}
|
||||
|
|
@ -56,4 +56,62 @@ public static class CEFRLevelService
|
|||
var nextIndex = Math.Min(currentIndex + steps, Levels.Length - 1);
|
||||
return Levels[nextIndex];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 取得所有有效的CEFR等級
|
||||
/// </summary>
|
||||
/// <returns>CEFR等級數組</returns>
|
||||
public static string[] GetAllLevels()
|
||||
{
|
||||
return (string[])Levels.Clone();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 驗證CEFR等級是否有效
|
||||
/// </summary>
|
||||
/// <param name="level">要驗證的等級</param>
|
||||
/// <returns>是否為有效等級</returns>
|
||||
public static bool IsValidLevel(string level)
|
||||
{
|
||||
return !string.IsNullOrEmpty(level) &&
|
||||
Array.IndexOf(Levels, level.ToUpper()) != -1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 取得等級的描述
|
||||
/// </summary>
|
||||
/// <param name="level">CEFR等級</param>
|
||||
/// <returns>等級描述</returns>
|
||||
public static string GetLevelDescription(string level)
|
||||
{
|
||||
return level.ToUpper() switch
|
||||
{
|
||||
"A1" => "初學者 - 能理解基本詞彙和簡單句子",
|
||||
"A2" => "基礎 - 能處理日常對話和常見主題",
|
||||
"B1" => "中級 - 能理解清楚標準語言的要點",
|
||||
"B2" => "中高級 - 能理解複雜文本的主要內容",
|
||||
"C1" => "高級 - 能流利表達,理解含蓄意思",
|
||||
"C2" => "精通 - 接近母語水平",
|
||||
_ => "未知等級"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 取得等級的範例詞彙
|
||||
/// </summary>
|
||||
/// <param name="level">CEFR等級</param>
|
||||
/// <returns>範例詞彙數組</returns>
|
||||
public static string[] GetLevelExamples(string level)
|
||||
{
|
||||
return level.ToUpper() switch
|
||||
{
|
||||
"A1" => new[] { "hello", "good", "house", "eat", "happy" },
|
||||
"A2" => new[] { "important", "difficult", "interesting", "beautiful", "understand" },
|
||||
"B1" => new[] { "analyze", "opportunity", "environment", "responsibility", "development" },
|
||||
"B2" => new[] { "sophisticated", "implications", "comprehensive", "substantial", "methodology" },
|
||||
"C1" => new[] { "meticulous", "predominantly", "intricate", "corroborate", "paradigm" },
|
||||
"C2" => new[] { "ubiquitous", "ephemeral", "perspicacious", "multifarious", "idiosyncratic" },
|
||||
_ => new[] { "example" }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -7,9 +7,7 @@ namespace DramaLing.Api.Services;
|
|||
public interface IGeminiService
|
||||
{
|
||||
Task<List<GeneratedCard>> GenerateCardsAsync(string inputText, string extractionType, int cardCount);
|
||||
Task<ValidationResult> ValidateCardAsync(Flashcard card);
|
||||
Task<SentenceAnalysisResponse> AnalyzeSentenceAsync(string inputText, string userLevel = "A2");
|
||||
Task<WordAnalysisResult> AnalyzeWordAsync(string word, string sentence);
|
||||
}
|
||||
|
||||
public class GeminiService : IGeminiService
|
||||
|
|
@ -50,7 +48,7 @@ public class GeminiService : IGeminiService
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// 真正的句子分析和翻譯 - 調用 Gemini AI
|
||||
/// 句子分析和翻譯 - 調用 Gemini AI
|
||||
/// </summary>
|
||||
public async Task<SentenceAnalysisResponse> AnalyzeSentenceAsync(string inputText, string userLevel = "A2")
|
||||
{
|
||||
|
|
@ -87,7 +85,9 @@ public class GeminiService : IGeminiService
|
|||
""partOfSpeech"": ""詞性"",
|
||||
""pronunciation"": ""音標"",
|
||||
""isHighValue"": true,
|
||||
""difficultyLevel"": ""CEFR等級""
|
||||
""difficultyLevel"": ""CEFR等級"",
|
||||
""example"": ""實用的例句展示該詞彙的真實用法"",
|
||||
""exampleTranslation"": ""例句的自然中文翻譯""
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
|
|
@ -95,8 +95,16 @@ public class GeminiService : IGeminiService
|
|||
要求:
|
||||
1. 翻譯要自然流暢,符合中文語法
|
||||
2. **基於學習者程度({userLevel}),標記 {targetRange} 等級的詞彙為重點學習**
|
||||
3. 如有語法錯誤請指出並修正
|
||||
4. 確保JSON格式正確
|
||||
3. **為每個詞彙提供實用的例句,展示真實語境和用法**
|
||||
4. **例句要有學習價值,避免簡單重複的句型**
|
||||
5. 如有語法錯誤請指出並修正
|
||||
6. 確保JSON格式正確
|
||||
|
||||
例句要求:
|
||||
- 使用真實場景(工作、學習、日常生活)
|
||||
- 展示詞彙的實際搭配和用法
|
||||
- 適合學習者程度,不要太簡單或太複雜
|
||||
- 中文翻譯要自然流暢
|
||||
|
||||
重點學習判定邏輯:
|
||||
- 學習者程度: {userLevel}
|
||||
|
|
@ -116,81 +124,7 @@ public class GeminiService : IGeminiService
|
|||
}
|
||||
}
|
||||
|
||||
public async Task<WordAnalysisResult> AnalyzeWordAsync(string word, string sentence)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(_apiKey) || _apiKey == "your-gemini-api-key-here")
|
||||
{
|
||||
throw new InvalidOperationException("Gemini API key not configured");
|
||||
}
|
||||
|
||||
var prompt = $@"
|
||||
請分析單字 ""{word}"" 在句子 ""{sentence}"" 中的詳細資訊:
|
||||
|
||||
單字: {word}
|
||||
語境: {sentence}
|
||||
|
||||
請以JSON格式回應,不要包含任何其他文字:
|
||||
{{
|
||||
""word"": ""{word}"",
|
||||
""translation"": ""繁體中文翻譯"",
|
||||
""definition"": ""英文定義"",
|
||||
""partOfSpeech"": ""詞性(n./v./adj./adv.等)"",
|
||||
""pronunciation"": ""IPA音標"",
|
||||
""difficultyLevel"": ""CEFR等級(A1/A2/B1/B2/C1/C2)"",
|
||||
""isHighValue"": false,
|
||||
""contextMeaning"": ""在此句子中的具體含義""
|
||||
}}
|
||||
|
||||
要求:
|
||||
1. 翻譯要準確自然
|
||||
2. 定義要簡潔易懂
|
||||
3. 音標使用標準IPA格式
|
||||
4. 根據語境判斷詞性和含義
|
||||
";
|
||||
|
||||
var response = await CallGeminiApiAsync(prompt);
|
||||
return ParseWordAnalysisResponse(response, word);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error analyzing word with Gemini API");
|
||||
|
||||
// 回退到基本資料
|
||||
return new WordAnalysisResult
|
||||
{
|
||||
Word = word,
|
||||
Translation = $"{word} (AI 暫時不可用)",
|
||||
Definition = $"Definition of {word} (service temporarily unavailable)",
|
||||
PartOfSpeech = "unknown",
|
||||
Pronunciation = $"/{word}/",
|
||||
DifficultyLevel = "unknown",
|
||||
IsHighValue = false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ValidationResult> ValidateCardAsync(Flashcard card)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(_apiKey) || _apiKey == "your-gemini-api-key-here")
|
||||
{
|
||||
throw new InvalidOperationException("Gemini API key not configured");
|
||||
}
|
||||
|
||||
var prompt = BuildValidationPrompt(card);
|
||||
var response = await CallGeminiApiAsync(prompt);
|
||||
|
||||
return ParseValidationResult(response);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error validating card with Gemini API");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private string BuildPrompt(string inputText, string extractionType, int cardCount)
|
||||
{
|
||||
|
|
@ -201,16 +135,6 @@ public class GeminiService : IGeminiService
|
|||
.Replace("{inputText}", inputText);
|
||||
}
|
||||
|
||||
private string BuildValidationPrompt(Flashcard card)
|
||||
{
|
||||
return CardValidationPrompt
|
||||
.Replace("{word}", card.Word)
|
||||
.Replace("{translation}", card.Translation)
|
||||
.Replace("{definition}", card.Definition)
|
||||
.Replace("{partOfSpeech}", card.PartOfSpeech ?? "")
|
||||
.Replace("{pronunciation}", card.Pronunciation ?? "")
|
||||
.Replace("{example}", card.Example ?? "");
|
||||
}
|
||||
|
||||
private async Task<string> CallGeminiApiAsync(string prompt)
|
||||
{
|
||||
|
|
@ -314,92 +238,7 @@ public class GeminiService : IGeminiService
|
|||
}
|
||||
}
|
||||
|
||||
private ValidationResult ParseValidationResult(string response)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cleanText = response.Trim().Replace("```json", "").Replace("```", "").Trim();
|
||||
var jsonResponse = JsonSerializer.Deserialize<JsonElement>(cleanText);
|
||||
|
||||
var issues = new List<ValidationIssue>();
|
||||
if (jsonResponse.TryGetProperty("issues", out var issuesElement))
|
||||
{
|
||||
foreach (var issueElement in issuesElement.EnumerateArray())
|
||||
{
|
||||
issues.Add(new ValidationIssue
|
||||
{
|
||||
Field = GetStringProperty(issueElement, "field"),
|
||||
Original = GetStringProperty(issueElement, "original"),
|
||||
Corrected = GetStringProperty(issueElement, "corrected"),
|
||||
Reason = GetStringProperty(issueElement, "reason"),
|
||||
Severity = GetStringProperty(issueElement, "severity")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var suggestions = new List<string>();
|
||||
if (jsonResponse.TryGetProperty("suggestions", out var suggestionsElement))
|
||||
{
|
||||
foreach (var suggestion in suggestionsElement.EnumerateArray())
|
||||
{
|
||||
suggestions.Add(suggestion.GetString() ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
return new ValidationResult
|
||||
{
|
||||
Issues = issues,
|
||||
Suggestions = suggestions,
|
||||
OverallScore = jsonResponse.TryGetProperty("overall_score", out var scoreElement)
|
||||
? scoreElement.GetInt32() : 85,
|
||||
Confidence = jsonResponse.TryGetProperty("confidence", out var confidenceElement)
|
||||
? confidenceElement.GetDouble() : 0.9
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error parsing validation result: {Response}", response);
|
||||
throw new InvalidOperationException($"Failed to parse validation response: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析 Gemini AI 詞彙分析響應
|
||||
/// </summary>
|
||||
private WordAnalysisResult ParseWordAnalysisResponse(string response, string word)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cleanText = response.Trim().Replace("```json", "").Replace("```", "").Trim();
|
||||
var jsonResponse = JsonSerializer.Deserialize<JsonElement>(cleanText);
|
||||
|
||||
return new WordAnalysisResult
|
||||
{
|
||||
Word = GetStringProperty(jsonResponse, "word") ?? word,
|
||||
Translation = GetStringProperty(jsonResponse, "translation") ?? $"{word} 的翻譯",
|
||||
Definition = GetStringProperty(jsonResponse, "definition") ?? $"Definition of {word}",
|
||||
PartOfSpeech = GetStringProperty(jsonResponse, "partOfSpeech") ?? "unknown",
|
||||
Pronunciation = GetStringProperty(jsonResponse, "pronunciation") ?? $"/{word}/",
|
||||
DifficultyLevel = GetStringProperty(jsonResponse, "difficultyLevel") ?? "A1",
|
||||
IsHighValue = jsonResponse.TryGetProperty("isHighValue", out var isHighValueElement) && isHighValueElement.GetBoolean()
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to parse word analysis response");
|
||||
|
||||
return new WordAnalysisResult
|
||||
{
|
||||
Word = word,
|
||||
Translation = $"{word} (解析失敗)",
|
||||
Definition = $"Definition of {word} (parsing failed)",
|
||||
PartOfSpeech = "unknown",
|
||||
Pronunciation = $"/{word}/",
|
||||
DifficultyLevel = "unknown",
|
||||
IsHighValue = false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析 Gemini AI 句子分析響應
|
||||
|
|
@ -456,7 +295,9 @@ public class GeminiService : IGeminiService
|
|||
PartOfSpeech = GetStringProperty(analysis, "partOfSpeech"),
|
||||
Pronunciation = GetStringProperty(analysis, "pronunciation"),
|
||||
IsHighValue = analysis.TryGetProperty("isHighValue", out var isHighValueElement) && isHighValueElement.GetBoolean(),
|
||||
DifficultyLevel = GetStringProperty(analysis, "difficultyLevel")
|
||||
DifficultyLevel = GetStringProperty(analysis, "difficultyLevel"),
|
||||
Example = GetStringProperty(analysis, "example"),
|
||||
ExampleTranslation = GetStringProperty(analysis, "exampleTranslation")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -550,23 +391,6 @@ public class GeminiService : IGeminiService
|
|||
|
||||
請按照相同的 JSON 格式回應...";
|
||||
|
||||
private const string CardValidationPrompt = @"
|
||||
請檢查以下詞卡內容的準確性:
|
||||
|
||||
單字: {word}
|
||||
翻譯: {translation}
|
||||
定義: {definition}
|
||||
詞性: {partOfSpeech}
|
||||
發音: {pronunciation}
|
||||
例句: {example}
|
||||
|
||||
請按照以下 JSON 格式回應:
|
||||
{
|
||||
""issues"": [],
|
||||
""suggestions"": [],
|
||||
""overall_score"": 85,
|
||||
""confidence"": 0.9
|
||||
}";
|
||||
}
|
||||
|
||||
// 支援類型
|
||||
|
|
@ -583,22 +407,7 @@ public class GeneratedCard
|
|||
public string DifficultyLevel { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class ValidationResult
|
||||
{
|
||||
public List<ValidationIssue> Issues { get; set; } = new();
|
||||
public List<string> Suggestions { get; set; } = new();
|
||||
public int OverallScore { get; set; }
|
||||
public double Confidence { get; set; }
|
||||
}
|
||||
|
||||
public class ValidationIssue
|
||||
{
|
||||
public string Field { get; set; } = string.Empty;
|
||||
public string Original { get; set; } = string.Empty;
|
||||
public string Corrected { get; set; } = string.Empty;
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
public string Severity { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
// 新增句子分析相關類型
|
||||
public class SentenceAnalysisResponse
|
||||
|
|
@ -619,6 +428,8 @@ public class WordAnalysisResult
|
|||
public string Pronunciation { get; set; } = string.Empty;
|
||||
public bool IsHighValue { get; set; }
|
||||
public string DifficultyLevel { get; set; } = string.Empty;
|
||||
public string? Example { get; set; }
|
||||
public string? ExampleTranslation { get; set; }
|
||||
}
|
||||
|
||||
public class GrammarCorrectionResult
|
||||
|
|
|
|||
|
|
@ -1,100 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
export default function DebugPage() {
|
||||
const [input, setInput] = useState('She felt ashamed of her mistake and apologized.')
|
||||
const [response, setResponse] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const testDirectApi = async () => {
|
||||
setLoading(true)
|
||||
setResponse('測試中...')
|
||||
|
||||
try {
|
||||
console.log('=== 開始API測試 ===')
|
||||
console.log('輸入:', input)
|
||||
|
||||
const apiResponse = await fetch('http://localhost:5000/api/ai/analyze-sentence', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
inputText: input,
|
||||
forceRefresh: true
|
||||
})
|
||||
})
|
||||
|
||||
console.log('API狀態:', apiResponse.status)
|
||||
|
||||
if (!apiResponse.ok) {
|
||||
throw new Error(`API錯誤: ${apiResponse.status}`)
|
||||
}
|
||||
|
||||
const data = await apiResponse.json()
|
||||
console.log('API回應:', data)
|
||||
|
||||
// 直接顯示結果,不經過複雜的狀態管理
|
||||
const translation = data.data?.sentenceMeaning?.translation || '無翻譯'
|
||||
const explanation = data.data?.sentenceMeaning?.explanation || '無解釋'
|
||||
const highValueWords = data.data?.highValueWords || []
|
||||
|
||||
setResponse(`
|
||||
✅ API調用成功
|
||||
|
||||
📖 翻譯: ${translation}
|
||||
|
||||
📝 解釋: ${explanation}
|
||||
|
||||
⭐ 高價值詞彙: ${JSON.stringify(highValueWords)}
|
||||
|
||||
🔍 完整響應: ${JSON.stringify(data, null, 2)}
|
||||
`)
|
||||
} catch (error) {
|
||||
console.error('API錯誤:', error)
|
||||
setResponse(`❌ 錯誤: ${error}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-4xl mx-auto">
|
||||
<h1 className="text-2xl font-bold mb-6">🐛 API 調試頁面</h1>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">測試句子:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
className="w-full p-3 border border-gray-300 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={testDirectApi}
|
||||
disabled={loading}
|
||||
className="bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? '測試中...' : '🔍 直接測試 API'}
|
||||
</button>
|
||||
|
||||
<div className="bg-gray-100 p-4 rounded-lg">
|
||||
<h3 className="font-bold mb-2">測試結果:</h3>
|
||||
<pre className="text-sm whitespace-pre-wrap">{response || '點擊按鈕開始測試'}</pre>
|
||||
</div>
|
||||
|
||||
<div className="bg-yellow-100 p-4 rounded-lg">
|
||||
<h3 className="font-bold mb-2">💡 測試說明:</h3>
|
||||
<p className="text-sm">
|
||||
這個頁面直接調用API,不經過複雜的狀態管理邏輯。
|
||||
如果這裡能正常顯示結果,說明問題出在其他頁面的前端邏輯。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,572 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ClickableTextV2 } from '@/components/ClickableTextV2'
|
||||
|
||||
export default function DemoV2Page() {
|
||||
const [mode, setMode] = useState<'manual' | 'screenshot'>('manual')
|
||||
const [textInput, setTextInput] = useState('')
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false)
|
||||
const [showAnalysisView, setShowAnalysisView] = useState(false)
|
||||
const [sentenceAnalysis, setSentenceAnalysis] = useState<any>(null)
|
||||
const [sentenceMeaning, setSentenceMeaning] = useState('')
|
||||
const [usageCount, setUsageCount] = useState(0)
|
||||
const [isPremium] = useState(false)
|
||||
|
||||
// 模擬分析後的句子資料(新版本)
|
||||
const mockSentenceAnalysis = {
|
||||
meaning: "他在我們的會議中提出了這件事,但沒有人同意。這句話表達了在會議中有人提出某個議題或想法,但得不到其他與會者的認同。",
|
||||
highValueWords: ["brought", "up", "meeting", "agreed"], // 高價值詞彙
|
||||
phrasesDetected: [
|
||||
{
|
||||
phrase: "bring up",
|
||||
words: ["brought", "up"],
|
||||
colorCode: "#F59E0B"
|
||||
}
|
||||
],
|
||||
words: {
|
||||
"he": {
|
||||
word: "he",
|
||||
translation: "他",
|
||||
definition: "Used to refer to a male person or animal",
|
||||
partOfSpeech: "pronoun",
|
||||
pronunciation: "/hiː/",
|
||||
synonyms: ["him", "that man"],
|
||||
antonyms: [],
|
||||
isPhrase: false,
|
||||
isHighValue: false, // 低價值詞彙
|
||||
learningPriority: "low",
|
||||
difficultyLevel: "A1",
|
||||
costIncurred: 1
|
||||
},
|
||||
"brought": {
|
||||
word: "brought",
|
||||
translation: "帶來、提出",
|
||||
definition: "Past tense of bring; to take or carry something to a place",
|
||||
partOfSpeech: "verb",
|
||||
pronunciation: "/brɔːt/",
|
||||
synonyms: ["carried", "took", "delivered"],
|
||||
antonyms: ["removed", "took away"],
|
||||
isPhrase: true,
|
||||
isHighValue: true, // 高價值片語
|
||||
learningPriority: "high",
|
||||
phraseInfo: {
|
||||
phrase: "bring up",
|
||||
meaning: "提出(話題)、養育",
|
||||
warning: "在這個句子中,\"brought up\" 是一個片語,意思是\"提出話題\",而不是單純的\"帶來\"",
|
||||
colorCode: "#F59E0B"
|
||||
},
|
||||
difficultyLevel: "B1",
|
||||
costIncurred: 0 // 高價值免費
|
||||
},
|
||||
"this": {
|
||||
word: "this",
|
||||
translation: "這個",
|
||||
definition: "Used to indicate something near or just mentioned",
|
||||
partOfSpeech: "pronoun",
|
||||
pronunciation: "/ðɪs/",
|
||||
synonyms: ["that", "it"],
|
||||
antonyms: [],
|
||||
isPhrase: false,
|
||||
isHighValue: false, // 低價值詞彙
|
||||
learningPriority: "low",
|
||||
difficultyLevel: "A1",
|
||||
costIncurred: 1
|
||||
},
|
||||
"thing": {
|
||||
word: "thing",
|
||||
translation: "事情、東西",
|
||||
definition: "An object, fact, or situation",
|
||||
partOfSpeech: "noun",
|
||||
pronunciation: "/θɪŋ/",
|
||||
synonyms: ["object", "matter", "item"],
|
||||
antonyms: [],
|
||||
isPhrase: false,
|
||||
isHighValue: false, // 低價值詞彙
|
||||
learningPriority: "low",
|
||||
difficultyLevel: "A1",
|
||||
costIncurred: 1
|
||||
},
|
||||
"up": {
|
||||
word: "up",
|
||||
translation: "向上",
|
||||
definition: "Toward a higher place or position",
|
||||
partOfSpeech: "adverb",
|
||||
pronunciation: "/ʌp/",
|
||||
synonyms: ["upward", "above"],
|
||||
antonyms: ["down", "below"],
|
||||
isPhrase: true,
|
||||
isHighValue: true, // 高價值片語部分
|
||||
learningPriority: "high",
|
||||
phraseInfo: {
|
||||
phrase: "bring up",
|
||||
meaning: "提出(話題)、養育",
|
||||
warning: "\"up\" 在這裡是片語 \"bring up\" 的一部分,不是單獨的\"向上\"的意思",
|
||||
colorCode: "#F59E0B"
|
||||
},
|
||||
difficultyLevel: "B1",
|
||||
costIncurred: 0 // 高價值免費
|
||||
},
|
||||
"during": {
|
||||
word: "during",
|
||||
translation: "在...期間",
|
||||
definition: "Throughout the course or duration of",
|
||||
partOfSpeech: "preposition",
|
||||
pronunciation: "/ˈdjʊərɪŋ/",
|
||||
synonyms: ["throughout", "while"],
|
||||
antonyms: [],
|
||||
isPhrase: false,
|
||||
isHighValue: false, // 低價值詞彙
|
||||
learningPriority: "low",
|
||||
difficultyLevel: "A2",
|
||||
costIncurred: 1
|
||||
},
|
||||
"our": {
|
||||
word: "our",
|
||||
translation: "我們的",
|
||||
definition: "Belonging to us",
|
||||
partOfSpeech: "pronoun",
|
||||
pronunciation: "/aʊər/",
|
||||
synonyms: ["ours"],
|
||||
antonyms: [],
|
||||
isPhrase: false,
|
||||
isHighValue: false, // 低價值詞彙
|
||||
learningPriority: "low",
|
||||
difficultyLevel: "A1",
|
||||
costIncurred: 1
|
||||
},
|
||||
"meeting": {
|
||||
word: "meeting",
|
||||
translation: "會議",
|
||||
definition: "An organized gathering of people for discussion",
|
||||
partOfSpeech: "noun",
|
||||
pronunciation: "/ˈmiːtɪŋ/",
|
||||
synonyms: ["conference", "assembly", "gathering"],
|
||||
antonyms: [],
|
||||
isPhrase: false,
|
||||
isHighValue: true, // 高價值單字(B2級)
|
||||
learningPriority: "high",
|
||||
difficultyLevel: "B2",
|
||||
costIncurred: 0 // 高價值免費
|
||||
},
|
||||
"and": {
|
||||
word: "and",
|
||||
translation: "和、而且",
|
||||
definition: "Used to connect words or clauses",
|
||||
partOfSpeech: "conjunction",
|
||||
pronunciation: "/ænd/",
|
||||
synonyms: ["plus", "also"],
|
||||
antonyms: [],
|
||||
isPhrase: false,
|
||||
isHighValue: false, // 低價值詞彙
|
||||
learningPriority: "low",
|
||||
difficultyLevel: "A1",
|
||||
costIncurred: 1
|
||||
},
|
||||
"no": {
|
||||
word: "no",
|
||||
translation: "沒有",
|
||||
definition: "Not any; not one",
|
||||
partOfSpeech: "determiner",
|
||||
pronunciation: "/nəʊ/",
|
||||
synonyms: ["none", "zero"],
|
||||
antonyms: ["some", "any"],
|
||||
isPhrase: false,
|
||||
isHighValue: false, // 低價值詞彙
|
||||
learningPriority: "low",
|
||||
difficultyLevel: "A1",
|
||||
costIncurred: 1
|
||||
},
|
||||
"one": {
|
||||
word: "one",
|
||||
translation: "一個人、任何人",
|
||||
definition: "A single person or thing",
|
||||
partOfSpeech: "pronoun",
|
||||
pronunciation: "/wʌn/",
|
||||
synonyms: ["someone", "anybody"],
|
||||
antonyms: ["none", "nobody"],
|
||||
isPhrase: false,
|
||||
isHighValue: false, // 低價值詞彙
|
||||
learningPriority: "low",
|
||||
difficultyLevel: "A1",
|
||||
costIncurred: 1
|
||||
},
|
||||
"agreed": {
|
||||
word: "agreed",
|
||||
translation: "同意",
|
||||
definition: "Past tense of agree; to have the same opinion",
|
||||
partOfSpeech: "verb",
|
||||
pronunciation: "/əˈɡriːd/",
|
||||
synonyms: ["consented", "accepted", "approved"],
|
||||
antonyms: ["disagreed", "refused"],
|
||||
isPhrase: false,
|
||||
isHighValue: true, // 高價值單字(B1級)
|
||||
learningPriority: "medium",
|
||||
difficultyLevel: "B1",
|
||||
costIncurred: 0 // 高價值免費
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 處理句子分析
|
||||
const handleAnalyzeSentence = async () => {
|
||||
if (!textInput.trim()) return
|
||||
|
||||
// 檢查使用次數限制
|
||||
if (!isPremium && usageCount >= 5) {
|
||||
alert('❌ 免費用戶 3 小時內只能分析 5 次句子,請稍後再試或升級到付費版本')
|
||||
return
|
||||
}
|
||||
|
||||
setIsAnalyzing(true)
|
||||
|
||||
try {
|
||||
// 模擬 API 調用
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
|
||||
setSentenceAnalysis(mockSentenceAnalysis.words)
|
||||
setSentenceMeaning(mockSentenceAnalysis.meaning)
|
||||
setShowAnalysisView(true)
|
||||
setUsageCount(prev => prev + 1) // 句子分析扣除1次
|
||||
} catch (error) {
|
||||
console.error('Error analyzing sentence:', error)
|
||||
alert('分析句子時發生錯誤,請稍後再試')
|
||||
} finally {
|
||||
setIsAnalyzing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getHighValueCount = () => {
|
||||
if (!sentenceAnalysis) return 0
|
||||
return Object.values(sentenceAnalysis).filter((word: any) => word.isHighValue).length
|
||||
}
|
||||
|
||||
const getLowValueCount = () => {
|
||||
if (!sentenceAnalysis) return 0
|
||||
return Object.values(sentenceAnalysis).filter((word: any) => !word.isHighValue).length
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Navigation */}
|
||||
<nav className="bg-white shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between h-16">
|
||||
<div className="flex items-center">
|
||||
<h1 className="text-2xl font-bold text-blue-600">DramaLing</h1>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-gray-600">高價值標記演示 v2.0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{!showAnalysisView ? (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-8">AI 智能生成詞卡 - 高價值標記系統</h1>
|
||||
|
||||
{/* 功能說明 */}
|
||||
<div className="bg-gradient-to-r from-blue-50 to-purple-50 rounded-xl p-6 mb-6 border border-blue-200">
|
||||
<h2 className="text-lg font-semibold mb-3 text-blue-800">🎯 高價值標記系統特色</h2>
|
||||
<div className="grid md:grid-cols-2 gap-4 text-sm">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 bg-yellow-400 border-2 border-yellow-500 rounded"></div>
|
||||
<span><strong>高價值片語</strong> - 免費無限點擊</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 bg-green-400 border-2 border-green-500 rounded"></div>
|
||||
<span><strong>高價值單字</strong> - 免費無限點擊</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 border-b-2 border-blue-400"></div>
|
||||
<span><strong>普通單字</strong> - 點擊扣除 1 次額度</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">⭐</span>
|
||||
<span><strong>星號標記</strong> - 高學習價值指標</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Input Mode Selection */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold mb-4">原始例句類型</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<button
|
||||
onClick={() => setMode('manual')}
|
||||
className={`p-4 rounded-lg border-2 transition-all ${
|
||||
mode === 'manual'
|
||||
? 'border-blue-600 bg-blue-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="text-2xl mb-2">✍️</div>
|
||||
<div className="font-semibold">手動輸入</div>
|
||||
<div className="text-sm text-gray-600 mt-1">貼上或輸入英文文本</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode('screenshot')}
|
||||
disabled={!isPremium}
|
||||
className={`p-4 rounded-lg border-2 transition-all relative ${
|
||||
mode === 'screenshot'
|
||||
? 'border-blue-600 bg-blue-50'
|
||||
: isPremium
|
||||
? 'border-gray-200 hover:border-gray-300'
|
||||
: 'border-gray-200 bg-gray-100 cursor-not-allowed opacity-60'
|
||||
}`}
|
||||
>
|
||||
<div className="text-2xl mb-2">📷</div>
|
||||
<div className="font-semibold">影劇截圖</div>
|
||||
<div className="text-sm text-gray-600 mt-1">上傳影劇截圖 (Phase 2)</div>
|
||||
{!isPremium && (
|
||||
<div className="absolute top-2 right-2 px-2 py-1 bg-yellow-100 text-yellow-700 text-xs rounded-full">
|
||||
訂閱功能
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Input */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-6 mb-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-4">輸入英文文本</h2>
|
||||
<textarea
|
||||
value={textInput}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
if (mode === 'manual' && value.length > 50) {
|
||||
return // 阻止輸入超過50字
|
||||
}
|
||||
setTextInput(value)
|
||||
}}
|
||||
placeholder={mode === 'manual'
|
||||
? "輸入英文句子(最多50字)..."
|
||||
: "貼上您想要學習的英文文本,例如影劇對話、文章段落..."
|
||||
}
|
||||
className={`w-full h-40 px-4 py-3 border rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-transparent outline-none resize-none ${
|
||||
mode === 'manual' && textInput.length >= 45 ? 'border-yellow-400' :
|
||||
mode === 'manual' && textInput.length >= 50 ? 'border-red-400' : 'border-gray-300'
|
||||
}`}
|
||||
/>
|
||||
<div className="mt-2 flex justify-between text-sm">
|
||||
<span className={`${
|
||||
mode === 'manual' && textInput.length >= 45 ? 'text-yellow-600' :
|
||||
mode === 'manual' && textInput.length >= 50 ? 'text-red-600' : 'text-gray-600'
|
||||
}`}>
|
||||
{mode === 'manual' ? `最多 50 字元 • 目前:${textInput.length} 字元` : `最多 5000 字元 • 目前:${textInput.length} 字元`}
|
||||
</span>
|
||||
{mode === 'manual' && textInput.length > 40 && (
|
||||
<span className={textInput.length >= 50 ? 'text-red-600' : 'text-yellow-600'}>
|
||||
{textInput.length >= 50 ? '已達上限!' : `還可輸入 ${50 - textInput.length} 字元`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 預設示例 */}
|
||||
{!textInput && (
|
||||
<div className="mt-4 p-3 bg-gray-50 rounded-lg">
|
||||
<div className="text-sm text-gray-700 mb-2">
|
||||
<strong>示例句子:</strong>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setTextInput("He brought this thing up during our meeting and no one agreed.")}
|
||||
className="text-sm text-blue-600 hover:text-blue-800 bg-blue-50 px-3 py-1 rounded border border-blue-200"
|
||||
>
|
||||
點擊使用示例:He brought this thing up during our meeting and no one agreed.
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 新的按鈕區域 */}
|
||||
<div className="space-y-4">
|
||||
{/* 分析句子按鈕 */}
|
||||
<button
|
||||
onClick={handleAnalyzeSentence}
|
||||
disabled={isAnalyzing || (mode === 'manual' && (!textInput || textInput.length > 50)) || (mode === 'screenshot')}
|
||||
className="w-full bg-blue-600 text-white py-4 rounded-lg font-semibold hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isAnalyzing ? (
|
||||
<span className="flex items-center justify-center">
|
||||
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
正在識別高價值詞彙...
|
||||
</span>
|
||||
) : (
|
||||
'🔍 分析句子並標記高價值詞彙'
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 使用次數顯示 */}
|
||||
<div className="text-center text-sm text-gray-600">
|
||||
{isPremium ? (
|
||||
<span className="text-green-600">🌟 付費用戶:無限制使用</span>
|
||||
) : (
|
||||
<span className={usageCount >= 4 ? 'text-red-600' : usageCount >= 3 ? 'text-yellow-600' : 'text-gray-600'}>
|
||||
免費用戶:已使用 {usageCount}/5 次 (3小時內)
|
||||
{usageCount >= 5 && <span className="block text-red-500 mt-1">已達上限,請稍後再試</span>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* 句子分析視圖 */
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-3xl font-bold">句子分析結果 - 高價值標記</h1>
|
||||
<button
|
||||
onClick={() => setShowAnalysisView(false)}
|
||||
className="text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
← 返回
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 分析統計 */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold mb-4">分析統計</h2>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="text-center p-3 bg-green-50 rounded-lg">
|
||||
<div className="text-2xl font-bold text-green-600">{getHighValueCount()}</div>
|
||||
<div className="text-sm text-green-700">高價值詞彙</div>
|
||||
<div className="text-xs text-green-600">⭐ 免費點擊</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-blue-50 rounded-lg">
|
||||
<div className="text-2xl font-bold text-blue-600">{getLowValueCount()}</div>
|
||||
<div className="text-sm text-blue-700">普通詞彙</div>
|
||||
<div className="text-xs text-blue-600">💰 點擊收費</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-gray-50 rounded-lg">
|
||||
<div className="text-2xl font-bold text-gray-600">1</div>
|
||||
<div className="text-sm text-gray-700">本次消耗</div>
|
||||
<div className="text-xs text-gray-600">🔍 句子分析</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 原始句子顯示 */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold mb-4">原始句子</h2>
|
||||
<div className="bg-gray-50 p-4 rounded-lg mb-4">
|
||||
<div className="text-lg leading-relaxed">
|
||||
{textInput}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-base font-semibold mb-2">整句意思</h3>
|
||||
<div className="text-gray-700 leading-relaxed">
|
||||
{sentenceMeaning}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 互動式文字 */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold mb-4">智能高價值標記 - 點擊查詢單字</h2>
|
||||
|
||||
{/* 圖例說明 */}
|
||||
<div className="p-4 bg-gradient-to-r from-blue-50 to-green-50 rounded-lg border border-blue-200 mb-4">
|
||||
<p className="text-sm text-blue-800 mb-3">
|
||||
<strong>💡 智能標記說明:</strong>AI 已識別高學習價值詞彙並標記為免費查詢
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="px-2 py-1 bg-yellow-100 border-2 border-yellow-400 rounded">brought⭐</div>
|
||||
<span>高價值片語(免費)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="px-2 py-1 bg-green-100 border-2 border-green-400 rounded">meeting⭐</div>
|
||||
<span>高價值單字(免費)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="px-2 py-1 border-b border-blue-300">thing</div>
|
||||
<span>普通單字(扣1次)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-gray-50 rounded-lg border-2 border-dashed border-gray-300">
|
||||
<ClickableTextV2
|
||||
text={textInput}
|
||||
analysis={sentenceAnalysis}
|
||||
highValueWords={mockSentenceAnalysis.highValueWords}
|
||||
phrasesDetected={mockSentenceAnalysis.phrasesDetected}
|
||||
remainingUsage={5 - usageCount}
|
||||
onWordClick={(word, analysis) => {
|
||||
console.log('Clicked word:', word, analysis)
|
||||
}}
|
||||
onWordCostConfirm={async (word, cost) => {
|
||||
if (usageCount >= 5) {
|
||||
alert('❌ 使用額度不足,無法查詢低價值詞彙')
|
||||
return false
|
||||
}
|
||||
|
||||
// 這裡可以顯示更詳細的確認對話框
|
||||
const confirmed = window.confirm(
|
||||
`查詢 "${word}" 將消耗 ${cost} 次使用額度,您剩餘 ${5 - usageCount} 次。\n\n是否繼續?`
|
||||
)
|
||||
|
||||
if (confirmed) {
|
||||
setUsageCount(prev => prev + cost)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 使用統計 */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold mb-4">使用統計</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="text-sm text-gray-600">今日句子分析</div>
|
||||
<div className="text-2xl font-bold text-blue-600">{usageCount}</div>
|
||||
<div className="text-xs text-gray-500">每次扣除 1 次額度</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-600">剩餘額度</div>
|
||||
<div className={`text-2xl font-bold ${5 - usageCount <= 1 ? 'text-red-600' : 'text-green-600'}`}>
|
||||
{5 - usageCount}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{isPremium ? '無限制' : '3小時內重置'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按鈕 */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-6">
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => setShowAnalysisView(false)}
|
||||
className="flex-1 bg-gray-200 text-gray-700 py-3 rounded-lg font-medium hover:bg-gray-300 transition-colors"
|
||||
>
|
||||
🔄 分析新句子
|
||||
</button>
|
||||
<button
|
||||
onClick={() => alert('詞卡生成功能開發中...')}
|
||||
className="flex-1 bg-blue-600 text-white py-3 rounded-lg font-medium hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
📖 生成詞卡
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,594 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { ClickableTextV2 } from '@/components/ClickableTextV2'
|
||||
import { GrammarCorrectionPanel } from '@/components/GrammarCorrectionPanel'
|
||||
|
||||
export default function DemoV3Page() {
|
||||
const [mode, setMode] = useState<'manual' | 'screenshot'>('manual')
|
||||
const [textInput, setTextInput] = useState('')
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false)
|
||||
const [showAnalysisView, setShowAnalysisView] = useState(false)
|
||||
const [sentenceAnalysis, setSentenceAnalysis] = useState<any>(null)
|
||||
const [sentenceMeaning, setSentenceMeaning] = useState('')
|
||||
const [grammarCorrection, setGrammarCorrection] = useState<any>(null)
|
||||
const [finalText, setFinalText] = useState('') // 最終用於分析的文本
|
||||
const [usageCount, setUsageCount] = useState(0)
|
||||
const [isPremium] = useState(false)
|
||||
const [apiConnected, setApiConnected] = useState(false)
|
||||
|
||||
// 模擬正確句子的分析資料
|
||||
const mockCorrectSentenceAnalysis = {
|
||||
meaning: "他在我們的會議中提出了這件事,但沒有人同意。這句話表達了在會議中有人提出某個議題或想法,但得不到其他與會者的認同。",
|
||||
grammarCorrection: {
|
||||
hasErrors: false,
|
||||
originalText: "He brought this thing up during our meeting and no one agreed.",
|
||||
correctedText: null,
|
||||
corrections: [],
|
||||
confidenceScore: 0.98
|
||||
},
|
||||
highValueWords: ["brought", "up", "meeting", "agreed"],
|
||||
words: {
|
||||
"brought": {
|
||||
word: "brought",
|
||||
translation: "帶來、提出",
|
||||
definition: "Past tense of bring; to take or carry something to a place",
|
||||
partOfSpeech: "verb",
|
||||
pronunciation: "/brɔːt/",
|
||||
synonyms: ["carried", "took", "delivered"],
|
||||
antonyms: ["removed", "took away"],
|
||||
isPhrase: true,
|
||||
isHighValue: true,
|
||||
learningPriority: "high",
|
||||
phraseInfo: {
|
||||
phrase: "bring up",
|
||||
meaning: "提出(話題)、養育",
|
||||
warning: "在這個句子中,\"brought up\" 是一個片語,意思是\"提出話題\",而不是單純的\"帶來\"",
|
||||
colorCode: "#F59E0B"
|
||||
},
|
||||
difficultyLevel: "B1"
|
||||
},
|
||||
"meeting": {
|
||||
word: "meeting",
|
||||
translation: "會議",
|
||||
definition: "An organized gathering of people for discussion",
|
||||
partOfSpeech: "noun",
|
||||
pronunciation: "/ˈmiːtɪŋ/",
|
||||
synonyms: ["conference", "assembly", "gathering"],
|
||||
antonyms: [],
|
||||
isPhrase: false,
|
||||
isHighValue: true,
|
||||
learningPriority: "high",
|
||||
difficultyLevel: "B2"
|
||||
},
|
||||
"thing": {
|
||||
word: "thing",
|
||||
translation: "事情、東西",
|
||||
definition: "An object, fact, or situation",
|
||||
partOfSpeech: "noun",
|
||||
pronunciation: "/θɪŋ/",
|
||||
synonyms: ["object", "matter", "item"],
|
||||
antonyms: [],
|
||||
isPhrase: false,
|
||||
isHighValue: false,
|
||||
learningPriority: "low",
|
||||
difficultyLevel: "A1",
|
||||
costIncurred: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 模擬有語法錯誤的句子分析資料
|
||||
const mockErrorSentenceAnalysis = {
|
||||
meaning: "我昨天去學校遇見了我的朋友們。這句話描述了過去發生的事情,表達了去學校並遇到朋友的經歷。",
|
||||
grammarCorrection: {
|
||||
hasErrors: true,
|
||||
originalText: "I go to school yesterday and meet my friends.",
|
||||
correctedText: "I went to school yesterday and met my friends.",
|
||||
corrections: [
|
||||
{
|
||||
position: { start: 2, end: 4 },
|
||||
errorType: "tense_mismatch",
|
||||
original: "go",
|
||||
corrected: "went",
|
||||
reason: "過去式時態修正:句子中有 'yesterday',應使用過去式",
|
||||
severity: "high"
|
||||
},
|
||||
{
|
||||
position: { start: 29, end: 33 },
|
||||
errorType: "tense_mismatch",
|
||||
original: "meet",
|
||||
corrected: "met",
|
||||
reason: "過去式時態修正:與 'went' 保持時態一致",
|
||||
severity: "high"
|
||||
}
|
||||
],
|
||||
confidenceScore: 0.95
|
||||
},
|
||||
highValueWords: ["went", "yesterday", "met", "friends"],
|
||||
words: {
|
||||
"went": {
|
||||
word: "went",
|
||||
translation: "去、前往",
|
||||
definition: "Past tense of go; to move or travel to a place",
|
||||
partOfSpeech: "verb",
|
||||
pronunciation: "/went/",
|
||||
synonyms: ["traveled", "moved", "proceeded"],
|
||||
antonyms: ["stayed", "remained"],
|
||||
isPhrase: false,
|
||||
isHighValue: true,
|
||||
learningPriority: "high",
|
||||
difficultyLevel: "A2"
|
||||
},
|
||||
"yesterday": {
|
||||
word: "yesterday",
|
||||
translation: "昨天",
|
||||
definition: "The day before today",
|
||||
partOfSpeech: "adverb",
|
||||
pronunciation: "/ˈjestədeɪ/",
|
||||
synonyms: ["the day before"],
|
||||
antonyms: ["tomorrow", "today"],
|
||||
isPhrase: false,
|
||||
isHighValue: true,
|
||||
learningPriority: "medium",
|
||||
difficultyLevel: "A1"
|
||||
},
|
||||
"met": {
|
||||
word: "met",
|
||||
translation: "遇見、認識",
|
||||
definition: "Past tense of meet; to encounter or come together with",
|
||||
partOfSpeech: "verb",
|
||||
pronunciation: "/met/",
|
||||
synonyms: ["encountered", "saw", "found"],
|
||||
antonyms: ["avoided", "missed"],
|
||||
isPhrase: false,
|
||||
isHighValue: true,
|
||||
learningPriority: "high",
|
||||
difficultyLevel: "A2"
|
||||
},
|
||||
"friends": {
|
||||
word: "friends",
|
||||
translation: "朋友們",
|
||||
definition: "People you like and know well",
|
||||
partOfSpeech: "noun",
|
||||
pronunciation: "/frends/",
|
||||
synonyms: ["companions", "buddies", "pals"],
|
||||
antonyms: ["enemies", "strangers"],
|
||||
isPhrase: false,
|
||||
isHighValue: true,
|
||||
learningPriority: "medium",
|
||||
difficultyLevel: "A1"
|
||||
},
|
||||
"school": {
|
||||
word: "school",
|
||||
translation: "學校",
|
||||
definition: "A place where children go to learn",
|
||||
partOfSpeech: "noun",
|
||||
pronunciation: "/skuːl/",
|
||||
synonyms: ["educational institution"],
|
||||
antonyms: [],
|
||||
isPhrase: false,
|
||||
isHighValue: false,
|
||||
learningPriority: "low",
|
||||
difficultyLevel: "A1",
|
||||
costIncurred: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 處理句子分析 - 使用真實API
|
||||
const handleAnalyzeSentence = async () => {
|
||||
if (!textInput.trim()) return
|
||||
|
||||
if (!isPremium && usageCount >= 5) {
|
||||
alert('❌ 免費用戶 3 小時內只能分析 5 次句子,請稍後再試或升級到付費版本')
|
||||
return
|
||||
}
|
||||
|
||||
setIsAnalyzing(true)
|
||||
|
||||
try {
|
||||
console.log('🚀 開始API調用')
|
||||
console.log('📝 輸入文本:', textInput)
|
||||
console.log('🌐 API URL:', 'http://localhost:5000/api/ai/analyze-sentence')
|
||||
|
||||
// 調用真實的後端API
|
||||
const response = await fetch('http://localhost:5000/api/ai/analyze-sentence', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
inputText: textInput,
|
||||
analysisMode: 'full',
|
||||
forceRefresh: true // 暫時強制刷新,避免舊快取問題
|
||||
})
|
||||
})
|
||||
|
||||
console.log('📡 API響應狀態:', response.status, response.statusText)
|
||||
console.log('📦 響應頭:', [...response.headers.entries()])
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
console.log('❌ 錯誤響應內容:', errorText)
|
||||
throw new Error(`API 錯誤: ${response.status} ${response.statusText} - ${errorText}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
console.log('✅ API響應數據:', result)
|
||||
|
||||
if (result.success) {
|
||||
console.log('💫 開始更新前端狀態')
|
||||
|
||||
// 確保數據結構完整
|
||||
const wordAnalysis = result.data.wordAnalysis || {}
|
||||
const sentenceMeaning = result.data.sentenceMeaning || {}
|
||||
const grammarCorrection = result.data.grammarCorrection || { hasErrors: false }
|
||||
const finalText = result.data.finalAnalysisText || textInput
|
||||
|
||||
console.log('📊 詞彙分析詞數:', Object.keys(wordAnalysis).length)
|
||||
console.log('🎯 高價值詞彙:', result.data.highValueWords)
|
||||
console.log('📝 翻譯內容:', sentenceMeaning.translation)
|
||||
|
||||
// 批次更新狀態,避免競態條件
|
||||
setSentenceAnalysis(wordAnalysis)
|
||||
setSentenceMeaning((sentenceMeaning.translation || '翻譯處理中...') + ' ' + (sentenceMeaning.explanation || '解釋處理中...'))
|
||||
setGrammarCorrection(grammarCorrection)
|
||||
setFinalText(finalText)
|
||||
|
||||
// 延遲顯示分析視圖,確保狀態更新完成
|
||||
setTimeout(() => {
|
||||
setShowAnalysisView(true)
|
||||
console.log('✅ 分析視圖已顯示')
|
||||
}, 100)
|
||||
|
||||
setUsageCount(prev => prev + 1)
|
||||
|
||||
console.log('🎉 狀態更新完成')
|
||||
} else {
|
||||
throw new Error(result.error || '分析失敗')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ API錯誤詳情:', error)
|
||||
|
||||
// 不要自動回退到模擬資料,讓用戶知道真實錯誤
|
||||
alert(`🔌 無法連接到後端API:\n\n${error instanceof Error ? error.message : '未知錯誤'}\n\n請檢查:\n1. 後端服務是否運行在 localhost:5000\n2. CORS 設定是否正確\n3. 網路連接是否正常`)
|
||||
|
||||
// 重置分析狀態
|
||||
setShowAnalysisView(false)
|
||||
} finally {
|
||||
setIsAnalyzing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAcceptCorrection = () => {
|
||||
if (grammarCorrection?.correctedText) {
|
||||
setFinalText(grammarCorrection.correctedText)
|
||||
// 這裡可以重新分析修正後的句子
|
||||
alert('✅ 已採用修正版本,後續學習將基於正確的句子進行!')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRejectCorrection = () => {
|
||||
setFinalText(grammarCorrection?.originalText || textInput)
|
||||
alert('📝 已保持原始版本,將基於您的原始輸入進行學習。')
|
||||
}
|
||||
|
||||
// 檢查API連接狀態
|
||||
useEffect(() => {
|
||||
const checkApiConnection = async () => {
|
||||
try {
|
||||
const response = await fetch('http://localhost:5000/api/ai/test/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ inputText: 'test', extractionType: 'vocabulary', cardCount: 1 })
|
||||
})
|
||||
setApiConnected(response.ok)
|
||||
} catch (error) {
|
||||
setApiConnected(false)
|
||||
}
|
||||
}
|
||||
|
||||
checkApiConnection()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Navigation */}
|
||||
<nav className="bg-white shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between h-16">
|
||||
<div className="flex items-center">
|
||||
<h1 className="text-2xl font-bold text-blue-600">DramaLing</h1>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-green-600 font-medium">🔗 真實API整合 v3.0</span>
|
||||
<span className={`text-xs px-2 py-1 rounded-full ${
|
||||
apiConnected
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-yellow-100 text-yellow-700'
|
||||
}`}>
|
||||
{apiConnected ? '✅ 後端已連接' : '⏳ 檢查中...'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{!showAnalysisView ? (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-8">AI 智能語法修正 + 高價值標記系統</h1>
|
||||
|
||||
{/* API 連接狀態 */}
|
||||
<div className={`p-4 rounded-lg mb-6 border ${
|
||||
apiConnected
|
||||
? 'bg-green-50 border-green-200'
|
||||
: 'bg-red-50 border-red-200'
|
||||
}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">
|
||||
{apiConnected ? '✅' : '❌'}
|
||||
</span>
|
||||
<span className={`font-medium ${
|
||||
apiConnected ? 'text-green-800' : 'text-red-800'
|
||||
}`}>
|
||||
後端 API 連接狀態: {apiConnected ? '已連接' : '未連接'}
|
||||
</span>
|
||||
</div>
|
||||
{!apiConnected && (
|
||||
<p className="text-red-700 text-sm mt-2">
|
||||
請確認後端服務正在 http://localhost:5000 運行
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 功能說明 */}
|
||||
<div className="bg-gradient-to-r from-red-50 to-green-50 rounded-xl p-6 mb-6 border border-red-200">
|
||||
<h2 className="text-lg font-semibold mb-3 text-red-800">🔧 語法修正 + 高價值標記特色</h2>
|
||||
<div className="grid md:grid-cols-2 gap-4 text-sm">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">❌</span>
|
||||
<span><strong>智能錯誤檢測</strong> - 9種語法錯誤類型</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">🔧</span>
|
||||
<span><strong>自動修正建議</strong> - 詳細修正說明</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">⭐</span>
|
||||
<span><strong>高價值標記</strong> - 基於修正後句子</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">💰</span>
|
||||
<span><strong>成本優化</strong> - 語法修正不額外收費</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Input */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold mb-4">輸入英文文本 (更新:300字限制)</h2>
|
||||
<textarea
|
||||
value={textInput}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
if (mode === 'manual' && value.length > 300) {
|
||||
return // 阻止輸入超過300字
|
||||
}
|
||||
setTextInput(value)
|
||||
}}
|
||||
placeholder={mode === 'manual'
|
||||
? "輸入英文句子(最多300字)..."
|
||||
: "貼上您想要學習的英文文本,例如影劇對話、文章段落..."
|
||||
}
|
||||
className={`w-full h-40 px-4 py-3 border rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-transparent outline-none resize-none ${
|
||||
mode === 'manual' && textInput.length >= 280 ? 'border-yellow-400' :
|
||||
mode === 'manual' && textInput.length >= 300 ? 'border-red-400' : 'border-gray-300'
|
||||
}`}
|
||||
/>
|
||||
<div className="mt-2 flex justify-between text-sm">
|
||||
<span className={`${
|
||||
mode === 'manual' && textInput.length >= 280 ? 'text-yellow-600' :
|
||||
mode === 'manual' && textInput.length >= 300 ? 'text-red-600' : 'text-gray-600'
|
||||
}`}>
|
||||
{mode === 'manual' ? `最多 300 字元 • 目前:${textInput.length} 字元` : `最多 5000 字元 • 目前:${textInput.length} 字元`}
|
||||
</span>
|
||||
{mode === 'manual' && textInput.length > 250 && (
|
||||
<span className={textInput.length >= 300 ? 'text-red-600' : 'text-yellow-600'}>
|
||||
{textInput.length >= 300 ? '已達上限!' : `還可輸入 ${300 - textInput.length} 字元`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 預設示例 - 包含語法錯誤和正確的句子 */}
|
||||
{!textInput && (
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="p-3 bg-gray-50 rounded-lg">
|
||||
<div className="text-sm text-gray-700 mb-2">
|
||||
<strong>✅ 正確語法示例:</strong>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setTextInput("He brought this thing up during our meeting and no one agreed.")}
|
||||
className="text-sm text-green-600 hover:text-green-800 bg-green-50 px-3 py-1 rounded border border-green-200 w-full text-left"
|
||||
>
|
||||
He brought this thing up during our meeting and no one agreed.
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-3 bg-red-50 rounded-lg">
|
||||
<div className="text-sm text-red-700 mb-2">
|
||||
<strong>❌ 語法錯誤示例(測試修正功能):</strong>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setTextInput("I go to school yesterday and meet my friends.")}
|
||||
className="text-sm text-red-600 hover:text-red-800 bg-red-50 px-3 py-1 rounded border border-red-200 w-full text-left"
|
||||
>
|
||||
I go to school yesterday and meet my friends.
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 分析按鈕 */}
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
onClick={handleAnalyzeSentence}
|
||||
disabled={isAnalyzing || (mode === 'manual' && (!textInput || textInput.length > 300)) || (mode === 'screenshot')}
|
||||
className="w-full bg-blue-600 text-white py-4 rounded-lg font-semibold hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isAnalyzing ? (
|
||||
<span className="flex items-center justify-center">
|
||||
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
正在檢查語法並標記高價值詞彙...
|
||||
</span>
|
||||
) : (
|
||||
'🔍 語法檢查 + 高價值詞彙分析'
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="text-center text-sm text-gray-600">
|
||||
{isPremium ? (
|
||||
<span className="text-green-600">🌟 付費用戶:無限制使用</span>
|
||||
) : (
|
||||
<span className={usageCount >= 4 ? 'text-red-600' : usageCount >= 3 ? 'text-yellow-600' : 'text-gray-600'}>
|
||||
免費用戶:已使用 {usageCount}/5 次 (3小時內)
|
||||
{usageCount >= 5 && <span className="block text-red-500 mt-1">已達上限,請稍後再試</span>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* 句子分析視圖 */
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-3xl font-bold">語法檢查 + 高價值標記結果</h1>
|
||||
<button
|
||||
onClick={() => setShowAnalysisView(false)}
|
||||
className="text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
← 返回
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 語法修正面板 */}
|
||||
{grammarCorrection && (
|
||||
<GrammarCorrectionPanel
|
||||
correction={grammarCorrection}
|
||||
onAcceptCorrection={handleAcceptCorrection}
|
||||
onRejectCorrection={handleRejectCorrection}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 原始句子 vs 分析句子 */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold mb-4">句子對比</h2>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-2">📝 用戶輸入</h3>
|
||||
<div className="p-3 bg-gray-50 rounded-lg border">
|
||||
<div className="text-base">{textInput}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-2">🎯 分析基礎</h3>
|
||||
<div className="p-3 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<div className="text-base font-medium">{finalText}</div>
|
||||
{finalText !== textInput && (
|
||||
<div className="text-xs text-blue-600 mt-1">✨ 已修正語法錯誤</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-2">📖 整句意思</h3>
|
||||
<div className="text-gray-700 leading-relaxed p-3 bg-gray-50 rounded-lg">
|
||||
{sentenceMeaning}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 互動式文字 */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold mb-4">智能高價值標記 - 點擊查詢單字</h2>
|
||||
|
||||
<div className="p-4 bg-gradient-to-r from-blue-50 to-green-50 rounded-lg border border-blue-200 mb-4">
|
||||
<p className="text-sm text-blue-800 mb-3">
|
||||
<strong>💡 基於{finalText !== textInput ? '修正後' : '原始'}句子的智能分析:</strong>
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="px-2 py-1 bg-yellow-100 border-2 border-yellow-400 rounded">片語⭐</div>
|
||||
<span>高價值片語(免費)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="px-2 py-1 bg-green-100 border-2 border-green-400 rounded">單字⭐</div>
|
||||
<span>高價值單字(免費)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="px-2 py-1 border-b border-blue-300">普通</div>
|
||||
<span>普通單字(扣1次)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-gray-50 rounded-lg border-2 border-dashed border-gray-300">
|
||||
<ClickableTextV2
|
||||
text={finalText}
|
||||
analysis={sentenceAnalysis}
|
||||
remainingUsage={5 - usageCount}
|
||||
onWordClick={(word, analysis) => {
|
||||
console.log('Clicked word:', word, analysis)
|
||||
}}
|
||||
onWordCostConfirm={async (word, cost) => {
|
||||
if (usageCount >= 5) {
|
||||
alert('❌ 使用額度不足,無法查詢低價值詞彙')
|
||||
return false
|
||||
}
|
||||
|
||||
const confirmed = window.confirm(
|
||||
`查詢 "${word}" 將消耗 ${cost} 次使用額度,您剩餘 ${5 - usageCount} 次。\n\n是否繼續?`
|
||||
)
|
||||
|
||||
if (confirmed) {
|
||||
setUsageCount(prev => prev + cost)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按鈕 */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-6">
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => setShowAnalysisView(false)}
|
||||
className="flex-1 bg-gray-200 text-gray-700 py-3 rounded-lg font-medium hover:bg-gray-300 transition-colors"
|
||||
>
|
||||
🔄 分析新句子
|
||||
</button>
|
||||
<button
|
||||
onClick={() => alert('詞卡生成功能開發中...')}
|
||||
className="flex-1 bg-blue-600 text-white py-3 rounded-lg font-medium hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
📖 生成詞卡
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,518 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useEffect, use } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Navigation } from '@/components/Navigation'
|
||||
import { ProtectedRoute } from '@/components/ProtectedRoute'
|
||||
import { flashcardsService, type Flashcard } from '@/lib/services/flashcards'
|
||||
|
||||
interface FlashcardDetailPageProps {
|
||||
params: Promise<{
|
||||
id: string
|
||||
}>
|
||||
}
|
||||
|
||||
export default function FlashcardDetailPage({ params }: FlashcardDetailPageProps) {
|
||||
const { id } = use(params)
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<FlashcardDetailContent cardId={id} />
|
||||
</ProtectedRoute>
|
||||
)
|
||||
}
|
||||
|
||||
function FlashcardDetailContent({ cardId }: { cardId: string }) {
|
||||
const router = useRouter()
|
||||
const [flashcard, setFlashcard] = useState<Flashcard | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [editedCard, setEditedCard] = useState<any>(null)
|
||||
|
||||
// 假資料 - 用於展示效果
|
||||
const mockCards: {[key: string]: any} = {
|
||||
'mock1': {
|
||||
id: 'mock1',
|
||||
word: 'hello',
|
||||
translation: '你好',
|
||||
partOfSpeech: 'interjection',
|
||||
pronunciation: '/həˈloʊ/',
|
||||
definition: 'A greeting word used when meeting someone or beginning a phone conversation',
|
||||
example: 'Hello, how are you today?',
|
||||
exampleTranslation: '你好,你今天怎麼樣?',
|
||||
masteryLevel: 95,
|
||||
timesReviewed: 15,
|
||||
isFavorite: true,
|
||||
nextReviewDate: '2025-09-21',
|
||||
cardSet: { name: '基礎詞彙', color: 'bg-blue-500' },
|
||||
difficultyLevel: 'A1',
|
||||
createdAt: '2025-09-17',
|
||||
synonyms: ['hi', 'greetings', 'good day']
|
||||
},
|
||||
'mock2': {
|
||||
id: 'mock2',
|
||||
word: 'elaborate',
|
||||
translation: '詳細說明',
|
||||
partOfSpeech: 'verb',
|
||||
pronunciation: '/ɪˈlæbərət/',
|
||||
definition: 'To explain something in more detail; to develop or present a theory, policy, or system in further detail',
|
||||
example: 'Could you elaborate on your proposal?',
|
||||
exampleTranslation: '你能詳細說明一下你的提案嗎?',
|
||||
masteryLevel: 45,
|
||||
timesReviewed: 5,
|
||||
isFavorite: false,
|
||||
nextReviewDate: '2025-09-19',
|
||||
cardSet: { name: '高級詞彙', color: 'bg-purple-500' },
|
||||
difficultyLevel: 'B2',
|
||||
createdAt: '2025-09-14',
|
||||
synonyms: ['explain', 'detail', 'expand', 'clarify']
|
||||
}
|
||||
}
|
||||
|
||||
// 載入詞卡資料
|
||||
useEffect(() => {
|
||||
const loadFlashcard = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
|
||||
// 首先檢查是否為假資料
|
||||
if (mockCards[cardId]) {
|
||||
setFlashcard(mockCards[cardId])
|
||||
setEditedCard(mockCards[cardId])
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// 載入真實詞卡 - 直接使用假資料,因為getFlashcard API不存在
|
||||
const defaultCard = mockCards['mock1']
|
||||
setFlashcard({
|
||||
...defaultCard,
|
||||
id: cardId,
|
||||
word: `示例詞卡`,
|
||||
translation: '示例翻譯',
|
||||
definition: 'This is a sample flashcard for demonstration purposes'
|
||||
})
|
||||
setEditedCard({
|
||||
...defaultCard,
|
||||
id: cardId,
|
||||
word: `示例詞卡`,
|
||||
translation: '示例翻譯',
|
||||
definition: 'This is a sample flashcard for demonstration purposes'
|
||||
})
|
||||
} catch (err) {
|
||||
setError('載入詞卡時發生錯誤')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadFlashcard()
|
||||
}, [cardId])
|
||||
|
||||
// 獲取CEFR等級顏色
|
||||
const getCEFRColor = (level: string) => {
|
||||
switch (level) {
|
||||
case 'A1': return 'bg-green-100 text-green-700 border-green-200'
|
||||
case 'A2': return 'bg-blue-100 text-blue-700 border-blue-200'
|
||||
case 'B1': return 'bg-yellow-100 text-yellow-700 border-yellow-200'
|
||||
case 'B2': return 'bg-orange-100 text-orange-700 border-orange-200'
|
||||
case 'C1': return 'bg-red-100 text-red-700 border-red-200'
|
||||
case 'C2': return 'bg-purple-100 text-purple-700 border-purple-200'
|
||||
default: return 'bg-gray-100 text-gray-700 border-gray-200'
|
||||
}
|
||||
}
|
||||
|
||||
// 獲取例句圖片
|
||||
const getExampleImage = (word: string) => {
|
||||
const imageMap: {[key: string]: string} = {
|
||||
'hello': '/images/examples/bring_up.png',
|
||||
'elaborate': '/images/examples/instinct.png',
|
||||
'beautiful': '/images/examples/warrant.png'
|
||||
}
|
||||
return imageMap[word?.toLowerCase()] || '/images/examples/bring_up.png'
|
||||
}
|
||||
|
||||
// 處理收藏切換
|
||||
const handleToggleFavorite = async () => {
|
||||
if (!flashcard) return
|
||||
|
||||
try {
|
||||
// 假資料處理
|
||||
if (flashcard.id.startsWith('mock')) {
|
||||
const updated = { ...flashcard, isFavorite: !flashcard.isFavorite }
|
||||
setFlashcard(updated)
|
||||
setEditedCard(updated)
|
||||
alert(`${flashcard.isFavorite ? '已取消收藏' : '已加入收藏'}「${flashcard.word}」`)
|
||||
return
|
||||
}
|
||||
|
||||
// 真實API調用
|
||||
const result = await flashcardsService.toggleFavorite(flashcard.id)
|
||||
if (result.success) {
|
||||
setFlashcard(prev => prev ? { ...prev, isFavorite: !prev.isFavorite } : null)
|
||||
alert(`${flashcard.isFavorite ? '已取消收藏' : '已加入收藏'}「${flashcard.word}」`)
|
||||
}
|
||||
} catch (error) {
|
||||
alert('操作失敗,請重試')
|
||||
}
|
||||
}
|
||||
|
||||
// 處理編輯保存
|
||||
const handleSaveEdit = async () => {
|
||||
if (!flashcard || !editedCard) return
|
||||
|
||||
try {
|
||||
// 假資料處理
|
||||
if (flashcard.id.startsWith('mock')) {
|
||||
setFlashcard(editedCard)
|
||||
setIsEditing(false)
|
||||
alert('詞卡更新成功!')
|
||||
return
|
||||
}
|
||||
|
||||
// 真實API調用
|
||||
const result = await flashcardsService.updateFlashcard(flashcard.id, {
|
||||
english: editedCard.word,
|
||||
chinese: editedCard.translation,
|
||||
pronunciation: editedCard.pronunciation,
|
||||
partOfSpeech: editedCard.partOfSpeech,
|
||||
example: editedCard.example
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
setFlashcard(editedCard)
|
||||
setIsEditing(false)
|
||||
alert('詞卡更新成功!')
|
||||
} else {
|
||||
alert(result.error || '更新失敗')
|
||||
}
|
||||
} catch (error) {
|
||||
alert('更新失敗,請重試')
|
||||
}
|
||||
}
|
||||
|
||||
// 處理刪除
|
||||
const handleDelete = async () => {
|
||||
if (!flashcard) return
|
||||
|
||||
if (!confirm(`確定要刪除詞卡「${flashcard.word}」嗎?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 假資料處理
|
||||
if (flashcard.id.startsWith('mock')) {
|
||||
alert('詞卡已刪除(模擬)')
|
||||
router.push('/flashcards')
|
||||
return
|
||||
}
|
||||
|
||||
// 真實API調用
|
||||
const result = await flashcardsService.deleteFlashcard(flashcard.id)
|
||||
if (result.success) {
|
||||
alert('詞卡已刪除')
|
||||
router.push('/flashcards')
|
||||
} else {
|
||||
alert(result.error || '刪除失敗')
|
||||
}
|
||||
} catch (error) {
|
||||
alert('刪除失敗,請重試')
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-lg text-gray-600">載入中...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !flashcard) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-red-600 text-lg mb-4">{error || '詞卡不存在'}</div>
|
||||
<button
|
||||
onClick={() => router.push('/flashcards')}
|
||||
className="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors"
|
||||
>
|
||||
返回詞卡列表
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
|
||||
<Navigation />
|
||||
|
||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||
{/* 導航欄 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => router.push('/flashcards')}
|
||||
className="text-gray-600 hover:text-gray-900 flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
返回詞卡列表
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 主要詞卡內容 - 學習功能風格 */}
|
||||
<div className="bg-white rounded-xl shadow-lg overflow-hidden mb-6 relative">
|
||||
{/* CEFR標籤 - 右上角 */}
|
||||
<div className="absolute top-4 right-4 z-10">
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-medium border ${getCEFRColor((flashcard as any).difficultyLevel || 'A1')}`}>
|
||||
{(flashcard as any).difficultyLevel || 'A1'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 標題區 */}
|
||||
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 p-6 border-b border-blue-200">
|
||||
<div className="pr-16">
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-3">{flashcard.word}</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm bg-gray-100 text-gray-700 px-3 py-1 rounded-full">
|
||||
{flashcard.partOfSpeech}
|
||||
</span>
|
||||
<span className="text-lg text-gray-600">{flashcard.pronunciation}</span>
|
||||
<button className="w-10 h-10 bg-blue-600 rounded-full flex items-center justify-center text-white hover:bg-blue-700 transition-colors">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 學習統計 */}
|
||||
<div className="grid grid-cols-3 gap-4 text-center mt-4">
|
||||
<div className="bg-white bg-opacity-60 rounded-lg p-3">
|
||||
<div className="text-2xl font-bold text-gray-900">{flashcard.masteryLevel}%</div>
|
||||
<div className="text-sm text-gray-600">掌握程度</div>
|
||||
</div>
|
||||
<div className="bg-white bg-opacity-60 rounded-lg p-3">
|
||||
<div className="text-2xl font-bold text-gray-900">{flashcard.timesReviewed}</div>
|
||||
<div className="text-sm text-gray-600">複習次數</div>
|
||||
</div>
|
||||
<div className="bg-white bg-opacity-60 rounded-lg p-3">
|
||||
<div className="text-2xl font-bold text-gray-900">
|
||||
{Math.ceil((new Date(flashcard.nextReviewDate).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24))}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">天後複習</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 內容區 - 學習卡片風格 */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* 翻譯區塊 */}
|
||||
<div className="bg-green-50 rounded-lg p-4 border border-green-200">
|
||||
<h3 className="font-semibold text-green-900 mb-3 text-left">中文翻譯</h3>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editedCard?.translation || ''}
|
||||
onChange={(e) => setEditedCard((prev: any) => ({ ...prev, translation: e.target.value }))}
|
||||
className="w-full p-3 border border-green-300 rounded-lg focus:ring-2 focus:ring-green-500 bg-white"
|
||||
placeholder="輸入中文翻譯"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-green-800 font-medium text-left text-lg">
|
||||
{flashcard.translation}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 定義區塊 */}
|
||||
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
|
||||
<h3 className="font-semibold text-gray-900 mb-3 text-left">英文定義</h3>
|
||||
{isEditing ? (
|
||||
<textarea
|
||||
value={editedCard?.definition || ''}
|
||||
onChange={(e) => setEditedCard((prev: any) => ({ ...prev, definition: e.target.value }))}
|
||||
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 bg-white h-20 resize-none"
|
||||
placeholder="輸入英文定義"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-gray-700 text-left leading-relaxed">
|
||||
{flashcard.definition}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 例句區塊 */}
|
||||
<div className="bg-blue-50 rounded-lg p-4 border border-blue-200">
|
||||
<h3 className="font-semibold text-blue-900 mb-3 text-left">例句</h3>
|
||||
|
||||
{/* 例句圖片 */}
|
||||
<div className="mb-4">
|
||||
<img
|
||||
src={getExampleImage(flashcard.word)}
|
||||
alt={`${flashcard.word} example`}
|
||||
className="w-full max-w-md mx-auto rounded-lg border border-blue-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<textarea
|
||||
value={editedCard?.example || ''}
|
||||
onChange={(e) => setEditedCard((prev: any) => ({ ...prev, example: e.target.value }))}
|
||||
className="w-full p-3 border border-blue-300 rounded-lg focus:ring-2 focus:ring-blue-500 bg-white h-16 resize-none"
|
||||
placeholder="輸入英文例句"
|
||||
/>
|
||||
<textarea
|
||||
value={editedCard?.exampleTranslation || ''}
|
||||
onChange={(e) => setEditedCard((prev: any) => ({ ...prev, exampleTranslation: e.target.value }))}
|
||||
className="w-full p-3 border border-blue-300 rounded-lg focus:ring-2 focus:ring-blue-500 bg-white h-16 resize-none"
|
||||
placeholder="輸入例句翻譯"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="relative">
|
||||
<p className="text-blue-800 text-left italic text-lg pr-12">
|
||||
"{flashcard.example}"
|
||||
</p>
|
||||
<div className="absolute bottom-0 right-0">
|
||||
<button className="w-10 h-10 bg-blue-600 rounded-full flex items-center justify-center text-white hover:bg-blue-700 transition-colors">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-blue-700 text-left text-base">
|
||||
"{flashcard.exampleTranslation}"
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 同義詞區塊 */}
|
||||
{(flashcard as any).synonyms && (flashcard as any).synonyms.length > 0 && (
|
||||
<div className="bg-purple-50 rounded-lg p-4 border border-purple-200">
|
||||
<h3 className="font-semibold text-purple-900 mb-3 text-left">同義詞</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(flashcard as any).synonyms.map((synonym: string, index: number) => (
|
||||
<span
|
||||
key={index}
|
||||
className="bg-white text-purple-700 px-3 py-1 rounded-full text-sm border border-purple-200 font-medium"
|
||||
>
|
||||
{synonym}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 詞卡資訊 */}
|
||||
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
|
||||
<h3 className="font-semibold text-gray-900 mb-3 text-left">詞卡資訊</h3>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-600">詞性:</span>
|
||||
<span className="ml-2 font-medium">{flashcard.partOfSpeech}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-600">創建時間:</span>
|
||||
<span className="ml-2 font-medium">{new Date(flashcard.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-600">下次複習:</span>
|
||||
<span className="ml-2 font-medium">{new Date(flashcard.nextReviewDate).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-600">複習次數:</span>
|
||||
<span className="ml-2 font-medium">{flashcard.timesReviewed} 次</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 編輯模式的操作按鈕 */}
|
||||
{isEditing && (
|
||||
<div className="px-6 pb-6">
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleSaveEdit}
|
||||
className="flex-1 bg-green-600 text-white py-3 rounded-lg font-medium hover:bg-green-700 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
保存修改
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsEditing(false)
|
||||
setEditedCard(flashcard)
|
||||
}}
|
||||
className="flex-1 bg-gray-500 text-white py-3 rounded-lg font-medium hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
取消編輯
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部操作區 - 平均延展按鈕 */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleToggleFavorite}
|
||||
className={`flex-1 py-3 rounded-lg font-medium transition-colors ${
|
||||
flashcard.isFavorite
|
||||
? 'bg-yellow-100 text-yellow-700 border border-yellow-300 hover:bg-yellow-200'
|
||||
: 'bg-gray-100 text-gray-600 border border-gray-300 hover:bg-yellow-50 hover:text-yellow-600'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<svg className="w-4 h-4" fill={flashcard.isFavorite ? "currentColor" : "none"} stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
|
||||
</svg>
|
||||
{flashcard.isFavorite ? '已收藏' : '收藏'}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setIsEditing(!isEditing)}
|
||||
className={`flex-1 py-3 rounded-lg font-medium transition-colors ${
|
||||
isEditing
|
||||
? 'bg-gray-100 text-gray-700 border border-gray-300'
|
||||
: 'bg-blue-100 text-blue-700 border border-blue-300 hover:bg-blue-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
{isEditing ? '取消編輯' : '編輯詞卡'}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="flex-1 py-3 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
刪除詞卡
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -6,11 +6,20 @@ import { ProtectedRoute } from '@/components/ProtectedRoute'
|
|||
import { Navigation } from '@/components/Navigation'
|
||||
import { FlashcardForm } from '@/components/FlashcardForm'
|
||||
import { flashcardsService, type CardSet, type Flashcard } from '@/lib/services/flashcards'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
function FlashcardsContent() {
|
||||
const [activeTab, setActiveTab] = useState('my-cards')
|
||||
const router = useRouter()
|
||||
const [activeTab, setActiveTab] = useState('all-cards')
|
||||
const [selectedSet, setSelectedSet] = useState<string | null>(null)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [showAdvancedSearch, setShowAdvancedSearch] = useState(false)
|
||||
const [searchFilters, setSearchFilters] = useState({
|
||||
cefrLevel: '',
|
||||
partOfSpeech: '',
|
||||
masteryLevel: '',
|
||||
onlyFavorites: false
|
||||
})
|
||||
|
||||
// Real data from API
|
||||
const [cardSets, setCardSets] = useState<CardSet[]>([])
|
||||
|
|
@ -18,10 +27,52 @@ function FlashcardsContent() {
|
|||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// 臨時使用學習功能的例句圖片作為測試
|
||||
const getExampleImage = (word: string): string => {
|
||||
const availableImages = [
|
||||
'/images/examples/bring_up.png',
|
||||
'/images/examples/instinct.png',
|
||||
'/images/examples/warrant.png'
|
||||
]
|
||||
|
||||
const imageMap: {[key: string]: string} = {
|
||||
'brought': '/images/examples/bring_up.png',
|
||||
'instincts': '/images/examples/instinct.png',
|
||||
'warrants': '/images/examples/warrant.png',
|
||||
'hello': '/images/examples/bring_up.png',
|
||||
'beautiful': '/images/examples/instinct.png',
|
||||
'understand': '/images/examples/warrant.png',
|
||||
'elaborate': '/images/examples/bring_up.png',
|
||||
'sophisticated': '/images/examples/instinct.png',
|
||||
'ubiquitous': '/images/examples/warrant.png'
|
||||
}
|
||||
|
||||
// 根據詞彙返回對應圖片,如果沒有則根據字母分配
|
||||
const mappedImage = imageMap[word?.toLowerCase()]
|
||||
if (mappedImage) return mappedImage
|
||||
|
||||
// 根據首字母分配圖片
|
||||
const firstChar = (word || 'a')[0].toLowerCase()
|
||||
const charCode = firstChar.charCodeAt(0) - 97 // a=0, b=1, c=2...
|
||||
const imageIndex = charCode % availableImages.length
|
||||
|
||||
return availableImages[imageIndex]
|
||||
}
|
||||
|
||||
// Form states
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingCard, setEditingCard] = useState<Flashcard | null>(null)
|
||||
|
||||
// 添加假資料用於展示CEFR效果
|
||||
const mockFlashcards = [
|
||||
{ id: 'mock1', word: 'hello', translation: '你好', partOfSpeech: 'interjection', pronunciation: '/həˈloʊ/', masteryLevel: 95, timesReviewed: 15, isFavorite: true, nextReviewDate: '2025-09-21', cardSet: { name: '基礎詞彙', color: 'bg-blue-500' }, difficultyLevel: 'A1', definition: 'A greeting word', example: 'Hello, how are you?', createdAt: '2025-09-17' },
|
||||
{ id: 'mock2', word: 'beautiful', translation: '美麗的', partOfSpeech: 'adjective', pronunciation: '/ˈbjuːtɪfəl/', masteryLevel: 78, timesReviewed: 8, isFavorite: false, nextReviewDate: '2025-09-22', cardSet: { name: '描述詞彙', color: 'bg-green-500' }, difficultyLevel: 'A2', definition: 'Pleasing to look at', example: 'The beautiful sunset', createdAt: '2025-09-16' },
|
||||
{ id: 'mock3', word: 'understand', translation: '理解', partOfSpeech: 'verb', pronunciation: '/ˌʌndərˈstænd/', masteryLevel: 65, timesReviewed: 12, isFavorite: true, nextReviewDate: '2025-09-20', cardSet: { name: '常用動詞', color: 'bg-yellow-500' }, difficultyLevel: 'B1', definition: 'To comprehend', example: 'I understand the concept', createdAt: '2025-09-15' },
|
||||
{ id: 'mock4', word: 'elaborate', translation: '詳細說明', partOfSpeech: 'verb', pronunciation: '/ɪˈlæbərət/', masteryLevel: 45, timesReviewed: 5, isFavorite: false, nextReviewDate: '2025-09-19', cardSet: { name: '高級詞彙', color: 'bg-purple-500' }, difficultyLevel: 'B2', definition: 'To explain in detail', example: 'Please elaborate on your idea', createdAt: '2025-09-14' },
|
||||
{ id: 'mock5', word: 'sophisticated', translation: '精密的', partOfSpeech: 'adjective', pronunciation: '/səˈfɪstɪkeɪtɪd/', masteryLevel: 30, timesReviewed: 3, isFavorite: true, nextReviewDate: '2025-09-18', cardSet: { name: '進階詞彙', color: 'bg-indigo-500' }, difficultyLevel: 'C1', definition: 'Highly developed', example: 'A sophisticated system', createdAt: '2025-09-13' },
|
||||
{ id: 'mock6', word: 'ubiquitous', translation: '無處不在的', partOfSpeech: 'adjective', pronunciation: '/juːˈbɪkwɪtəs/', masteryLevel: 15, timesReviewed: 1, isFavorite: false, nextReviewDate: '2025-09-17', cardSet: { name: '學術詞彙', color: 'bg-red-500' }, difficultyLevel: 'C2', definition: 'Present everywhere', example: 'Smartphones are ubiquitous', createdAt: '2025-09-12' }
|
||||
]
|
||||
|
||||
// Load data from API
|
||||
useEffect(() => {
|
||||
loadCardSets()
|
||||
|
|
@ -109,20 +160,124 @@ function FlashcardsContent() {
|
|||
}
|
||||
}
|
||||
|
||||
// Filter data
|
||||
const filteredSets = cardSets.filter(set =>
|
||||
set.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
set.description.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
const handleToggleFavorite = async (card: any) => {
|
||||
try {
|
||||
// 如果是假資料,只更新本地狀態
|
||||
if (card.id.startsWith('mock')) {
|
||||
const updatedMockCards = mockFlashcards.map(mockCard =>
|
||||
mockCard.id === card.id
|
||||
? { ...mockCard, isFavorite: !mockCard.isFavorite }
|
||||
: mockCard
|
||||
)
|
||||
// 這裡需要更新state,但由於是const,我們直接重新載入頁面來模擬效果
|
||||
alert(`${card.isFavorite ? '已取消收藏' : '已加入收藏'}「${card.word}」`)
|
||||
return
|
||||
}
|
||||
|
||||
const filteredCards = flashcards.filter(card => {
|
||||
if (searchTerm) {
|
||||
return card.word?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
card.translation?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
// 真實API調用
|
||||
const result = await flashcardsService.toggleFavorite(card.id)
|
||||
if (result.success) {
|
||||
loadFlashcards()
|
||||
alert(`${card.isFavorite ? '已取消收藏' : '已加入收藏'}「${card.word}」`)
|
||||
} else {
|
||||
alert(result.error || '操作失敗')
|
||||
}
|
||||
} catch (err) {
|
||||
alert('操作失敗,請重試')
|
||||
}
|
||||
}
|
||||
|
||||
// 獲取CEFR等級顏色
|
||||
const getCEFRColor = (level: string) => {
|
||||
switch (level) {
|
||||
case 'A1': return 'bg-green-100 text-green-700 border-green-200' // 淺綠 - 最基礎
|
||||
case 'A2': return 'bg-blue-100 text-blue-700 border-blue-200' // 淺藍 - 基礎
|
||||
case 'B1': return 'bg-yellow-100 text-yellow-700 border-yellow-200' // 淺黃 - 中級
|
||||
case 'B2': return 'bg-orange-100 text-orange-700 border-orange-200' // 淺橙 - 中高級
|
||||
case 'C1': return 'bg-red-100 text-red-700 border-red-200' // 淺紅 - 高級
|
||||
case 'C2': return 'bg-purple-100 text-purple-700 border-purple-200' // 淺紫 - 精通
|
||||
default: return 'bg-gray-100 text-gray-700 border-gray-200' // 預設灰色
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const allCards = [...flashcards, ...mockFlashcards] // 合併真實和假資料
|
||||
|
||||
// 進階搜尋邏輯
|
||||
const filteredCards = allCards.filter(card => {
|
||||
// 基本文字搜尋
|
||||
if (searchTerm) {
|
||||
const searchLower = searchTerm.toLowerCase()
|
||||
const matchesText =
|
||||
card.word?.toLowerCase().includes(searchLower) ||
|
||||
card.translation?.toLowerCase().includes(searchLower) ||
|
||||
card.definition?.toLowerCase().includes(searchLower)
|
||||
|
||||
if (!matchesText) return false
|
||||
}
|
||||
|
||||
// CEFR等級篩選
|
||||
if (searchFilters.cefrLevel && (card as any).difficultyLevel !== searchFilters.cefrLevel) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 詞性篩選
|
||||
if (searchFilters.partOfSpeech && card.partOfSpeech !== searchFilters.partOfSpeech) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 掌握度篩選
|
||||
if (searchFilters.masteryLevel) {
|
||||
const mastery = card.masteryLevel || 0
|
||||
if (searchFilters.masteryLevel === 'high' && mastery < 80) return false
|
||||
if (searchFilters.masteryLevel === 'medium' && (mastery < 60 || mastery >= 80)) return false
|
||||
if (searchFilters.masteryLevel === 'low' && mastery >= 60) return false
|
||||
}
|
||||
|
||||
// 收藏篩選
|
||||
if (searchFilters.onlyFavorites && !card.isFavorite) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
// 清除所有篩選
|
||||
const clearAllFilters = () => {
|
||||
setSearchTerm('')
|
||||
setSearchFilters({
|
||||
cefrLevel: '',
|
||||
partOfSpeech: '',
|
||||
masteryLevel: '',
|
||||
onlyFavorites: false
|
||||
})
|
||||
}
|
||||
|
||||
// 檢查是否有活動篩選
|
||||
const hasActiveFilters = searchTerm ||
|
||||
searchFilters.cefrLevel ||
|
||||
searchFilters.partOfSpeech ||
|
||||
searchFilters.masteryLevel ||
|
||||
searchFilters.onlyFavorites
|
||||
|
||||
// 搜尋結果高亮函數
|
||||
const highlightSearchTerm = (text: string, searchTerm: string) => {
|
||||
if (!searchTerm || !text) return text
|
||||
|
||||
const regex = new RegExp(`(${searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi')
|
||||
const parts = text.split(regex)
|
||||
|
||||
return parts.map((part, index) =>
|
||||
regex.test(part) ? (
|
||||
<mark key={index} className="bg-yellow-200 text-yellow-900 px-1 rounded">
|
||||
{part}
|
||||
</mark>
|
||||
) : (
|
||||
part
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Add loading and error states
|
||||
if (loading) {
|
||||
return (
|
||||
|
|
@ -150,8 +305,7 @@ function FlashcardsContent() {
|
|||
{/* Page Header */}
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">詞卡管理</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">管理你的詞卡集合</p>
|
||||
<h1 className="text-3xl font-bold text-gray-900">我的詞卡</h1>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
|
|
@ -169,18 +323,8 @@ function FlashcardsContent() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
{/* 簡化的Tabs - 移除卡組功能 */}
|
||||
<div className="flex space-x-8 mb-6 border-b border-gray-200">
|
||||
<button
|
||||
onClick={() => setActiveTab('my-cards')}
|
||||
className={`pb-4 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'my-cards'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
我的卡組 ({filteredSets.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('all-cards')}
|
||||
className={`pb-4 px-1 border-b-2 font-medium text-sm ${
|
||||
|
|
@ -192,84 +336,354 @@ function FlashcardsContent() {
|
|||
所有詞卡 ({filteredCards.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const defaultSet = cardSets.find(set => set.isDefault)
|
||||
if (defaultSet) {
|
||||
setSelectedSet(defaultSet.id)
|
||||
setActiveTab('all-cards')
|
||||
}
|
||||
}}
|
||||
className={`pb-4 px-1 border-b-2 font-medium text-sm ${
|
||||
selectedSet && cardSets.find(set => set.id === selectedSet)?.isDefault
|
||||
onClick={() => setActiveTab('favorites')}
|
||||
className={`pb-4 px-1 border-b-2 font-medium text-sm flex items-center gap-1 ${
|
||||
activeTab === 'favorites'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
📂 未分類詞卡
|
||||
<span className="text-yellow-500">⭐</span>
|
||||
收藏詞卡 ({allCards.filter(card => card.isFavorite).length})
|
||||
</button>
|
||||
</div>
|
||||
{/* Search */}
|
||||
<div className="mb-6">
|
||||
<div className="relative">
|
||||
{/* 進階搜尋區域 */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">搜尋詞卡</h2>
|
||||
<button
|
||||
onClick={() => setShowAdvancedSearch(!showAdvancedSearch)}
|
||||
className="text-sm text-blue-600 hover:text-blue-700 font-medium flex items-center gap-1"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4" />
|
||||
</svg>
|
||||
{showAdvancedSearch ? '收起篩選' : '進階篩選'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 主要搜尋框 */}
|
||||
<div className="relative mb-4">
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="搜尋詞卡或卡組..."
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
placeholder="搜尋詞彙、翻譯或定義..."
|
||||
className="w-full pl-12 pr-20 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent text-base"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
setSearchTerm('')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||
<svg className="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
{(searchTerm || hasActiveFilters) && (
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500 bg-blue-100 px-2 py-1 rounded-full">
|
||||
{filteredCards.length} 結果
|
||||
</span>
|
||||
<button
|
||||
onClick={clearAllFilters}
|
||||
className="text-gray-400 hover:text-gray-600 p-1 rounded-full hover:bg-gray-100 transition-colors"
|
||||
title="清除搜尋"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 進階篩選選項 */}
|
||||
{showAdvancedSearch && (
|
||||
<div className="bg-gray-50 rounded-lg p-4 space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{/* CEFR等級篩選 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">CEFR等級</label>
|
||||
<select
|
||||
value={searchFilters.cefrLevel}
|
||||
onChange={(e) => setSearchFilters(prev => ({ ...prev, cefrLevel: e.target.value }))}
|
||||
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-primary focus:border-primary"
|
||||
>
|
||||
<option value="">所有等級</option>
|
||||
<option value="A1">A1 - 基礎</option>
|
||||
<option value="A2">A2 - 基礎</option>
|
||||
<option value="B1">B1 - 中級</option>
|
||||
<option value="B2">B2 - 中高級</option>
|
||||
<option value="C1">C1 - 高級</option>
|
||||
<option value="C2">C2 - 精通</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 詞性篩選 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">詞性</label>
|
||||
<select
|
||||
value={searchFilters.partOfSpeech}
|
||||
onChange={(e) => setSearchFilters(prev => ({ ...prev, partOfSpeech: e.target.value }))}
|
||||
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-primary focus:border-primary"
|
||||
>
|
||||
<option value="">所有詞性</option>
|
||||
<option value="noun">名詞 (noun)</option>
|
||||
<option value="verb">動詞 (verb)</option>
|
||||
<option value="adjective">形容詞 (adjective)</option>
|
||||
<option value="adverb">副詞 (adverb)</option>
|
||||
<option value="preposition">介詞 (preposition)</option>
|
||||
<option value="interjection">感嘆詞 (interjection)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 掌握度篩選 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">掌握程度</label>
|
||||
<select
|
||||
value={searchFilters.masteryLevel}
|
||||
onChange={(e) => setSearchFilters(prev => ({ ...prev, masteryLevel: e.target.value }))}
|
||||
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-primary focus:border-primary"
|
||||
>
|
||||
<option value="">所有程度</option>
|
||||
<option value="high">已熟練 (80%+)</option>
|
||||
<option value="medium">學習中 (60-79%)</option>
|
||||
<option value="low">需加強 (<60%)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 收藏篩選 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">收藏狀態</label>
|
||||
<label className="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={searchFilters.onlyFavorites}
|
||||
onChange={(e) => setSearchFilters(prev => ({ ...prev, onlyFavorites: e.target.checked }))}
|
||||
className="w-4 h-4 text-yellow-600 bg-gray-100 border-gray-300 rounded focus:ring-yellow-500"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-gray-700 flex items-center gap-1">
|
||||
<span className="text-yellow-500">⭐</span>
|
||||
僅顯示收藏詞卡
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 快速篩選按鈕 */}
|
||||
<div className="flex items-center gap-2 pt-2 border-t border-gray-200">
|
||||
<span className="text-sm text-gray-600">快速篩選:</span>
|
||||
<button
|
||||
onClick={() => setSearchFilters(prev => ({ ...prev, masteryLevel: 'low' }))}
|
||||
className="px-3 py-1 bg-red-100 text-red-700 rounded-full text-xs font-medium hover:bg-red-200 transition-colors"
|
||||
>
|
||||
需加強詞卡
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSearchFilters(prev => ({ ...prev, onlyFavorites: true }))}
|
||||
className="px-3 py-1 bg-yellow-100 text-yellow-700 rounded-full text-xs font-medium hover:bg-yellow-200 transition-colors"
|
||||
>
|
||||
收藏詞卡
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSearchFilters(prev => ({ ...prev, cefrLevel: 'C1' }))}
|
||||
className="px-3 py-1 bg-purple-100 text-purple-700 rounded-full text-xs font-medium hover:bg-purple-200 transition-colors"
|
||||
>
|
||||
高級詞彙
|
||||
</button>
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={clearAllFilters}
|
||||
className="px-3 py-1 bg-gray-100 text-gray-700 rounded-full text-xs font-medium hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
清除全部
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 搜尋結果統計 */}
|
||||
{(searchTerm || hasActiveFilters) && (
|
||||
<div className="flex items-center justify-between text-sm text-gray-600 bg-blue-50 px-4 py-2 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
<span>
|
||||
找到 <strong className="text-blue-700">{filteredCards.length}</strong> 個詞卡
|
||||
{searchTerm && (
|
||||
<span>,包含 "<strong className="text-blue-700">{searchTerm}</strong>"</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={clearAllFilters}
|
||||
className="text-blue-600 hover:text-blue-700 font-medium"
|
||||
>
|
||||
清除篩選
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Card Sets Tab */}
|
||||
{activeTab === 'my-cards' && (
|
||||
|
||||
{/* Favorites Tab */}
|
||||
{activeTab === 'favorites' && (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-semibold">共 {filteredSets.length} 個卡組</h3>
|
||||
<h3 className="text-lg font-semibold">共 {allCards.filter(card => card.isFavorite).length} 個詞卡</h3>
|
||||
</div>
|
||||
|
||||
{filteredSets.length === 0 ? (
|
||||
{allCards.filter(card => card.isFavorite).length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 mb-4">還沒有詞卡集合</p>
|
||||
<Link
|
||||
href="/generate"
|
||||
className="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
創建第一個卡組
|
||||
</Link>
|
||||
<div className="text-yellow-500 text-6xl mb-4">⭐</div>
|
||||
<p className="text-gray-500 mb-4">還沒有收藏的詞卡</p>
|
||||
<p className="text-sm text-gray-400">在詞卡列表中點擊星星按鈕來收藏重要的詞彙</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredSets.map(set => (
|
||||
<div
|
||||
key={set.id}
|
||||
className={`border rounded-lg hover:shadow-lg transition-shadow cursor-pointer ${
|
||||
set.isDefault ? 'ring-2 ring-gray-300' : ''
|
||||
}`}
|
||||
onClick={() => {
|
||||
setSelectedSet(set.id)
|
||||
setActiveTab('all-cards')
|
||||
}}
|
||||
>
|
||||
<div className={`${set.isDefault ? 'bg-slate-700' : set.color} text-white p-4 rounded-t-lg`}>
|
||||
<div className="flex items-center space-x-2">
|
||||
{set.isDefault && <span>📂</span>}
|
||||
<h4 className="font-semibold text-lg">
|
||||
{set.name}
|
||||
{set.isDefault && <span className="text-xs ml-2 opacity-75">(預設)</span>}
|
||||
</h4>
|
||||
</div>
|
||||
<p className="text-sm opacity-90">{set.description}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-white rounded-b-lg">
|
||||
<div className="flex justify-between items-center text-sm text-gray-600">
|
||||
<span>{set.cardCount} 張詞卡</span>
|
||||
<span>進度: {set.progress}%</span>
|
||||
<div className="space-y-2">
|
||||
{allCards.filter(card => card.isFavorite).map(card => (
|
||||
<div key={card.id} className="bg-white border border-gray-200 rounded-lg hover:shadow-md transition-all duration-200 relative">
|
||||
<div className="p-4">
|
||||
{/* 收藏詞卡內容 - 與普通詞卡相同的佈局 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="absolute top-3 right-3">
|
||||
<span className={`text-xs px-2 py-1 rounded-full font-medium border ${getCEFRColor((card as any).difficultyLevel || 'A1')}`}>
|
||||
{(card as any).difficultyLevel || 'A1'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-54 h-36 bg-gray-100 rounded-lg overflow-hidden border border-gray-200 flex items-center justify-center">
|
||||
<img
|
||||
src={getExampleImage(card.word)}
|
||||
alt={`${card.word} example`}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = 'none'
|
||||
target.parentElement!.innerHTML = `
|
||||
<div class="text-gray-400 text-xs text-center">
|
||||
<svg class="w-6 h-6 mx-auto mb-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
例句圖
|
||||
</div>
|
||||
`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-xl font-bold text-gray-900">
|
||||
{searchTerm ? highlightSearchTerm(card.word || '未設定', searchTerm) : (card.word || '未設定')}
|
||||
</h3>
|
||||
<span className="text-sm bg-gray-100 text-gray-700 px-2 py-1 rounded">
|
||||
{card.partOfSpeech || 'unknown'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mt-1">
|
||||
<span className="text-lg text-gray-900 font-medium">
|
||||
{searchTerm ? highlightSearchTerm(card.translation || '未設定', searchTerm) : (card.translation || '未設定')}
|
||||
</span>
|
||||
{card.pronunciation && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500">{card.pronunciation}</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
console.log(`播放 ${card.word} 的發音`)
|
||||
}}
|
||||
className="w-6 h-6 bg-blue-600 rounded-full flex items-center justify-center text-white hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mt-2 text-sm text-gray-500">
|
||||
<span>創建: {new Date(card.createdAt).toLocaleDateString()}</span>
|
||||
<span>掌握度: {card.masteryLevel}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右側:重新設計的操作按鈕區 */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 收藏按鈕 */}
|
||||
<button
|
||||
onClick={() => handleToggleFavorite(card)}
|
||||
className={`px-3 py-2 rounded-lg font-medium transition-colors ${
|
||||
card.isFavorite
|
||||
? 'bg-yellow-100 text-yellow-700 border border-yellow-300 hover:bg-yellow-200'
|
||||
: 'bg-gray-100 text-gray-600 border border-gray-300 hover:bg-yellow-50 hover:text-yellow-600 hover:border-yellow-300'
|
||||
}`}
|
||||
title={card.isFavorite ? "取消收藏" : "加入收藏"}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<svg className="w-4 h-4" fill={card.isFavorite ? "currentColor" : "none"} stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
|
||||
</svg>
|
||||
<span className="text-sm">
|
||||
{card.isFavorite ? '已收藏' : '收藏'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* 編輯按鈕 */}
|
||||
<button
|
||||
onClick={() => handleEdit(card)}
|
||||
className="px-3 py-2 bg-blue-100 text-blue-700 border border-blue-300 rounded-lg font-medium hover:bg-blue-200 transition-colors"
|
||||
title="編輯詞卡"
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
<span className="text-sm">編輯</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* 刪除按鈕 */}
|
||||
<button
|
||||
onClick={() => handleDelete(card)}
|
||||
className="px-3 py-2 bg-red-100 text-red-700 border border-red-300 rounded-lg font-medium hover:bg-red-200 transition-colors"
|
||||
title="刪除詞卡"
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
<span className="text-sm">刪除</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* 查看詳情按鈕 - 導航到詳細頁面 */}
|
||||
<button
|
||||
onClick={() => {
|
||||
router.push(`/flashcards/${card.id}`)
|
||||
}}
|
||||
className="px-4 py-2 bg-gray-100 text-gray-700 border border-gray-300 rounded-lg font-medium hover:bg-gray-200 hover:text-gray-900 transition-colors"
|
||||
title="查看詳細資訊"
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm">詳細</span>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -284,35 +698,8 @@ function FlashcardsContent() {
|
|||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-semibold">共 {filteredCards.length} 個詞卡</h3>
|
||||
{selectedSet && (
|
||||
<button
|
||||
onClick={() => setSelectedSet(null)}
|
||||
className="text-sm text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
顯示所有詞卡
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 未分類提醒 */}
|
||||
{selectedSet && cardSets.find(set => set.id === selectedSet)?.isDefault && filteredCards.length > 15 && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-blue-600">💡</span>
|
||||
<div className="flex-1">
|
||||
<p className="text-blue-800 text-sm">
|
||||
您有 {filteredCards.length} 個未分類詞卡,建議整理到不同主題的卡組中,有助於更好地組織學習內容。
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setActiveTab('my-cards')}
|
||||
className="text-blue-600 text-sm font-medium hover:text-blue-800"
|
||||
>
|
||||
查看卡組
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{filteredCards.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
|
|
@ -325,46 +712,153 @@ function FlashcardsContent() {
|
|||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
{filteredCards.map(card => (
|
||||
<div key={card.id} className="bg-white border rounded-lg p-4 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div key={card.id} className="bg-white border border-gray-200 rounded-lg hover:shadow-md transition-all duration-200 relative">
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* 詞卡右上角CEFR標註 */}
|
||||
<div className="absolute top-3 right-3">
|
||||
<span className={`text-xs px-2 py-1 rounded-full font-medium border ${getCEFRColor((card as any).difficultyLevel || 'A1')}`}>
|
||||
{(card as any).difficultyLevel || 'A1'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 左側:詞彙基本信息 */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* 例句圖片 - 超大尺寸 */}
|
||||
<div className="w-54 h-36 bg-gray-100 rounded-lg overflow-hidden border border-gray-200 flex items-center justify-center">
|
||||
<img
|
||||
src={getExampleImage(card.word)}
|
||||
alt={`${card.word} example`}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
// 圖片載入失敗時顯示佔位符
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = 'none'
|
||||
target.parentElement!.innerHTML = `
|
||||
<div class="text-gray-400 text-xs text-center">
|
||||
<svg class="w-6 h-6 mx-auto mb-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
例句圖
|
||||
</div>
|
||||
`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<h4 className="font-semibold text-lg">{card.word || '未設定'}</h4>
|
||||
<span className="text-sm text-gray-500">{card.partOfSpeech}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-xl font-bold text-gray-900">
|
||||
{searchTerm ? highlightSearchTerm(card.word || '未設定', searchTerm) : (card.word || '未設定')}
|
||||
</h3>
|
||||
<span className="text-sm bg-gray-100 text-gray-700 px-2 py-1 rounded">
|
||||
{card.partOfSpeech || 'unknown'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mt-1">
|
||||
<span className="text-lg text-gray-900 font-medium">
|
||||
{searchTerm ? highlightSearchTerm(card.translation || '未設定', searchTerm) : (card.translation || '未設定')}
|
||||
</span>
|
||||
{card.pronunciation && (
|
||||
<span className="text-sm text-blue-600">{card.pronunciation}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500">{card.pronunciation}</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
// TODO: 播放發音
|
||||
console.log(`播放 ${card.word} 的發音`)
|
||||
}}
|
||||
className="w-6 h-6 bg-blue-600 rounded-full flex items-center justify-center text-white hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-gray-700 mt-1">{card.translation || '未設定'}</p>
|
||||
{card.example && (
|
||||
<p className="text-sm text-gray-600 mt-2 italic">例句: {card.example}</p>
|
||||
)}
|
||||
<div className="flex items-center space-x-4 mt-2 text-xs text-gray-500">
|
||||
<span>卡組: {card.cardSet.name}</span>
|
||||
<span>熟練度: {card.masteryLevel}/5</span>
|
||||
<span>複習: {card.timesReviewed} 次</span>
|
||||
<span>下次複習: {new Date(card.nextReviewDate).toLocaleDateString()}</span>
|
||||
|
||||
{/* 簡要統計 */}
|
||||
<div className="flex items-center gap-4 mt-2 text-sm text-gray-500">
|
||||
<span>創建: {new Date(card.createdAt).toLocaleDateString()}</span>
|
||||
<span>掌握度: {card.masteryLevel}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => handleEdit(card)}
|
||||
className="text-blue-600 hover:text-blue-800 text-sm"
|
||||
>
|
||||
編輯
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(card)}
|
||||
className="text-red-600 hover:text-red-800 text-sm"
|
||||
>
|
||||
刪除
|
||||
</button>
|
||||
|
||||
{/* 右側:操作按鈕 */}
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
{/* 重新設計的操作按鈕區 */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 收藏按鈕 */}
|
||||
<button
|
||||
onClick={() => handleToggleFavorite(card)}
|
||||
className={`px-3 py-2 rounded-lg font-medium transition-colors ${
|
||||
card.isFavorite
|
||||
? 'bg-yellow-100 text-yellow-700 border border-yellow-300 hover:bg-yellow-200'
|
||||
: 'bg-gray-100 text-gray-600 border border-gray-300 hover:bg-yellow-50 hover:text-yellow-600 hover:border-yellow-300'
|
||||
}`}
|
||||
title={card.isFavorite ? "取消收藏" : "加入收藏"}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<svg className="w-4 h-4" fill={card.isFavorite ? "currentColor" : "none"} stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
|
||||
</svg>
|
||||
<span className="text-sm">
|
||||
{card.isFavorite ? '已收藏' : '收藏'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* 編輯按鈕 */}
|
||||
<button
|
||||
onClick={() => handleEdit(card)}
|
||||
className="px-3 py-2 bg-blue-100 text-blue-700 border border-blue-300 rounded-lg font-medium hover:bg-blue-200 transition-colors"
|
||||
title="編輯詞卡"
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
<span className="text-sm">編輯</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* 刪除按鈕 */}
|
||||
<button
|
||||
onClick={() => handleDelete(card)}
|
||||
className="px-3 py-2 bg-red-100 text-red-700 border border-red-300 rounded-lg font-medium hover:bg-red-200 transition-colors"
|
||||
title="刪除詞卡"
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
<span className="text-sm">刪除</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* 查看詳情按鈕 - 導航到詳細頁面 */}
|
||||
<button
|
||||
onClick={() => {
|
||||
router.push(`/flashcards/${card.id}`)
|
||||
}}
|
||||
className="px-4 py-2 bg-gray-100 text-gray-700 border border-gray-300 rounded-lg font-medium hover:bg-gray-200 hover:text-gray-900 transition-colors"
|
||||
title="查看詳細資訊"
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm">詳細</span>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -382,8 +876,9 @@ function FlashcardsContent() {
|
|||
initialData={editingCard ? {
|
||||
id: editingCard.id,
|
||||
cardSetId: editingCard.cardSet ? cardSets.find(cs => cs.name === editingCard.cardSet.name)?.id || cardSets[0]?.id : cardSets[0]?.id,
|
||||
english: editingCard.word,
|
||||
chinese: editingCard.translation,
|
||||
word: editingCard.word,
|
||||
translation: editingCard.translation,
|
||||
definition: editingCard.definition,
|
||||
pronunciation: editingCard.pronunciation,
|
||||
partOfSpeech: editingCard.partOfSpeech,
|
||||
example: editingCard.example,
|
||||
|
|
|
|||
|
|
@ -1,389 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ClickableText } from '@/components/ClickableText'
|
||||
|
||||
export default function GenerateDemoPage() {
|
||||
const [mode, setMode] = useState<'manual' | 'screenshot'>('manual')
|
||||
const [textInput, setTextInput] = useState('')
|
||||
const [extractionType, setExtractionType] = useState<'vocabulary' | 'smart'>('vocabulary')
|
||||
const [cardCount, setCardCount] = useState(10)
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false)
|
||||
const [showAnalysisView, setShowAnalysisView] = useState(false)
|
||||
const [sentenceAnalysis, setSentenceAnalysis] = useState<any>(null)
|
||||
const [sentenceMeaning, setSentenceMeaning] = useState('')
|
||||
const [usageCount, setUsageCount] = useState(0)
|
||||
const [isPremium] = useState(false)
|
||||
|
||||
// 模擬分析後的句子資料
|
||||
const mockSentenceAnalysis = {
|
||||
meaning: "他在我們的會議中提出了這件事,但沒有人同意。這句話表達了在會議中有人提出某個議題或想法,但得不到其他與會者的認同。",
|
||||
words: {
|
||||
"he": {
|
||||
word: "he",
|
||||
translation: "他",
|
||||
definition: "Used to refer to a male person or animal",
|
||||
partOfSpeech: "pronoun",
|
||||
pronunciation: "/hiː/",
|
||||
synonyms: ["him", "that man"],
|
||||
isPhrase: false
|
||||
},
|
||||
"brought": {
|
||||
word: "brought",
|
||||
translation: "帶來、提出",
|
||||
definition: "Past tense of bring; to take or carry something to a place",
|
||||
partOfSpeech: "verb",
|
||||
pronunciation: "/brɔːt/",
|
||||
synonyms: ["carried", "took", "delivered"],
|
||||
isPhrase: true,
|
||||
phraseInfo: {
|
||||
phrase: "bring up",
|
||||
meaning: "提出(話題)、養育",
|
||||
warning: "在這個句子中,\"brought up\" 是一個片語,意思是\"提出話題\",而不是單純的\"帶來\""
|
||||
}
|
||||
},
|
||||
"this": {
|
||||
word: "this",
|
||||
translation: "這個",
|
||||
definition: "Used to indicate something near or just mentioned",
|
||||
partOfSpeech: "pronoun",
|
||||
pronunciation: "/ðɪs/",
|
||||
synonyms: ["that", "it"],
|
||||
isPhrase: false
|
||||
},
|
||||
"thing": {
|
||||
word: "thing",
|
||||
translation: "事情、東西",
|
||||
definition: "An object, fact, or situation",
|
||||
partOfSpeech: "noun",
|
||||
pronunciation: "/θɪŋ/",
|
||||
synonyms: ["object", "matter", "item"],
|
||||
isPhrase: false
|
||||
},
|
||||
"up": {
|
||||
word: "up",
|
||||
translation: "向上",
|
||||
definition: "Toward a higher place or position",
|
||||
partOfSpeech: "adverb",
|
||||
pronunciation: "/ʌp/",
|
||||
synonyms: ["upward", "above"],
|
||||
isPhrase: true,
|
||||
phraseInfo: {
|
||||
phrase: "bring up",
|
||||
meaning: "提出(話題)、養育",
|
||||
warning: "\"up\" 在這裡是片語 \"bring up\" 的一部分,不是單獨的\"向上\"的意思"
|
||||
}
|
||||
},
|
||||
"during": {
|
||||
word: "during",
|
||||
translation: "在...期間",
|
||||
definition: "Throughout the course or duration of",
|
||||
partOfSpeech: "preposition",
|
||||
pronunciation: "/ˈdjʊərɪŋ/",
|
||||
synonyms: ["throughout", "while"],
|
||||
isPhrase: false
|
||||
},
|
||||
"our": {
|
||||
word: "our",
|
||||
translation: "我們的",
|
||||
definition: "Belonging to us",
|
||||
partOfSpeech: "pronoun",
|
||||
pronunciation: "/aʊər/",
|
||||
synonyms: ["ours"],
|
||||
isPhrase: false
|
||||
},
|
||||
"meeting": {
|
||||
word: "meeting",
|
||||
translation: "會議",
|
||||
definition: "An organized gathering of people for discussion",
|
||||
partOfSpeech: "noun",
|
||||
pronunciation: "/ˈmiːtɪŋ/",
|
||||
synonyms: ["conference", "assembly", "gathering"],
|
||||
isPhrase: false
|
||||
},
|
||||
"and": {
|
||||
word: "and",
|
||||
translation: "和、而且",
|
||||
definition: "Used to connect words or clauses",
|
||||
partOfSpeech: "conjunction",
|
||||
pronunciation: "/ænd/",
|
||||
synonyms: ["plus", "also"],
|
||||
isPhrase: false
|
||||
},
|
||||
"no": {
|
||||
word: "no",
|
||||
translation: "沒有",
|
||||
definition: "Not any; not one",
|
||||
partOfSpeech: "determiner",
|
||||
pronunciation: "/nəʊ/",
|
||||
synonyms: ["none", "zero"],
|
||||
isPhrase: false
|
||||
},
|
||||
"one": {
|
||||
word: "one",
|
||||
translation: "一個人、任何人",
|
||||
definition: "A single person or thing",
|
||||
partOfSpeech: "pronoun",
|
||||
pronunciation: "/wʌn/",
|
||||
synonyms: ["someone", "anybody"],
|
||||
isPhrase: false
|
||||
},
|
||||
"agreed": {
|
||||
word: "agreed",
|
||||
translation: "同意",
|
||||
definition: "Past tense of agree; to have the same opinion",
|
||||
partOfSpeech: "verb",
|
||||
pronunciation: "/əˈɡriːd/",
|
||||
synonyms: ["consented", "accepted", "approved"],
|
||||
isPhrase: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 處理句子分析
|
||||
const handleAnalyzeSentence = async () => {
|
||||
if (!textInput.trim()) return
|
||||
|
||||
// 檢查使用次數限制
|
||||
if (!isPremium && usageCount >= 5) {
|
||||
alert('❌ 免費用戶 3 小時內只能分析 5 次句子,請稍後再試或升級到付費版本')
|
||||
return
|
||||
}
|
||||
|
||||
setIsAnalyzing(true)
|
||||
|
||||
try {
|
||||
// 模擬 API 調用
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
|
||||
setSentenceAnalysis(mockSentenceAnalysis.words)
|
||||
setSentenceMeaning(mockSentenceAnalysis.meaning)
|
||||
setShowAnalysisView(true)
|
||||
setUsageCount(prev => prev + 1)
|
||||
} catch (error) {
|
||||
console.error('Error analyzing sentence:', error)
|
||||
alert('分析句子時發生錯誤,請稍後再試')
|
||||
} finally {
|
||||
setIsAnalyzing(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Navigation */}
|
||||
<nav className="bg-white shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between h-16">
|
||||
<div className="flex items-center">
|
||||
<h1 className="text-2xl font-bold text-blue-600">DramaLing</h1>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-gray-600">Demo 版本</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{!showAnalysisView ? (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-8">AI 智能生成詞卡 - 演示版</h1>
|
||||
|
||||
{/* Input Mode Selection */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold mb-4">原始例句類型</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<button
|
||||
onClick={() => setMode('manual')}
|
||||
className={`p-4 rounded-lg border-2 transition-all ${
|
||||
mode === 'manual'
|
||||
? 'border-blue-600 bg-blue-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="text-2xl mb-2">✍️</div>
|
||||
<div className="font-semibold">手動輸入</div>
|
||||
<div className="text-sm text-gray-600 mt-1">貼上或輸入英文文本</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode('screenshot')}
|
||||
disabled={!isPremium}
|
||||
className={`p-4 rounded-lg border-2 transition-all relative ${
|
||||
mode === 'screenshot'
|
||||
? 'border-blue-600 bg-blue-50'
|
||||
: isPremium
|
||||
? 'border-gray-200 hover:border-gray-300'
|
||||
: 'border-gray-200 bg-gray-100 cursor-not-allowed opacity-60'
|
||||
}`}
|
||||
>
|
||||
<div className="text-2xl mb-2">📷</div>
|
||||
<div className="font-semibold">影劇截圖</div>
|
||||
<div className="text-sm text-gray-600 mt-1">上傳影劇截圖 (Phase 2)</div>
|
||||
{!isPremium && (
|
||||
<div className="absolute top-2 right-2 px-2 py-1 bg-yellow-100 text-yellow-700 text-xs rounded-full">
|
||||
訂閱功能
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Input */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-6 mb-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-4">輸入英文文本</h2>
|
||||
<textarea
|
||||
value={textInput}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
if (mode === 'manual' && value.length > 50) {
|
||||
return // 阻止輸入超過50字
|
||||
}
|
||||
setTextInput(value)
|
||||
}}
|
||||
placeholder={mode === 'manual'
|
||||
? "輸入英文句子(最多50字)..."
|
||||
: "貼上您想要學習的英文文本,例如影劇對話、文章段落..."
|
||||
}
|
||||
className={`w-full h-40 px-4 py-3 border rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-transparent outline-none resize-none ${
|
||||
mode === 'manual' && textInput.length >= 45 ? 'border-yellow-400' :
|
||||
mode === 'manual' && textInput.length >= 50 ? 'border-red-400' : 'border-gray-300'
|
||||
}`}
|
||||
/>
|
||||
<div className="mt-2 flex justify-between text-sm">
|
||||
<span className={`${
|
||||
mode === 'manual' && textInput.length >= 45 ? 'text-yellow-600' :
|
||||
mode === 'manual' && textInput.length >= 50 ? 'text-red-600' : 'text-gray-600'
|
||||
}`}>
|
||||
{mode === 'manual' ? `最多 50 字元 • 目前:${textInput.length} 字元` : `最多 5000 字元 • 目前:${textInput.length} 字元`}
|
||||
</span>
|
||||
{mode === 'manual' && textInput.length > 40 && (
|
||||
<span className={textInput.length >= 50 ? 'text-red-600' : 'text-yellow-600'}>
|
||||
{textInput.length >= 50 ? '已達上限!' : `還可輸入 ${50 - textInput.length} 字元`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 預設示例 */}
|
||||
{!textInput && (
|
||||
<div className="mt-4 p-3 bg-gray-50 rounded-lg">
|
||||
<div className="text-sm text-gray-700 mb-2">
|
||||
<strong>示例句子:</strong>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setTextInput("He brought this thing up during our meeting and no one agreed.")}
|
||||
className="text-sm text-blue-600 hover:text-blue-800 bg-blue-50 px-3 py-1 rounded border border-blue-200"
|
||||
>
|
||||
點擊使用示例:He brought this thing up during our meeting and no one agreed.
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 新的按鈕區域 */}
|
||||
<div className="space-y-4">
|
||||
{/* 分析句子按鈕 */}
|
||||
<button
|
||||
onClick={handleAnalyzeSentence}
|
||||
disabled={isAnalyzing || (mode === 'manual' && (!textInput || textInput.length > 50)) || (mode === 'screenshot')}
|
||||
className="w-full bg-blue-600 text-white py-4 rounded-lg font-semibold hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isAnalyzing ? (
|
||||
<span className="flex items-center justify-center">
|
||||
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
正在分析句子中...
|
||||
</span>
|
||||
) : (
|
||||
'🔍 分析句子(點擊查詢單字)'
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 使用次數顯示 */}
|
||||
<div className="text-center text-sm text-gray-600">
|
||||
{isPremium ? (
|
||||
<span className="text-green-600">🌟 付費用戶:無限制使用</span>
|
||||
) : (
|
||||
<span className={usageCount >= 4 ? 'text-red-600' : usageCount >= 3 ? 'text-yellow-600' : 'text-gray-600'}>
|
||||
免費用戶:已使用 {usageCount}/5 次 (3小時內)
|
||||
{usageCount >= 5 && <span className="block text-red-500 mt-1">已達上限,請稍後再試</span>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* 句子分析視圖 */
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-3xl font-bold">句子分析結果</h1>
|
||||
<button
|
||||
onClick={() => setShowAnalysisView(false)}
|
||||
className="text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
← 返回
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 原始句子顯示 */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold mb-4">原始句子</h2>
|
||||
<div className="bg-gray-50 p-4 rounded-lg mb-4">
|
||||
<div className="text-lg leading-relaxed">
|
||||
{textInput}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-base font-semibold mb-2">整句意思</h3>
|
||||
<div className="text-gray-700 leading-relaxed">
|
||||
{sentenceMeaning}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 互動式文字 */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold mb-4">點擊查詢單字意思</h2>
|
||||
<div className="p-4 bg-blue-50 rounded-lg border-l-4 border-blue-400 mb-4">
|
||||
<p className="text-sm text-blue-800">
|
||||
💡 <strong>使用說明:</strong>點擊下方句子中的任何單字,可以立即查看詳細意思。
|
||||
黃色背景表示該單字屬於片語或俚語,會優先顯示片語意思。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-gray-50 rounded-lg border-2 border-dashed border-gray-300">
|
||||
<ClickableText
|
||||
text={textInput}
|
||||
analysis={sentenceAnalysis}
|
||||
onWordClick={(word, analysis) => {
|
||||
console.log('Clicked word:', word, analysis)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按鈕 */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-6">
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => setShowAnalysisView(false)}
|
||||
className="flex-1 bg-gray-200 text-gray-700 py-3 rounded-lg font-medium hover:bg-gray-300 transition-colors"
|
||||
>
|
||||
🔄 分析新句子
|
||||
</button>
|
||||
<button
|
||||
onClick={() => alert('詞卡生成功能開發中...')}
|
||||
className="flex-1 bg-blue-600 text-white py-3 rounded-lg font-medium hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
📖 生成詞卡
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,21 +1,16 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { ProtectedRoute } from '@/components/ProtectedRoute'
|
||||
import { Navigation } from '@/components/Navigation'
|
||||
import { ClickableTextV2 } from '@/components/ClickableTextV2'
|
||||
import { GrammarCorrectionPanel } from '@/components/GrammarCorrectionPanel'
|
||||
import { flashcardsService } from '@/lib/services/flashcards'
|
||||
import Link from 'next/link'
|
||||
|
||||
function GenerateContent() {
|
||||
const [mode, setMode] = useState<'manual' | 'screenshot'>('manual')
|
||||
const [textInput, setTextInput] = useState('')
|
||||
const [extractionType, setExtractionType] = useState<'vocabulary' | 'smart'>('vocabulary')
|
||||
const [cardCount, setCardCount] = useState(10)
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false)
|
||||
const [generatedCards, setGeneratedCards] = useState<any[]>([])
|
||||
const [showPreview, setShowPreview] = useState(false)
|
||||
const [showAnalysisView, setShowAnalysisView] = useState(false)
|
||||
const [sentenceAnalysis, setSentenceAnalysis] = useState<any>(null)
|
||||
const [sentenceMeaning, setSentenceMeaning] = useState('')
|
||||
|
|
@ -23,82 +18,297 @@ function GenerateContent() {
|
|||
const [finalText, setFinalText] = useState('')
|
||||
const [usageCount, setUsageCount] = useState(0)
|
||||
const [isPremium] = useState(true)
|
||||
// 移除快取狀態,每次都是新查詢
|
||||
const [phrasePopup, setPhrasePopup] = useState<{
|
||||
phrase: string
|
||||
analysis: any
|
||||
position: { x: number, y: number }
|
||||
} | null>(null)
|
||||
|
||||
// 處理句子分析 - 使用真實AI API
|
||||
|
||||
// 處理句子分析 - 使用假資料測試
|
||||
const handleAnalyzeSentence = async () => {
|
||||
console.log('🚀 handleAnalyzeSentence 被調用')
|
||||
console.log('📝 輸入文本:', textInput)
|
||||
console.log('🚀 handleAnalyzeSentence 被調用 (假資料模式)')
|
||||
|
||||
if (!textInput.trim()) {
|
||||
console.log('❌ 文本為空,退出')
|
||||
return
|
||||
}
|
||||
|
||||
// 取得用戶設定的程度
|
||||
const userLevel = localStorage.getItem('userEnglishLevel') || 'A2';
|
||||
console.log('🎯 使用用戶程度:', userLevel);
|
||||
|
||||
if (!isPremium && usageCount >= 5) {
|
||||
console.log('❌ 使用次數超限')
|
||||
alert('❌ 免費用戶 3 小時內只能分析 5 次句子,請稍後再試或升級到付費版本')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('✅ 開始分析,設定 loading 狀態')
|
||||
setIsAnalyzing(true)
|
||||
|
||||
try {
|
||||
// 調用真實的後端AI API
|
||||
console.log('🌐 發送API請求到:', 'http://localhost:5000/api/ai/analyze-sentence')
|
||||
const response = await fetch('http://localhost:5000/api/ai/analyze-sentence', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// 模擬API延遲
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
// 使用有語法錯誤的測試句子
|
||||
const testSentence = "She just join the team, so let's cut her some slack until she get used to the workflow."
|
||||
|
||||
// 假資料:完整詞彙分析結果 (包含句子中的所有詞彙)
|
||||
const mockAnalysis = {
|
||||
"she": {
|
||||
word: "she",
|
||||
translation: "她",
|
||||
definition: "female person pronoun",
|
||||
partOfSpeech: "pronoun",
|
||||
pronunciation: "/ʃiː/",
|
||||
difficultyLevel: "A1",
|
||||
isPhrase: false,
|
||||
synonyms: ["her"],
|
||||
example: "She is a teacher.",
|
||||
exampleTranslation: "她是一名老師。"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
inputText: textInput,
|
||||
userLevel: userLevel, // 傳遞用戶程度
|
||||
analysisMode: 'full'
|
||||
})
|
||||
"just": {
|
||||
word: "just",
|
||||
translation: "剛剛;僅僅",
|
||||
definition: "recently; only",
|
||||
partOfSpeech: "adverb",
|
||||
pronunciation: "/dʒʌst/",
|
||||
difficultyLevel: "A2",
|
||||
isPhrase: false,
|
||||
synonyms: ["recently", "only", "merely"],
|
||||
example: "I just arrived.",
|
||||
exampleTranslation: "我剛到。"
|
||||
},
|
||||
"join": {
|
||||
word: "join",
|
||||
translation: "加入",
|
||||
definition: "to become a member of",
|
||||
partOfSpeech: "verb",
|
||||
pronunciation: "/dʒɔɪn/",
|
||||
difficultyLevel: "B1",
|
||||
isPhrase: false,
|
||||
synonyms: ["enter", "become part of"],
|
||||
example: "I want to join the team.",
|
||||
exampleTranslation: "我想加入團隊。"
|
||||
},
|
||||
"the": {
|
||||
word: "the",
|
||||
translation: "定冠詞",
|
||||
definition: "definite article",
|
||||
partOfSpeech: "article",
|
||||
pronunciation: "/ðə/",
|
||||
difficultyLevel: "A1",
|
||||
isPhrase: false,
|
||||
synonyms: [],
|
||||
example: "The cat is sleeping.",
|
||||
exampleTranslation: "貓在睡覺。"
|
||||
},
|
||||
"team": {
|
||||
word: "team",
|
||||
translation: "團隊",
|
||||
definition: "a group of people working together",
|
||||
partOfSpeech: "noun",
|
||||
pronunciation: "/tiːm/",
|
||||
difficultyLevel: "A2",
|
||||
isPhrase: false,
|
||||
synonyms: ["group", "crew"],
|
||||
example: "Our team works well together.",
|
||||
exampleTranslation: "我們的團隊合作得很好。"
|
||||
},
|
||||
"so": {
|
||||
word: "so",
|
||||
translation: "所以;如此",
|
||||
definition: "therefore; to such a degree",
|
||||
partOfSpeech: "adverb",
|
||||
pronunciation: "/soʊ/",
|
||||
difficultyLevel: "A1",
|
||||
isPhrase: false,
|
||||
synonyms: ["therefore", "thus"],
|
||||
example: "It was raining, so I stayed home.",
|
||||
exampleTranslation: "下雨了,所以我待在家裡。"
|
||||
},
|
||||
"let's": {
|
||||
word: "let's",
|
||||
translation: "讓我們",
|
||||
definition: "let us (contraction)",
|
||||
partOfSpeech: "contraction",
|
||||
pronunciation: "/lets/",
|
||||
difficultyLevel: "A1",
|
||||
isPhrase: false,
|
||||
synonyms: ["let us"],
|
||||
example: "Let's go to the park.",
|
||||
exampleTranslation: "我們去公園吧。"
|
||||
},
|
||||
"cut": {
|
||||
word: "cut",
|
||||
translation: "切;削減",
|
||||
definition: "to use a knife or other sharp tool to divide something",
|
||||
partOfSpeech: "verb",
|
||||
pronunciation: "/kʌt/",
|
||||
difficultyLevel: "A2",
|
||||
isPhrase: false,
|
||||
synonyms: ["slice", "chop", "reduce"],
|
||||
example: "Please cut the apple.",
|
||||
exampleTranslation: "請切蘋果。"
|
||||
},
|
||||
"her": {
|
||||
word: "her",
|
||||
translation: "她的;她",
|
||||
definition: "belonging to or associated with a female",
|
||||
partOfSpeech: "pronoun",
|
||||
pronunciation: "/hər/",
|
||||
difficultyLevel: "A1",
|
||||
isPhrase: false,
|
||||
synonyms: ["hers"],
|
||||
example: "This is her book.",
|
||||
exampleTranslation: "這是她的書。"
|
||||
},
|
||||
"some": {
|
||||
word: "some",
|
||||
translation: "一些",
|
||||
definition: "an unspecified amount or number of",
|
||||
partOfSpeech: "determiner",
|
||||
pronunciation: "/sʌm/",
|
||||
difficultyLevel: "A1",
|
||||
isPhrase: false,
|
||||
synonyms: ["several", "a few"],
|
||||
example: "I need some help.",
|
||||
exampleTranslation: "我需要一些幫助。"
|
||||
},
|
||||
"slack": {
|
||||
word: "slack",
|
||||
translation: "寬鬆;懈怠",
|
||||
definition: "looseness; lack of tension",
|
||||
partOfSpeech: "noun",
|
||||
pronunciation: "/slæk/",
|
||||
difficultyLevel: "B1",
|
||||
isPhrase: false,
|
||||
synonyms: ["looseness", "leeway"],
|
||||
example: "There's too much slack in this rope.",
|
||||
exampleTranslation: "這條繩子太鬆了。"
|
||||
},
|
||||
"until": {
|
||||
word: "until",
|
||||
translation: "直到",
|
||||
definition: "up to a particular time",
|
||||
partOfSpeech: "preposition",
|
||||
pronunciation: "/ʌnˈtɪl/",
|
||||
difficultyLevel: "A2",
|
||||
isPhrase: false,
|
||||
synonyms: ["till", "up to"],
|
||||
example: "Wait until tomorrow.",
|
||||
exampleTranslation: "等到明天。"
|
||||
},
|
||||
"get": {
|
||||
word: "get",
|
||||
translation: "變得;獲得",
|
||||
definition: "to become or obtain",
|
||||
partOfSpeech: "verb",
|
||||
pronunciation: "/ɡet/",
|
||||
difficultyLevel: "A1",
|
||||
isPhrase: false,
|
||||
synonyms: ["become", "obtain"],
|
||||
example: "I get tired easily.",
|
||||
exampleTranslation: "我很容易累。"
|
||||
},
|
||||
"used": {
|
||||
word: "used",
|
||||
translation: "習慣的",
|
||||
definition: "familiar with something (used to)",
|
||||
partOfSpeech: "adjective",
|
||||
pronunciation: "/juːzd/",
|
||||
difficultyLevel: "A2",
|
||||
isPhrase: false,
|
||||
synonyms: ["accustomed", "familiar"],
|
||||
example: "I'm not used to this weather.",
|
||||
exampleTranslation: "我不習慣這種天氣。"
|
||||
},
|
||||
"to": {
|
||||
word: "to",
|
||||
translation: "到;向",
|
||||
definition: "preposition expressing direction",
|
||||
partOfSpeech: "preposition",
|
||||
pronunciation: "/tu/",
|
||||
difficultyLevel: "A1",
|
||||
isPhrase: false,
|
||||
synonyms: [],
|
||||
example: "I'm going to school.",
|
||||
exampleTranslation: "我要去學校。"
|
||||
},
|
||||
"workflow": {
|
||||
word: "workflow",
|
||||
translation: "工作流程",
|
||||
definition: "the sequence of processes through which work passes",
|
||||
partOfSpeech: "noun",
|
||||
pronunciation: "/ˈwɜːrkfloʊ/",
|
||||
difficultyLevel: "B2",
|
||||
isPhrase: false,
|
||||
synonyms: ["process", "procedure", "system"],
|
||||
example: "We need to improve our workflow.",
|
||||
exampleTranslation: "我們需要改善工作流程。"
|
||||
},
|
||||
"joined": {
|
||||
word: "joined",
|
||||
translation: "加入",
|
||||
definition: "became a member of (past tense of join)",
|
||||
partOfSpeech: "verb",
|
||||
pronunciation: "/dʒɔɪnd/",
|
||||
difficultyLevel: "B1",
|
||||
isPhrase: false,
|
||||
synonyms: ["entered", "became part of"],
|
||||
example: "He joined the company last year.",
|
||||
exampleTranslation: "他去年加入了這家公司。"
|
||||
},
|
||||
"gets": {
|
||||
word: "gets",
|
||||
translation: "變得;獲得",
|
||||
definition: "becomes or obtains (third person singular)",
|
||||
partOfSpeech: "verb",
|
||||
pronunciation: "/ɡets/",
|
||||
difficultyLevel: "A1",
|
||||
isPhrase: false,
|
||||
synonyms: ["becomes", "obtains"],
|
||||
example: "It gets cold at night.",
|
||||
exampleTranslation: "晚上會變冷。"
|
||||
},
|
||||
"cut someone some slack": {
|
||||
word: "cut someone some slack",
|
||||
translation: "對某人寬容一點",
|
||||
definition: "to be more lenient or forgiving with someone",
|
||||
partOfSpeech: "idiom",
|
||||
pronunciation: "/kʌt ˈsʌmwʌn sʌm slæk/",
|
||||
difficultyLevel: "B2",
|
||||
isPhrase: true,
|
||||
synonyms: ["be lenient", "be forgiving", "give leeway"],
|
||||
example: "Cut him some slack, he's new here.",
|
||||
exampleTranslation: "對他寬容一點,他是新來的。"
|
||||
},
|
||||
}
|
||||
|
||||
// 設定結果 - 包含語法錯誤情境
|
||||
setFinalText("She just joined the team, so let's cut her some slack until she gets used to the workflow.") // 修正後的句子
|
||||
setSentenceAnalysis(mockAnalysis)
|
||||
setSentenceMeaning("她剛加入團隊,所以讓我們對她寬容一點,直到她習慣工作流程。")
|
||||
setGrammarCorrection({
|
||||
hasErrors: true,
|
||||
originalText: testSentence, // 有錯誤的原始句子
|
||||
correctedText: "She just joined the team, so let's cut her some slack until she gets used to the workflow.",
|
||||
corrections: [
|
||||
{
|
||||
error: "join",
|
||||
correction: "joined",
|
||||
type: "時態錯誤",
|
||||
explanation: "第三人稱單數過去式應使用 'joined'"
|
||||
},
|
||||
{
|
||||
error: "get",
|
||||
correction: "gets",
|
||||
type: "時態錯誤",
|
||||
explanation: "第三人稱單數現在式應使用 'gets'"
|
||||
}
|
||||
]
|
||||
})
|
||||
setShowAnalysisView(true)
|
||||
|
||||
console.log('📡 API響應狀態:', response.status, response.statusText)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API 錯誤: ${response.status}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
console.log('📦 完整API響應:', result)
|
||||
|
||||
if (result.success) {
|
||||
// 移除快取狀態,每次都是新的 AI 分析
|
||||
|
||||
// 使用真實AI的回應資料 - 支援兩種key格式 (小寫/大寫)
|
||||
setSentenceAnalysis(result.data.wordAnalysis || result.data.WordAnalysis || {})
|
||||
|
||||
// 安全處理 sentenceMeaning - 支援兩種key格式 (小寫/大寫)
|
||||
const sentenceMeaning = result.data.sentenceMeaning || result.data.SentenceMeaning || {}
|
||||
const translation = sentenceMeaning.Translation || sentenceMeaning.translation || '翻譯處理中...'
|
||||
|
||||
setSentenceMeaning(translation)
|
||||
|
||||
setGrammarCorrection(result.data.grammarCorrection || result.data.GrammarCorrection || { hasErrors: false })
|
||||
setFinalText(result.data.finalAnalysisText || result.data.FinalAnalysisText || textInput)
|
||||
setShowAnalysisView(true)
|
||||
setUsageCount(prev => prev + 1)
|
||||
} else {
|
||||
throw new Error(result.error || '分析失敗')
|
||||
}
|
||||
console.log('✅ 假資料設定完成')
|
||||
} catch (error) {
|
||||
console.error('Error analyzing sentence:', error)
|
||||
console.error('Error in real API analysis:', error)
|
||||
alert(`分析句子時發生錯誤: ${error instanceof Error ? error.message : '未知錯誤'}`)
|
||||
} finally {
|
||||
setIsAnalyzing(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const handleAcceptCorrection = () => {
|
||||
if (grammarCorrection?.correctedText) {
|
||||
setFinalText(grammarCorrection.correctedText)
|
||||
|
|
@ -111,42 +321,28 @@ function GenerateContent() {
|
|||
alert('📝 已保持原始版本,將基於您的原始輸入進行學習。')
|
||||
}
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!textInput.trim()) return
|
||||
|
||||
setIsGenerating(true)
|
||||
|
||||
// 保存單個詞彙
|
||||
const handleSaveWord = async (word: string, analysis: any) => {
|
||||
try {
|
||||
const response = await fetch('http://localhost:5000/api/ai/test/generate', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
inputText: textInput,
|
||||
extractionType: extractionType,
|
||||
cardCount: cardCount
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
const cardData = {
|
||||
word: word,
|
||||
translation: analysis.translation || analysis.Translation || '',
|
||||
definition: analysis.definition || analysis.Definition || '',
|
||||
pronunciation: analysis.pronunciation || analysis.Pronunciation || `/${word}/`,
|
||||
partOfSpeech: analysis.partOfSpeech || analysis.PartOfSpeech || 'unknown',
|
||||
example: `Example sentence with ${word}.` // 提供預設例句
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
const response = await flashcardsService.createFlashcard(cardData)
|
||||
|
||||
if (result.success) {
|
||||
setGeneratedCards(result.data)
|
||||
setShowPreview(true)
|
||||
setShowAnalysisView(false)
|
||||
if (response.success) {
|
||||
alert(`✅ 已將「${word}」保存到詞卡!`)
|
||||
} else {
|
||||
throw new Error(result.error || '生成詞卡失敗')
|
||||
throw new Error(response.error || '保存失敗')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error generating cards:', error)
|
||||
alert(`生成詞卡時發生錯誤: ${error instanceof Error ? error.message : '未知錯誤'}`)
|
||||
} finally {
|
||||
setIsGenerating(false)
|
||||
console.error('Save word error:', error)
|
||||
throw error // 重新拋出錯誤讓組件處理
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -155,7 +351,7 @@ function GenerateContent() {
|
|||
<Navigation />
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{!showAnalysisView && !showPreview ? (
|
||||
{!showAnalysisView ? (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-8">AI 智能生成詞卡</h1>
|
||||
|
||||
|
|
@ -327,220 +523,314 @@ function GenerateContent() {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : showAnalysisView ? (
|
||||
/* 句子分析視圖 */
|
||||
) : (
|
||||
/* 重新設計的句子分析視圖 - 簡潔流暢 */
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-3xl font-bold">句子分析結果</h1>
|
||||
<button
|
||||
onClick={() => setShowAnalysisView(false)}
|
||||
className="text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
← 返回
|
||||
</button>
|
||||
</div>
|
||||
{/* 移除冗餘標題,直接進入內容 */}
|
||||
|
||||
{/* 語法修正面板 */}
|
||||
{grammarCorrection && (
|
||||
<GrammarCorrectionPanel
|
||||
correction={grammarCorrection}
|
||||
onAcceptCorrection={handleAcceptCorrection}
|
||||
onRejectCorrection={handleRejectCorrection}
|
||||
/>
|
||||
)}
|
||||
{/* 語法修正面板 - 如果需要的話 */}
|
||||
{grammarCorrection && grammarCorrection.hasErrors && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-xl p-6 mb-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="text-yellow-600 text-2xl">⚠️</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-yellow-800 mb-2">發現語法問題</h3>
|
||||
<p className="text-yellow-700 mb-4">AI建議修正以下內容,這將提高學習效果:</p>
|
||||
|
||||
{/* 原始句子顯示 */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">句子分析</h2>
|
||||
<div className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-100 text-blue-800">
|
||||
<span className="mr-1">🤖</span>
|
||||
<span>AI 分析</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-2">📝 用戶輸入</h3>
|
||||
<div className="p-3 bg-gray-50 rounded-lg border">
|
||||
<div className="text-base">{textInput}</div>
|
||||
<div className="space-y-3 mb-4">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-yellow-700">原始輸入:</span>
|
||||
<div className="bg-white p-3 rounded border border-yellow-300 mt-1">
|
||||
{textInput}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-yellow-700">建議修正:</span>
|
||||
<div className="bg-yellow-100 p-3 rounded border border-yellow-300 mt-1 font-medium">
|
||||
{grammarCorrection.correctedText || finalText}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleAcceptCorrection}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
|
||||
>
|
||||
✅ 採用修正
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRejectCorrection}
|
||||
className="px-4 py-2 bg-gray-500 text-white rounded-lg hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
📝 保持原樣
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{finalText !== textInput && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-2">🎯 分析基礎(修正後)</h3>
|
||||
<div className="p-3 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<div className="text-base font-medium">{finalText}</div>
|
||||
<div className="text-xs text-blue-600 mt-1">✨ 已修正語法錯誤</div>
|
||||
{/* 主句子展示 - 最重要的內容 */}
|
||||
<div className="bg-white rounded-xl shadow-lg p-8 mb-6">
|
||||
{/* 詞彙統計卡片區 */}
|
||||
{sentenceAnalysis && (() => {
|
||||
// 計算各類詞彙數量
|
||||
const userLevel = localStorage.getItem('userEnglishLevel') || 'A2'
|
||||
const getLevelIndex = (level: string): number => {
|
||||
const levels = ['A1', 'A2', 'B1', 'B2', 'C1', 'C2']
|
||||
return levels.indexOf(level)
|
||||
}
|
||||
|
||||
let simpleCount = 0
|
||||
let moderateCount = 0
|
||||
let difficultCount = 0
|
||||
let phraseCount = 0
|
||||
|
||||
Object.entries(sentenceAnalysis).forEach(([word, wordData]: [string, any]) => {
|
||||
const isPhrase = wordData?.isPhrase || wordData?.IsPhrase
|
||||
const difficultyLevel = wordData?.difficultyLevel || 'A1'
|
||||
|
||||
if (isPhrase) {
|
||||
phraseCount++
|
||||
} else {
|
||||
const userIndex = getLevelIndex(userLevel)
|
||||
const wordIndex = getLevelIndex(difficultyLevel)
|
||||
|
||||
if (userIndex > wordIndex) {
|
||||
simpleCount++
|
||||
} else if (userIndex === wordIndex) {
|
||||
moderateCount++
|
||||
} else {
|
||||
difficultCount++
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-4 mb-6">
|
||||
{/* 簡單詞彙卡片 */}
|
||||
<div className="bg-gray-50 border border-dashed border-gray-300 rounded-lg p-4 text-center">
|
||||
<div className="text-2xl font-bold text-gray-600 mb-1">{simpleCount}</div>
|
||||
<div className="text-gray-600 text-sm font-medium">簡單詞彙</div>
|
||||
</div>
|
||||
|
||||
{/* 適中詞彙卡片 */}
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4 text-center">
|
||||
<div className="text-2xl font-bold text-green-700 mb-1">{moderateCount}</div>
|
||||
<div className="text-green-700 text-sm font-medium">適中詞彙</div>
|
||||
</div>
|
||||
|
||||
{/* 艱難詞彙卡片 */}
|
||||
<div className="bg-orange-50 border border-orange-200 rounded-lg p-4 text-center">
|
||||
<div className="text-2xl font-bold text-orange-700 mb-1">{difficultCount}</div>
|
||||
<div className="text-orange-700 text-sm font-medium">艱難詞彙</div>
|
||||
</div>
|
||||
|
||||
{/* 片語與俚語卡片 */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-center">
|
||||
<div className="text-2xl font-bold text-blue-700 mb-1">{phraseCount}</div>
|
||||
<div className="text-blue-700 text-sm font-medium">片語俚語</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* 句子主體展示 */}
|
||||
<div className="text-left mb-8">
|
||||
<div className="text-3xl font-medium text-gray-900 mb-6" >
|
||||
<ClickableTextV2
|
||||
text={finalText}
|
||||
analysis={sentenceAnalysis}
|
||||
remainingUsage={5 - usageCount}
|
||||
showPhrasesInline={false}
|
||||
onWordClick={(word, analysis) => {
|
||||
console.log('Clicked word:', word, analysis)
|
||||
}}
|
||||
onSaveWord={handleSaveWord}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 翻譯 - 參考翻卡背面設計 */}
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<h3 className="font-semibold text-gray-900 mb-2 text-left">中文翻譯</h3>
|
||||
<p className="text-gray-700 text-left">{sentenceMeaning}</p>
|
||||
</div>
|
||||
|
||||
{/* 片語和慣用語展示區 */}
|
||||
{(() => {
|
||||
if (!sentenceAnalysis) return null
|
||||
|
||||
// 提取片語
|
||||
const phrases: Array<{
|
||||
phrase: string
|
||||
meaning: string
|
||||
difficultyLevel: string
|
||||
}> = []
|
||||
|
||||
Object.entries(sentenceAnalysis).forEach(([word, wordData]: [string, any]) => {
|
||||
const isPhrase = wordData?.isPhrase || wordData?.IsPhrase
|
||||
if (isPhrase) {
|
||||
phrases.push({
|
||||
phrase: wordData?.word || word,
|
||||
meaning: wordData?.translation || '',
|
||||
difficultyLevel: wordData?.difficultyLevel || 'A1'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if (phrases.length === 0) return null
|
||||
|
||||
// 獲取CEFR等級顏色
|
||||
const getCEFRColor = (level: string) => {
|
||||
switch (level) {
|
||||
case 'A1': return 'bg-green-100 text-green-700 border-green-200'
|
||||
case 'A2': return 'bg-blue-100 text-blue-700 border-blue-200'
|
||||
case 'B1': return 'bg-yellow-100 text-yellow-700 border-yellow-200'
|
||||
case 'B2': return 'bg-orange-100 text-orange-700 border-orange-200'
|
||||
case 'C1': return 'bg-red-100 text-red-700 border-red-200'
|
||||
case 'C2': return 'bg-purple-100 text-purple-700 border-purple-200'
|
||||
default: return 'bg-gray-100 text-gray-700 border-gray-200'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50 rounded-lg p-4 mt-4">
|
||||
<h3 className="font-semibold text-gray-900 mb-2 text-left">片語俚語</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{phrases.map((phrase, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="cursor-pointer transition-all duration-200 rounded-lg relative mx-0.5 px-1 py-0.5 inline-flex items-center gap-1 bg-blue-50 border border-blue-200 hover:bg-blue-100 hover:shadow-lg transform hover:-translate-y-0.5 text-blue-700 font-medium"
|
||||
onClick={(e) => {
|
||||
// 找到片語的完整分析資料
|
||||
const phraseAnalysis = sentenceAnalysis?.["cut someone some slack"]
|
||||
|
||||
if (phraseAnalysis) {
|
||||
// 設定片語彈窗狀態
|
||||
setPhrasePopup({
|
||||
phrase: phrase.phrase,
|
||||
analysis: phraseAnalysis,
|
||||
position: {
|
||||
x: e.currentTarget.getBoundingClientRect().left + e.currentTarget.getBoundingClientRect().width / 2,
|
||||
y: e.currentTarget.getBoundingClientRect().bottom + 10
|
||||
}
|
||||
})
|
||||
}
|
||||
}}
|
||||
title={`${phrase.phrase}: ${phrase.meaning}`}
|
||||
>
|
||||
{phrase.phrase}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* 下方操作區 - 簡化 */}
|
||||
<div className="flex justify-center">
|
||||
<button
|
||||
onClick={() => setShowAnalysisView(false)}
|
||||
className="px-8 py-3 bg-primary text-white rounded-lg font-medium hover:bg-primary-hover transition-colors flex items-center gap-2"
|
||||
>
|
||||
<span>分析新句子</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 片語彈窗 */}
|
||||
{phrasePopup && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 z-40"
|
||||
onClick={() => setPhrasePopup(null)}
|
||||
/>
|
||||
<div
|
||||
className="fixed z-50 bg-white rounded-xl shadow-lg w-96 max-w-md overflow-hidden"
|
||||
style={{
|
||||
left: `${phrasePopup.position.x}px`,
|
||||
top: `${phrasePopup.position.y}px`,
|
||||
transform: 'translate(-50%, 8px)',
|
||||
maxHeight: '85vh',
|
||||
overflowY: 'auto'
|
||||
}}
|
||||
>
|
||||
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 p-5 border-b border-blue-200">
|
||||
<div className="flex justify-end mb-3">
|
||||
<button
|
||||
onClick={() => setPhrasePopup(null)}
|
||||
className="text-gray-400 hover:text-gray-600 w-6 h-6 rounded-full bg-white bg-opacity-80 hover:bg-opacity-100 transition-all flex items-center justify-center"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<h3 className="text-2xl font-bold text-gray-900">{phrasePopup.analysis.word}</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm bg-gray-100 text-gray-700 px-3 py-1 rounded-full">
|
||||
{phrasePopup.analysis.partOfSpeech}
|
||||
</span>
|
||||
<span className="text-base text-gray-600">{phrasePopup.analysis.pronunciation}</span>
|
||||
</div>
|
||||
|
||||
<span className="px-3 py-1 rounded-full text-sm font-medium border bg-blue-100 text-blue-700 border-blue-200">
|
||||
{phrasePopup.analysis.difficultyLevel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="bg-green-50 rounded-lg p-3 border border-green-200">
|
||||
<h4 className="font-semibold text-green-900 mb-2 text-left text-sm">中文翻譯</h4>
|
||||
<p className="text-green-800 font-medium text-left">{phrasePopup.analysis.translation}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-3 border border-gray-200">
|
||||
<h4 className="font-semibold text-gray-900 mb-2 text-left text-sm">英文定義</h4>
|
||||
<p className="text-gray-700 text-left text-sm leading-relaxed">{phrasePopup.analysis.definition}</p>
|
||||
</div>
|
||||
|
||||
{phrasePopup.analysis.example && (
|
||||
<div className="bg-blue-50 rounded-lg p-3 border border-blue-200">
|
||||
<h4 className="font-semibold text-blue-900 mb-2 text-left text-sm">例句</h4>
|
||||
<div className="space-y-2">
|
||||
<p className="text-blue-800 text-left text-sm italic">
|
||||
"{phrasePopup.analysis.example}"
|
||||
</p>
|
||||
<p className="text-blue-700 text-left text-sm">
|
||||
{phrasePopup.analysis.exampleTranslation}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-2">📖 整句意思</h3>
|
||||
<div className="text-gray-700 leading-relaxed p-3 bg-gray-50 rounded-lg">
|
||||
{sentenceMeaning}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 互動式文字 */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold mb-4">詞彙分析</h2>
|
||||
|
||||
<div className="p-4 bg-blue-50 rounded-lg border-l-4 border-blue-400 mb-4">
|
||||
<p className="text-sm text-blue-800">
|
||||
💡 <strong>使用說明:</strong>點擊下方句子中的任何單字,可以立即查看詳細意思。<br/>
|
||||
{/* 🟡 <strong>黃色邊框 + ⭐</strong> = 高價值片語(免費點擊)<br/>
|
||||
🟢 <strong>綠色邊框 + ⭐</strong> = 高價值單字(免費點擊)<br/>
|
||||
🔵 <strong>藍色下劃線</strong> = 普通單字(點擊扣 1 次) */}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-gray-50 rounded-lg border-2 border-dashed border-gray-300">
|
||||
<ClickableTextV2
|
||||
text={finalText}
|
||||
analysis={sentenceAnalysis}
|
||||
remainingUsage={5 - usageCount}
|
||||
onWordClick={(word, analysis) => {
|
||||
console.log('Clicked word:', word, analysis)
|
||||
}}
|
||||
onWordCostConfirm={async () => {
|
||||
return true // 移除付費限制,直接允許
|
||||
}}
|
||||
onNewWordAnalysis={(word, newAnalysis) => {
|
||||
// 將新的詞彙分析資料加入到現有分析中
|
||||
setSentenceAnalysis((prev: any) => ({
|
||||
...prev,
|
||||
[word]: newAnalysis
|
||||
}))
|
||||
console.log(`✅ 新增詞彙分析: ${word}`, newAnalysis)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按鈕 */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-6">
|
||||
<div className="flex gap-4">
|
||||
<div className="p-4 pt-2">
|
||||
<button
|
||||
onClick={() => setShowAnalysisView(false)}
|
||||
className="flex-1 bg-gray-200 text-gray-700 py-3 rounded-lg font-medium hover:bg-gray-300 transition-colors"
|
||||
>
|
||||
🔄 分析新句子
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowAnalysisView(false)
|
||||
setShowPreview(true)
|
||||
// 這裡可以整合從分析結果生成詞卡的功能
|
||||
onClick={async () => {
|
||||
try {
|
||||
await handleSaveWord(phrasePopup.phrase, phrasePopup.analysis)
|
||||
setPhrasePopup(null)
|
||||
} catch (error) {
|
||||
console.error('Save phrase error:', error)
|
||||
}
|
||||
}}
|
||||
className="flex-1 bg-primary text-white py-3 rounded-lg font-medium hover:bg-primary-hover transition-colors"
|
||||
className="w-full bg-primary text-white py-3 rounded-lg font-medium hover:bg-primary-hover transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
📖 生成詞卡
|
||||
<span className="font-medium">保存到詞卡</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* 現有的詞卡預覽功能保持不變 */
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-3xl font-bold">預覽生成的詞卡</h1>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowPreview(false)
|
||||
setShowAnalysisView(true)
|
||||
}}
|
||||
className="text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
← 返回分析
|
||||
</button>
|
||||
<span className="text-gray-300">|</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowPreview(false)
|
||||
setShowAnalysisView(false)
|
||||
}}
|
||||
className="text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
← 返回輸入
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{generatedCards.map((card, index) => (
|
||||
<div key={index} className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
{/* 詞卡內容 */}
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-xl font-bold text-gray-900">{card.word}</h3>
|
||||
<span className="text-sm bg-gray-100 text-gray-600 px-2 py-1 rounded">
|
||||
{card.partOfSpeech}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-500">發音</span>
|
||||
<p className="text-gray-700">{card.pronunciation}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-500">翻譯</span>
|
||||
<p className="text-gray-900 font-medium">{card.translation}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-500">定義</span>
|
||||
<p className="text-gray-700">{card.definition}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-500">例句</span>
|
||||
<p className="text-gray-700 italic">"{card.example}"</p>
|
||||
<p className="text-gray-600 text-sm mt-1">{card.exampleTranslation}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-500">同義詞</span>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{card.synonyms.map((synonym: string, idx: number) => (
|
||||
<span key={idx} className="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded-full">
|
||||
{synonym}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2 border-t">
|
||||
<span className="text-xs text-gray-500">難度: {card.difficultyLevel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 操作按鈕 */}
|
||||
<div className="mt-8 flex justify-center gap-4">
|
||||
<button
|
||||
onClick={() => setShowPreview(false)}
|
||||
className="px-6 py-3 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
|
||||
>
|
||||
🔄 重新生成
|
||||
</button>
|
||||
<button
|
||||
onClick={() => alert('保存功能開發中...')}
|
||||
className="px-6 py-3 bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors"
|
||||
>
|
||||
💾 保存詞卡
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,186 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
export default function TestApiPage() {
|
||||
const [textInput, setTextInput] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [result, setResult] = useState<any>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleTest = async () => {
|
||||
if (!textInput.trim()) return
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
setResult(null)
|
||||
|
||||
try {
|
||||
console.log('發送API請求到:', 'http://localhost:5000/api/ai/analyze-sentence')
|
||||
console.log('請求數據:', { inputText: textInput, analysisMode: 'full' })
|
||||
|
||||
const response = await fetch('http://localhost:5000/api/ai/analyze-sentence', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
inputText: textInput,
|
||||
analysisMode: 'full'
|
||||
})
|
||||
})
|
||||
|
||||
console.log('API響應狀態:', response.status, response.statusText)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API 錯誤: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
console.log('API響應數據:', result)
|
||||
|
||||
setResult(result)
|
||||
|
||||
if (result.success) {
|
||||
console.log('✅ API調用成功')
|
||||
} else {
|
||||
console.log('❌ API返回失敗:', result.error)
|
||||
setError(result.error)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ API調用錯誤:', error)
|
||||
setError(error instanceof Error ? error.message : '未知錯誤')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-8">API 連接測試</h1>
|
||||
|
||||
{/* 輸入區域 */}
|
||||
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold mb-4">測試句子分析API</h2>
|
||||
<div className="space-y-4">
|
||||
<textarea
|
||||
value={textInput}
|
||||
onChange={(e) => setTextInput(e.target.value)}
|
||||
placeholder="輸入英文句子進行測試..."
|
||||
className="w-full h-32 px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-transparent outline-none resize-none"
|
||||
/>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setTextInput("He brought this thing up during our meeting.")}
|
||||
className="px-4 py-2 bg-green-100 text-green-700 rounded-lg text-sm hover:bg-green-200"
|
||||
>
|
||||
使用正確語法示例
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTextInput("I go to school yesterday and meet my friends.")}
|
||||
className="px-4 py-2 bg-red-100 text-red-700 rounded-lg text-sm hover:bg-red-200"
|
||||
>
|
||||
使用語法錯誤示例
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleTest}
|
||||
disabled={isLoading || !textInput.trim()}
|
||||
className="w-full bg-blue-600 text-white py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center justify-center">
|
||||
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
正在測試API連接...
|
||||
</span>
|
||||
) : (
|
||||
'🔍 測試 API 連接'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 錯誤顯示 */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
|
||||
<h3 className="text-red-800 font-semibold mb-2">❌ 錯誤</h3>
|
||||
<p className="text-red-700">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 結果顯示 */}
|
||||
{result && (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">📋 API 響應結果</h3>
|
||||
|
||||
{result.success ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-green-600 text-lg">✅</span>
|
||||
<span className="font-medium">API 調用成功</span>
|
||||
{result.cached && <span className="text-blue-600 text-sm">(使用快取)</span>}
|
||||
{result.cacheHit && <span className="text-purple-600 text-sm">(快取命中)</span>}
|
||||
</div>
|
||||
|
||||
{/* 語法檢查結果 */}
|
||||
{result.data?.grammarCorrection && (
|
||||
<div className="p-3 bg-gray-50 rounded-lg">
|
||||
<h4 className="font-medium mb-2">語法檢查</h4>
|
||||
{result.data.grammarCorrection.hasErrors ? (
|
||||
<div className="text-red-600">
|
||||
❌ 發現語法錯誤:{result.data.grammarCorrection.corrections?.length || 0} 個
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-green-600">✅ 語法正確</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 句子意思 */}
|
||||
{result.data?.sentenceMeaning && (
|
||||
<div className="p-3 bg-blue-50 rounded-lg">
|
||||
<h4 className="font-medium mb-2">句子翻譯</h4>
|
||||
<p className="text-blue-800">{result.data.sentenceMeaning.translation}</p>
|
||||
<p className="text-blue-600 text-sm mt-1">{result.data.sentenceMeaning.explanation}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 高價值詞彙 */}
|
||||
{result.data?.highValueWords && (
|
||||
<div className="p-3 bg-green-50 rounded-lg">
|
||||
<h4 className="font-medium mb-2">高價值詞彙</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{result.data.highValueWords.map((word: string, idx: number) => (
|
||||
<span key={idx} className="bg-green-200 text-green-800 px-2 py-1 rounded text-sm">
|
||||
{word} ⭐
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 原始響應(調試用) */}
|
||||
<details className="mt-4">
|
||||
<summary className="cursor-pointer text-gray-600 text-sm">查看原始JSON響應</summary>
|
||||
<pre className="mt-2 p-3 bg-gray-100 rounded text-xs overflow-auto">
|
||||
{JSON.stringify(result, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-red-600">
|
||||
❌ API 返回失敗: {result.error || '未知錯誤'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
export default function TestSimplePage() {
|
||||
const [result, setResult] = useState('')
|
||||
|
||||
const testApi = async () => {
|
||||
try {
|
||||
setResult('正在測試...')
|
||||
|
||||
const response = await fetch('http://localhost:5000/api/ai/analyze-sentence', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
inputText: 'Test sentence for debugging',
|
||||
analysisMode: 'full'
|
||||
})
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setResult(`✅ API 成功: ${JSON.stringify(data, null, 2)}`)
|
||||
} else {
|
||||
setResult(`❌ API 錯誤: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
} catch (error) {
|
||||
setResult(`💥 連接錯誤: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h1 className="text-2xl font-bold mb-4">API 連接簡單測試</h1>
|
||||
|
||||
<button
|
||||
onClick={testApi}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded mb-4"
|
||||
>
|
||||
測試 API 連接
|
||||
</button>
|
||||
|
||||
<pre className="bg-gray-100 p-4 rounded text-sm overflow-auto max-h-96">
|
||||
{result || '點擊按鈕測試 API 連接'}
|
||||
</pre>
|
||||
|
||||
<div className="mt-4 p-4 bg-yellow-50 border border-yellow-200 rounded">
|
||||
<h3 className="font-bold mb-2">手動測試指令:</h3>
|
||||
<code className="text-sm">
|
||||
curl -s http://localhost:5000/api/ai/analyze-sentence -X POST -H "Content-Type: application/json" -d '{"inputText":"test"}'
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { ClickableText } from '@/components/ClickableText'
|
||||
|
||||
const mockAnalysis = {
|
||||
"brought": {
|
||||
word: "brought",
|
||||
translation: "帶來、提出",
|
||||
definition: "Past tense of bring; to take or carry something to a place",
|
||||
partOfSpeech: "verb",
|
||||
pronunciation: "/brɔːt/",
|
||||
synonyms: ["carried", "took", "delivered"],
|
||||
isPhrase: true,
|
||||
phraseInfo: {
|
||||
phrase: "bring up",
|
||||
meaning: "提出(話題)、養育",
|
||||
warning: "在這個句子中,\"brought up\" 是一個片語,意思是\"提出話題\",而不是單純的\"帶來\""
|
||||
}
|
||||
},
|
||||
"this": {
|
||||
word: "this",
|
||||
translation: "這個",
|
||||
definition: "Used to indicate something near or just mentioned",
|
||||
partOfSpeech: "pronoun",
|
||||
pronunciation: "/ðɪs/",
|
||||
synonyms: ["that", "it"],
|
||||
isPhrase: false
|
||||
},
|
||||
"thing": {
|
||||
word: "thing",
|
||||
translation: "事情、東西",
|
||||
definition: "An object, fact, or situation",
|
||||
partOfSpeech: "noun",
|
||||
pronunciation: "/θɪŋ/",
|
||||
synonyms: ["object", "matter", "item"],
|
||||
isPhrase: false
|
||||
},
|
||||
"up": {
|
||||
word: "up",
|
||||
translation: "向上",
|
||||
definition: "Toward a higher place or position",
|
||||
partOfSpeech: "adverb",
|
||||
pronunciation: "/ʌp/",
|
||||
synonyms: ["upward", "above"],
|
||||
isPhrase: true,
|
||||
phraseInfo: {
|
||||
phrase: "bring up",
|
||||
meaning: "提出(話題)、養育",
|
||||
warning: "\"up\" 在這裡是片語 \"bring up\" 的一部分,不是單獨的\"向上\"的意思"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function TestPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-8">互動式單字查詢測試</h1>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">點擊查詢單字意思</h2>
|
||||
<div className="p-4 bg-blue-50 rounded-lg border-l-4 border-blue-400 mb-4">
|
||||
<p className="text-sm text-blue-800">
|
||||
💡 <strong>使用說明:</strong>點擊下方句子中的任何單字,可以立即查看詳細意思。
|
||||
黃色背景表示該單字屬於片語或俚語,會優先顯示片語意思。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-gray-50 rounded-lg border-2 border-dashed border-gray-300">
|
||||
<ClickableText
|
||||
text="He brought this thing up during our meeting."
|
||||
analysis={mockAnalysis}
|
||||
onWordClick={(word, analysis) => {
|
||||
console.log('Clicked word:', word, analysis)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,267 @@
|
|||
'use client'
|
||||
|
||||
import React, { useState, useMemo } from 'react'
|
||||
import { Modal } from './ui/Modal'
|
||||
import { Check, Loader2 } from 'lucide-react'
|
||||
|
||||
interface GeneratedCard {
|
||||
word: string
|
||||
translation: string
|
||||
definition: string
|
||||
partOfSpeech?: string
|
||||
pronunciation?: string
|
||||
example?: string
|
||||
exampleTranslation?: string
|
||||
synonyms?: string[]
|
||||
difficultyLevel?: string
|
||||
}
|
||||
|
||||
interface CardSet {
|
||||
id: string
|
||||
name: string
|
||||
color: string
|
||||
}
|
||||
|
||||
interface CardSelectionDialogProps {
|
||||
isOpen: boolean
|
||||
generatedCards: GeneratedCard[]
|
||||
cardSets: CardSet[]
|
||||
onClose: () => void
|
||||
onSave: (selectedCards: GeneratedCard[], cardSetId?: string) => Promise<void>
|
||||
}
|
||||
|
||||
export const CardSelectionDialog: React.FC<CardSelectionDialogProps> = ({
|
||||
isOpen,
|
||||
generatedCards,
|
||||
cardSets,
|
||||
onClose,
|
||||
onSave
|
||||
}) => {
|
||||
const [selectedCardIndices, setSelectedCardIndices] = useState<Set<number>>(
|
||||
new Set(generatedCards.map((_, index) => index)) // 預設全選
|
||||
)
|
||||
const [selectedCardSetId, setSelectedCardSetId] = useState<string>('')
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
const selectedCount = selectedCardIndices.size
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedCardIndices(new Set(generatedCards.map((_, index) => index)))
|
||||
} else {
|
||||
setSelectedCardIndices(new Set())
|
||||
}
|
||||
}
|
||||
|
||||
const handleCardToggle = (index: number, checked: boolean) => {
|
||||
const newSelected = new Set(selectedCardIndices)
|
||||
if (checked) {
|
||||
newSelected.add(index)
|
||||
} else {
|
||||
newSelected.delete(index)
|
||||
}
|
||||
setSelectedCardIndices(newSelected)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (selectedCount === 0) {
|
||||
alert('請至少選擇一張詞卡')
|
||||
return
|
||||
}
|
||||
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const selectedCards = Array.from(selectedCardIndices).map(index => generatedCards[index])
|
||||
await onSave(selectedCards, selectedCardSetId || undefined)
|
||||
} catch (error) {
|
||||
console.error('Save error:', error)
|
||||
alert(`保存失敗: ${error instanceof Error ? error.message : '未知錯誤'}`)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const isAllSelected = selectedCount === generatedCards.length
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="選擇要保存的詞卡"
|
||||
size="xl"
|
||||
>
|
||||
<div className="p-6 space-y-6">
|
||||
{/* 操作工具列 */}
|
||||
<div className="flex justify-between items-center p-4 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center space-x-4">
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isAllSelected}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
全選 ({selectedCount}/{generatedCards.length})
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm text-gray-600">保存到:</span>
|
||||
<select
|
||||
value={selectedCardSetId}
|
||||
onChange={(e) => setSelectedCardSetId(e.target.value)}
|
||||
className="border border-gray-300 rounded-md px-3 py-1 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="">預設卡組</option>
|
||||
{cardSets.map(set => (
|
||||
<option key={set.id} value={set.id}>
|
||||
{set.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 詞卡列表 */}
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||
{generatedCards.map((card, index) => (
|
||||
<CardPreviewItem
|
||||
key={index}
|
||||
card={card}
|
||||
index={index}
|
||||
isSelected={selectedCardIndices.has(index)}
|
||||
onToggle={(checked) => handleCardToggle(index, checked)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 底部操作按鈕 */}
|
||||
<div className="flex justify-end space-x-3 pt-4 border-t">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isSaving}
|
||||
className="px-4 py-2 text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 transition-colors disabled:opacity-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={selectedCount === 0 || isSaving}
|
||||
className="flex items-center space-x-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span>保存中...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="w-4 h-4" />
|
||||
<span>保存 {selectedCount} 張詞卡</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
interface CardPreviewItemProps {
|
||||
card: GeneratedCard
|
||||
index: number
|
||||
isSelected: boolean
|
||||
onToggle: (checked: boolean) => void
|
||||
}
|
||||
|
||||
const CardPreviewItem: React.FC<CardPreviewItemProps> = ({
|
||||
card,
|
||||
index,
|
||||
isSelected,
|
||||
onToggle
|
||||
}) => {
|
||||
return (
|
||||
<div className={`
|
||||
border rounded-lg p-4 transition-all duration-200
|
||||
${isSelected
|
||||
? 'border-blue-500 bg-blue-50 shadow-sm'
|
||||
: 'border-gray-200 bg-white hover:border-gray-300'
|
||||
}
|
||||
`}>
|
||||
<div className="flex items-start space-x-3">
|
||||
<label className="flex items-center cursor-pointer mt-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={(e) => onToggle(e.target.checked)}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
{card.word}
|
||||
</h3>
|
||||
{card.difficultyLevel && (
|
||||
<span className="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-600 rounded">
|
||||
{card.difficultyLevel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">翻譯:</span>
|
||||
<span className="text-gray-900">{card.translation}</span>
|
||||
</div>
|
||||
|
||||
{card.partOfSpeech && (
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">詞性:</span>
|
||||
<span className="text-gray-900">{card.partOfSpeech}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{card.pronunciation && (
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">發音:</span>
|
||||
<span className="text-gray-900">{card.pronunciation}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">定義:</span>
|
||||
<p className="text-gray-900 leading-relaxed">{card.definition}</p>
|
||||
</div>
|
||||
|
||||
{card.example && (
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">例句:</span>
|
||||
<p className="text-gray-900 italic">"{card.example}"</p>
|
||||
{card.exampleTranslation && (
|
||||
<p className="text-gray-600 text-sm mt-1">{card.exampleTranslation}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{card.synonyms && card.synonyms.length > 0 && (
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">同義詞:</span>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{card.synonyms.map((synonym, idx) => (
|
||||
<span key={idx} className="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded-full">
|
||||
{synonym}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,187 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
// 模擬分析後的詞彙資料
|
||||
interface WordAnalysis {
|
||||
word: string
|
||||
translation: string
|
||||
definition: string
|
||||
partOfSpeech: string
|
||||
pronunciation: string
|
||||
synonyms: string[]
|
||||
isPhrase: boolean
|
||||
phraseInfo?: {
|
||||
phrase: string
|
||||
meaning: string
|
||||
warning: string
|
||||
}
|
||||
}
|
||||
|
||||
interface ClickableTextProps {
|
||||
text: string
|
||||
analysis?: Record<string, WordAnalysis>
|
||||
onWordClick?: (word: string, analysis: WordAnalysis) => void
|
||||
}
|
||||
|
||||
export function ClickableText({ text, analysis, onWordClick }: ClickableTextProps) {
|
||||
const [selectedWord, setSelectedWord] = useState<string | null>(null)
|
||||
const [popupPosition, setPopupPosition] = useState({ x: 0, y: 0 })
|
||||
|
||||
// 將文字分割成單字
|
||||
const words = text.split(/(\s+|[.,!?;:])/g).filter(word => word.trim())
|
||||
|
||||
const handleWordClick = (word: string, event: React.MouseEvent) => {
|
||||
const cleanWord = word.toLowerCase().replace(/[.,!?;:]/g, '')
|
||||
const wordAnalysis = analysis?.[cleanWord]
|
||||
|
||||
if (wordAnalysis) {
|
||||
const rect = event.currentTarget.getBoundingClientRect()
|
||||
setPopupPosition({
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top - 10
|
||||
})
|
||||
setSelectedWord(cleanWord)
|
||||
onWordClick?.(cleanWord, wordAnalysis)
|
||||
}
|
||||
}
|
||||
|
||||
const closePopup = () => {
|
||||
setSelectedWord(null)
|
||||
}
|
||||
|
||||
const getWordClass = (word: string) => {
|
||||
const cleanWord = word.toLowerCase().replace(/[.,!?;:]/g, '')
|
||||
const wordAnalysis = analysis?.[cleanWord]
|
||||
|
||||
if (!wordAnalysis) return "cursor-default"
|
||||
|
||||
const baseClass = "cursor-pointer transition-all duration-200 hover:bg-blue-100 rounded px-1"
|
||||
|
||||
if (wordAnalysis.isPhrase) {
|
||||
return `${baseClass} bg-yellow-100 border-b-2 border-yellow-400 hover:bg-yellow-200`
|
||||
}
|
||||
|
||||
return `${baseClass} hover:bg-blue-200 border-b border-blue-300`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* 點擊區域遮罩 */}
|
||||
{selectedWord && (
|
||||
<div
|
||||
className="fixed inset-0 z-10"
|
||||
onClick={closePopup}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 文字內容 */}
|
||||
<div className="text-lg leading-relaxed">
|
||||
{words.map((word, index) => {
|
||||
if (word.trim() === '') return <span key={index}>{word}</span>
|
||||
|
||||
return (
|
||||
<span
|
||||
key={index}
|
||||
className={getWordClass(word)}
|
||||
onClick={(e) => handleWordClick(word, e)}
|
||||
>
|
||||
{word}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 彈出視窗 */}
|
||||
{selectedWord && analysis?.[selectedWord] && (
|
||||
<div
|
||||
className="fixed z-20 bg-white border border-gray-300 rounded-lg shadow-lg p-4 w-80 max-w-sm"
|
||||
style={{
|
||||
left: `${popupPosition.x}px`,
|
||||
top: `${popupPosition.y}px`,
|
||||
transform: 'translate(-50%, -100%)',
|
||||
}}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{/* 標題 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-bold text-gray-900">
|
||||
{analysis[selectedWord].word}
|
||||
</h3>
|
||||
<button
|
||||
onClick={closePopup}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 片語警告 */}
|
||||
{analysis[selectedWord].isPhrase && analysis[selectedWord].phraseInfo && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="text-yellow-600 text-lg">⚠️</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-yellow-800">
|
||||
注意:這個單字屬於片語
|
||||
</div>
|
||||
<div className="text-sm text-yellow-700 mt-1">
|
||||
<strong>片語:</strong>{analysis[selectedWord].phraseInfo.phrase}
|
||||
</div>
|
||||
<div className="text-sm text-yellow-700">
|
||||
<strong>意思:</strong>{analysis[selectedWord].phraseInfo.meaning}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 詞性和發音 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm bg-gray-100 px-2 py-1 rounded">
|
||||
{analysis[selectedWord].partOfSpeech}
|
||||
</span>
|
||||
<span className="text-sm text-gray-600">
|
||||
{analysis[selectedWord].pronunciation}
|
||||
</span>
|
||||
<button className="text-blue-600 hover:text-blue-800">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 翻譯 */}
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-700">翻譯</div>
|
||||
<div className="text-base text-gray-900">{analysis[selectedWord].translation}</div>
|
||||
</div>
|
||||
|
||||
{/* 定義 */}
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-700">定義</div>
|
||||
<div className="text-sm text-gray-600">{analysis[selectedWord].definition}</div>
|
||||
</div>
|
||||
|
||||
{/* 同義詞 */}
|
||||
{analysis[selectedWord].synonyms.length > 0 && (
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-700">同義詞</div>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{analysis[selectedWord].synonyms.map((synonym, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded-full"
|
||||
>
|
||||
{synonym}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
// 更新的詞彙分析介面
|
||||
interface WordAnalysis {
|
||||
word: string
|
||||
translation: string
|
||||
|
|
@ -12,463 +12,292 @@ interface WordAnalysis {
|
|||
synonyms: string[]
|
||||
antonyms?: string[]
|
||||
isPhrase: boolean
|
||||
isHighValue: boolean // 高學習價值標記
|
||||
learningPriority: 'high' | 'medium' | 'low' // 學習優先級
|
||||
isHighValue: boolean
|
||||
learningPriority: 'high' | 'medium' | 'low'
|
||||
phraseInfo?: {
|
||||
phrase: string
|
||||
meaning: string
|
||||
warning: string
|
||||
colorCode: string // 片語顏色代碼
|
||||
colorCode: string
|
||||
}
|
||||
difficultyLevel: string
|
||||
costIncurred?: number // 點擊此詞彙的成本
|
||||
costIncurred?: number
|
||||
}
|
||||
|
||||
interface ClickableTextProps {
|
||||
text: string
|
||||
analysis?: Record<string, WordAnalysis>
|
||||
highValueWords?: string[] // 高價值詞彙列表
|
||||
phrasesDetected?: Array<{
|
||||
phrase: string
|
||||
words: string[]
|
||||
colorCode: string
|
||||
}>
|
||||
onWordClick?: (word: string, analysis: WordAnalysis) => void
|
||||
onWordCostConfirm?: (word: string, cost: number) => Promise<boolean> // 收費確認
|
||||
onNewWordAnalysis?: (word: string, analysis: WordAnalysis) => void // 新詞彙分析資料回調
|
||||
remainingUsage?: number // 剩餘使用次數
|
||||
onSaveWord?: (word: string, analysis: WordAnalysis) => Promise<void>
|
||||
remainingUsage?: number
|
||||
showPhrasesInline?: boolean
|
||||
}
|
||||
|
||||
const POPUP_CONFIG = {
|
||||
WIDTH: 320,
|
||||
HEIGHT: 400,
|
||||
PADDING: 16,
|
||||
MOBILE_BREAKPOINT: 640
|
||||
} as const
|
||||
|
||||
export function ClickableTextV2({
|
||||
text,
|
||||
analysis,
|
||||
highValueWords = [],
|
||||
phrasesDetected = [],
|
||||
onWordClick,
|
||||
onWordCostConfirm,
|
||||
onNewWordAnalysis,
|
||||
remainingUsage = 5
|
||||
onSaveWord,
|
||||
remainingUsage = 5,
|
||||
showPhrasesInline = true
|
||||
}: ClickableTextProps) {
|
||||
const [selectedWord, setSelectedWord] = useState<string | null>(null)
|
||||
const [popupPosition, setPopupPosition] = useState({ x: 0, y: 0 })
|
||||
const [showCostConfirm, setShowCostConfirm] = useState<{
|
||||
word: string
|
||||
cost: number
|
||||
position: { x: number, y: number }
|
||||
} | null>(null)
|
||||
const [popupPosition, setPopupPosition] = useState({ x: 0, y: 0, showBelow: false })
|
||||
const [isSavingWord, setIsSavingWord] = useState(false)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
// 輔助函數:兼容大小寫屬性名稱
|
||||
const getWordProperty = (wordData: any, propName: string) => {
|
||||
const lowerProp = propName.toLowerCase()
|
||||
const upperProp = propName.charAt(0).toUpperCase() + propName.slice(1)
|
||||
return wordData?.[lowerProp] || wordData?.[upperProp]
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
const getCEFRColor = (level: string) => {
|
||||
switch (level) {
|
||||
case 'A1': return 'bg-green-100 text-green-700 border-green-200'
|
||||
case 'A2': return 'bg-blue-100 text-blue-700 border-blue-200'
|
||||
case 'B1': return 'bg-yellow-100 text-yellow-700 border-yellow-200'
|
||||
case 'B2': return 'bg-orange-100 text-orange-700 border-orange-200'
|
||||
case 'C1': return 'bg-red-100 text-red-700 border-red-200'
|
||||
case 'C2': return 'bg-purple-100 text-purple-700 border-purple-200'
|
||||
default: return 'bg-gray-100 text-gray-700 border-gray-200'
|
||||
}
|
||||
}
|
||||
|
||||
const getWordProperty = (wordData: any, propName: string) => {
|
||||
if (!wordData) return undefined;
|
||||
|
||||
const variations = [
|
||||
propName,
|
||||
propName.toLowerCase(),
|
||||
propName.charAt(0).toUpperCase() + propName.slice(1),
|
||||
propName.charAt(0).toLowerCase() + propName.slice(1)
|
||||
];
|
||||
|
||||
for (const variation of variations) {
|
||||
if (wordData[variation] !== undefined) {
|
||||
return wordData[variation];
|
||||
}
|
||||
}
|
||||
|
||||
if (propName === 'synonyms') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const findWordAnalysis = (word: string) => {
|
||||
const cleanWord = word.toLowerCase().replace(/[.,!?;:]/g, '')
|
||||
return analysis?.[cleanWord] || analysis?.[word] || analysis?.[word.toLowerCase()] || null
|
||||
}
|
||||
|
||||
const getLevelIndex = (level: string): number => {
|
||||
const levels = ['A1', 'A2', 'B1', 'B2', 'C1', 'C2']
|
||||
return levels.indexOf(level)
|
||||
}
|
||||
|
||||
const getWordClass = (word: string) => {
|
||||
const wordAnalysis = findWordAnalysis(word)
|
||||
const baseClass = "cursor-pointer transition-all duration-200 rounded relative mx-0.5 px-1 py-0.5"
|
||||
|
||||
if (wordAnalysis) {
|
||||
const isPhrase = getWordProperty(wordAnalysis, 'isPhrase')
|
||||
const difficultyLevel = getWordProperty(wordAnalysis, 'difficultyLevel') || 'A1'
|
||||
const userLevel = typeof window !== 'undefined' ? localStorage.getItem('userEnglishLevel') || 'A2' : 'A2'
|
||||
|
||||
// 如果是片語,跳過標記
|
||||
if (isPhrase) {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 直接進行CEFR等級比較
|
||||
const userIndex = getLevelIndex(userLevel)
|
||||
const wordIndex = getLevelIndex(difficultyLevel)
|
||||
|
||||
if (userIndex > wordIndex) {
|
||||
// 簡單詞彙:學習者程度 > 詞彙程度
|
||||
return `${baseClass} bg-gray-50 border border-dashed border-gray-300 hover:bg-gray-100 hover:border-gray-400 text-gray-600 opacity-80`
|
||||
} else if (userIndex === wordIndex) {
|
||||
// 適中詞彙:學習者程度 = 詞彙程度
|
||||
return `${baseClass} bg-green-50 border border-green-200 hover:bg-green-100 hover:shadow-lg transform hover:-translate-y-0.5 text-green-700 font-medium`
|
||||
} else {
|
||||
// 艱難詞彙:學習者程度 < 詞彙程度
|
||||
return `${baseClass} bg-orange-50 border border-orange-200 hover:bg-orange-100 hover:shadow-lg transform hover:-translate-y-0.5 text-orange-700 font-medium`
|
||||
}
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
const getWordIcon = (word: string) => {
|
||||
// 移除所有圖標,保持簡潔設計
|
||||
return null
|
||||
}
|
||||
|
||||
// 將文字分割成單字,保留空格
|
||||
const words = text.split(/(\s+|[.,!?;:])/g)
|
||||
|
||||
const handleWordClick = async (word: string, event: React.MouseEvent) => {
|
||||
const cleanWord = word.toLowerCase().replace(/[.,!?;:]/g, '')
|
||||
const wordAnalysis = analysis?.[cleanWord]
|
||||
const wordAnalysis = findWordAnalysis(word)
|
||||
|
||||
if (!wordAnalysis) return
|
||||
|
||||
const rect = event.currentTarget.getBoundingClientRect()
|
||||
const position = {
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top - 10
|
||||
y: rect.bottom + 10,
|
||||
showBelow: true
|
||||
}
|
||||
|
||||
if (wordAnalysis) {
|
||||
// 場景A:有預存資料的詞彙
|
||||
const isHighValue = getWordProperty(wordAnalysis, 'isHighValue')
|
||||
if (isHighValue) {
|
||||
// 高價值詞彙 → 直接免費顯示
|
||||
setPopupPosition(position)
|
||||
setSelectedWord(cleanWord)
|
||||
onWordClick?.(cleanWord, wordAnalysis)
|
||||
} else {
|
||||
// 低價值詞彙 → 直接顯示(移除付費限制)
|
||||
setPopupPosition(position)
|
||||
setSelectedWord(cleanWord)
|
||||
onWordClick?.(cleanWord, wordAnalysis)
|
||||
}
|
||||
} else {
|
||||
// 場景B:無預存資料的詞彙 → 即時調用 AI 查詢
|
||||
await queryWordWithAI(cleanWord, position)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCostConfirm = async () => {
|
||||
if (!showCostConfirm) return
|
||||
|
||||
const confirmed = await onWordCostConfirm?.(showCostConfirm.word, showCostConfirm.cost)
|
||||
|
||||
if (confirmed) {
|
||||
// 調用真實的單字查詢API
|
||||
try {
|
||||
const response = await fetch('http://localhost:5000/api/ai/query-word', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
word: showCostConfirm.word,
|
||||
sentence: text,
|
||||
analysisId: null // 可以傳入分析ID
|
||||
})
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json()
|
||||
if (result.success) {
|
||||
// 更新分析資料
|
||||
const newAnalysis = {
|
||||
...analysis,
|
||||
[showCostConfirm.word]: result.data.analysis
|
||||
}
|
||||
|
||||
setPopupPosition(showCostConfirm.position)
|
||||
setSelectedWord(showCostConfirm.word)
|
||||
onWordClick?.(showCostConfirm.word, result.data.analysis)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Query word API error:', error)
|
||||
// 回退到現有資料
|
||||
const wordAnalysis = analysis?.[showCostConfirm.word]
|
||||
if (wordAnalysis) {
|
||||
setPopupPosition(showCostConfirm.position)
|
||||
setSelectedWord(showCostConfirm.word)
|
||||
onWordClick?.(showCostConfirm.word, wordAnalysis)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setShowCostConfirm(null)
|
||||
setPopupPosition(position)
|
||||
setSelectedWord(cleanWord)
|
||||
onWordClick?.(cleanWord, wordAnalysis)
|
||||
}
|
||||
|
||||
const closePopup = () => {
|
||||
setSelectedWord(null)
|
||||
}
|
||||
|
||||
const queryWordWithAI = async (word: string, position: { x: number, y: number }) => {
|
||||
const handleSaveWord = async () => {
|
||||
if (!selectedWord || !analysis?.[selectedWord] || !onSaveWord) return
|
||||
|
||||
setIsSavingWord(true)
|
||||
try {
|
||||
console.log(`🤖 查詢單字: ${word}`)
|
||||
|
||||
const response = await fetch('http://localhost:5000/api/ai/query-word', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
word: word,
|
||||
sentence: text,
|
||||
analysisId: null
|
||||
})
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json()
|
||||
console.log('AI 查詢結果:', result)
|
||||
|
||||
if (result.success && result.data?.analysis) {
|
||||
// 將新的分析資料通知父組件
|
||||
onNewWordAnalysis?.(word, result.data.analysis)
|
||||
|
||||
// 顯示分析結果
|
||||
setPopupPosition(position)
|
||||
setSelectedWord(word)
|
||||
onWordClick?.(word, result.data.analysis)
|
||||
} else {
|
||||
alert(`❌ 查詢 "${word}" 失敗,請稍後再試`)
|
||||
}
|
||||
} else {
|
||||
throw new Error(`API 錯誤: ${response.status}`)
|
||||
}
|
||||
await onSaveWord(selectedWord, analysis[selectedWord])
|
||||
setSelectedWord(null)
|
||||
} catch (error) {
|
||||
console.error('AI 查詢錯誤:', error)
|
||||
alert(`❌ 查詢 "${word}" 時發生錯誤,請稍後再試`)
|
||||
console.error('Save word error:', error)
|
||||
alert(`保存詞彙失敗: ${error instanceof Error ? error.message : '未知錯誤'}`)
|
||||
} finally {
|
||||
setIsSavingWord(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getWordClass = (word: string) => {
|
||||
const cleanWord = word.toLowerCase().replace(/[.,!?;:]/g, '')
|
||||
const wordAnalysis = analysis?.[cleanWord]
|
||||
const VocabPopup = () => {
|
||||
if (!selectedWord || !analysis?.[selectedWord] || !mounted) return null
|
||||
|
||||
const baseClass = "cursor-pointer transition-all duration-200 rounded relative mx-0.5 px-1 py-0.5"
|
||||
|
||||
if (wordAnalysis) {
|
||||
// 有預存資料的詞彙
|
||||
const isHighValue = getWordProperty(wordAnalysis, 'isHighValue')
|
||||
const isPhrase = getWordProperty(wordAnalysis, 'isPhrase')
|
||||
|
||||
// 高價值片語(黃色系)
|
||||
if (isHighValue && isPhrase) {
|
||||
return `${baseClass} bg-yellow-100 border-2 border-yellow-400 hover:bg-yellow-200 hover:shadow-sm transform hover:-translate-y-0.5`
|
||||
}
|
||||
|
||||
// 高價值單字(綠色系)
|
||||
if (isHighValue && !isPhrase) {
|
||||
return `${baseClass} bg-green-100 border-2 border-green-400 hover:bg-green-200 hover:shadow-sm transform hover:-translate-y-0.5`
|
||||
}
|
||||
|
||||
// 普通單字(藍色系)
|
||||
return `${baseClass} bg-blue-100 border-2 border-blue-300 hover:bg-blue-200 hover:shadow-sm`
|
||||
} else {
|
||||
// 無預存資料的詞彙(灰色虛線,表示需要即時查詢)
|
||||
return `${baseClass} border-2 border-dashed border-gray-300 hover:border-gray-400 bg-gray-50 hover:bg-gray-100`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* 點擊區域遮罩 */}
|
||||
{selectedWord && (
|
||||
return createPortal(
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-10"
|
||||
className="fixed inset-0 bg-black bg-opacity-50 z-40"
|
||||
onClick={closePopup}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 文字內容 */}
|
||||
<div className="text-lg leading-relaxed">
|
||||
{words.map((word, index) => {
|
||||
// 如果是空格或標點,直接顯示
|
||||
if (word.trim() === '' || /^[.,!?;:\s]+$/.test(word)) {
|
||||
return <span key={index}>{word}</span>
|
||||
}
|
||||
|
||||
const className = getWordClass(word)
|
||||
const cleanWord = word.toLowerCase().replace(/[.,!?;:]/g, '')
|
||||
const wordAnalysis = analysis?.[cleanWord]
|
||||
const isHighValue = wordAnalysis?.isHighValue || wordAnalysis?.IsHighValue
|
||||
|
||||
return (
|
||||
<span
|
||||
key={index}
|
||||
className={`${className} ${isHighValue ? 'relative' : ''}`}
|
||||
onClick={(e) => handleWordClick(word, e)}
|
||||
>
|
||||
{word}
|
||||
{isHighValue && (
|
||||
<span className="absolute -top-1 -right-1 text-xs">⭐</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 單字資訊彈窗 */}
|
||||
{selectedWord && analysis?.[selectedWord] && (
|
||||
<div
|
||||
className="fixed z-20 bg-white border border-gray-300 rounded-lg shadow-lg p-4 w-80 max-w-sm"
|
||||
className="fixed z-50 bg-white rounded-xl shadow-lg w-96 max-w-md overflow-hidden"
|
||||
style={{
|
||||
left: `${popupPosition.x}px`,
|
||||
top: `${popupPosition.y}px`,
|
||||
transform: 'translate(-50%, -100%)',
|
||||
transform: 'translate(-50%, 8px)',
|
||||
maxHeight: '85vh',
|
||||
overflowY: 'auto'
|
||||
}}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{/* 標題 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-bold text-gray-900">
|
||||
{getWordProperty(analysis[selectedWord], 'word')}
|
||||
</h3>
|
||||
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 p-5 border-b border-blue-200">
|
||||
<div className="flex justify-end mb-3">
|
||||
<button
|
||||
onClick={closePopup}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
className="text-gray-400 hover:text-gray-600 w-6 h-6 rounded-full bg-white bg-opacity-80 hover:bg-opacity-100 transition-all flex items-center justify-center"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 重點學習標記 */}
|
||||
{getWordProperty(analysis[selectedWord], 'isHighValue') && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-green-600 text-lg">🎯</div>
|
||||
<div className="text-sm font-medium text-green-800">
|
||||
重點學習詞彙
|
||||
</div>
|
||||
<div className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded-full">
|
||||
學習價值:{getWordProperty(analysis[selectedWord], 'learningPriority') === 'high' ? '⭐⭐⭐⭐⭐' :
|
||||
getWordProperty(analysis[selectedWord], 'learningPriority') === 'medium' ? '⭐⭐⭐' : '⭐'}
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<h3 className="text-2xl font-bold text-gray-900">{getWordProperty(analysis[selectedWord], 'word')}</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm bg-gray-100 text-gray-700 px-3 py-1 rounded-full">
|
||||
{getWordProperty(analysis[selectedWord], 'partOfSpeech')}
|
||||
</span>
|
||||
<span className="text-base text-gray-600">{getWordProperty(analysis[selectedWord], 'pronunciation')}</span>
|
||||
</div>
|
||||
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-medium border ${getCEFRColor(getWordProperty(analysis[selectedWord], 'difficultyLevel'))}`}>
|
||||
{getWordProperty(analysis[selectedWord], 'difficultyLevel')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="bg-green-50 rounded-lg p-3 border border-green-200">
|
||||
<h4 className="font-semibold text-green-900 mb-2 text-left text-sm">中文翻譯</h4>
|
||||
<p className="text-green-800 font-medium text-left">{getWordProperty(analysis[selectedWord], 'translation')}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-3 border border-gray-200">
|
||||
<h4 className="font-semibold text-gray-900 mb-2 text-left text-sm">英文定義</h4>
|
||||
<p className="text-gray-700 text-left text-sm leading-relaxed">{getWordProperty(analysis[selectedWord], 'definition')}</p>
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const example = getWordProperty(analysis[selectedWord], 'example');
|
||||
return example && example !== 'null' && example !== 'undefined';
|
||||
})() && (
|
||||
<div className="bg-blue-50 rounded-lg p-3 border border-blue-200">
|
||||
<h4 className="font-semibold text-blue-900 mb-2 text-left text-sm">例句</h4>
|
||||
<div className="space-y-2">
|
||||
<p className="text-blue-800 text-left text-sm italic">
|
||||
"{getWordProperty(analysis[selectedWord], 'example')}"
|
||||
</p>
|
||||
<p className="text-blue-700 text-left text-sm">
|
||||
{getWordProperty(analysis[selectedWord], 'exampleTranslation')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 片語警告 */}
|
||||
{analysis[selectedWord].isPhrase && analysis[selectedWord].phraseInfo && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="text-yellow-600 text-lg">⚠️</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-yellow-800">
|
||||
注意:這個單字屬於片語!
|
||||
</div>
|
||||
<div className="text-sm text-yellow-700 mt-1">
|
||||
<strong>片語:</strong>{analysis[selectedWord].phraseInfo.phrase}
|
||||
</div>
|
||||
<div className="text-sm text-yellow-700">
|
||||
<strong>意思:</strong>{analysis[selectedWord].phraseInfo.meaning}
|
||||
</div>
|
||||
<div className="text-xs text-yellow-600 mt-2 italic">
|
||||
{analysis[selectedWord].phraseInfo.warning}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 詞性和發音 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm bg-gray-100 px-2 py-1 rounded">
|
||||
{getWordProperty(analysis[selectedWord], 'partOfSpeech')}
|
||||
</span>
|
||||
<span className="text-sm text-gray-600">
|
||||
{getWordProperty(analysis[selectedWord], 'pronunciation')}
|
||||
</span>
|
||||
<button className="text-blue-600 hover:text-blue-800">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
|
||||
</svg>
|
||||
{onSaveWord && (
|
||||
<div className="p-4 pt-2">
|
||||
<button
|
||||
onClick={handleSaveWord}
|
||||
disabled={isSavingWord}
|
||||
className="w-full bg-primary text-white py-3 rounded-lg font-medium hover:bg-primary-hover transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<span className="font-medium">{isSavingWord ? '保存中...' : '保存到詞卡'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 翻譯 */}
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-700">翻譯</div>
|
||||
<div className="text-base text-gray-900">{getWordProperty(analysis[selectedWord], 'translation')}</div>
|
||||
</div>
|
||||
|
||||
{/* 定義 */}
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-700">定義</div>
|
||||
<div className="text-sm text-gray-600">{getWordProperty(analysis[selectedWord], 'definition')}</div>
|
||||
</div>
|
||||
|
||||
{/* 同義詞 */}
|
||||
{getWordProperty(analysis[selectedWord], 'synonyms')?.length > 0 && (
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-700">同義詞</div>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{getWordProperty(analysis[selectedWord], 'synonyms')?.map((synonym, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded-full"
|
||||
>
|
||||
{synonym}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 反義詞 */}
|
||||
{getWordProperty(analysis[selectedWord], 'antonyms')?.length > 0 && (
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-700">反義詞</div>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{getWordProperty(analysis[selectedWord], 'antonyms')?.map((antonym, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="text-xs bg-red-100 text-red-700 px-2 py-1 rounded-full"
|
||||
>
|
||||
{antonym}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 難度等級 */}
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-700">難度等級</div>
|
||||
<div className="inline-flex items-center gap-1 mt-1">
|
||||
<span className={`text-xs px-2 py-1 rounded-full ${
|
||||
(() => {
|
||||
const difficulty = getWordProperty(analysis[selectedWord], 'difficultyLevel')
|
||||
return difficulty === 'A1' || difficulty === 'A2' ? 'bg-green-100 text-green-700' :
|
||||
difficulty === 'B1' || difficulty === 'B2' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-red-100 text-red-700'
|
||||
})()
|
||||
}`}>
|
||||
CEFR {getWordProperty(analysis[selectedWord], 'difficultyLevel')}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
({(() => {
|
||||
const difficulty = getWordProperty(analysis[selectedWord], 'difficultyLevel')
|
||||
return difficulty === 'A1' || difficulty === 'A2' ? '基礎' :
|
||||
difficulty === 'B1' || difficulty === 'B2' ? '中級' : '高級'
|
||||
})()})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
{/* 收費確認對話框 */}
|
||||
{showCostConfirm && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setShowCostConfirm(null)} />
|
||||
<div
|
||||
className="fixed z-20 bg-white border border-gray-300 rounded-lg shadow-lg p-4 w-72"
|
||||
style={{
|
||||
left: `${showCostConfirm.position.x}px`,
|
||||
top: `${showCostConfirm.position.y}px`,
|
||||
transform: 'translate(-50%, -100%)',
|
||||
}}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-bold text-gray-900">
|
||||
{showCostConfirm.word}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowCostConfirm(null)}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="text-lg" style={{lineHeight: '2.5'}}>
|
||||
{words.map((word, index) => {
|
||||
if (word.trim() === '' || /^[.,!?;:\s]+$/.test(word)) {
|
||||
return <span key={index}>{word}</span>
|
||||
}
|
||||
|
||||
<div className="bg-orange-50 border border-orange-200 rounded-lg p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="text-orange-600 text-lg">💰</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-orange-800">
|
||||
低價值詞彙(需消耗額度)
|
||||
</div>
|
||||
<div className="text-sm text-orange-700 mt-1">
|
||||
此查詢將消耗 <strong>{showCostConfirm.cost} 次</strong> 使用額度
|
||||
</div>
|
||||
<div className="text-sm text-orange-600 mt-1">
|
||||
剩餘額度:<strong>{remainingUsage}</strong> 次
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
const className = getWordClass(word)
|
||||
const icon = getWordIcon(word)
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleCostConfirm}
|
||||
className="flex-1 bg-blue-600 text-white py-2 px-4 rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
✅ 確認查詢
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCostConfirm(null)}
|
||||
className="flex-1 bg-gray-200 text-gray-700 py-2 px-4 rounded-lg text-sm font-medium hover:bg-gray-300 transition-colors"
|
||||
>
|
||||
❌ 取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
return (
|
||||
<span
|
||||
key={index}
|
||||
className={className}
|
||||
onClick={(e) => handleWordClick(word, e)}
|
||||
>
|
||||
{word}
|
||||
{icon}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<VocabPopup />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -30,8 +30,9 @@ export function FlashcardForm({ cardSets, initialData, isEdit = false, onSuccess
|
|||
|
||||
const [formData, setFormData] = useState<CreateFlashcardRequest>({
|
||||
cardSetId: getDefaultCardSetId(),
|
||||
english: initialData?.english || '',
|
||||
chinese: initialData?.chinese || '',
|
||||
word: initialData?.word || '',
|
||||
translation: initialData?.translation || '',
|
||||
definition: initialData?.definition || '',
|
||||
pronunciation: initialData?.pronunciation || '',
|
||||
partOfSpeech: initialData?.partOfSpeech || '名詞',
|
||||
example: initialData?.example || '',
|
||||
|
|
@ -158,16 +159,16 @@ export function FlashcardForm({ cardSets, initialData, isEdit = false, onSuccess
|
|||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={formData.english}
|
||||
onChange={(e) => handleChange('english', e.target.value)}
|
||||
value={formData.word}
|
||||
onChange={(e) => handleChange('word', e.target.value)}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
placeholder="例如:negotiate"
|
||||
required
|
||||
/>
|
||||
{formData.english && (
|
||||
{formData.word && (
|
||||
<div className="flex-shrink-0">
|
||||
<AudioPlayer
|
||||
text={formData.english}
|
||||
text={formData.word}
|
||||
className="w-auto"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -182,8 +183,8 @@ export function FlashcardForm({ cardSets, initialData, isEdit = false, onSuccess
|
|||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.chinese}
|
||||
onChange={(e) => handleChange('chinese', e.target.value)}
|
||||
value={formData.translation}
|
||||
onChange={(e) => handleChange('translation', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
placeholder="例如:談判,協商"
|
||||
required
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { Mic, Square, Play, Upload } from 'lucide-react';
|
||||
import AudioPlayer from './AudioPlayer';
|
||||
|
||||
export interface PronunciationScore {
|
||||
overall: number;
|
||||
|
|
@ -21,6 +22,9 @@ export interface PhonemeScore {
|
|||
|
||||
export interface VoiceRecorderProps {
|
||||
targetText: string;
|
||||
targetTranslation?: string;
|
||||
exampleImage?: string;
|
||||
instructionText?: string;
|
||||
onScoreReceived?: (score: PronunciationScore) => void;
|
||||
onRecordingComplete?: (audioBlob: Blob) => void;
|
||||
maxDuration?: number;
|
||||
|
|
@ -30,6 +34,9 @@ export interface VoiceRecorderProps {
|
|||
|
||||
export default function VoiceRecorder({
|
||||
targetText,
|
||||
targetTranslation,
|
||||
exampleImage,
|
||||
instructionText,
|
||||
onScoreReceived,
|
||||
onRecordingComplete,
|
||||
maxDuration = 30, // 30 seconds default
|
||||
|
|
@ -233,20 +240,54 @@ export default function VoiceRecorder({
|
|||
}, [audioUrl]);
|
||||
|
||||
return (
|
||||
<div className={`voice-recorder p-6 border-2 border-dashed border-gray-300 rounded-xl ${className}`}>
|
||||
<div className={`voice-recorder ${className}`}>
|
||||
{/* 隱藏的音頻元素 */}
|
||||
<audio ref={audioRef} />
|
||||
|
||||
{/* Example Image */}
|
||||
{exampleImage && (
|
||||
<div className="mb-4">
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<img
|
||||
src={exampleImage}
|
||||
alt="Example context"
|
||||
className="w-full max-w-md mx-auto rounded-lg cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* 目標文字顯示 */}
|
||||
<div className="text-center mb-6">
|
||||
<h3 className="text-lg font-semibold mb-2">請朗讀以下內容:</h3>
|
||||
<p className="text-2xl font-medium text-gray-800 p-4 bg-blue-50 rounded-lg">
|
||||
{targetText}
|
||||
</p>
|
||||
<div className="mb-6">
|
||||
<div className="p-4 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1">
|
||||
<div className="text-gray-800 text-lg mb-2">{targetText}</div>
|
||||
{targetTranslation && (
|
||||
<div className="text-gray-600 text-base">{targetTranslation}</div>
|
||||
)}
|
||||
</div>
|
||||
<AudioPlayer
|
||||
text={targetText}
|
||||
className="flex-shrink-0 mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Instruction Text */}
|
||||
{instructionText && (
|
||||
<div className="mb-6">
|
||||
<p className="text-lg text-gray-700 text-left">
|
||||
{instructionText}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 錄音控制區 */}
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="p-6 border-2 border-dashed border-gray-300 rounded-xl">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
{/* 錄音按鈕 */}
|
||||
<button
|
||||
onClick={isRecording ? stopRecording : startRecording}
|
||||
|
|
@ -360,6 +401,7 @@ export default function VoiceRecorder({
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
'use client'
|
||||
|
||||
import React, { useEffect } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
title?: string
|
||||
children: React.ReactNode
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl'
|
||||
}
|
||||
|
||||
export const Modal: React.FC<ModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
size = 'lg'
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
document.body.style.overflow = 'unset'
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = 'unset'
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
}
|
||||
}, [isOpen, onClose])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'max-w-md',
|
||||
md: 'max-w-lg',
|
||||
lg: 'max-w-2xl',
|
||||
xl: 'max-w-4xl'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black bg-opacity-50"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal Content */}
|
||||
<div className={`
|
||||
relative bg-white rounded-lg shadow-xl max-h-[90vh] overflow-hidden
|
||||
${sizeClasses[size]} w-full mx-4
|
||||
`}>
|
||||
{/* Header */}
|
||||
{title && (
|
||||
<div className="flex items-center justify-between p-6 border-b">
|
||||
<h2 className="text-xl font-semibold text-gray-900">{title}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body */}
|
||||
<div className="overflow-y-auto max-h-[calc(90vh-8rem)]">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -42,8 +42,9 @@ export interface CreateCardSetRequest {
|
|||
|
||||
export interface CreateFlashcardRequest {
|
||||
cardSetId?: string;
|
||||
english: string;
|
||||
chinese: string;
|
||||
word: string;
|
||||
translation: string;
|
||||
definition: string;
|
||||
pronunciation: string;
|
||||
partOfSpeech: string;
|
||||
example: string;
|
||||
|
|
@ -206,6 +207,39 @@ class FlashcardsService {
|
|||
};
|
||||
}
|
||||
}
|
||||
|
||||
async batchCreateFlashcards(request: BatchCreateFlashcardsRequest): Promise<ApiResponse<BatchCreateFlashcardsResponse>> {
|
||||
try {
|
||||
return await this.makeRequest<ApiResponse<BatchCreateFlashcardsResponse>>('/flashcards/batch', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to save flashcards',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 新增批量創建相關介面
|
||||
export interface BatchCreateFlashcardsRequest {
|
||||
cardSetId?: string;
|
||||
cards: CreateFlashcardRequest[];
|
||||
}
|
||||
|
||||
export interface BatchCreateFlashcardsResponse {
|
||||
savedCards: SavedCard[];
|
||||
savedCount: number;
|
||||
errorCount: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface SavedCard {
|
||||
id: string;
|
||||
word: string;
|
||||
translation: string;
|
||||
}
|
||||
|
||||
export const flashcardsService = new FlashcardsService();
|
||||
|
|
@ -0,0 +1,509 @@
|
|||
# 個人化詞彙庫功能規格
|
||||
|
||||
## 🎯 功能概述
|
||||
|
||||
個人化詞彙庫是一個用戶專屬的詞彙管理系統,允許用戶收集、組織和追蹤自己的學習詞彙,並根據學習表現提供個人化的學習建議。
|
||||
|
||||
## 📋 核心功能需求
|
||||
|
||||
### 1. 詞彙收集功能
|
||||
|
||||
#### 1.1 手動添加詞彙
|
||||
- **功能描述**:用戶可以手動輸入新詞彙到個人詞彙庫
|
||||
- **輸入欄位**:
|
||||
- 英文詞彙(必填)
|
||||
- 詞性(可選,下拉選單)
|
||||
- 發音(可選,自動生成或手動輸入)
|
||||
- 定義(可選,自動生成或手動輸入)
|
||||
- 中文翻譯(可選,自動生成或手動輸入)
|
||||
- 個人筆記(可選,用戶自訂)
|
||||
- **自動補全**:系統自動查詢並填入詞彙資訊
|
||||
- **重複檢查**:避免添加重複詞彙
|
||||
|
||||
#### 1.2 學習中收集
|
||||
- **學習頁面收藏**:在任何測驗模式中點擊「收藏」按鈕
|
||||
- **困難詞彙標記**:答錯的詞彙自動標記為需要加強
|
||||
- **快速收集**:一鍵添加當前學習的詞彙到個人庫
|
||||
|
||||
#### 1.3 批量導入
|
||||
- **文字檔導入**:支援 .txt 格式的詞彙列表
|
||||
- **CSV 導入**:支援結構化的詞彙資料
|
||||
- **從學習記錄導入**:將過往答錯的詞彙批量加入
|
||||
|
||||
### 2. 詞彙組織功能
|
||||
|
||||
#### 2.1 分類管理
|
||||
- **預設分類**:
|
||||
- 新學詞彙(New)
|
||||
- 學習中(Learning)
|
||||
- 熟悉(Familiar)
|
||||
- 精通(Mastered)
|
||||
- 困難詞彙(Difficult)
|
||||
- **自訂分類**:用戶可創建自己的分類標籤
|
||||
- **多重分類**:單一詞彙可屬於多個分類
|
||||
|
||||
#### 2.2 標籤系統
|
||||
- **難度標籤**:A1, A2, B1, B2, C1, C2
|
||||
- **主題標籤**:商業、旅遊、學術、日常等
|
||||
- **來源標籤**:書籍、電影、新聞、會話等
|
||||
- **自訂標籤**:用戶可創建個人標籤
|
||||
|
||||
#### 2.3 優先級管理
|
||||
- **高優先級**:急需掌握的詞彙
|
||||
- **中優先級**:重要但不緊急的詞彙
|
||||
- **低優先級**:選擇性學習的詞彙
|
||||
|
||||
### 3. 學習追蹤功能
|
||||
|
||||
#### 3.1 熟悉度評分
|
||||
- **評分機制**:0-100 分的熟悉度評分
|
||||
- **多維度評估**:
|
||||
- 認識度(Recognition):看到詞彙能理解
|
||||
- 回想度(Recall):能主動想起詞彙
|
||||
- 應用度(Application):能在語境中正確使用
|
||||
- **動態調整**:根據測驗表現自動調整評分
|
||||
|
||||
#### 3.2 學習歷史
|
||||
- **學習次數**:詞彙被學習的總次數
|
||||
- **正確率**:各種測驗模式的正確率統計
|
||||
- **最後學習時間**:記錄最近一次學習時間
|
||||
- **學習軌跡**:詳細的學習歷程記錄
|
||||
|
||||
#### 3.3 遺忘曲線追蹤
|
||||
- **複習提醒**:基於遺忘曲線的智能提醒
|
||||
- **複習間隔**:動態調整複習時間間隔
|
||||
- **記憶強度**:評估詞彙在記憶中的鞏固程度
|
||||
|
||||
### 4. 個人化學習功能
|
||||
|
||||
#### 4.1 智能推薦
|
||||
- **弱點分析**:識別用戶的學習弱點
|
||||
- **相似詞彙**:推薦語義相關的詞彙
|
||||
- **同根詞擴展**:推薦同詞根的相關詞彙
|
||||
- **搭配詞推薦**:推薦常見的詞彙搭配
|
||||
|
||||
#### 4.2 個人化測驗
|
||||
- **客製化題組**:根據個人詞彙庫生成測驗
|
||||
- **弱點加強**:針對困難詞彙的專門訓練
|
||||
- **複習模式**:基於遺忘曲線的複習測驗
|
||||
- **混合練習**:結合不同來源詞彙的綜合測驗
|
||||
|
||||
#### 4.3 學習計劃
|
||||
- **每日目標**:設定每日學習詞彙數量
|
||||
- **週期計劃**:制定短期和長期學習目標
|
||||
- **進度追蹤**:視覺化顯示學習進度
|
||||
- **成就系統**:學習里程碑和獎勵機制
|
||||
|
||||
## 🗃️ 資料結構設計
|
||||
|
||||
### 個人詞彙資料模型
|
||||
```typescript
|
||||
interface PersonalVocabulary {
|
||||
id: string;
|
||||
userId: string;
|
||||
word: string;
|
||||
partOfSpeech?: string;
|
||||
pronunciation?: string;
|
||||
definition?: string;
|
||||
translation?: string;
|
||||
personalNotes?: string;
|
||||
|
||||
// 分類和標籤
|
||||
categories: string[];
|
||||
tags: string[];
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
|
||||
// 學習追蹤
|
||||
familiarityScore: number; // 0-100
|
||||
recognitionScore: number; // 0-100
|
||||
recallScore: number; // 0-100
|
||||
applicationScore: number; // 0-100
|
||||
|
||||
// 學習統計
|
||||
totalPractices: number;
|
||||
correctAnswers: number;
|
||||
incorrectAnswers: number;
|
||||
lastPracticed: Date;
|
||||
nextReview: Date;
|
||||
|
||||
// 測驗模式統計
|
||||
flipMemoryStats: TestModeStats;
|
||||
vocabChoiceStats: TestModeStats;
|
||||
sentenceFillStats: TestModeStats;
|
||||
// ... 其他測驗模式
|
||||
|
||||
// 元資料
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
source?: string; // 詞彙來源
|
||||
}
|
||||
|
||||
interface TestModeStats {
|
||||
attempts: number;
|
||||
correct: number;
|
||||
averageTime: number; // 平均回答時間(秒)
|
||||
lastAttempt: Date;
|
||||
}
|
||||
```
|
||||
|
||||
### 學習會話記錄
|
||||
```typescript
|
||||
interface LearningSession {
|
||||
id: string;
|
||||
userId: string;
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
mode: string;
|
||||
vocabulariesPracticed: string[]; // 詞彙 IDs
|
||||
totalQuestions: number;
|
||||
correctAnswers: number;
|
||||
timeSpent: number; // 秒
|
||||
performance: SessionPerformance;
|
||||
}
|
||||
|
||||
interface SessionPerformance {
|
||||
accuracy: number; // 正確率
|
||||
speed: number; // 平均回答速度
|
||||
improvement: number; // 相對上次的進步
|
||||
weakWords: string[]; // 表現較差的詞彙
|
||||
strongWords: string[]; // 表現較好的詞彙
|
||||
}
|
||||
```
|
||||
|
||||
## 🔧 技術實現方案
|
||||
|
||||
### 前端實現
|
||||
|
||||
#### 1. 狀態管理
|
||||
```typescript
|
||||
// 使用 Context API 或 Zustand
|
||||
interface PersonalVocabStore {
|
||||
vocabularies: PersonalVocabulary[];
|
||||
currentSession: LearningSession | null;
|
||||
filters: VocabFilters;
|
||||
|
||||
// Actions
|
||||
addVocabulary: (vocab: Partial<PersonalVocabulary>) => void;
|
||||
updateVocabulary: (id: string, updates: Partial<PersonalVocabulary>) => void;
|
||||
deleteVocabulary: (id: string) => void;
|
||||
updateFamiliarity: (id: string, testResult: TestResult) => void;
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 本地儲存策略
|
||||
- **IndexedDB**:大量詞彙資料的本地儲存
|
||||
- **localStorage**:用戶偏好和設定
|
||||
- **同步機制**:與伺服器的雙向同步
|
||||
|
||||
#### 3. UI 組件結構
|
||||
```
|
||||
/components/PersonalVocab/
|
||||
├── VocabLibrary.tsx # 詞彙庫主頁面
|
||||
├── VocabCard.tsx # 單一詞彙卡片
|
||||
├── VocabForm.tsx # 新增/編輯詞彙表單
|
||||
├── VocabFilters.tsx # 篩選和搜尋
|
||||
├── VocabStats.tsx # 學習統計
|
||||
├── CategoryManager.tsx # 分類管理
|
||||
├── TagManager.tsx # 標籤管理
|
||||
└── ReviewScheduler.tsx # 複習排程
|
||||
```
|
||||
|
||||
### 後端實現
|
||||
|
||||
#### 1. API 端點設計
|
||||
```
|
||||
GET /api/personal-vocab # 獲取用戶詞彙庫
|
||||
POST /api/personal-vocab # 新增詞彙
|
||||
PUT /api/personal-vocab/:id # 更新詞彙
|
||||
DELETE /api/personal-vocab/:id # 刪除詞彙
|
||||
POST /api/personal-vocab/batch # 批量操作
|
||||
|
||||
GET /api/personal-vocab/stats # 獲取學習統計
|
||||
POST /api/personal-vocab/practice # 記錄練習結果
|
||||
GET /api/personal-vocab/review # 獲取需要複習的詞彙
|
||||
|
||||
GET /api/learning-sessions # 獲取學習會話記錄
|
||||
POST /api/learning-sessions # 記錄學習會話
|
||||
```
|
||||
|
||||
#### 2. 資料庫設計
|
||||
```sql
|
||||
-- 個人詞彙表
|
||||
CREATE TABLE personal_vocabularies (
|
||||
id UUID PRIMARY KEY,
|
||||
user_id UUID REFERENCES users(id),
|
||||
word VARCHAR(100) NOT NULL,
|
||||
part_of_speech VARCHAR(20),
|
||||
pronunciation VARCHAR(200),
|
||||
definition TEXT,
|
||||
translation TEXT,
|
||||
personal_notes TEXT,
|
||||
familiarity_score INTEGER DEFAULT 0,
|
||||
recognition_score INTEGER DEFAULT 0,
|
||||
recall_score INTEGER DEFAULT 0,
|
||||
application_score INTEGER DEFAULT 0,
|
||||
total_practices INTEGER DEFAULT 0,
|
||||
correct_answers INTEGER DEFAULT 0,
|
||||
incorrect_answers INTEGER DEFAULT 0,
|
||||
last_practiced TIMESTAMP,
|
||||
next_review TIMESTAMP,
|
||||
priority VARCHAR(10) DEFAULT 'medium',
|
||||
source VARCHAR(100),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 詞彙分類表
|
||||
CREATE TABLE vocab_categories (
|
||||
id UUID PRIMARY KEY,
|
||||
user_id UUID REFERENCES users(id),
|
||||
name VARCHAR(50) NOT NULL,
|
||||
color VARCHAR(7), -- HEX color
|
||||
description TEXT,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 詞彙-分類關聯表
|
||||
CREATE TABLE vocab_category_relations (
|
||||
vocab_id UUID REFERENCES personal_vocabularies(id),
|
||||
category_id UUID REFERENCES vocab_categories(id),
|
||||
PRIMARY KEY (vocab_id, category_id)
|
||||
);
|
||||
|
||||
-- 學習會話表
|
||||
CREATE TABLE learning_sessions (
|
||||
id UUID PRIMARY KEY,
|
||||
user_id UUID REFERENCES users(id),
|
||||
mode VARCHAR(50) NOT NULL,
|
||||
start_time TIMESTAMP NOT NULL,
|
||||
end_time TIMESTAMP,
|
||||
total_questions INTEGER DEFAULT 0,
|
||||
correct_answers INTEGER DEFAULT 0,
|
||||
time_spent INTEGER DEFAULT 0, -- 秒
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 詞彙練習記錄表
|
||||
CREATE TABLE vocab_practice_records (
|
||||
id UUID PRIMARY KEY,
|
||||
session_id UUID REFERENCES learning_sessions(id),
|
||||
vocab_id UUID REFERENCES personal_vocabularies(id),
|
||||
test_mode VARCHAR(50) NOT NULL,
|
||||
is_correct BOOLEAN NOT NULL,
|
||||
response_time INTEGER, -- 毫秒
|
||||
user_answer TEXT,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
## 🎨 使用者介面設計
|
||||
|
||||
### 主要頁面結構
|
||||
|
||||
#### 1. 詞彙庫總覽頁面 (`/personal-vocab`)
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 🏠 個人詞彙庫 (1,247 個詞彙) │
|
||||
├─────────────────────────────────────┤
|
||||
│ [搜尋框] [篩選] [排序] [新增詞彙] │
|
||||
├─────────────────────────────────────┤
|
||||
│ 📊 學習統計 │
|
||||
│ • 今日學習:12 個詞彙 │
|
||||
│ • 本週進度:85% 完成 │
|
||||
│ • 平均正確率:78% │
|
||||
├─────────────────────────────────────┤
|
||||
│ 📚 詞彙分類 │
|
||||
│ [新學詞彙 124] [學習中 89] [熟悉 856] │
|
||||
│ [困難詞彙 45] [我的收藏 67] │
|
||||
├─────────────────────────────────────┤
|
||||
│ 📝 詞彙列表 │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ brought (動詞) ⭐⭐⭐⭐☆ │ │
|
||||
│ │ 發音: /brɔːt/ | 熟悉度: 80% │ │
|
||||
│ │ 定義: Past tense of bring... │ │
|
||||
│ │ [編輯] [練習] [刪除] │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ (更多詞彙卡片...) │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### 2. 詞彙詳情頁面 (`/personal-vocab/:id`)
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ ← 返回詞彙庫 │
|
||||
├─────────────────────────────────────┤
|
||||
│ 📝 brought │
|
||||
│ 動詞 | 難度: B1 | 優先級: 高 │
|
||||
├─────────────────────────────────────┤
|
||||
│ 🔊 發音: /brɔːt/ [播放] │
|
||||
│ 📖 定義: Past tense of bring... │
|
||||
│ 🈲 翻譯: 提出、帶來 │
|
||||
│ 📝 個人筆記: [編輯區域] │
|
||||
├─────────────────────────────────────┤
|
||||
│ 📊 學習統計 │
|
||||
│ • 熟悉度: ████████░░ 80% │
|
||||
│ • 總練習: 25 次 │
|
||||
│ • 正確率: 85% │
|
||||
│ • 上次練習: 2 小時前 │
|
||||
│ • 下次複習: 明天 14:00 │
|
||||
├─────────────────────────────────────┤
|
||||
│ 🎯 各模式表現 │
|
||||
│ • 翻卡記憶: 90% (15/15) │
|
||||
│ • 詞彙選擇: 75% (12/16) │
|
||||
│ • 例句填空: 80% (8/10) │
|
||||
├─────────────────────────────────────┤
|
||||
│ 🏷️ 分類標籤 │
|
||||
│ [學習中] [商業英語] [重要詞彙] │
|
||||
│ [+ 添加標籤] │
|
||||
├─────────────────────────────────────┤
|
||||
│ 🎮 快速練習 │
|
||||
│ [翻卡記憶] [詞彙選擇] [例句填空] │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### 3. 新增詞彙頁面 (`/personal-vocab/add`)
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ ➕ 新增詞彙到個人庫 │
|
||||
├─────────────────────────────────────┤
|
||||
│ 📝 英文詞彙: [輸入框] *必填 │
|
||||
│ 🔍 [智能查詢] - 自動填入詞彙資訊 │
|
||||
├─────────────────────────────────────┤
|
||||
│ 📖 詞彙資訊 │
|
||||
│ • 詞性: [下拉選單] │
|
||||
│ • 發音: [輸入框] [生成] │
|
||||
│ • 定義: [文字區域] [自動生成] │
|
||||
│ • 翻譯: [輸入框] [自動翻譯] │
|
||||
├─────────────────────────────────────┤
|
||||
│ 🏷️ 分類設定 │
|
||||
│ • 分類: [多選下拉] [新增分類] │
|
||||
│ • 標籤: [標籤選擇器] [新增標籤] │
|
||||
│ • 優先級: ⚫ 高 ⚪ 中 ⚪ 低 │
|
||||
├─────────────────────────────────────┤
|
||||
│ 📝 個人筆記 │
|
||||
│ [多行文字輸入區域] │
|
||||
├─────────────────────────────────────┤
|
||||
│ [取消] [儲存詞彙] │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 🔄 學習流程整合
|
||||
|
||||
### 1. 學習中的詞彙收集
|
||||
- **收藏按鈕**:每個測驗頁面都有收藏功能
|
||||
- **自動收集**:答錯的詞彙自動標記為需要加強
|
||||
- **學習後提醒**:學習會話結束後推薦收藏的詞彙
|
||||
|
||||
### 2. 個人化測驗生成
|
||||
- **我的詞彙測驗**:從個人庫選取詞彙生成測驗
|
||||
- **弱點強化**:針對低熟悉度詞彙的專門練習
|
||||
- **混合模式**:結合系統詞彙和個人詞彙的測驗
|
||||
|
||||
### 3. 複習提醒系統
|
||||
- **智能排程**:基於遺忘曲線安排複習時間
|
||||
- **推送通知**:瀏覽器通知提醒複習時間
|
||||
- **複習優化**:根據表現調整複習頻率
|
||||
|
||||
## 📱 響應式設計考量
|
||||
|
||||
### 桌面版 (>= 1024px)
|
||||
- **三欄布局**:側邊欄(分類)+ 詞彙列表 + 詳情面板
|
||||
- **拖拉操作**:支援拖拉詞彙到不同分類
|
||||
- **快速鍵**:鍵盤快速鍵支援
|
||||
|
||||
### 平板版 (768px - 1023px)
|
||||
- **兩欄布局**:詞彙列表 + 詳情面板
|
||||
- **觸控優化**:適合觸控操作的按鈕尺寸
|
||||
|
||||
### 手機版 (< 768px)
|
||||
- **單欄布局**:全螢幕顯示當前頁面
|
||||
- **底部導航**:快速切換功能
|
||||
- **手勢支援**:滑動操作和長按功能
|
||||
|
||||
## 🚀 實施階段規劃
|
||||
|
||||
### 階段 1:基礎詞彙管理 (第 1-2 週)
|
||||
- [ ] 資料庫設計和建立
|
||||
- [ ] 基本 CRUD API 開發
|
||||
- [ ] 詞彙列表頁面
|
||||
- [ ] 新增/編輯詞彙功能
|
||||
- [ ] 基本搜尋和篩選
|
||||
|
||||
### 階段 2:學習追蹤系統 (第 3-4 週)
|
||||
- [ ] 熟悉度評分系統
|
||||
- [ ] 學習歷史記錄
|
||||
- [ ] 測驗結果整合
|
||||
- [ ] 學習統計儀表板
|
||||
|
||||
### 階段 3:智能化功能 (第 5-6 週)
|
||||
- [ ] 遺忘曲線算法
|
||||
- [ ] 複習提醒系統
|
||||
- [ ] 個人化推薦
|
||||
- [ ] 弱點分析
|
||||
|
||||
### 階段 4:高級功能 (第 7-8 週)
|
||||
- [ ] 批量導入/導出
|
||||
- [ ] 學習計劃制定
|
||||
- [ ] 成就系統
|
||||
- [ ] 社交分享功能
|
||||
|
||||
## 📊 成功指標
|
||||
|
||||
### 用戶行為指標
|
||||
- **詞彙庫使用率**:> 80% 用戶建立個人詞彙庫
|
||||
- **收藏率**:> 60% 學習中的詞彙被收藏
|
||||
- **複習完成率**:> 70% 的複習提醒被完成
|
||||
- **熟悉度提升**:平均熟悉度每週提升 5%
|
||||
|
||||
### 學習效果指標
|
||||
- **記憶保持率**:複習詞彙的正確率 > 85%
|
||||
- **學習效率**:個人詞彙的學習時間縮短 30%
|
||||
- **長期記憶**:30 天後的詞彙記憶率 > 70%
|
||||
|
||||
### 系統性能指標
|
||||
- **回應時間**:詞彙庫載入時間 < 2 秒
|
||||
- **同步效率**:資料同步成功率 > 99%
|
||||
- **儲存效率**:本地儲存空間使用 < 50MB
|
||||
|
||||
## 🔐 隱私和安全考量
|
||||
|
||||
### 資料隱私
|
||||
- **用戶授權**:明確的隱私政策和使用條款
|
||||
- **資料加密**:敏感資料的端到端加密
|
||||
- **匿名化**:學習統計資料的匿名化處理
|
||||
|
||||
### 資料安全
|
||||
- **備份機制**:定期備份用戶資料
|
||||
- **版本控制**:資料變更的版本記錄
|
||||
- **災難恢復**:資料遺失的恢復機制
|
||||
|
||||
## 🔮 未來擴展功能
|
||||
|
||||
### 社交學習功能
|
||||
- **詞彙分享**:分享個人詞彙庫給其他用戶
|
||||
- **學習小組**:創建詞彙學習小組
|
||||
- **競賽模式**:與朋友的詞彙學習競賽
|
||||
|
||||
### AI 智能功能
|
||||
- **智能生成**:AI 生成個人化例句
|
||||
- **發音評估**:AI 評估發音準確度
|
||||
- **學習建議**:AI 提供個人化學習建議
|
||||
|
||||
### 多媒體功能
|
||||
- **語音筆記**:錄音形式的個人筆記
|
||||
- **圖片聯想**:為詞彙添加個人化圖片
|
||||
- **影片連結**:連結相關的學習影片
|
||||
|
||||
---
|
||||
|
||||
## 📝 附註
|
||||
|
||||
本規格文件為個人化詞彙庫功能的完整設計,包含前後端實現細節和用戶體驗考量。實際開發時可根據優先級和資源情況分階段實施。
|
||||
|
||||
**建議優先實施階段 1 和階段 2**,建立穩固的基礎功能,再逐步添加智能化和高級功能。
|
||||
|
||||
---
|
||||
|
||||
*最後更新:2025-09-20*
|
||||
*版本:v1.0*
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
方式:
|
||||
- 翻卡題:詞彙,自己憑感覺評估記憶情況 (對詞彙全面的初步認識)
|
||||
- 選擇題:給定義,選詞彙 (加深詞彙定義與詞彙連結)
|
||||
- 詞彙聽力題:聽詞彙,選詞彙 (對詞彙的發音記憶,但因為人類有很強的短期記憶能力,因此對於學習新單字沒幫助)
|
||||
- 例句聽力題:聽例句,選例句 (對例句的發音記憶,但因為人類有很強的短期記憶能力,因此對於學習新單字沒幫助)
|
||||
- 填空題:給挖空例句,自己填詞彙 (練拼字,加深詞彙與情境的連結)
|
||||
- 例句重組題:打亂例句單字,重組 (快速練習組織句子)
|
||||
- 例句口說題:給例句,念例句 (練習看著例句圖去揣摩情境,並練習說出整句話,加深例句與情境的連結,同時也練習母語者的表達)
|
||||
|
||||
A1學習者
|
||||
- 複習方式:翻卡題、詞彙聽力題、選擇題
|
||||
補充:因為A1對於發音是完全沒概念,所以詞彙聽力這時候是有幫助的
|
||||
|
||||
簡單 (學習者程度 > 詞彙程度)
|
||||
- 複習方式:例句重組題、填空題
|
||||
|
||||
適中 (學習者程度 = 詞彙程度)
|
||||
- 複習方式:填空題、例句重組題、例句口說題
|
||||
|
||||
困難 (學習者程度 < 詞彙程度)
|
||||
- 複習方式:翻卡題、選擇題
|
||||
|
||||
|
||||
詞彙口袋大複習
|
||||
- 配對題:給圖片和詞彙,但有個問題就是,有時候詞彙和圖的意境其實相關性不高
|
||||
- 克漏字:
|
||||
- 詞彙聽力題:聽詞彙,選詞彙 (對詞彙的發音記憶,但因為人類有很強的短期記憶能力,因此對於學習新單字沒幫助)
|
||||
- 例句聽力題:聽例句,選例句 (對例句的發音記憶,但因為人類有很強的短期記憶能力,因此對於學習新單字沒幫助)
|
||||
|
||||
|
||||
|
||||
|
||||
- 翻卡題:詞彙,自己憑感覺評估記憶情況 (對詞彙全面的初步認識)
|
||||
- 選擇題:給定義,選詞彙 (加深詞彙定義與詞彙連結)
|
||||
- 詞彙聽力題:聽詞彙,選詞彙 (對詞彙的發音記憶,但因為人類有很強的短期記憶能力,因此對於學習新單字沒幫助)
|
||||
- 例句聽力題:聽例句,選例句 (對例句的發音記憶,但因為人類有很強的短期記憶能力,因此對於學習新單字沒幫助)
|
||||
- 填空題:給挖空例句,自己填詞彙 (練拼字,加深詞彙與情境的連結)
|
||||
- 例句重組題:打亂例句單字,重組 (快速練習組織句子)
|
||||
- 例句口說題:給例句,念例句 (練習看著例句圖去揣摩情境,並練習說出整句話,加深例句與情境的連結,同時也練習
|
||||
|
||||
- 翻卡記憶:詞彙,自己憑感覺評估記憶情況 (對詞彙全面的初步認識)
|
||||
- 詞彙選擇:給定義,選詞彙 (加深詞彙定義與詞彙連結)
|
||||
- 詞彙聽力:聽詞彙,選詞彙 (對詞彙的發音記憶,但因為人類有很強的短期記憶能力,因此對於學習新單字沒幫助)
|
||||
- 例句聽力:聽例句,選例句 (對例句的發音記憶,但因為人類有很強的短期記憶能力,因此對於學習新單字沒幫助)
|
||||
- 例句填空:給挖空例句,自己填詞彙 (練拼字,加深詞彙與情境的連結)
|
||||
- 例句重組:打亂例句單字,重組 (快速練習組織句子)
|
||||
- 例句口說:給例句,念例句 (練習看著例句圖去揣摩情境,並練習說出整句話,加深例句與情境的連結,同時也練習
|
||||
|
||||
> 例句填空\
|
||||
系統會提供例句\
|
||||
然後例句會有挖空處,有可能是多個單字(因為片語就是多個單字)
|
||||
使用者點選挖空處就可以輸入單字\
|
||||
點選顯示提示,系統會顯示詞彙定義\
|
||||
在例句上方是例句圖\
|
||||
\
|
||||
\
|
||||
以上功能請協助修改
|
||||
Loading…
Reference in New Issue