feat: 實現互動式單字查詢系統,包含語法修正和高價值標記功能

## 主要功能
- 智能語法錯誤檢測和修正建議(9種錯誤類型)
- 高價值詞彙標記系統(片語/俚語/中高級單字)
- 三色視覺區分:🟡高價值片語 🟢高價值單字 🔵普通單字
- 成本優化:高價值詞彙免費查詢,低價值詞彙按需收費
- 字數限制提升:50字→300字

## 技術實現
- 新增 ClickableTextV2 和 GrammarCorrectionPanel 組件
- 更新 Generate 頁面整合完整功能
- 完整的設計規格文檔(API、UI線框圖、功能規格)
- 多個演示頁面展示功能效果

## 成本效益
- 一次API調用,多次免費查詢
- 預估API成本降低80-95%
- 智能收費機制精準控制成本

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
鄭沛軒 2025-09-17 19:57:08 +08:00
parent 336f235684
commit 76e95dbef2
13 changed files with 5254 additions and 690 deletions

View File

@ -63,22 +63,48 @@
- 難度選擇A1, A2, B1, B2, C1, C2 - 難度選擇A1, A2, B1, B2, C1, C2
#### 1.2.2 AI 生成規格 #### 1.2.2 AI 生成規格
- **生成方式** - **原始例句輸入**
1. 原始例句類型 - 輸入方式
- 影劇截圖(訂閱功能, phase2) 1. 影劇截圖(訂閱功能, phase2)
- 手動輸入 2. 手動輸入
2. 詞彙萃取把每個單字拿去查詢字典API並標記CEFR - 輸入資料
3. 智能萃取(訂閱功能)將原始例句拿去問AI有無常用片語或俚語並直接生成相關詞彙內容 - 可接受多句子
- 字數限制規則:
- 若為手動輸入則限定300字以內在前端畫面做阻擋
- 若為影劇截圖則無300字限制
- **生成數量** - **互動式單字查詢(低成本設計)**
- 預設10個詞卡 1. 預分析機制
- 範圍5-20個用戶可調 - 用戶輸入句子後AI 一次性分析整句內容
- 免費用戶: - 獲取原始例句意思
- 無法自行生成例句圖,但若系統中匹配到現成例句圖,可直接使用 - 識別具備高學習價值的片語/俚語/單字,並標記為高價值,並於當次直接生成具標記的項目內容詳情(參考「生成內容詳情」)
- 每日學習數量無限制 - 分析結果存儲於快取中(避免重複 API 調用)
- 訂閱用戶每天最多生成50張例句圖 - 當次操作扣除使用次數一次
2. 點擊查詢體驗
- 句子顯示為可點擊的單字
- 點擊對象
- 若為高價值標記,則即時顯示意思(無延遲,讀取預分析資料),不扣除使用次數
- 若非高價值標記則拿當前點擊單字及當前句子給AI分析並生成內容詳情扣除使用次數一次
- 片語/俚語特殊高亮顯示
- 智能提醒:當單字屬於片語/俚語時,優先顯示片語意思並提醒
- 若出現多筆片語/俚語需標記時,請使用不同顏色區分
3. 成本優化策略
- **核心原則**:一句一次 API 調用,多次查詢零成本
- 相同句子分析結果快取24小時
- 常用單字基礎資訊本地快取
- 預估 API 成本降低 80-95%
4. 收費策略(phase 2)
- 免費用戶5次/3小時
- 付費用戶:無限制
- **生成內容詳情** - **生成內容詳情**
- **原始例句**
- 整體意思:不論原始例句是多句、一句、片段,就是將原始例句整體意思描述出來
- 修正語法錯誤:若原始例句有語法錯誤,則進行修正,並說明修正原因,且後續學習內容皆以正確的版本進行
- **單字/片語** - **單字/片語**
- 原形展示 - 原形展示
- 詞性標註n./v./adj./adv./phrase/slang - 詞性標註n./v./adj./adv./phrase/slang
@ -100,6 +126,9 @@
- 例句中文翻譯 - 例句中文翻譯
- 重點標示highlight目標詞 - 重點標示highlight目標詞
- 例句圖 - 例句圖
- 收費策略(phase 2)
- 免費用戶:無法自行生成例句圖,但若系統中匹配到現成例句圖,可直接使用
- 訂閱用戶每天最多生成50張例句圖
- 例句發音 - 例句發音
- **生成後處理** - **生成後處理**
@ -438,6 +467,11 @@
**目標**:提升用戶體驗 **目標**:提升用戶體驗
- ✅ 標籤系統 - ✅ 標籤系統
- ✅ 搜尋篩選 - ✅ 搜尋篩選
- ⬜ **互動式單字查詢系統**
- 句子預分析 API 端點
- 可點擊文字組件
- 片語/俚語智能提醒
- 快取機制實現
- ✅ 進階統計圖表 - ✅ 進階統計圖表
- ✅ 成就系統 - ✅ 成就系統
- ✅ 學習提醒 - ✅ 學習提醒
@ -481,7 +515,9 @@
- 內容版權問題 - 內容版權問題
### 7.3 緩解措施 ### 7.3 緩解措施
- 實施 API 快取機制 - 實施 API 快取機制(重點:單字查詢預分析快取)
- 準備備用 AI 服務 - 準備備用 AI 服務
- 建立用戶反饋循環 - 建立用戶反饋循環
- 確保內容合規性 - 確保內容合規性
- 監控 AI API 使用量並設定預算警告
- 實現降級機制API 配額用盡時使用離線字典

View File

@ -0,0 +1,801 @@
# 互動式單字查詢 API 規格書
## 1. API 概覽
### 1.1 基本資訊
- **Base URL**: `https://api.dramaling.com/v1`
- **認證方式**: Bearer Token (JWT)
- **請求格式**: JSON
- **響應格式**: JSON
- **字元編碼**: UTF-8
### 1.2 通用響應格式
#### 成功響應
```json
{
"success": true,
"data": { /* 實際資料 */ },
"message": "操作成功描述",
"timestamp": "2025-09-17T09:48:00Z",
"requestId": "uuid"
}
```
#### 錯誤響應
```json
{
"success": false,
"error": {
"code": "ERROR_CODE",
"message": "錯誤描述",
"details": "詳細錯誤資訊",
"field": "相關欄位" // 如適用
},
"timestamp": "2025-09-17T09:48:00Z",
"requestId": "uuid"
}
```
### 1.3 HTTP 狀態碼
- `200 OK`: 請求成功
- `201 Created`: 資源創建成功
- `400 Bad Request`: 請求參數錯誤
- `401 Unauthorized`: 未認證或認證失效
- `403 Forbidden`: 無權限訪問
- `404 Not Found`: 資源不存在
- `429 Too Many Requests`: 超過使用限制
- `500 Internal Server Error`: 伺服器內部錯誤
## 2. 認證 API
### 2.1 獲取 Access Token
```http
POST /auth/login
Content-Type: application/json
{
"email": "user@example.com",
"password": "user_password"
}
```
**響應**:
```json
{
"success": true,
"data": {
"accessToken": "eyJhbGciOiJIUzI1NiIs...",
"refreshToken": "eyJhbGciOiJIUzI1NiIs...",
"expiresIn": 900,
"user": {
"id": "uuid",
"email": "user@example.com",
"name": "User Name",
"subscription": "free" | "premium",
"createdAt": "2025-09-17T09:48:00Z"
}
}
}
```
## 3. 句子分析 API
### 3.1 分析句子
#### 請求
```http
POST /ai/analyze-sentence
Authorization: Bearer {accessToken}
Content-Type: application/json
{
"inputText": "He brought this thing up during our meeting and no one agreed.",
"options": {
"forceRefresh": false,
"includeExamples": true,
"includeAudio": true,
"difficultyLevel": "auto", // auto, A1, A2, B1, B2, C1, C2
"analysisMode": "full" // full: 完整分析並標記高價值詞彙
}
}
```
#### 響應
```json
{
"success": true,
"data": {
"analysisId": "uuid",
"inputText": "He brought this thing up during our meeting and no one agreed.",
"textHash": "sha256_hash",
"grammarCorrection": {
"hasErrors": false,
"originalText": "He brought this thing up during our meeting and no one agreed.",
"correctedText": null,
"corrections": [],
"confidenceScore": 0.98
},
"sentenceMeaning": {
"translation": "他在我們的會議中提出了這件事,但沒有人同意。",
"explanation": "這句話表達了在會議中有人提出某個議題或想法,但得不到其他與會者的認同。",
"context": "正式會議場合",
"tone": "中性陳述"
},
"finalAnalysisText": "He brought this thing up during our meeting and no one agreed.", // 用於後續分析的文本(修正後)
"wordAnalysis": {
"he": {
"word": "he",
"lemma": "he",
"translation": "他",
"definition": "Used to refer to a male person or animal previously mentioned",
"partOfSpeech": "pronoun",
"pronunciation": {
"ipa": "/hiː/",
"us": "/hiː/",
"uk": "/hiː/"
},
"synonyms": ["him", "that man"],
"antonyms": [],
"isPhrase": false,
"phraseInfo": null,
"examples": {
"original": {
"sentence": "He brought this thing up during our meeting",
"translation": "他在我們的會議中提出了這件事",
"highlightWord": "He"
},
"generated": {
"sentence": "He went to the store yesterday",
"translation": "他昨天去了商店",
"imageUrl": "/images/examples/he_store.png",
"audioUrl": "/audio/examples/he_store.mp3"
}
},
"difficultyLevel": "A1",
"frequency": "very_high",
"tags": ["basic", "pronoun"]
},
"brought": {
"word": "brought",
"lemma": "bring",
"translation": "帶來、提出",
"definition": "Past tense of bring; to take or carry something to a place",
"partOfSpeech": "verb",
"pronunciation": {
"ipa": "/brɔːt/",
"us": "/brɔːt/",
"uk": "/brɔːt/"
},
"synonyms": ["carried", "took", "delivered"],
"antonyms": ["removed", "took away"],
"isPhrase": true,
"isHighValue": true, // 高學習價值標記
"learningPriority": "high", // high, medium, low
"phraseInfo": {
"phrase": "bring up",
"meaning": "提出(話題)、養育",
"type": "phrasal_verb",
"warning": "在這個句子中,\"brought up\" 是一個片語動詞,意思是\"提出話題\",而不是單純的\"帶來\"",
"colorCode": "#F59E0B", // 片語顏色代碼
"examples": [
{
"sentence": "She brought up an important point",
"translation": "她提出了一個重要觀點"
}
]
},
"examples": {
"original": {
"sentence": "He brought this thing up during our meeting",
"translation": "他在我們的會議中提出了這件事",
"highlightWord": "brought"
},
"generated": {
"sentence": "She brought up the topic in yesterday's discussion",
"translation": "她在昨天的討論中提出了這個話題",
"imageUrl": "/images/examples/bring_up_meeting.png",
"audioUrl": "/audio/examples/bring_up_meeting.mp3"
}
},
"difficultyLevel": "B1",
"frequency": "high",
"tags": ["phrasal_verb", "meeting", "communication"]
}
// ... 其他單字
},
"highValueWords": ["brought", "up", "meeting"], // 高學習價值詞彙列表
"phrases": [
{
"phrase": "bring up",
"words": ["brought", "up"],
"meaning": "提出(話題)、養育",
"type": "phrasal_verb",
"definition": "To mention or introduce a topic in conversation; to raise a child",
"colorCode": "#F59E0B",
"isHighValue": true,
"examples": [
{
"sentence": "Don't bring up that topic again",
"translation": "不要再提起那個話題了"
}
]
}
],
"metadata": {
"analysisTime": 2.5,
"wordsCount": 12,
"phrasesCount": 1,
"highValueWordsCount": 3, // 高價值詞彙數量
"averageDifficulty": "B1",
"detectedLanguage": "en",
"confidence": 0.98
},
"usageStatistics": {
"remainingAnalyses": 4,
"totalUsedToday": 1,
"dailyLimit": 5,
"resetTime": "2025-09-17T12:48:00Z",
"subscription": "free"
},
"cache": {
"cached": false,
"cacheKey": "sha256_hash",
"expiresAt": "2025-09-18T09:48:00Z",
"ttl": 86400
}
},
"message": "句子分析完成"
}
```
### 3.2 單字點擊查詢
#### 請求
```http
POST /ai/query-word
Authorization: Bearer {accessToken}
Content-Type: application/json
{
"word": "thing",
"sentence": "He brought this thing up during our meeting and no one agreed.",
"analysisId": "uuid", // 來自預分析結果
"context": {
"position": 3, // 單字在句子中的位置
"surroundingWords": ["this", "thing", "up"] // 周圍單字上下文
}
}
```
#### 響應
```json
{
"success": true,
"data": {
"word": "thing",
"isHighValue": false, // 非高價值詞彙
"wasPreAnalyzed": false, // 未在預分析中包含
"costIncurred": 1, // 扣除1次使用次數
"analysis": {
"word": "thing",
"translation": "事情、東西",
"definition": "An object, fact, or situation",
"partOfSpeech": "noun",
"pronunciation": {
"ipa": "/θɪŋ/",
"us": "/θɪŋ/",
"uk": "/θɪŋ/"
},
"synonyms": ["object", "matter", "item"],
"antonyms": [],
"isPhrase": false,
"isHighValue": false,
"learningPriority": "low",
"examples": {
"original": {
"sentence": "He brought this thing up during our meeting",
"translation": "他在會議中提出了這件事",
"highlightWord": "thing"
},
"generated": {
"sentence": "That's an important thing to remember",
"translation": "那是需要記住的重要事情",
"imageUrl": "/images/examples/important_thing.png",
"audioUrl": "/audio/examples/important_thing.mp3"
}
},
"difficultyLevel": "A1"
},
"usageStatistics": {
"remainingAnalyses": 3,
"totalUsedToday": 2,
"costType": "word_query" // sentence_analysis, word_query
}
},
"message": "單字查詢完成"
}
```
### 3.3 獲取快取分析結果
#### 請求
```http
GET /ai/analysis-cache/{textHash}
Authorization: Bearer {accessToken}
```
#### 響應
```json
{
"success": true,
"data": {
// 與分析句子相同的結構
}
}
```
### 3.4 語法錯誤修正示例
#### 有錯誤句子的分析響應
```json
{
"success": true,
"data": {
"analysisId": "uuid",
"inputText": "I go to school yesterday and meet my friends.",
"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}, // "go" 的位置
"errorType": "tense_mismatch",
"original": "go",
"corrected": "went",
"reason": "過去式時態修正:句子中有 'yesterday',應使用過去式",
"severity": "high"
},
{
"position": {"start": 29, "end": 33}, // "meet" 的位置
"errorType": "tense_mismatch",
"original": "meet",
"corrected": "met",
"reason": "過去式時態修正:與 'went' 保持時態一致",
"severity": "high"
}
],
"confidenceScore": 0.95
},
"sentenceMeaning": {
"translation": "我昨天去學校遇見了我的朋友們。",
"explanation": "這句話描述了過去發生的事情,表達了去學校並遇到朋友的經歷。"
},
"finalAnalysisText": "I went to school yesterday and met my friends.", // 後續分析使用修正版本
"wordAnalysis": {
// 基於修正後句子的分析結果
"went": {
"word": "went",
"translation": "去、前往",
"definition": "Past tense of go; to move or travel to a place",
"isHighValue": true,
"learningPriority": "high"
// ... 其他詳情
}
// ... 其他單字
}
}
}
```
### 3.5 清除快取
#### 請求
```http
DELETE /ai/analysis-cache/{analysisId}
Authorization: Bearer {accessToken}
```
#### 響應
```json
{
"success": true,
"message": "快取已清除"
}
```
## 4. 使用統計 API
### 4.1 獲取使用統計
#### 請求
```http
GET /users/usage-stats
Authorization: Bearer {accessToken}
```
#### 響應
```json
{
"success": true,
"data": {
"daily": {
"date": "2025-09-17",
"analysisCount": 1,
"wordClickCount": 15,
"uniqueWordsQueried": 8,
"totalTimeSpent": 320,
"remainingAnalyses": 4
},
"weekly": {
"startDate": "2025-09-11",
"endDate": "2025-09-17",
"analysisCount": 12,
"wordClickCount": 180,
"uniqueWordsQueried": 95,
"totalTimeSpent": 2400
},
"monthly": {
"month": "2025-09",
"analysisCount": 45,
"wordClickCount": 720,
"uniqueWordsQueried": 350,
"totalTimeSpent": 9600
},
"limits": {
"subscription": "free",
"dailyAnalysisLimit": 5,
"dailyImageGenerationLimit": 0,
"resetTime": "2025-09-17T12:48:00Z"
}
}
}
```
### 4.2 記錄單字點擊
#### 請求
```http
POST /users/word-interactions
Authorization: Bearer {accessToken}
Content-Type: application/json
{
"analysisId": "uuid",
"word": "brought",
"action": "click", // click, audio_play, image_view
"timestamp": "2025-09-17T09:48:00Z",
"context": {
"sentencePosition": 2,
"isPhrase": true,
"difficultyLevel": "B1"
}
}
```
#### 響應
```json
{
"success": true,
"message": "互動記錄已保存"
}
```
## 5. 詞卡生成 API
### 5.1 從分析結果生成詞卡
#### 請求
```http
POST /flashcards/generate-from-analysis
Authorization: Bearer {accessToken}
Content-Type: application/json
{
"analysisId": "uuid",
"selectedWords": ["brought", "meeting", "agreed"],
"cardSetId": "uuid", // 可選,不提供則創建新卡組
"options": {
"includePhrasesOnly": false,
"difficultyFilter": ["B1", "B2"],
"generateImages": true // 付費用戶才能使用
}
}
```
#### 響應
```json
{
"success": true,
"data": {
"cardSetId": "uuid",
"cardsGenerated": 3,
"cards": [
{
"id": "uuid",
"word": "brought",
"translation": "帶來、提出",
"definition": "Past tense of bring; to take or carry something to a place",
"partOfSpeech": "verb",
"pronunciation": "/brɔːt/",
"example": "He brought this thing up during our meeting",
"exampleTranslation": "他在我們的會議中提出了這件事",
"difficultyLevel": "B1",
"isPhrase": true,
"phraseInfo": {
"phrase": "bring up",
"meaning": "提出(話題)、養育"
},
"createdAt": "2025-09-17T09:48:00Z"
}
// ... 其他詞卡
]
},
"message": "詞卡生成成功"
}
```
## 6. 音頻服務 API
### 6.1 生成單字發音
#### 請求
```http
POST /audio/generate-pronunciation
Authorization: Bearer {accessToken}
Content-Type: application/json
{
"text": "brought",
"accent": "us", // us, uk
"speed": "normal", // slow, normal, fast
"format": "mp3" // mp3, wav, ogg
}
```
#### 響應
```json
{
"success": true,
"data": {
"audioUrl": "/audio/pronunciation/brought_us_normal.mp3",
"duration": 0.8,
"fileSize": 12480,
"expiresAt": "2025-09-24T09:48:00Z"
}
}
```
### 6.2 生成例句發音
#### 請求
```http
POST /audio/generate-sentence
Authorization: Bearer {accessToken}
Content-Type: application/json
{
"text": "He brought this thing up during our meeting",
"accent": "us",
"speed": "normal",
"highlightWord": "brought", // 可選,高亮某個單字
"format": "mp3"
}
```
#### 響應
```json
{
"success": true,
"data": {
"audioUrl": "/audio/sentences/sentence_hash_us_normal.mp3",
"duration": 3.2,
"fileSize": 51200,
"wordTimestamps": [
{"word": "He", "start": 0.0, "end": 0.2},
{"word": "brought", "start": 0.2, "end": 0.6},
// ... 其他單字時間戳
],
"expiresAt": "2025-09-24T09:48:00Z"
}
}
```
## 7. 圖片服務 API
### 7.1 生成例句圖片 (付費功能)
#### 請求
```http
POST /images/generate-example
Authorization: Bearer {accessToken}
Content-Type: application/json
{
"word": "brought",
"phrase": "bring up",
"example": "She brought up an important point in the meeting",
"context": "business meeting",
"style": "illustration", // illustration, photo, cartoon
"prompt": "A professional woman raising her hand in a business meeting"
}
```
#### 響應
```json
{
"success": true,
"data": {
"imageId": "uuid",
"imageUrl": "/images/examples/brought_up_meeting_123.png",
"thumbnailUrl": "/images/examples/thumbs/brought_up_meeting_123.png",
"width": 1024,
"height": 768,
"fileSize": 245760,
"style": "illustration",
"prompt": "A professional woman raising her hand in a business meeting",
"generatedAt": "2025-09-17T09:48:00Z",
"expiresAt": "2025-12-17T09:48:00Z"
}
}
```
### 7.2 獲取現有例句圖片
#### 請求
```http
GET /images/examples
Authorization: Bearer {accessToken}
Query Parameters:
- word: string (可選)
- phrase: string (可選)
- context: string (可選)
- limit: number (預設 20)
- offset: number (預設 0)
```
#### 響應
```json
{
"success": true,
"data": {
"images": [
{
"imageId": "uuid",
"word": "brought",
"phrase": "bring up",
"imageUrl": "/images/examples/brought_up_meeting_123.png",
"thumbnailUrl": "/images/examples/thumbs/brought_up_meeting_123.png",
"context": "business meeting",
"usage": "free", // free, premium
"downloads": 1250
}
],
"total": 150,
"hasMore": true
}
}
```
## 8. 錯誤代碼定義
### 8.1 認證錯誤 (AUTH_*)
- `AUTH_INVALID_TOKEN`: 無效的 Token
- `AUTH_TOKEN_EXPIRED`: Token 已過期
- `AUTH_INSUFFICIENT_PERMISSIONS`: 權限不足
### 8.2 使用限制錯誤 (LIMIT_*)
- `LIMIT_DAILY_ANALYSIS_EXCEEDED`: 超過每日分析次數限制
- `LIMIT_TEXT_TOO_LONG`: 文字長度超過限制
- `LIMIT_RATE_EXCEEDED`: 請求頻率過高
### 8.3 AI 服務錯誤 (AI_*)
- `AI_SERVICE_UNAVAILABLE`: AI 服務暫時不可用
- `AI_ANALYSIS_FAILED`: 句子分析失敗
- `AI_INVALID_LANGUAGE`: 不支援的語言
### 8.4 資源錯誤 (RESOURCE_*)
- `RESOURCE_NOT_FOUND`: 資源不存在
- `RESOURCE_CACHE_MISS`: 快取未命中
- `RESOURCE_GENERATION_FAILED`: 資源生成失敗
### 8.5 語法修正錯誤 (GRAMMAR_*)
- `GRAMMAR_CORRECTION_FAILED`: 語法修正失敗
- `GRAMMAR_TOO_MANY_ERRORS`: 錯誤過多無法修正
- `GRAMMAR_AMBIGUOUS_MEANING`: 語意模糊無法確定修正方向
### 8.6 語法錯誤類型定義
- `tense_mismatch`: 時態錯誤
- `subject_verb_disagreement`: 主謂不一致
- `wrong_preposition`: 介詞錯誤
- `word_order`: 詞序錯誤
- `spelling_error`: 拼寫錯誤
- `article_error`: 冠詞錯誤
- `plural_singular`: 單複數錯誤
- `missing_word`: 缺少詞彙
- `redundant_word`: 冗餘詞彙
## 9. SDK 和範例
### 9.1 JavaScript SDK 範例
```javascript
import { DramaLingClient } from '@dramaling/sdk';
const client = new DramaLingClient({
apiKey: 'your-api-key',
baseURL: 'https://api.dramaling.com/v1'
});
// 分析句子
async function analyzeSentence(text) {
try {
const result = await client.analyzeSentence({
inputText: text,
options: {
includeExamples: true,
includeAudio: true
}
});
return result.data;
} catch (error) {
console.error('Analysis failed:', error);
throw error;
}
}
// 使用範例
const analysis = await analyzeSentence(
"He brought this thing up during our meeting and no one agreed."
);
console.log('句子意思:', analysis.sentenceMeaning.translation);
console.log('單字分析:', analysis.wordAnalysis);
```
### 9.2 cURL 範例
```bash
# 分析句子
curl -X POST https://api.dramaling.com/v1/ai/analyze-sentence \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"inputText": "He brought this thing up during our meeting and no one agreed.",
"options": {
"includeExamples": true,
"includeAudio": true
}
}'
# 獲取使用統計
curl -X GET https://api.dramaling.com/v1/users/usage-stats \
-H "Authorization: Bearer YOUR_TOKEN"
```
## 10. API 版本管理
### 10.1 版本策略
- **當前版本**: v1
- **版本格式**: `/v{major}`
- **向後相容**: 保持至少 6 個月
- **棄用通知**: 提前 3 個月通知
### 10.2 版本變更日誌
#### v1.0.0 (2025-09-17)
- 初始版本發布
- 句子分析 API
- 使用統計 API
- 音頻服務 API
- 基礎圖片服務 API
#### v1.1.0 (計劃中)
- 批量分析 API
- 進階圖片生成選項
- 詞彙學習追蹤 API
- WebSocket 即時通知
這份 API 規格書提供了完整的介面定義,包括請求/響應格式、錯誤處理、使用範例和 SDK 說明,為前端開發和第三方整合提供了詳細的參考文檔。

View File

@ -1,172 +0,0 @@
# Figma 設計稿連結管理
## 📋 概述
本文件集中管理所有 Figma 設計稿連結,確保團隊成員能快速找到最新的設計資源。
> **注意**: Drama Ling 主要使用 HTML/CSS 元件庫作為設計系統Figma 用於高階概念設計和協作討論。
## 🎨 設計檔案結構
### 主設計系統
| 檔案名稱 | 連結 | 最後更新 | 負責人 | 狀態 |
|---------|------|----------|--------|------|
| Drama Ling Design System | [Figma Link](#) | 2025-09-15 | 設計團隊 | 🟢 最新 |
| Component Library | [Figma Link](#) | 2025-09-15 | 設計團隊 | 🟢 最新 |
| Design Tokens | [Figma Link](#) | 2025-09-15 | 設計團隊 | 🟢 最新 |
### Web 端設計
| 頁面名稱 | 連結 | 狀態 | HTML原型 | 備註 |
|---------|------|------|----------|------|
| 登入/註冊 | [Figma](#) | ✅ 完成 | [HTML](../component-library/pages/login-page.html) | |
| 儀表板 | [Figma](#) | ✅ 完成 | [HTML](../component-library/pages/dashboard.html) | |
| 學習頁面 | [Figma](#) | ✅ 完成 | [HTML](../component-library/pages/learning-page.html) | |
| 詞彙學習 | [Figma](#) | 🔄 進行中 | - | 預計9/20完成 |
| 口說練習 | [Figma](#) | 📋 規劃中 | - | |
| 情境對話 | [Figma](#) | 📋 規劃中 | - | |
| 成就系統 | [Figma](#) | 📋 規劃中 | - | |
| 商店頁面 | [Figma](#) | 📋 規劃中 | - | |
### 移動端設計
| 頁面名稱 | 連結 | 狀態 | 備註 |
|---------|------|------|------|
| iOS 設計稿 | [Figma](#) | 📋 規劃中 | |
| Android 設計稿 | [Figma](#) | 📋 規劃中 | |
| 響應式斷點 | [Figma](#) | ✅ 完成 | |
### 原型和流程
| 名稱 | 連結 | 類型 | 備註 |
|------|------|------|------|
| 用戶流程圖 | [Figma](#) | Flow | |
| 互動原型 | [Figma](#) | Prototype | |
| 線框圖 | [Figma](#) | Wireframe | |
## 🔗 快速連結
### 常用頁面
- 🎯 [最新設計系統](#)
- 📚 [元件庫](#)
- 🎨 [色彩系統](#)
- 📝 [字體規範](#)
- 📐 [間距系統](#)
### 開發者資源
- 💻 [HTML/CSS 元件庫](../component-library/index.html)
- 📖 [設計規範文檔](../design-system/README.md)
- 🛠️ [開發者交接文件](#)
## 📝 使用指南
### 查看設計稿
1. 點擊上方表格中的 Figma 連結
2. 使用公司帳號登入 Figma
3. 查看最新版本(檢查右上角版本標記)
### 導出資源
1. 在 Figma 中選擇需要的元素
2. 右側面板選擇 "Export"
3. 選擇格式:
- **圖標**: SVG
- **圖片**: PNG 2x
- **插圖**: SVG 或 PNG
### 提供反饋
1. 在 Figma 中使用評論功能
2. 標記 @設計師名稱
3. 描述具體問題或建議
## 🔄 版本管理
### 命名規範
```
[項目名稱]_[版本]_[日期]
範例: DramaLing_Dashboard_v2.1_20250915
```
### 版本標記
- 🟢 **最新**: 生產環境使用
- 🟡 **審核中**: 等待確認
- 🔴 **過時**: 僅供參考
## 👥 團隊協作
### 設計師職責
- 維護 Figma 設計稿
- 更新此文件連結
- 導出設計資源
- 與開發團隊溝通
### 開發者職責
- 實現 HTML/CSS 元件
- 提供技術反饋
- 更新實現狀態
- 維護元件庫
### 產品經理職責
- 審核設計方案
- 確認用戶流程
- 管理設計優先級
- 協調資源
## 📊 設計系統映射
| Figma 元件 | HTML/CSS 元件 | 狀態 | 備註 |
|-----------|--------------|------|------|
| Button | [btn-*](../component-library/index.html#buttons) | ✅ | |
| Input Field | [input-field](../component-library/index.html#inputs) | ✅ | |
| Card | [card-*](../component-library/index.html#cards) | ✅ | |
| Modal | [modal-*](../component-library/components/01-interactive/modals.html) | ✅ | |
| Navigation | [navbar, sidebar](../component-library/components/05-navigation/navigation.html) | ✅ | |
| Form Elements | [forms](../component-library/components/02-input/forms.html) | ✅ | |
| Data Display | [table, list](../component-library/components/03-display/data-display.html) | ✅ | |
| Gamification | [achievements, levels](../component-library/components/06-gamification/game-elements.html) | ✅ | |
## 🚀 工作流程
### 設計到開發流程
```mermaid
graph LR
A[Figma 設計] --> B[設計審核]
B --> C[導出資源]
C --> D[HTML/CSS 實現]
D --> E[元件庫更新]
E --> F[開發使用]
```
### 設計更新流程
1. **設計師** 更新 Figma 設計稿
2. **設計師** 更新此文件連結和狀態
3. **開發者** 查看變更並評估影響
4. **開發者** 更新 HTML/CSS 元件
5. **QA** 驗證實現符合設計
## 📅 更新記錄
### 2025-09-15
- 建立 Figma 連結管理系統
- 整合 HTML/CSS 元件庫映射
- 添加團隊協作指南
### 待更新項目
- [ ] 補充實際 Figma 連結
- [ ] 添加設計審核流程
- [ ] 建立自動同步機制
## 🔧 工具和插件
### 推薦 Figma 插件
- **Figma Tokens**: 管理設計代幣
- **Able**: 無障礙性檢查
- **Figma to HTML**: 代碼導出輔助
- **Content Reel**: 填充真實數據
### 開發工具
- [設計系統同步工具](../design-system/automation/design-sync.sh)
- [元件驗證工具](../design-system/automation/component-validator.js)
- [HTML/CSS 元件庫](../component-library/index.html)
---
**維護者**: Drama Ling 設計團隊
**最後更新**: 2025-09-15
**聯絡方式**: design@dramaling.com

View File

@ -0,0 +1,935 @@
# 互動式單字查詢功能設計規格書
## 1. 功能概述
### 1.1 目標
實現低成本、高效率的互動式單字查詢系統,讓用戶能夠點擊句子中的任何單字即時查看詳細意思,同時智能識別片語和俚語。
### 1.2 核心優勢
- **成本效益**:一次 API 調用,多次查詢零成本
- **即時響應**:點擊查詢無延遲
- **智能識別**:片語/俚語優先顯示和警告
- **用戶友善**:視覺化高亮和直觀操作
## 2. 系統架構設計
### 2.1 整體流程
```
用戶輸入句子 → AI 預分析 → 高價值標記 → 分析結果快取 → 互動式顯示 → 點擊查詢
↓ ↓ ↓ ↓ ↓ ↓
50字限制 一次API調用 識別學習價值 24小時快取 可點擊文字 智能計費
(扣除1次) 重要詞彙 存儲詳情 不同顏色 高價值免費
低價值收費
```
### 2.2 API 架構
#### 2.2.1 句子分析 API
```
POST /api/ai/analyze-sentence
Content-Type: application/json
Request:
{
"inputText": "He brought this thing up during our meeting and no one agreed.",
"userId": "uuid", // 用於使用次數統計
"forceRefresh": false, // 是否強制重新分析
"analysisMode": "full" // full: 完整分析並標記高價值詞彙
}
Response:
{
"success": true,
"data": {
"analysisId": "uuid",
"sentenceMeaning": {
"translation": "他在我們的會議中提出了這件事,但沒有人同意。",
"explanation": "這句話表達了在會議中有人提出某個議題或想法,但得不到其他與會者的認同。"
},
"grammarCorrection": {
"hasErrors": false, // 是否有語法錯誤
"originalText": "He brought this thing up during our meeting and no one agreed.",
"correctedText": null, // 如無錯誤則為null
"corrections": [] // 錯誤修正列表
},
"wordAnalysis": {
"brought": {
"word": "brought",
"translation": "帶來、提出",
"definition": "Past tense of bring; to take or carry something to a place",
"partOfSpeech": "verb",
"pronunciation": {
"ipa": "/brɔːt/",
"us": "/brɔːt/",
"uk": "/brɔːt/"
},
"synonyms": ["carried", "took", "delivered"],
"antonyms": ["removed", "took away"],
"isPhrase": true,
"isHighValue": true, // 高學習價值標記
"learningPriority": "high", // high, medium, low
"phraseInfo": {
"phrase": "bring up",
"meaning": "提出(話題)、養育",
"warning": "在這個句子中,\"brought up\" 是片語,意思是\"提出話題\",而不是單純的\"帶來\"",
"colorCode": "#F59E0B" // 片語顏色代碼
},
"examples": {
"original": "He brought this thing up during our meeting",
"originalTranslation": "他在會議中提出了這件事",
"generated": "She brought up an interesting point",
"generatedTranslation": "她提出了一個有趣的觀點",
"imageUrl": "/images/examples/bring_up.png",
"audioUrl": "/audio/examples/bring_up.mp3"
},
"difficultyLevel": "B1"
}
},
"finalAnalysisText": "He brought this thing up during our meeting and no one agreed.", // 最終用於學習的文本(修正後)
"highValueWords": ["brought", "up", "meeting"], // 高價值詞彙列表
"phrasesDetected": [
{
"phrase": "bring up",
"words": ["brought", "up"],
"colorCode": "#F59E0B"
}
],
"usageStatistics": {
"remainingAnalyses": 4,
"resetTime": "2025-09-17T12:48:00Z",
"costIncurred": 1 // 本次分析扣除次數
},
"cachedUntil": "2025-09-18T09:48:00Z"
},
"message": "Sentence analyzed successfully"
}
```
#### 2.2.2 單字點擊查詢 API
```
POST /api/ai/query-word
Content-Type: application/json
Request:
{
"word": "thing",
"sentence": "He brought this thing up during our meeting and no one agreed.",
"analysisId": "uuid", // 來自預分析結果
"userId": "uuid"
}
Response:
{
"success": true,
"data": {
"word": "thing",
"isHighValue": false, // 非高價值詞彙
"wasPreAnalyzed": false, // 未在預分析中
"costIncurred": 1, // 扣除1次使用次數
"analysis": {
// 完整詞彙分析資料
}
}
}
```
#### 2.2.3 快取管理 API
```
GET /api/ai/analysis-cache/{inputTextHash}
DELETE /api/ai/analysis-cache/{analysisId}
```
### 2.3 資料庫設計
#### 2.3.1 句子分析快取表
```sql
CREATE TABLE SentenceAnalysisCache (
Id UNIQUEIDENTIFIER PRIMARY KEY,
InputTextHash NVARCHAR(64) NOT NULL, -- SHA256 hash
InputText NVARCHAR(1000) NOT NULL,
CorrectedText NVARCHAR(1000), -- 修正後的文本
HasGrammarErrors BIT DEFAULT 0, -- 是否有語法錯誤
GrammarCorrections NVARCHAR(MAX), -- JSON 格式,語法修正詳情
AnalysisResult NVARCHAR(MAX) NOT NULL, -- JSON 格式
HighValueWords NVARCHAR(MAX) NOT NULL, -- JSON 格式,高價值詞彙列表
PhrasesDetected NVARCHAR(MAX), -- JSON 格式,檢測到的片語
CreatedAt DATETIME2 NOT NULL,
ExpiresAt DATETIME2 NOT NULL,
AccessCount INT DEFAULT 0,
LastAccessedAt DATETIME2
);
CREATE INDEX IX_SentenceAnalysisCache_Hash ON SentenceAnalysisCache(InputTextHash);
CREATE INDEX IX_SentenceAnalysisCache_Expires ON SentenceAnalysisCache(ExpiresAt);
```
#### 2.3.2 使用統計表
```sql
CREATE TABLE WordQueryUsageStats (
Id UNIQUEIDENTIFIER PRIMARY KEY,
UserId UNIQUEIDENTIFIER NOT NULL,
Date DATE NOT NULL,
SentenceAnalysisCount INT DEFAULT 0, -- 句子分析次數
HighValueWordClicks INT DEFAULT 0, -- 高價值詞彙點擊(免費)
LowValueWordClicks INT DEFAULT 0, -- 低價值詞彙點擊(收費)
TotalApiCalls INT DEFAULT 0, -- 總 API 調用次數
UniqueWordsQueried INT DEFAULT 0,
CreatedAt DATETIME2 NOT NULL,
UpdatedAt DATETIME2 NOT NULL
);
CREATE UNIQUE INDEX IX_WordQueryUsageStats_UserDate ON WordQueryUsageStats(UserId, Date);
```
## 3. 前端組件設計
### 3.1 主要組件架構
```
WordQueryPage
├── SentenceInputForm // 句子輸入表單
├── AnalysisLoadingState // 分析中狀態
├── GrammarCorrectionPanel // 語法修正面板
│ ├── ErrorHighlight // 錯誤標記顯示
│ ├── CorrectionSuggestion // 修正建議
│ └── UserChoiceButtons // 用戶選擇按鈕
├── InteractiveTextDisplay // 互動式文字顯示
│ ├── ClickableWord // 可點擊單字
│ └── WordInfoPopup // 單字資訊彈窗
├── UsageStatistics // 使用統計顯示
└── ActionButtons // 操作按鈕組
```
### 3.2 組件詳細設計
#### 3.2.1 GrammarCorrectionPanel 組件
```typescript
interface GrammarCorrection {
hasErrors: boolean;
originalText: string;
correctedText: string | null;
corrections: Array<{
position: { start: number; end: number };
errorType: string;
original: string;
corrected: string;
reason: string;
severity: 'high' | 'medium' | 'low';
}>;
confidenceScore: number;
}
interface GrammarCorrectionPanelProps {
correction: GrammarCorrection;
onAcceptCorrection: () => void;
onRejectCorrection: () => void;
onManualEdit: (text: string) => void;
}
const GrammarCorrectionPanel: React.FC<GrammarCorrectionPanelProps> = ({
correction,
onAcceptCorrection,
onRejectCorrection,
onManualEdit
}) => {
// 錯誤高亮顯示
// 修正建議卡片
// 修正原因說明
// 用戶選擇按鈕
};
```
#### 3.2.2 SentenceInputForm 組件
```typescript
interface SentenceInputFormProps {
maxLength: number; // 300 for manual input
onSubmit: (text: string) => void;
onModeChange: (mode: 'manual' | 'screenshot') => void;
disabled: boolean;
remainingAnalyses: number;
}
const SentenceInputForm: React.FC<SentenceInputFormProps> = ({
maxLength,
onSubmit,
onModeChange,
disabled,
remainingAnalyses
}) => {
// 即時字數統計
// 300字限制阻擋
// 模式切換UI
// 示例句子填入
// 分析按鈕狀態
};
```
#### 3.2.3 InteractiveTextDisplay 組件
```typescript
interface WordAnalysis {
word: string;
translation: string;
definition: string;
partOfSpeech: string;
pronunciation: {
ipa: string;
us: string;
uk: string;
};
synonyms: string[];
antonyms: string[];
isPhrase: boolean;
isHighValue: boolean; // 高學習價值標記
learningPriority: 'high' | 'medium' | 'low'; // 學習優先級
phraseInfo?: {
phrase: string;
meaning: string;
warning: string;
colorCode: string; // 片語顏色代碼
};
examples: {
original: string;
originalTranslation: string;
generated: string;
generatedTranslation: string;
imageUrl?: string;
audioUrl?: string;
};
difficultyLevel: string;
}
interface InteractiveTextDisplayProps {
text: string;
analysis: Record<string, WordAnalysis>;
onWordClick: (word: string, analysis: WordAnalysis) => void;
}
```
#### 3.2.4 WordInfoPopup 組件
```typescript
interface WordInfoPopupProps {
word: string;
analysis: WordAnalysis;
position: { x: number; y: number };
onClose: () => void;
onPlayAudio: (audioUrl: string) => void;
}
const WordInfoPopup: React.FC<WordInfoPopupProps> = ({
word,
analysis,
position,
onClose,
onPlayAudio
}) => {
// 片語警告顯示
// 發音播放按鈕
// 例句圖片顯示
// 同義詞/反義詞標籤
// 難度等級標示
};
```
## 4. 用戶介面設計
### 4.1 頁面佈局
#### 4.1.1 輸入階段
```
┌─────────────────────────────────────────────────────────┐
│ DramaLing │
├─────────────────────────────────────────────────────────┤
│ AI 智能生成詞卡 - 互動式單字查詢 │
│ │
│ ┌─ 原始例句類型 ──────────────────────────────────┐ │
│ │ [✍️ 手動輸入] [📷 影劇截圖] (訂閱功能) │ │
│ └───────────────────────────────────────────────────┘ │
│ │
│ ┌─ 輸入英文文本 ──────────────────────────────────┐ │
│ │ ┌─────────────────────────────────────────────┐ │ │
│ │ │ 輸入英文句子最多50字... │ │ │
│ │ │ │ │ │
│ │ └─────────────────────────────────────────────┘ │ │
│ │ 最多 50 字元 • 目前0 字元 │ │
│ │ │ │
│ │ 💡 示例句子: │ │
│ │ [點擊使用示例He brought this thing up...] │ │
│ └───────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 🔍 分析句子(點擊查詢單字) │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ 免費用戶:已使用 0/5 次 (3小時內) │
└─────────────────────────────────────────────────────────┘
```
#### 4.1.2 分析結果階段
```
┌─────────────────────────────────────────────────────────┐
│ ← 返回 句子分析結果 │
├─────────────────────────────────────────────────────────┤
│ ┌─ 原始句子 ──────────────────────────────────────┐ │
│ │ He brought this thing up during our meeting. │ │
│ │ │ │
│ │ 整句意思: │ │
│ │ 他在我們的會議中提出了這件事,但沒有人同意... │ │
│ └───────────────────────────────────────────────────┘ │
│ │
│ ┌─ 點擊查詢單字意思 ──────────────────────────────┐ │
│ │ 💡 使用說明:點擊下方句子中的任何單字,可以立即 │ │
│ │ 查看詳細意思。黃色背景表示該單字屬於片語或俚語。 │ │
│ │ │ │
│ │ ╔═══════════════════════════════════════════╗ │ │
│ │ ║ He [brought] this [thing] [up] during ║ │ │
│ │ ║ our [meeting] and no one [agreed]. ║ │ │
│ │ ╚═══════════════════════════════════════════╝ │ │
│ │ // brought 和 up 有黃色背景 │ │
│ │ // 其他單字有藍色下劃線 │ │
│ └───────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ [🔄 分析新句子] [📖 生成詞卡] │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
```
#### 4.1.3 單字彈窗設計
```
┌─ brought ─────────────────── ×
│ │
│ ⚠️ 注意:這個單字屬於片語 │
│ 片語bring up │
│ 意思:提出(話題)、養育 │
│ │
│ verb | /brɔːt/ | 🔊 │
│ │
│ 翻譯:帶來、提出 │
│ │
│ 定義Past tense of bring; │
│ to take or carry something │
│ │
│ 同義詞:[carried] [took] │
│ 反義詞:[removed] │
│ │
│ 例句: │
│ • 原始He brought this... │
│ 翻譯:他提出了這件事... │
│ • 生成She brought up... │
│ 翻譯:她提出了一個... │
│ [📷 查看例句圖] [🔊 播放] │
│ │
│ 難度B1 │
└───────────────────────────────┘
```
### 4.2 視覺設計規範
#### 4.2.1 顏色系統
```css
/* 主色彩 */
--primary-blue: #3B82F6;
--primary-blue-hover: #2563EB;
--primary-blue-light: #DBEAFE;
/* 單字價值顏色系統 */
--word-high-phrase: #F59E0B; /* 高價值片語 */
--word-high-single: #10B981; /* 高價值單字 */
--word-normal: #3B82F6; /* 普通單字 */
--word-hover: #1E40AF; /* 懸停狀態 */
/* 背景顏色 */
--word-high-phrase-bg: #FEF3C7; /* 高價值片語背景 */
--word-high-single-bg: #ECFDF5; /* 高價值單字背景 */
--word-normal-bg: transparent; /* 普通單字背景 */
--word-hover-bg: #DBEAFE; /* 懸停背景 */
/* 邊框顏色 */
--border-high-phrase: #F59E0B; /* 高價值片語邊框 */
--border-high-single: #10B981; /* 高價值單字邊框 */
--border-normal: #3B82F6; /* 普通單字邊框 */
/* 狀態顏色 */
--success: #10B981;
--warning: #F59E0B;
--error: #EF4444;
--info: #3B82F6;
--premium: #8B5CF6; /* 付費功能 */
```
#### 4.2.2 互動效果
```css
/* 可點擊單字基礎樣式 */
.clickable-word {
cursor: pointer;
transition: all 0.2s ease;
padding: 2px 4px;
border-radius: 4px;
margin: 0 1px;
position: relative;
}
/* 高價值片語樣式 */
.clickable-word.high-value.phrase {
background-color: var(--word-high-phrase-bg);
border: 2px solid var(--border-high-phrase);
}
.clickable-word.high-value.phrase::after {
content: "⭐";
position: absolute;
top: -8px;
right: -8px;
font-size: 12px;
}
/* 高價值單字樣式 */
.clickable-word.high-value.single {
background-color: var(--word-high-single-bg);
border: 2px solid var(--border-high-single);
}
.clickable-word.high-value.single::after {
content: "⭐";
position: absolute;
top: -8px;
right: -8px;
font-size: 12px;
}
/* 普通單字樣式 */
.clickable-word.normal {
border-bottom: 1px solid var(--border-normal);
}
/* 懸停效果 */
.clickable-word:hover {
background-color: var(--word-hover-bg);
transform: translateY(-1px);
}
```
#### 4.2.3 彈窗動畫
```css
/* 彈窗進入動畫 */
@keyframes popup-enter {
from {
opacity: 0;
transform: translate(-50%, -100%) scale(0.9);
}
to {
opacity: 1;
transform: translate(-50%, -100%) scale(1);
}
}
.word-popup {
animation: popup-enter 0.2s ease-out;
}
```
## 5. 技術實現細節
### 5.1 前端狀態管理
#### 5.1.1 Zustand Store 設計
```typescript
interface WordQueryStore {
// 分析狀態
isAnalyzing: boolean;
analysisResult: SentenceAnalysis | null;
analysisError: string | null;
// 互動狀態
selectedWord: string | null;
popupPosition: { x: number; y: number } | null;
// 使用統計
usageStats: {
remainingAnalyses: number;
resetTime: string;
};
// 快取
analysisCache: Map<string, SentenceAnalysis>;
// Actions
analyzeSentence: (text: string) => Promise<void>;
selectWord: (word: string, position: { x: number; y: number }) => void;
closeWordPopup: () => void;
clearCache: () => void;
}
```
#### 5.1.2 快取策略
```typescript
// 本地快取實現
class AnalysisCache {
private cache = new Map<string, CacheItem>();
get(textHash: string): SentenceAnalysis | null {
const item = this.cache.get(textHash);
if (!item || this.isExpired(item)) {
this.cache.delete(textHash);
return null;
}
return item.data;
}
set(textHash: string, data: SentenceAnalysis, ttl: number): void {
this.cache.set(textHash, {
data,
expiresAt: Date.now() + ttl
});
}
private isExpired(item: CacheItem): boolean {
return Date.now() > item.expiresAt;
}
}
```
### 5.2 後端實現細節
#### 5.2.1 句子分析服務
```csharp
public class SentenceAnalysisService : ISentenceAnalysisService
{
private readonly IGeminiService _geminiService;
private readonly IAnalysisCacheService _cacheService;
private readonly IUsageTrackingService _usageService;
public async Task<SentenceAnalysisResult> AnalyzeSentenceAsync(
string inputText,
Guid userId,
bool forceRefresh = false)
{
// 1. 檢查使用限制
await _usageService.CheckUsageLimitAsync(userId);
// 2. 檢查快取
var textHash = GenerateTextHash(inputText);
if (!forceRefresh)
{
var cached = await _cacheService.GetAsync(textHash);
if (cached != null)
{
await _usageService.RecordCacheHitAsync(userId);
return cached;
}
}
// 3. AI 分析
var analysis = await _geminiService.AnalyzeSentenceAsync(inputText);
// 4. 存入快取
await _cacheService.SetAsync(textHash, analysis, TimeSpan.FromHours(24));
// 5. 記錄使用
await _usageService.RecordAnalysisAsync(userId);
return analysis;
}
}
```
#### 5.2.2 Gemini Prompt 設計
```csharp
private const string SENTENCE_ANALYSIS_PROMPT = @"
請分析以下英文句子,先檢查語法錯誤並修正,然後提供完整的單字和片語解析:
句子:{inputText}
請按照以下 JSON 格式回應:
{
""grammarCorrection"": {
""hasErrors"": true/false,
""originalText"": ""原始輸入句子"",
""correctedText"": ""修正後句子"" // 如無錯誤則與原始相同,
""corrections"": [
{
""position"": {""start"": 2, ""end"": 4},
""errorType"": ""tense_mismatch"",
""original"": ""錯誤詞彙"",
""corrected"": ""修正詞彙"",
""reason"": ""修正原因說明"",
""severity"": ""high/medium/low""
}
],
""confidenceScore"": 0.95
},
""sentenceMeaning"": {
""translation"": ""整句的繁體中文意思"",
""explanation"": ""詳細解釋""
},
""finalAnalysisText"": ""用於後續分析的最終文本(修正後)"",
""wordAnalysis"": {
""單字原形"": {
""word"": ""單字原形"",
""translation"": ""繁體中文翻譯"",
""definition"": ""英文定義(A1-A2程度)"",
""partOfSpeech"": ""詞性(n./v./adj./adv./phrase/slang)"",
""pronunciation"": {
""ipa"": ""IPA音標"",
""us"": ""美式音標"",
""uk"": ""英式音標""
},
""synonyms"": [""同義詞1"", ""同義詞2"", ""同義詞3""],
""antonyms"": [""反義詞1"", ""反義詞2""],
""isPhrase"": true/false,
""phraseInfo"": {
""phrase"": ""完整片語"",
""meaning"": ""片語意思"",
""warning"": ""警告說明""
},
""examples"": {
""original"": ""來自原句的例句"",
""originalTranslation"": ""原句例句翻譯"",
""generated"": ""AI生成的新例句"",
""generatedTranslation"": ""新例句翻譯""
},
""difficultyLevel"": ""CEFR等級(A1/A2/B1/B2/C1/C2)""
}
}
}
分析要求:
1. **首要任務:語法檢查和修正**
- 檢測語法、拼寫、時態、介詞、詞序錯誤
- 提供修正建議和詳細說明
- 後續分析基於修正後的句子進行
- 保持原句意思不變
2. 識別所有有意義的單字(忽略 a, an, the 等功能詞)
3. **重點:標記高學習價值詞彙**
- 片語和俚語isHighValue: true, learningPriority: "high"
- 中級以上單字B1+isHighValue: true, learningPriority: "high"
- 專業術語isHighValue: true, learningPriority: "medium"
- 基礎功能詞isHighValue: false, learningPriority: "low"
4. 特別注意片語和俚語,設定 isPhrase: true
5. 為片語提供警告說明和顏色代碼
6. 英文定義保持在 A1-A2 程度
7. 提供實用的同義詞和反義詞(如適用)
8. 例句要清楚展示單字用法
9. 準確標記 CEFR 難度等級
10. **優先處理高價值詞彙**:為高價值詞彙生成完整內容詳情
";
```
## 6. 性能優化策略
### 6.1 前端優化
#### 6.1.1 組件懶加載
```typescript
// 懶加載重型組件
const WordInfoPopup = lazy(() => import('./WordInfoPopup'));
const ExampleImageViewer = lazy(() => import('./ExampleImageViewer'));
// 使用 Suspense 包裝
<Suspense fallback={<LoadingSpinner />}>
<WordInfoPopup {...props} />
</Suspense>
```
#### 6.1.2 虛擬化長文本
```typescript
// 對於長句子使用虛擬化渲染
const VirtualizedText = ({ words, analysis, onWordClick }) => {
const [visibleRange, setVisibleRange] = useState({ start: 0, end: 50 });
return (
<div className="virtual-text-container">
{words.slice(visibleRange.start, visibleRange.end).map((word, index) => (
<ClickableWord
key={index}
word={word}
analysis={analysis[word]}
onClick={onWordClick}
/>
))}
</div>
);
};
```
### 6.2 後端優化
#### 6.2.1 快取層次設計
```
L1: 記憶體快取 (Redis) - 1小時 TTL
L2: 資料庫快取 (SQLite) - 24小時 TTL
L3: 磁碟快取 (File System) - 7天 TTL
```
#### 6.2.2 批量分析優化
```csharp
// 批量處理多個句子
public async Task<List<SentenceAnalysisResult>> AnalyzeMultipleSentencesAsync(
List<string> sentences,
Guid userId)
{
// 1. 批量檢查快取
var cacheResults = await _cacheService.GetMultipleAsync(
sentences.Select(GenerateTextHash)
);
// 2. 只分析未快取的句子
var uncachedSentences = sentences
.Where((s, i) => cacheResults[i] == null)
.ToList();
// 3. 批量調用 AI API
var newAnalyses = await _geminiService.AnalyzeMultipleSentencesAsync(
uncachedSentences
);
// 4. 合併結果
return MergeResults(cacheResults, newAnalyses);
}
```
## 7. 測試策略
### 7.1 單元測試
#### 7.1.1 前端組件測試
```typescript
describe('ClickableText Component', () => {
it('should highlight phrase words correctly', () => {
const analysis = {
'brought': { isPhrase: true, /* ... */ },
'up': { isPhrase: true, /* ... */ }
};
render(<ClickableText text="He brought this up" analysis={analysis} />);
expect(screen.getByText('brought')).toHaveClass('phrase');
expect(screen.getByText('up')).toHaveClass('phrase');
});
it('should show word popup on click', async () => {
const mockOnClick = jest.fn();
render(<ClickableText onWordClick={mockOnClick} />);
fireEvent.click(screen.getByText('brought'));
expect(mockOnClick).toHaveBeenCalledWith('brought', expect.any(Object));
});
});
```
#### 7.1.2 後端服務測試
```csharp
[Test]
public async Task AnalyzeSentence_ShouldReturnCachedResult_WhenCacheExists()
{
// Arrange
var inputText = "Test sentence";
var userId = Guid.NewGuid();
var cachedResult = new SentenceAnalysisResult();
_cacheService.Setup(c => c.GetAsync(It.IsAny<string>()))
.ReturnsAsync(cachedResult);
// Act
var result = await _analysisService.AnalyzeSentenceAsync(inputText, userId);
// Assert
Assert.AreEqual(cachedResult, result);
_geminiService.Verify(g => g.AnalyzeSentenceAsync(It.IsAny<string>()),
Times.Never);
}
```
### 7.2 整合測試
#### 7.2.1 E2E 測試流程
```typescript
describe('Word Query Flow', () => {
it('should complete full analysis and query flow', async () => {
// 1. 輸入句子
await page.fill('[data-testid=sentence-input]', 'He brought this up');
await page.click('[data-testid=analyze-button]');
// 2. 等待分析完成
await page.waitForSelector('[data-testid=interactive-text]');
// 3. 點擊單字
await page.click('[data-testid=word-brought]');
// 4. 驗證彈窗顯示
await expect(page.locator('[data-testid=word-popup]')).toBeVisible();
await expect(page.locator('[data-testid=phrase-warning]')).toBeVisible();
// 5. 播放發音
await page.click('[data-testid=play-pronunciation]');
// 6. 關閉彈窗
await page.click('[data-testid=close-popup]');
await expect(page.locator('[data-testid=word-popup]')).toBeHidden();
});
});
```
## 8. 部署和監控
### 8.1 部署策略
#### 8.1.1 前端部署
- **平台**Vercel
- **環境變量**API_BASE_URL, CACHE_TTL
- **CDN**:自動優化靜態資源
- **快取策略**:分析結果本地存儲 24 小時
#### 8.1.2 後端部署
- **平台**Azure App Service / AWS Lambda
- **資料庫**Azure SQL Database / AWS RDS
- **快取**Azure Redis Cache / AWS ElastiCache
- **檔案存儲**Azure Blob Storage / AWS S3
### 8.2 監控指標
#### 8.2.1 業務指標
- 句子分析成功率
- 平均分析響應時間
- 快取命中率
- 用戶使用次數分佈
- 單字點擊熱度排行
#### 8.2.2 技術指標
- API 響應時間 (P95 < 200ms)
- Gemini API 調用延遲
- 快取效能指標
- 錯誤率 (< 1%)
- 系統可用性 (> 99.9%)
#### 8.2.3 成本監控
- Gemini API 調用次數和費用
- 快取存儲成本
- CDN 流量費用
- 基礎設施總成本
## 9. 未來擴展計劃
### 9.1 功能增強
- **多語言支持**:支援其他語言的句子分析
- **語音輸入**:整合語音識別進行句子輸入
- **個人化推薦**:基於用戶查詢歷史推薦相關詞彙
- **社交分享**:分享有趣的句子分析結果
### 9.2 技術升級
- **AI 模型本地化**:部署本地 LLM 降低外部依賴
- **即時協作**:多用戶同時查詢同一句子
- **離線支持**PWA 實現離線查詢基礎詞彙
- **效能優化**WebAssembly 加速文本處理
### 9.3 商業化功能
- **高級分析**:更深度的語法和語義分析
- **專業詞典**:整合專業領域詞典
- **學習追蹤**:詳細的學習進度和成效分析
- **導師模式**AI 導師指導詞彙學習

View File

@ -0,0 +1,600 @@
# 互動式單字查詢 UI 線框圖設計
## 1. 頁面流程概覽
```
首頁 → 登入/註冊 → Dashboard → 詞卡生成頁 → 句子分析模式 → 互動式查詢 → 詞卡生成
↓ ↓ ↓ ↓ ↓ ↓ ↓
引導頁 認證流程 快速訪問 輸入界面 分析處理 點擊查詢 學習材料
```
## 2. 主要頁面線框圖
### 2.1 詞卡生成頁 - 輸入模式
```
┌─────────────────────────────────────────────────────────────────────────┐
│ [≡] DramaLing [🔔] [👤] jett@email │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ AI 智能生成詞卡 - 互動式單字查詢 │
│ │
│ ┌─ 原始例句類型 ──────────────────────────────────────────────────┐ │
│ │ │ │
│ │ ┌─────────────────────┐ ┌─────────────────────────────────┐ │ │
│ │ │ [✍️ 手動輸入] │ │ [📷 影劇截圖] (訂閱功能) │ │ │
│ │ │ │ │ │ │ │
│ │ │ ● 選中狀態 │ │ ○ 未選中 │ │ │
│ │ │ 貼上或輸入英文文本 │ │ 上傳影劇截圖 (Phase 2) │ │ │
│ │ └─────────────────────┘ └─────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ 輸入英文文本 ──────────────────────────────────────────────────┐ │
│ │ │ │
│ │ 輸入英文句子最多300字... │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ │ │ │
│ │ │ │ │ │
│ │ │ │ │ │
│ │ │ │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ 最多 300 字元 • 目前0 字元 [還可輸入 300 字] │ │
│ │ │ │
│ │ 💡 示例句子: │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ [點擊使用] He brought this thing up during our meeting... │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ 🔍 分析句子(點擊查詢單字) │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ 🆓 免費用戶:已使用 0/5 次 (3小時內) │ │
│ │ ⏰ 下次重置時間2025-09-17 15:48 │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
```
### 2.2 分析中狀態
```
┌─────────────────────────────────────────────────────────────────────────┐
│ [≡] DramaLing [🔔] [👤] jett@email │
├─────────────────────────────────────────────────────────────────────────┤
│ [←] 返回輸入 句子分析中 │
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ 🔄 正在分析句子... │ │
│ │ │ │
│ │ He brought this thing up during our meeting │ │
│ │ and no one agreed. │ │
│ │ │ │
│ │ ⚡ AI 正在解析單字和片語 │ │
│ │ │ │
│ │ ████████████████████░░░░░░ 80% │ │
│ │ │ │
│ │ 預計完成時間5 秒 │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ 💡 小提示:分析完成後,您可以點擊任何單字查看詳細意思! │
│ │
└─────────────────────────────────────────────────────────────────────────┘
```
### 2.3 分析結果 - 互動式查詢模式
```
┌─────────────────────────────────────────────────────────────────────────┐
│ [≡] DramaLing [🔔] [👤] jett@email │
├─────────────────────────────────────────────────────────────────────────┤
│ [←] 返回輸入 句子分析結果 │
│ │
│ ┌─ 原始句子分析 ──────────────────────────────────────────────────┐ │
│ │ │ │
│ │ 📝 用戶輸入: │ │
│ │ He brought this thing up during our meeting and no one agreed.│ │
│ │ │ │
│ │ ✅ 語法檢查:無錯誤 │ │
│ │ │ │
│ │ 📖 整句意思: │ │
│ │ 他在我們的會議中提出了這件事,但沒有人同意。這句話表達了在會 │ │
│ │ 議中有人提出某個議題或想法,但得不到其他與會者的認同。 │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ 點擊查詢單字意思 ──────────────────────────────────────────────┐ │
│ │ │ │
│ │ 💡 使用說明:點擊下方句子中的任何單字,可以立即查看詳細意思。 │ │
│ │ 🟡 黃色邊框 = 高價值片語 🟢 綠色邊框 = 高價值單字 🔵 藍色 = 其他 │ │
│ │ ⭐ 高價值詞彙點擊免費 | 💰 其他詞彙點擊收費 │ │
│ │ │ │
│ │ ╔═══════════════════════════════════════════════════════════╗ │ │
│ │ ║ He [brought]⭐ this [thing] [up]⭐ during our [meeting]⭐ ║ │ │
│ │ ║ and no [one] [agreed]. ║ │ │
│ │ ╚═══════════════════════════════════════════════════════════╝ │ │
│ │ │ │
│ │ 範例: │ │
│ │ • brought/up 🟡 黃色邊框 + ⭐(高價值片語,免費點擊) │ │
│ │ • meeting 🟢 綠色邊框 + ⭐(高價值單字,免費點擊) │ │
│ │ • thing/one 🔵 藍色邊框低價值單字點擊扣1次 │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ 操作選項 ──────────────────────────────────────────────────────┐ │
│ │ [🔄 分析新句子] [📖 生成學習詞卡] │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ 📊 使用統計:今日已分析 1 個句子,剩餘 4 次免費額度 │
└─────────────────────────────────────────────────────────────────────────┘
```
### 2.4 單字資訊彈窗 - 低價值單字 (收費)
```
┌─ thing ─────────────────────── ×
│ │
│ 💰 低價值詞彙(扣除 1 次使用額度)│
│ ┌─────────────────────────────┐ │
│ │ ⚠️ 此查詢將消耗 1 次額度 │ │
│ │ 剩餘額度3/5 次 │ │
│ │ [✅ 確認查詢] [❌ 取消] │ │
│ └─────────────────────────────┘ │
│ │
│ 📘 noun | /θɪŋ/ | [🔊 US] [🔊 UK] │
│ │
│ 🇹🇼 翻譯:事情、東西 │
│ │
│ 📖 定義An object, fact, or │
│ situation │
│ │
│ 🔗 同義詞: │
│ [object] [matter] [item] │
│ │
│ 📝 例句: │
│ • 原始He brought this thing up │
│ 翻譯:他提出了這件事 │
│ │
│ • 生成That's an important │
│ thing to remember │
│ 翻譯:那是需要記住的重要事情 │
│ [📷 查看例句圖] [🔊 播放] │
│ │
│ 🎯 難度A1 (基礎) │
│ │
│ [📚 加入學習清單] [📖 生成詞卡] │
└─────────────────────────────────┘
```
### 2.5 單字資訊彈窗 - 高價值片語 (免費)
```
┌─ brought ───────────────────────── ×
│ │
│ ⭐ 高價值片語(免費查詢) │
│ ┌─────────────────────────────────┐ │
│ │ 🟡 片語bring up │ │
│ │ 🇹🇼 意思:提出(話題)、養育 │ │
│ │ ⚡ 提醒:在這個句子中,"brought │ │
│ │ up" 是一個片語,意思是"提出話題" │ │
│ │ ,而不是單純的"帶來" │ │
│ │ 🎯 學習價值:⭐⭐⭐⭐⭐ (極高) │ │
│ └─────────────────────────────────┘ │
│ │
│ 📘 verb | /brɔːt/ | [🔊 US] [🔊 UK] │
│ │
│ 🇹🇼 單字翻譯:帶來、提出 │
│ │
│ 📖 單字定義Past tense of bring; │
│ to take or carry something to a │
│ place │
│ │
│ 🔗 同義詞:[carried] [took] [delivered] │
│ 🔄 反義詞:[removed] [took away] │
│ │
│ 📝 例句: │
│ • 原始He brought this thing up │
│ 翻譯:他提出了這件事 │
│ │
│ • 生成She brought up an important │
│ point about the budget │
│ 翻譯:她提出了關於預算的重要觀點 │
│ [📷 查看例句圖] [🔊 播放] │
│ │
│ 🎯 難度B1 (中級) │
│ │
│ [📚 加入學習清單] [📖 生成詞卡] │
└─────────────────────────────────────┘
```
### 2.6 例句圖片檢視器
```
┌─────────────────────────────────────────────────────────────────────────┐
│ [× 關閉] │
│ │
│ 📷 例句情境圖 │
│ "bring up" - 提出話題 │
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ [圖片載入中...] │ │
│ │ │ │
│ │ 會議室場景插圖 │ │
│ │ 一個人在會議中舉手發言 │ │
│ │ │ │
│ │ 💬 "Let me bring up another point..." │ │
│ │ │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ 📝 情境說明:會議中提出新話題 │
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ [🔊 播放例句] [📱 分享圖片] [💾 儲存到相簿] [❤️ 收藏] │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
```
### 2.7 語法錯誤修正顯示
```
┌─────────────────────────────────────────────────────────────────────────┐
│ [←] 返回輸入 句子分析結果 │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─ 原始句子分析 ──────────────────────────────────────────────────┐ │
│ │ │ │
│ │ 📝 用戶輸入: │ │
│ │ I go to school yesterday and meet my friends. │ │
│ │ │ │
│ │ ❌ 語法檢查:發現 2 個錯誤 │ │
│ │ ┌─ 建議修正 ───────────────────────────────────────────────┐ │ │
│ │ │ │ │ │
│ │ │ 🔧 修正後: │ │ │
│ │ │ I went to school yesterday and met my friends. │ │ │
│ │ │ │ │ │
│ │ │ 📋 修正說明: │ │ │
│ │ │ 1. "go" → "went" (過去式時態修正) │ │ │
│ │ │ 2. "meet" → "met" (過去式時態修正) │ │ │
│ │ │ │ │ │
│ │ │ [✅ 使用修正版本] [❌ 保持原始版本] │ │ │
│ │ └─────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ 📖 整句意思: │ │
│ │ 我昨天去學校遇見了我的朋友們。這句話描述了過去發生的事情... │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ 點擊查詢單字意思 ──────────────────────────────────────────────┐ │
│ │ 💡 以下基於修正後的句子進行分析 │ │
│ │ 🟡 黃色邊框 + ⭐ = 高價值片語 🟢 綠色邊框 + ⭐ = 高價值單字 │ │
│ │ │ │
│ │ ╔═══════════════════════════════════════════════════════════╗ │ │
│ │ ║ I [went]⭐ to school [yesterday] and [met]⭐ my friends. ║ │ │
│ │ ╚═══════════════════════════════════════════════════════════╝ │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
```
### 2.8 使用限制提醒
```
┌─────────────────────────────────────────────────────────────────────────┐
│ ⚠️ 使用限制提醒 │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 🆓 免費用戶限制 │
│ │
│ 您今日的句子分析額度已用完 (5/5 次) │
│ │
│ ⏰ 下次重置時間3小時後 │
│ (2025-09-17 18:48) │
│ │
│ ┌─ 升級建議 ──────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ 🌟 升級到付費方案,享受無限制分析: │ │
│ │ │ │
│ │ ✅ 無限次句子分析 │ │
│ │ ✅ 每日50張例句圖生成 │ │
│ │ ✅ 進階AI分析功能 │ │
│ │ ✅ 優先客服支援 │ │
│ │ │ │
│ │ [🚀 立即升級 NT$99/月] │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ 其他選項 ──────────────────────────────────────────────────────┐ │
│ │ [⏰ 設定提醒] [📚 瀏覽現有詞卡] [🏠 返回首頁] │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
```
## 3. 行動裝置適配版本
### 3.1 手機版 - 輸入介面 (375px)
```
┌─────────────────────────────────┐
│ ≡ DramaLing 🔔 👤 │
├─────────────────────────────────┤
│ │
│ AI 智能詞卡生成 │
│ │
│ ┌─ 輸入類型 ─────────────────┐ │
│ │ [✍️ 手動] [📷 截圖] │ │
│ │ ● ○ │ │
│ └───────────────────────────────┘ │
│ │
│ ┌─ 英文句子 ─────────────────┐ │
│ │ 輸入英文句子最多300字 │ │
│ │ ┌─────────────────────────┐ │ │
│ │ │ │ │ │
│ │ │ │ │ │
│ │ │ │ │ │
│ │ └─────────────────────────┘ │ │
│ │ 0/50 字 │ │
│ │ │ │
│ │ 💡 示例: │ │
│ │ [He brought this up...] │ │
│ └───────────────────────────────┘ │
│ │
│ ┌─────────────────────────────┐ │
│ │ 🔍 分析句子(點擊查詢) │ │
│ └─────────────────────────────┘ │
│ │
│ 🆓 免費0/5 次 │
│ ⏰ 重置3小時後 │
│ │
└─────────────────────────────────┘
```
### 3.2 手機版 - 分析結果
```
┌─────────────────────────────────┐
│ ← 返回 句子分析結果 │
├─────────────────────────────────┤
│ │
│ ┌─ 原始句子 ─────────────────┐ │
│ │ He brought this thing up │ │
│ │ during our meeting and no │ │
│ │ one agreed. │ │
│ │ │ │
│ │ 📝 整句意思: │ │
│ │ 他在會議中提出了這件事... │ │
│ └───────────────────────────────┘ │
│ │
│ ┌─ 點擊查詢 ─────────────────┐ │
│ │ 💡 點擊單字查看詳細意思 │ │
│ │ 🟡 黃色=片語 🔵 藍色=單字 │ │
│ │ │ │
│ │ ╔═════════════════════════╗ │ │
│ │ ║ He [brought] this ║ │ │
│ │ ║ [thing] [up] during our ║ │ │
│ │ ║ [meeting] and no [one] ║ │ │
│ │ ║ [agreed]. ║ │ │
│ │ ╚═════════════════════════╝ │ │
│ └───────────────────────────────┘ │
│ │
│ ┌─ 操作 ─────────────────────┐ │
│ │ [🔄 新句子] [📖 生成詞卡] │ │
│ └───────────────────────────────┘ │
│ │
│ 📊 今日1/5 次 │
└─────────────────────────────────┘
```
### 3.3 手機版 - 單字彈窗
```
┌─────────────────────────────────┐
×
│ brought │
├─────────────────────────────────┤
│ ⚠️ 這個單字屬於片語! │
│ │
│ 🔶 片語bring up │
│ 🇹🇼 意思:提出(話題)、養育 │
│ ⚡ 提醒:這裡是"提出話題"的意思 │
│ │
├─────────────────────────────────┤
│ 📘 verb | /brɔːt/ │
│ [🔊 US] [🔊 UK] │
│ │
│ 🇹🇼 翻譯:帶來、提出 │
│ │
│ 📖 定義Past tense of bring; │
│ to take or carry something to │
│ a place │
│ │
│ 🔗 同義詞: │
│ [carried] [took] [delivered] │
│ │
│ 📝 例句: │
│ • 原始He brought this up │
│ 翻譯:他提出了這件事 │
│ │
│ • 生成She brought up a point │
│ 翻譯:她提出了一個觀點 │
│ [📷] [🔊] │
│ │
│ 🎯 難度B1 │
│ │
│ [📚 加入清單] [📖 生成詞卡] │
│ │
└─────────────────────────────────┘
```
## 4. 互動流程圖
### 4.1 完整用戶旅程
```
開始
選擇輸入模式 (手動/截圖)
[手動模式]
輸入英文句子 (≤50字)
點擊「分析句子」
檢查使用限制
↓ ↓
通過 超限
↓ ↓
顯示分析中... 顯示升級提醒
↓ ↓
AI 分析完成 [升級] or [等待]
顯示互動式文字
點擊任意單字
顯示單字資訊彈窗
↓ ↓
普通單字 片語/俚語
↓ ↓
基礎資訊 片語警告 + 基礎資訊
↓ ↓
[播放發音] [播放發音]
[查看例句圖] [查看例句圖]
[加入學習清單] [加入學習清單]
[生成詞卡] [生成詞卡]
關閉彈窗
繼續查詢其他單字 or 分析新句子 or 生成詞卡
```
### 4.2 錯誤處理流程
```
用戶操作
系統檢查
↓ ↓ ↓ ↓
正常 網路錯誤 API錯誤 使用超限
↓ ↓ ↓ ↓
執行 重試提示 錯誤提示 升級提示
↓ ↓ ↓
[重試] [回報] [升級/等待]
[取消] [返回] [返回]
```
## 5. 響應式設計斷點
### 5.1 斷點定義
```css
/* 手機 */
@media (max-width: 767px) {
.container { padding: 1rem; }
.word-popup {
width: 90vw;
position: fixed;
bottom: 0;
transform: none;
}
}
/* 平板 */
@media (min-width: 768px) and (max-width: 1023px) {
.container { padding: 1.5rem; }
.interactive-text { font-size: 1.1rem; }
}
/* 桌面 */
@media (min-width: 1024px) {
.container { padding: 2rem; }
.word-popup { min-width: 400px; }
}
```
### 5.2 觸控優化
```css
/* 手機觸控優化 */
@media (max-width: 767px) {
.clickable-word {
min-height: 44px; /* iOS 建議最小觸控面積 */
padding: 8px 4px;
margin: 2px;
}
.close-button {
min-width: 44px;
min-height: 44px;
}
}
```
## 6. 無障礙設計 (A11y)
### 6.1 鍵盤導航
```
Tab 鍵順序:
1. 輸入模式選擇
2. 文字輸入框
3. 分析按鈕
4. 可點擊單字 (依序)
5. 彈窗內元素
6. 關閉按鈕
```
### 6.2 螢幕閱讀器支援
```html
<!-- 語義化 HTML -->
<main role="main" aria-label="互動式單字查詢">
<section aria-label="句子輸入區">
<input
aria-label="輸入英文句子最多300字元"
aria-describedby="char-count"
/>
<div id="char-count" aria-live="polite">目前0 字元</div>
</section>
<section aria-label="分析結果區">
<div role="button"
tabindex="0"
aria-label="單字brought點擊查看詳細資訊"
data-word="brought">
brought
</div>
</section>
</main>
<!-- 彈窗 -->
<dialog role="dialog"
aria-labelledby="word-title"
aria-describedby="word-definition">
<h2 id="word-title">brought</h2>
<p id="word-definition">Past tense of bring...</p>
</dialog>
```
### 6.3 色彩對比
```css
/* 符合 WCAG AA 標準 */
:root {
--text-primary: #1a1a1a; /* 對比度 13.26:1 */
--text-secondary: #4a4a4a; /* 對比度 8.59:1 */
--border-focus: #0066cc; /* 高對比度焦點邊框 */
--bg-phrase: #fff4cc; /* 片語背景 */
--bg-phrase-text: #8b4513; /* 片語文字 */
}
.clickable-word:focus {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
}
```
這份 UI 線框圖設計涵蓋了完整的用戶介面規劃,包括主要頁面、互動流程、響應式適配和無障礙設計,為開發團隊提供了詳細的實現指南。

View File

@ -0,0 +1,572 @@
'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>
)
}

View File

@ -0,0 +1,500 @@
'use client'
import { useState } 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 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
}
}
}
// 處理句子分析
const handleAnalyzeSentence = async () => {
if (!textInput.trim()) return
if (!isPremium && usageCount >= 5) {
alert('❌ 免費用戶 3 小時內只能分析 5 次句子,請稍後再試或升級到付費版本')
return
}
setIsAnalyzing(true)
try {
await new Promise(resolve => setTimeout(resolve, 2500))
// 根據輸入文本決定使用哪個模擬資料
const hasGrammarErrors = textInput.toLowerCase().includes('go to school yesterday') ||
textInput.toLowerCase().includes('meet my friends')
if (hasGrammarErrors) {
setSentenceAnalysis(mockErrorSentenceAnalysis.words)
setSentenceMeaning(mockErrorSentenceAnalysis.meaning)
setGrammarCorrection(mockErrorSentenceAnalysis.grammarCorrection)
setFinalText(mockErrorSentenceAnalysis.grammarCorrection.correctedText || textInput)
} else {
setSentenceAnalysis(mockCorrectSentenceAnalysis.words)
setSentenceMeaning(mockCorrectSentenceAnalysis.meaning)
setGrammarCorrection(mockCorrectSentenceAnalysis.grammarCorrection)
setFinalText(textInput)
}
setShowAnalysisView(true)
setUsageCount(prev => prev + 1)
} catch (error) {
console.error('Error analyzing sentence:', error)
alert('分析句子時發生錯誤,請稍後再試')
} finally {
setIsAnalyzing(false)
}
}
const handleAcceptCorrection = () => {
if (grammarCorrection?.correctedText) {
setFinalText(grammarCorrection.correctedText)
// 這裡可以重新分析修正後的句子
alert('✅ 已採用修正版本,後續學習將基於正確的句子進行!')
}
}
const handleRejectCorrection = () => {
setFinalText(grammarCorrection?.originalText || textInput)
alert('📝 已保持原始版本,將基於您的原始輸入進行學習。')
}
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"> v3.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-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>
)
}

View File

@ -0,0 +1,389 @@
'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>
)
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,82 @@
'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>
)
}

View File

@ -0,0 +1,187 @@
'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>
)
}

View File

@ -0,0 +1,375 @@
'use client'
import { useState } from 'react'
// 更新的詞彙分析介面
interface WordAnalysis {
word: string
translation: string
definition: string
partOfSpeech: string
pronunciation: string
synonyms: string[]
antonyms?: string[]
isPhrase: boolean
isHighValue: boolean // 高學習價值標記
learningPriority: 'high' | 'medium' | 'low' // 學習優先級
phraseInfo?: {
phrase: string
meaning: string
warning: string
colorCode: string // 片語顏色代碼
}
difficultyLevel: string
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> // 收費確認
remainingUsage?: number // 剩餘使用次數
}
export function ClickableTextV2({
text,
analysis,
highValueWords = [],
phrasesDetected = [],
onWordClick,
onWordCostConfirm,
remainingUsage = 5
}: 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 words = text.split(/(\s+|[.,!?;:])/g).filter(word => word.trim())
const handleWordClick = async (word: string, event: React.MouseEvent) => {
const cleanWord = word.toLowerCase().replace(/[.,!?;:]/g, '')
const wordAnalysis = analysis?.[cleanWord]
if (wordAnalysis) {
const rect = event.currentTarget.getBoundingClientRect()
const position = {
x: rect.left + rect.width / 2,
y: rect.top - 10
}
// 檢查是否為高價值詞彙(免費)
if (wordAnalysis.isHighValue) {
setPopupPosition(position)
setSelectedWord(cleanWord)
onWordClick?.(cleanWord, wordAnalysis)
} else {
// 低價值詞彙需要收費確認
setShowCostConfirm({
word: cleanWord,
cost: 1,
position
})
}
}
}
const handleCostConfirm = async () => {
if (!showCostConfirm) return
const confirmed = await onWordCostConfirm?.(showCostConfirm.word, showCostConfirm.cost)
if (confirmed) {
const wordAnalysis = analysis?.[showCostConfirm.word]
if (wordAnalysis) {
setPopupPosition(showCostConfirm.position)
setSelectedWord(showCostConfirm.word)
onWordClick?.(showCostConfirm.word, wordAnalysis)
}
}
setShowCostConfirm(null)
}
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 rounded relative mx-0.5 px-1 py-0.5"
// 高價值片語(黃色系)
if (wordAnalysis.isHighValue && wordAnalysis.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 (wordAnalysis.isHighValue && !wordAnalysis.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} border-b border-blue-300 hover:bg-blue-100 hover:border-blue-400`
}
const renderWordWithStar = (word: string, className: string) => {
const cleanWord = word.toLowerCase().replace(/[.,!?;:]/g, '')
const wordAnalysis = analysis?.[cleanWord]
const isHighValue = wordAnalysis?.isHighValue
return (
<span
className={`${className} ${isHighValue ? 'relative' : ''}`}
onClick={(e) => handleWordClick(word, e)}
>
{word}
{isHighValue && (
<span className="absolute -top-1 -right-1 text-xs"></span>
)}
</span>
)
}
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 renderWordWithStar(word, getWordClass(word))
})}
</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].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">
{analysis[selectedWord].learningPriority === 'high' ? '⭐⭐⭐⭐⭐' :
analysis[selectedWord].learningPriority === 'medium' ? '⭐⭐⭐' : '⭐'}
</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">
{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>
)}
{/* 反義詞 */}
{analysis[selectedWord].antonyms && 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">
{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 ${
analysis[selectedWord].difficultyLevel === 'A1' || analysis[selectedWord].difficultyLevel === 'A2' ? 'bg-green-100 text-green-700' :
analysis[selectedWord].difficultyLevel === 'B1' || analysis[selectedWord].difficultyLevel === 'B2' ? 'bg-yellow-100 text-yellow-700' :
'bg-red-100 text-red-700'
}`}>
CEFR {analysis[selectedWord].difficultyLevel}
</span>
<span className="text-xs text-gray-500">
({analysis[selectedWord].difficultyLevel === 'A1' || analysis[selectedWord].difficultyLevel === 'A2' ? '基礎' :
analysis[selectedWord].difficultyLevel === 'B1' || analysis[selectedWord].difficultyLevel === 'B2' ? '中級' : '高級'})
</span>
</div>
</div>
</div>
</div>
)}
{/* 收費確認對話框 */}
{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>
<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>
<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>
</>
)}
</div>
)
}

View File

@ -0,0 +1,273 @@
'use client'
import { useState } from 'react'
interface GrammarCorrection {
hasErrors: boolean
originalText: string
correctedText: string | null
corrections: Array<{
position: { start: number; end: number }
errorType: string
original: string
corrected: string
reason: string
severity: 'high' | 'medium' | 'low'
}>
confidenceScore: number
}
interface GrammarCorrectionPanelProps {
correction: GrammarCorrection
onAcceptCorrection: () => void
onRejectCorrection: () => void
onManualEdit?: (text: string) => void
}
export function GrammarCorrectionPanel({
correction,
onAcceptCorrection,
onRejectCorrection,
onManualEdit
}: GrammarCorrectionPanelProps) {
const [isExpanded, setIsExpanded] = useState(true)
if (!correction.hasErrors) {
return (
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
<div className="flex items-center gap-2">
<div className="text-green-600 text-lg"></div>
<div>
<div className="font-medium text-green-800"></div>
<div className="text-sm text-green-700">
</div>
</div>
</div>
</div>
)
}
const renderHighlightedText = (text: string, corrections: typeof correction.corrections) => {
if (corrections.length === 0) return text
let result: React.ReactNode[] = []
let lastIndex = 0
corrections.forEach((corr, index) => {
// 添加錯誤前的正常文字
if (corr.position.start > lastIndex) {
result.push(
<span key={`normal-${index}`}>
{text.slice(lastIndex, corr.position.start)}
</span>
)
}
// 添加錯誤文字(紅色標記)
result.push(
<span
key={`error-${index}`}
className="relative bg-red-100 border-b-2 border-red-400 px-1 rounded"
title={`錯誤:${corr.reason}`}
>
{corr.original}
<span className="absolute -top-1 -right-1 text-xs text-red-600"></span>
</span>
)
lastIndex = corr.position.end
})
// 添加最後剩餘的正常文字
if (lastIndex < text.length) {
result.push(
<span key="final">
{text.slice(lastIndex)}
</span>
)
}
return <>{result}</>
}
const renderCorrectedText = (text: string, corrections: typeof correction.corrections) => {
if (corrections.length === 0 || !text) return text
let result: React.ReactNode[] = []
let lastIndex = 0
let offset = 0 // 修正後文字長度變化的偏移量
corrections.forEach((corr, index) => {
const adjustedStart = corr.position.start + offset
const originalLength = corr.original.length
const correctedLength = corr.corrected.length
// 添加修正前的正常文字
if (adjustedStart > lastIndex) {
result.push(
<span key={`normal-${index}`}>
{text.slice(lastIndex, adjustedStart)}
</span>
)
}
// 添加修正後的文字(綠色標記)
result.push(
<span
key={`corrected-${index}`}
className="relative bg-green-100 border-b-2 border-green-400 px-1 rounded font-medium"
title={`修正:${corr.reason}`}
>
{corr.corrected}
<span className="absolute -top-1 -right-1 text-xs text-green-600"></span>
</span>
)
lastIndex = adjustedStart + correctedLength
offset += (correctedLength - originalLength)
})
// 添加最後剩餘的正常文字
if (lastIndex < text.length) {
result.push(
<span key="final">
{text.slice(lastIndex)}
</span>
)
}
return <>{result}</>
}
const getSeverityColor = (severity: string) => {
switch (severity) {
case 'high':
return 'bg-red-100 text-red-700 border-red-300'
case 'medium':
return 'bg-yellow-100 text-yellow-700 border-yellow-300'
case 'low':
return 'bg-blue-100 text-blue-700 border-blue-300'
default:
return 'bg-gray-100 text-gray-700 border-gray-300'
}
}
return (
<div className="bg-white border border-red-200 rounded-lg shadow-sm mb-6">
{/* 標題區 */}
<div className="bg-red-50 px-6 py-4 border-b border-red-200 rounded-t-lg">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="text-red-600 text-xl"></div>
<div>
<h3 className="font-semibold text-red-800">
{correction.corrections.length}
</h3>
<div className="text-sm text-red-700">
</div>
</div>
</div>
<button
onClick={() => setIsExpanded(!isExpanded)}
className="text-red-600 hover:text-red-800"
>
{isExpanded ? '收起' : '展開'}
</button>
</div>
</div>
{isExpanded && (
<div className="p-6 space-y-6">
{/* 原始句子 */}
<div>
<h4 className="font-medium text-gray-800 mb-2">📝 </h4>
<div className="p-4 bg-gray-50 rounded-lg border border-gray-200">
<div className="text-lg leading-relaxed">
{renderHighlightedText(correction.originalText, correction.corrections)}
</div>
</div>
</div>
{/* 修正建議 */}
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<h4 className="font-medium text-green-800 mb-3 flex items-center gap-2">
<span className="text-lg">🔧</span>
</h4>
<div className="p-4 bg-white rounded-lg border border-green-300 mb-4">
<div className="text-lg leading-relaxed">
{correction.correctedText && renderCorrectedText(correction.correctedText, correction.corrections)}
</div>
</div>
{/* 修正說明列表 */}
<div className="space-y-3">
<h5 className="font-medium text-green-800">📋 </h5>
{correction.corrections.map((corr, index) => (
<div
key={index}
className={`p-3 rounded-lg border ${getSeverityColor(corr.severity)}`}
>
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-white flex items-center justify-center text-sm font-bold">
{index + 1}
</div>
<div className="flex-1">
<div className="font-medium mb-1">
"{corr.original}" "{corr.corrected}"
</div>
<div className="text-sm">
{corr.reason}
</div>
<div className="text-xs mt-1 opacity-75">
{corr.errorType} | {corr.severity}
</div>
</div>
</div>
</div>
))}
</div>
{/* 信心度 */}
<div className="mt-4 text-sm text-green-700">
🎯 {(correction.confidenceScore * 100).toFixed(1)}%
</div>
</div>
{/* 操作按鈕 */}
<div className="flex gap-4">
<button
onClick={onAcceptCorrection}
className="flex-1 bg-green-600 text-white py-3 px-4 rounded-lg font-medium hover:bg-green-700 transition-colors flex items-center justify-center gap-2"
>
<span className="text-lg"></span>
使
</button>
<button
onClick={onRejectCorrection}
className="flex-1 bg-gray-200 text-gray-700 py-3 px-4 rounded-lg font-medium hover:bg-gray-300 transition-colors flex items-center justify-center gap-2"
>
<span className="text-lg"></span>
</button>
</div>
{/* 學習提醒 */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<div className="flex items-start gap-2">
<div className="text-blue-600 text-lg">💡</div>
<div className="text-sm text-blue-800">
<strong></strong>
使
</div>
</div>
</div>
</div>
)}
</div>
)
}