Compare commits
21 Commits
452cdef641
...
f90286ad88
| Author | SHA1 | Date |
|---|---|---|
|
|
f90286ad88 | |
|
|
dcacba2523 | |
|
|
0bf0541c87 | |
|
|
1b937f85c0 | |
|
|
4f16cbfa08 | |
|
|
4311c1c3b5 | |
|
|
255adc62c9 | |
|
|
e940d86f4a | |
|
|
1fb7cadd52 | |
|
|
63b1018d97 | |
|
|
c0edf93c8a | |
|
|
d69ba4ea8a | |
|
|
95097cf3f1 | |
|
|
76e95dbef2 | |
|
|
336f235684 | |
|
|
59e94b957a | |
|
|
3838be2ea3 | |
|
|
448883ff97 | |
|
|
303f0ac727 | |
|
|
97606d8c56 | |
|
|
715b735c4d |
|
|
@ -1,42 +0,0 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Read(//Users/jettcheng1018/code/**)",
|
||||
"Bash(npm install)",
|
||||
"Bash(npm install:*)",
|
||||
"Bash(npx:*)",
|
||||
"Bash(tree:*)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(xargs:*)",
|
||||
"Bash(npm init:*)",
|
||||
"Bash(npm run dev:*)",
|
||||
"Bash(npm uninstall:*)",
|
||||
"Bash(git push:*)",
|
||||
"Bash(rm:*)",
|
||||
"Bash(dotnet new:*)",
|
||||
"Bash(brew install:*)",
|
||||
"Bash(chmod:*)",
|
||||
"Bash(./install-dotnet.sh:*)",
|
||||
"Bash(export PATH=\"$HOME/.dotnet:$PATH\")",
|
||||
"Bash(export DOTNET_ROOT=\"$HOME/.dotnet\")",
|
||||
"Bash(dotnet --version)",
|
||||
"Bash(bash:*)",
|
||||
"Bash(dotnet add package:*)",
|
||||
"Bash(dotnet build)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(./start-frontend.sh)",
|
||||
"Bash(dotnet run:*)",
|
||||
"Bash(./start-dotnet-api.sh:*)",
|
||||
"Bash(curl:*)",
|
||||
"Bash(echo:*)",
|
||||
"Bash(cat:*)",
|
||||
"Bash(git log:*)",
|
||||
"Bash(git init:*)",
|
||||
"Bash(git remote add:*)",
|
||||
"Bash(sqlite3:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
}
|
||||
}
|
||||
46
.env.example
46
.env.example
|
|
@ -1,46 +0,0 @@
|
|||
# ==============================================
|
||||
# DramaLing 環境變數配置範本
|
||||
# 前後端分離架構 (Next.js + .NET Core)
|
||||
# ==============================================
|
||||
|
||||
# ================
|
||||
# 前端配置 (Next.js)
|
||||
# ================
|
||||
|
||||
# Supabase 前端配置 (認證用)
|
||||
NEXT_PUBLIC_SUPABASE_URL=your_supabase_project_url
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
|
||||
|
||||
# API 服務配置
|
||||
NEXT_PUBLIC_API_URL=http://localhost:5000
|
||||
NEXT_PUBLIC_API_URL_PROD=https://your-dotnet-api.com
|
||||
|
||||
# 應用程式配置
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3001
|
||||
|
||||
# ================
|
||||
# 後端配置 (.NET Core)
|
||||
# ================
|
||||
# 注意:以下配置應複製到 backend/DramaLing.Api/appsettings.Development.json
|
||||
|
||||
# 資料庫連接
|
||||
# ConnectionStrings__DefaultConnection=Host=db.supabase.co;Database=postgres;Username=postgres;Password=your-password;Port=5432;SSL Mode=Require;
|
||||
|
||||
# Supabase 後端配置
|
||||
# Supabase__Url=your_supabase_project_url
|
||||
# Supabase__ServiceRoleKey=your_supabase_service_role_key
|
||||
# Supabase__JwtSecret=your_supabase_jwt_secret
|
||||
|
||||
# Google Gemini AI
|
||||
# AI__GeminiApiKey=your_gemini_api_key
|
||||
|
||||
# ================
|
||||
# 部署配置
|
||||
# ================
|
||||
|
||||
# 前端部署 (Vercel)
|
||||
# VERCEL_URL=your-vercel-deployment-url
|
||||
|
||||
# 後端部署 (Azure/Railway)
|
||||
# AZURE_APP_URL=your-azure-app-url
|
||||
# RAILWAY_APP_URL=your-railway-app-url
|
||||
|
|
@ -12,6 +12,8 @@ node_modules/
|
|||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
frontend/.next/
|
||||
frontend/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
|
@ -68,4 +70,7 @@ logs/
|
|||
|
||||
# OS files
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
ehthumbs.db
|
||||
|
||||
# Claude Code personal settings
|
||||
.claude/
|
||||
|
|
@ -0,0 +1,575 @@
|
|||
# 🗃️ 查詢歷史快取系統 - 功能規格計劃
|
||||
|
||||
**專案**: DramaLing 英語學習平台
|
||||
**功能**: 查詢歷史記錄與智能快取系統
|
||||
**文檔版本**: v1.0
|
||||
**建立日期**: 2025-01-18
|
||||
**核心概念**: 將技術快取包裝為用戶查詢歷史,提升體驗透明度
|
||||
|
||||
---
|
||||
|
||||
## 🎯 **核心設計理念**
|
||||
|
||||
### **從「快取機制」到「查詢歷史」**
|
||||
|
||||
| 技術實現 | 用戶概念 | 實際意義 |
|
||||
|----------|----------|----------|
|
||||
| Cache Hit | 查詢過的句子 | "您之前查詢過這個句子" |
|
||||
| Cache Miss | 新句子查詢 | "正在為您分析新句子..." |
|
||||
| Word Cache | 查詢過的詞彙 | "您之前查詢過這個詞彙" |
|
||||
| API Call | 即時查詢 | "正在為您查詢詞彙資訊..." |
|
||||
|
||||
### **使用者場景**
|
||||
```
|
||||
場景1: 句子查詢
|
||||
用戶輸入: "Hello world"
|
||||
第1次: "正在分析..." (3-5秒) → 存入查詢歷史
|
||||
第2次: "您之前查詢過,立即顯示" (<200ms)
|
||||
|
||||
場景2: 詞彙查詢
|
||||
句子: "The apple"
|
||||
點擊 "The": "正在查詢..." → 存入詞彙查詢歷史
|
||||
新句子: "The orange"
|
||||
點擊 "The": "您之前查詢過,立即顯示" → 從歷史載入
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 **技術規格設計**
|
||||
|
||||
## 🎯 **A. 句子查詢歷史系統**
|
||||
|
||||
### **A1. 當前實現改造**
|
||||
**現有**: `SentenceAnalysisCache` (技術導向命名)
|
||||
**改為**: 保持技術實現,改變用戶訊息
|
||||
|
||||
#### **API 回應訊息改造**
|
||||
**檔案**: `/backend/DramaLing.Api/Controllers/AIController.cs:547`
|
||||
|
||||
```csharp
|
||||
// 當前 (技術導向)
|
||||
return Ok(new {
|
||||
Success = true,
|
||||
Data = cachedResult,
|
||||
Message = "句子分析完成(快取)", // ❌ 技術術語
|
||||
Cached = true,
|
||||
CacheHit = true
|
||||
});
|
||||
|
||||
// 改為 (用戶導向)
|
||||
return Ok(new {
|
||||
Success = true,
|
||||
Data = cachedResult,
|
||||
Message = "您之前查詢過這個句子,立即為您顯示結果", // ✅ 用戶友善
|
||||
FromHistory = true, // ✅ 更直觀的欄位名
|
||||
QueryDate = cachedAnalysis.CreatedAt,
|
||||
TimesQueried = cachedAnalysis.AccessCount
|
||||
});
|
||||
```
|
||||
|
||||
### **A2. 前端顯示改造**
|
||||
**檔案**: `/frontend/app/generate/page.tsx`
|
||||
|
||||
```typescript
|
||||
// 查詢歷史狀態顯示
|
||||
{queryStatus && (
|
||||
<div className={`inline-flex items-center px-4 py-2 rounded-lg text-sm font-medium ${
|
||||
queryStatus.fromHistory
|
||||
? 'bg-purple-100 text-purple-800'
|
||||
: 'bg-blue-100 text-blue-800'
|
||||
}`}>
|
||||
{queryStatus.fromHistory ? (
|
||||
<>
|
||||
<span className="mr-2">🗃️</span>
|
||||
<span>查詢歷史 (第{queryStatus.timesQueried}次)</span>
|
||||
<span className="ml-2 text-xs text-purple-600">
|
||||
首次查詢: {formatDate(queryStatus.queryDate)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="mr-2">🔍</span>
|
||||
<span>新句子分析中...</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 **B. 詞彙查詢歷史系統**
|
||||
|
||||
### **B1. 新增詞彙查詢快取表**
|
||||
```sql
|
||||
-- 用戶詞彙查詢歷史表
|
||||
CREATE TABLE UserVocabularyQueryHistory (
|
||||
Id UNIQUEIDENTIFIER PRIMARY KEY,
|
||||
UserId UNIQUEIDENTIFIER NOT NULL, -- 用戶ID (未來用戶系統)
|
||||
Word NVARCHAR(100) NOT NULL, -- 查詢的詞彙
|
||||
WordLowercase NVARCHAR(100) NOT NULL, -- 小寫版本 (查詢鍵)
|
||||
|
||||
-- 查詢結果快取
|
||||
AnalysisResult NVARCHAR(MAX) NOT NULL, -- JSON 格式的分析結果
|
||||
Translation NVARCHAR(200) NOT NULL, -- 快速存取的翻譯
|
||||
Definition NVARCHAR(500) NOT NULL, -- 快速存取的定義
|
||||
|
||||
-- 查詢上下文
|
||||
FirstQueriedInSentence NVARCHAR(1000), -- 首次查詢時的句子語境
|
||||
LastQueriedInSentence NVARCHAR(1000), -- 最後查詢時的句子語境
|
||||
|
||||
-- 查詢歷史統計
|
||||
FirstQueriedAt DATETIME2 NOT NULL, -- 首次查詢時間
|
||||
LastQueriedAt DATETIME2 NOT NULL, -- 最後查詢時間
|
||||
QueryCount INT DEFAULT 1, -- 查詢次數
|
||||
|
||||
-- 系統欄位
|
||||
CreatedAt DATETIME2 NOT NULL,
|
||||
UpdatedAt DATETIME2 NOT NULL,
|
||||
|
||||
-- 索引優化
|
||||
INDEX IX_UserVocabularyQueryHistory_UserId_Word (UserId, WordLowercase),
|
||||
INDEX IX_UserVocabularyQueryHistory_LastQueriedAt (LastQueriedAt),
|
||||
|
||||
-- 暫時不設定外鍵,因為用戶系統還未完全實現
|
||||
-- FOREIGN KEY (UserId) REFERENCES Users(Id)
|
||||
);
|
||||
```
|
||||
|
||||
### **B2. 詞彙查詢服務重構**
|
||||
**檔案**: `/backend/DramaLing.Api/Services/VocabularyQueryService.cs`
|
||||
|
||||
```csharp
|
||||
public interface IVocabularyQueryService
|
||||
{
|
||||
Task<VocabularyQueryResponse> QueryWordAsync(string word, string sentence, Guid? userId = null);
|
||||
Task<List<UserVocabularyQueryHistory>> GetUserQueryHistoryAsync(Guid userId, int limit = 50);
|
||||
}
|
||||
|
||||
public class VocabularyQueryService : IVocabularyQueryService
|
||||
{
|
||||
private readonly DramaLingDbContext _context;
|
||||
private readonly IGeminiService _geminiService;
|
||||
private readonly ILogger<VocabularyQueryService> _logger;
|
||||
|
||||
public async Task<VocabularyQueryResponse> QueryWordAsync(string word, string sentence, Guid? userId = null)
|
||||
{
|
||||
var wordLower = word.ToLower();
|
||||
var mockUserId = userId ?? Guid.Parse("00000000-0000-0000-0000-000000000001"); // 模擬用戶
|
||||
|
||||
// 1. 檢查用戶的詞彙查詢歷史
|
||||
var queryHistory = await _context.UserVocabularyQueryHistory
|
||||
.FirstOrDefaultAsync(h => h.UserId == mockUserId && h.WordLowercase == wordLower);
|
||||
|
||||
if (queryHistory != null)
|
||||
{
|
||||
// 更新查詢統計
|
||||
queryHistory.LastQueriedAt = DateTime.UtcNow;
|
||||
queryHistory.LastQueriedInSentence = sentence;
|
||||
queryHistory.QueryCount++;
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// 返回歷史查詢結果
|
||||
var historicalAnalysis = JsonSerializer.Deserialize<object>(queryHistory.AnalysisResult);
|
||||
|
||||
return new VocabularyQueryResponse
|
||||
{
|
||||
Success = true,
|
||||
Data = new
|
||||
{
|
||||
Word = word,
|
||||
Analysis = historicalAnalysis,
|
||||
QueryHistory = new
|
||||
{
|
||||
IsFromHistory = true,
|
||||
FirstQueriedAt = queryHistory.FirstQueriedAt,
|
||||
QueryCount = queryHistory.QueryCount,
|
||||
DaysSinceFirstQuery = (DateTime.UtcNow - queryHistory.FirstQueriedAt).Days,
|
||||
FirstContext = queryHistory.FirstQueriedInSentence,
|
||||
CurrentContext = sentence
|
||||
}
|
||||
},
|
||||
Message = $"您之前查詢過 \"{word}\",這是第{queryHistory.QueryCount}次查詢"
|
||||
};
|
||||
}
|
||||
|
||||
// 2. 新詞彙查詢 - 調用 AI
|
||||
var aiAnalysis = await AnalyzeWordWithAI(word, sentence);
|
||||
|
||||
// 3. 存入查詢歷史
|
||||
var newHistory = new UserVocabularyQueryHistory
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = mockUserId,
|
||||
Word = word,
|
||||
WordLowercase = wordLower,
|
||||
AnalysisResult = JsonSerializer.Serialize(aiAnalysis),
|
||||
Translation = aiAnalysis.Translation,
|
||||
Definition = aiAnalysis.Definition,
|
||||
FirstQueriedInSentence = sentence,
|
||||
LastQueriedInSentence = sentence,
|
||||
FirstQueriedAt = DateTime.UtcNow,
|
||||
LastQueriedAt = DateTime.UtcNow,
|
||||
QueryCount = 1,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_context.UserVocabularyQueryHistory.Add(newHistory);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return new VocabularyQueryResponse
|
||||
{
|
||||
Success = true,
|
||||
Data = new
|
||||
{
|
||||
Word = word,
|
||||
Analysis = aiAnalysis,
|
||||
QueryHistory = new
|
||||
{
|
||||
IsFromHistory = false,
|
||||
IsNewQuery = true,
|
||||
FirstQueriedAt = DateTime.UtcNow,
|
||||
QueryCount = 1,
|
||||
Context = sentence
|
||||
}
|
||||
},
|
||||
Message = $"首次查詢 \"{word}\",已加入您的查詢歷史"
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<object> AnalyzeWordWithAI(string word, string sentence)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 🚀 這裡應該是真實的 AI 調用,不是模擬
|
||||
var prompt = $@"
|
||||
請分析單字 ""{word}"" 在句子 ""{sentence}"" 中的詳細資訊:
|
||||
|
||||
單字: {word}
|
||||
語境: {sentence}
|
||||
|
||||
請以JSON格式回應:
|
||||
{{
|
||||
""word"": ""{word}"",
|
||||
""translation"": ""繁體中文翻譯"",
|
||||
""definition"": ""英文定義"",
|
||||
""partOfSpeech"": ""詞性"",
|
||||
""pronunciation"": ""IPA音標"",
|
||||
""difficultyLevel"": ""CEFR等級"",
|
||||
""contextMeaning"": ""在此句子中的具體含義"",
|
||||
""isHighValue"": false,
|
||||
""examples"": [""例句1"", ""例句2""]
|
||||
}}
|
||||
";
|
||||
|
||||
var response = await _geminiService.CallGeminiApiAsync(prompt);
|
||||
return ParseVocabularyAnalysisResponse(response);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "AI vocabulary analysis failed, using fallback data");
|
||||
|
||||
// 回退到基本資料
|
||||
return new
|
||||
{
|
||||
word = word,
|
||||
translation = $"{word} 的翻譯",
|
||||
definition = $"Definition of {word}",
|
||||
partOfSpeech = "unknown",
|
||||
pronunciation = $"/{word}/",
|
||||
difficultyLevel = "unknown",
|
||||
contextMeaning = $"在句子 \"{sentence}\" 中的含義",
|
||||
isHighValue = false,
|
||||
examples = new string[0]
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 **C. API 端點重構**
|
||||
|
||||
### **C1. 更新現有端點**
|
||||
**檔案**: `/backend/DramaLing.Api/Controllers/AIController.cs`
|
||||
|
||||
#### **句子分析端點保持不變**
|
||||
```http
|
||||
POST /api/ai/analyze-sentence
|
||||
```
|
||||
**只修改回應訊息,讓用戶理解是查詢歷史**
|
||||
|
||||
#### **詞彙查詢端點整合歷史服務**
|
||||
```csharp
|
||||
[HttpPost("query-word")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult> QueryWord([FromBody] QueryWordRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 使用新的查詢歷史服務
|
||||
var result = await _vocabularyQueryService.QueryWordAsync(
|
||||
request.Word,
|
||||
request.Sentence,
|
||||
userId: null // 暫時使用模擬用戶
|
||||
);
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in vocabulary query");
|
||||
return StatusCode(500, new
|
||||
{
|
||||
Success = false,
|
||||
Error = "詞彙查詢失敗",
|
||||
Details = ex.Message
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 **D. 前端查詢歷史整合**
|
||||
|
||||
### **D1. ClickableTextV2 組件改造**
|
||||
**檔案**: `/frontend/components/ClickableTextV2.tsx`
|
||||
|
||||
```typescript
|
||||
// 修改詞彙查詢成功的處理
|
||||
if (result.success && result.data?.analysis) {
|
||||
// 顯示查詢歷史資訊
|
||||
const queryHistory = result.data.queryHistory;
|
||||
|
||||
if (queryHistory.isFromHistory) {
|
||||
console.log(`📚 從查詢歷史載入: ${word} (第${queryHistory.queryCount}次查詢)`);
|
||||
} else {
|
||||
console.log(`🔍 新詞彙查詢: ${word} (已加入查詢歷史)`);
|
||||
}
|
||||
|
||||
// 將新的分析資料通知父組件
|
||||
onNewWordAnalysis?.(word, {
|
||||
...result.data.analysis,
|
||||
queryHistory: queryHistory // 附帶查詢歷史資訊
|
||||
});
|
||||
|
||||
// 顯示分析結果
|
||||
setPopupPosition(position);
|
||||
setSelectedWord(word);
|
||||
onWordClick?.(word, result.data.analysis);
|
||||
}
|
||||
```
|
||||
|
||||
### **D2. 詞彙彈窗增加歷史資訊**
|
||||
```typescript
|
||||
// 在詞彙彈窗中顯示查詢歷史
|
||||
function VocabularyPopup({ word, analysis, queryHistory }: Props) {
|
||||
return (
|
||||
<div className="vocabulary-popup bg-white border rounded-lg shadow-lg p-4 w-80">
|
||||
{/* 詞彙基本資訊 */}
|
||||
<div className="word-basic-info mb-3">
|
||||
<h3 className="text-lg font-bold">{word}</h3>
|
||||
<p className="text-gray-600">{analysis.pronunciation}</p>
|
||||
<p className="text-blue-600 font-medium">{analysis.translation}</p>
|
||||
<p className="text-gray-700 text-sm mt-1">{analysis.definition}</p>
|
||||
</div>
|
||||
|
||||
{/* 查詢歷史資訊 */}
|
||||
{queryHistory && (
|
||||
<div className="query-history bg-gray-50 p-3 rounded-lg">
|
||||
<h4 className="font-semibold text-xs text-gray-700 mb-2 flex items-center">
|
||||
<span className="mr-1">🗃️</span>
|
||||
查詢歷史
|
||||
</h4>
|
||||
|
||||
{queryHistory.isFromHistory ? (
|
||||
<div className="text-xs text-gray-600 space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span>查詢次數:</span>
|
||||
<span className="font-medium">{queryHistory.queryCount} 次</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>首次查詢:</span>
|
||||
<span className="font-medium">{formatDate(queryHistory.firstQueriedAt)}</span>
|
||||
</div>
|
||||
{queryHistory.firstContext !== queryHistory.currentContext && (
|
||||
<div className="mt-2 p-2 bg-blue-50 rounded text-xs">
|
||||
<p className="text-blue-700">
|
||||
<strong>首次語境:</strong> {queryHistory.firstContext}
|
||||
</p>
|
||||
<p className="text-blue-700 mt-1">
|
||||
<strong>當前語境:</strong> {queryHistory.currentContext}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-green-600">
|
||||
✨ 首次查詢,已加入您的查詢歷史
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 **E. 用戶介面語言優化**
|
||||
|
||||
### **E1. 訊息文案改造**
|
||||
|
||||
| 情況 | 技術訊息 | 用戶友善訊息 |
|
||||
|------|----------|--------------|
|
||||
| 快取命中 | "句子分析完成(快取)" | "您之前查詢過這個句子,立即為您顯示結果" |
|
||||
| 新查詢 | "AI句子分析完成" | "新句子分析完成,已加入您的查詢歷史" |
|
||||
| 詞彙快取 | "高價值詞彙查詢完成(免費)" | "您之前查詢過這個詞彙 (第N次查詢)" |
|
||||
| 詞彙新查詢 | "低價值詞彙查詢完成" | "首次查詢此詞彙,已加入查詢歷史" |
|
||||
|
||||
### **E2. 載入狀態文案**
|
||||
```typescript
|
||||
// 分析中的狀態提示
|
||||
const getLoadingMessage = (type: 'sentence' | 'vocabulary', isNew: boolean) => {
|
||||
if (type === 'sentence') {
|
||||
return isNew
|
||||
? "🔍 正在分析新句子,約需 3-5 秒..."
|
||||
: "📚 從查詢歷史載入...";
|
||||
} else {
|
||||
return isNew
|
||||
? "🤖 正在查詢詞彙資訊..."
|
||||
: "🗃️ 從查詢歷史載入...";
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ **實施計劃**
|
||||
|
||||
### **📋 Phase 1: 後端查詢歷史服務 (1-2天)**
|
||||
|
||||
#### **1.1 建立詞彙查詢歷史表**
|
||||
```bash
|
||||
# 建立 Entity Framework 遷移
|
||||
dotnet ef migrations add AddUserVocabularyQueryHistory
|
||||
dotnet ef database update
|
||||
```
|
||||
|
||||
#### **1.2 建立查詢歷史服務**
|
||||
- 新增 `VocabularyQueryService.cs`
|
||||
- 實現真實的 AI 詞彙查詢 (替換模擬)
|
||||
- 整合查詢歷史記錄功能
|
||||
|
||||
#### **1.3 修改現有 API 回應訊息**
|
||||
- 將技術術語改為用戶友善語言
|
||||
- 新增查詢歷史相關欄位
|
||||
- 保持 API 結構相容性
|
||||
|
||||
### **📋 Phase 2: 前端查詢歷史整合 (2-3天)**
|
||||
|
||||
#### **2.1 更新 ClickableTextV2 組件**
|
||||
- 整合查詢歷史資訊顯示
|
||||
- 優化詞彙彈窗包含歷史資訊
|
||||
- 改善視覺提示系統
|
||||
|
||||
#### **2.2 修改 generate 頁面**
|
||||
- 更新查詢狀態顯示
|
||||
- 改善載入狀態文案
|
||||
- 新增查詢歷史統計
|
||||
|
||||
#### **2.3 訊息文案全面優化**
|
||||
- 替換所有技術術語
|
||||
- 採用用戶友善的描述
|
||||
- 增加情境化的提示
|
||||
|
||||
### **📋 Phase 3: 查詢歷史頁面 (3-4天)**
|
||||
|
||||
#### **3.1 建立查詢歷史頁面**
|
||||
```typescript
|
||||
// 新頁面: /frontend/app/query-history/page.tsx
|
||||
- 顯示所有查詢過的句子
|
||||
- 顯示所有查詢過的詞彙
|
||||
- 提供搜尋和篩選功能
|
||||
- 支援重新查詢功能
|
||||
```
|
||||
|
||||
#### **3.2 導航整合**
|
||||
- 在主導航中新增「查詢歷史」
|
||||
- 在 generate 頁面新增快速連結
|
||||
- 在詞彙彈窗中新增「查看完整歷史」
|
||||
|
||||
---
|
||||
|
||||
## 📊 **與現有快取系統的關係**
|
||||
|
||||
### **保持底層技術優勢**
|
||||
- ✅ **效能優化**: 繼續享受快取帶來的速度提升
|
||||
- ✅ **成本控制**: 避免重複的 AI API 調用
|
||||
- ✅ **系統穩定性**: 保持現有的錯誤處理機制
|
||||
|
||||
### **改善用戶認知**
|
||||
- 🔄 **概念轉換**: 從「快取」到「查詢歷史」
|
||||
- 📊 **透明化**: 讓用戶了解系統行為
|
||||
- 🎯 **價值感知**: 用戶看到查詢的累積價值
|
||||
|
||||
### **技術實現不變,體驗大幅提升**
|
||||
```
|
||||
底層: 仍然是高效的快取機制
|
||||
表層: 包裝為有意義的查詢歷史體驗
|
||||
結果: 技術效益 + 用戶體驗雙贏
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 **預期效果**
|
||||
|
||||
### **用戶體驗轉變**
|
||||
- **舊**: "為什麼這個查詢這麼快?"
|
||||
- **新**: "我之前查詢過這個詞彙,這是第3次遇到"
|
||||
|
||||
### **系統感知轉變**
|
||||
- **舊**: 神秘的黑盒子系統
|
||||
- **新**: 透明的查詢歷史助手
|
||||
|
||||
### **價值感知轉變**
|
||||
- **舊**: 一次性工具
|
||||
- **新**: 個人化查詢資料庫
|
||||
|
||||
## 📋 **成功指標**
|
||||
|
||||
### **定量指標**
|
||||
- **歷史查看率**: >60% 用戶注意到查詢歷史資訊
|
||||
- **重複查詢滿意度**: >80% 用戶對快速載入感到滿意
|
||||
- **功能理解度**: >90% 用戶理解為什麼有些查詢很快
|
||||
|
||||
### **定性指標**
|
||||
- **透明感**: 用戶明白系統行為邏輯
|
||||
- **積累感**: 用戶感受到查詢的累積價值
|
||||
- **信任感**: 用戶信任系統會記住他們的查詢
|
||||
|
||||
---
|
||||
|
||||
**© 2025 DramaLing Development Team**
|
||||
**設計理念**: 技術服務於用戶體驗,快取包裝為查詢歷史
|
||||
**核心價值**: 讓用戶感受到每次查詢的累積意義
|
||||
|
||||
|
||||
> 我覺得快取機制不太貼切,\
|
||||
具體應該改成歷史紀錄的概念\
|
||||
使用者查完某個原始例句後\
|
||||
就會存成紀錄\
|
||||
如果在查詢非高價值的詞彙,因為還沒有紀錄所以就會再去問ad\
|
||||
然後再存到紀錄中\\
|
||||
\
|
||||
\
|
||||
這不是學習歷史\
|
||||
使用者也沒有儲存詞彙\
|
||||
那只是查詢的歷史而已\
|
||||
\
|
||||
請你設計這個功能\
|
||||
寫成功能規格計劃再根目錄
|
||||
|
|
@ -0,0 +1,345 @@
|
|||
# 🎯 個人化高價值詞彙判定系統 - 更新版實施計劃
|
||||
|
||||
**專案**: DramaLing 英語學習平台
|
||||
**功能**: 個人化高價值詞彙智能判定
|
||||
**計劃版本**: v2.0 (根據當前代碼狀況更新)
|
||||
**更新日期**: 2025-01-18
|
||||
**預計開發時程**: 1.5週 (優化後的架構加速開發)
|
||||
|
||||
---
|
||||
|
||||
## 📋 **當前代碼狀況分析**
|
||||
|
||||
### **✅ 已完成的優化 (有利於個人化實施)**
|
||||
- ✅ **移除快取機制**: 簡化了邏輯,每次都是新 AI 分析
|
||||
- ✅ **移除 explanation**: 簡化了回應格式
|
||||
- ✅ **代碼大幅精簡**: AIController 減少 200+ 行
|
||||
- ✅ **架構清晰**: Service 層職責明確
|
||||
|
||||
### **🔧 當前架構分析**
|
||||
|
||||
#### **User 實體**
|
||||
**位置**: `/backend/DramaLing.Api/Models/Entities/User.cs:30`
|
||||
**狀態**: ✅ 完美適合擴充,Preferences 後正好可新增 EnglishLevel
|
||||
|
||||
#### **AnalyzeSentenceRequest**
|
||||
**位置**: `/backend/DramaLing.Api/Controllers/AIController.cs:1313`
|
||||
**當前結構**:
|
||||
```csharp
|
||||
public class AnalyzeSentenceRequest
|
||||
{
|
||||
public string InputText { get; set; } = string.Empty;
|
||||
public bool ForceRefresh { get; set; } = false;
|
||||
public string AnalysisMode { get; set; } = "full";
|
||||
}
|
||||
```
|
||||
**狀態**: ✅ 簡潔易擴充
|
||||
|
||||
#### **GeminiService.AnalyzeSentenceAsync**
|
||||
**位置**: `/backend/DramaLing.Api/Services/GeminiService.cs:55`
|
||||
**當前簽名**: `AnalyzeSentenceAsync(string inputText)`
|
||||
**當前 Prompt** (第64-96行): 已簡化,無 explanation 欄位
|
||||
**狀態**: ✅ 適合個人化擴充
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ **更新版實施計劃**
|
||||
|
||||
## **📋 Phase 1: 資料模型擴充 (第1天)**
|
||||
|
||||
### **1.1 User 實體擴充** ✅ 無變動
|
||||
**檔案**: `/backend/DramaLing.Api/Models/Entities/User.cs`
|
||||
**位置**: 第30行 `public Dictionary<string, object> Preferences` 後
|
||||
|
||||
```csharp
|
||||
[MaxLength(10)]
|
||||
public string EnglishLevel { get; set; } = "A2"; // A1, A2, B1, B2, C1, C2
|
||||
|
||||
public DateTime LevelUpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public bool IsLevelVerified { get; set; } = false; // 是否通過測試驗證
|
||||
|
||||
[MaxLength(500)]
|
||||
public string? LevelNotes { get; set; } // 程度設定備註
|
||||
```
|
||||
|
||||
### **1.2 API 請求模型更新** ✅ 無變動
|
||||
**檔案**: `/backend/DramaLing.Api/Controllers/AIController.cs:1313`
|
||||
|
||||
```csharp
|
||||
public class AnalyzeSentenceRequest
|
||||
{
|
||||
public string InputText { get; set; } = string.Empty;
|
||||
public string UserLevel { get; set; } = "A2"; // 🆕 新增
|
||||
public bool ForceRefresh { get; set; } = false;
|
||||
public string AnalysisMode { get; set; } = "full";
|
||||
}
|
||||
```
|
||||
|
||||
### **1.3 資料庫遷移** ✅ 無變動
|
||||
```bash
|
||||
cd /backend/DramaLing.Api/
|
||||
dotnet ef migrations add AddUserEnglishLevel
|
||||
dotnet ef database update
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## **📋 Phase 2: Service 層個人化 (第2-3天)**
|
||||
|
||||
### **2.1 建立 CEFR 等級服務** ✅ 無變動
|
||||
**新檔案**: `/backend/DramaLing.Api/Services/CEFRLevelService.cs`
|
||||
(代碼與原計劃相同)
|
||||
|
||||
### **2.2 更新 GeminiService** 🔄 根據當前狀況調整
|
||||
|
||||
**檔案**: `/backend/DramaLing.Api/Services/GeminiService.cs`
|
||||
**修改位置**: 第55行的 `AnalyzeSentenceAsync` 方法
|
||||
|
||||
**當前方法簽名**:
|
||||
```csharp
|
||||
public async Task<SentenceAnalysisResponse> AnalyzeSentenceAsync(string inputText)
|
||||
```
|
||||
|
||||
**修改後簽名**:
|
||||
```csharp
|
||||
public async Task<SentenceAnalysisResponse> AnalyzeSentenceAsync(
|
||||
string inputText,
|
||||
string userLevel = "A2")
|
||||
```
|
||||
|
||||
**🔄 更新版 Prompt (第64-96行) - 已適配移除 explanation**:
|
||||
```csharp
|
||||
var prompt = $@"
|
||||
請分析以下英文句子,提供翻譯和個人化詞彙分析:
|
||||
|
||||
句子:{inputText}
|
||||
學習者程度:{userLevel}
|
||||
|
||||
請按照以下JSON格式回應,不要包含任何其他文字:
|
||||
|
||||
{{
|
||||
""translation"": ""自然流暢的繁體中文翻譯"",
|
||||
""grammarCorrection"": {{
|
||||
""hasErrors"": false,
|
||||
""originalText"": ""{inputText}"",
|
||||
""correctedText"": null,
|
||||
""corrections"": []
|
||||
}},
|
||||
""highValueWords"": [""重要詞彙1"", ""重要詞彙2""],
|
||||
""wordAnalysis"": {{
|
||||
""單字"": {{
|
||||
""translation"": ""中文翻譯"",
|
||||
""definition"": ""英文定義"",
|
||||
""partOfSpeech"": ""詞性"",
|
||||
""pronunciation"": ""音標"",
|
||||
""isHighValue"": true,
|
||||
""difficultyLevel"": ""CEFR等級""
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
|
||||
要求:
|
||||
1. 翻譯要自然流暢,符合中文語法
|
||||
2. **基於學習者程度({userLevel}),標記 {CEFRLevelService.GetTargetLevelRange(userLevel)} 等級的詞彙為高價值**
|
||||
3. 如有語法錯誤請指出並修正
|
||||
4. 確保JSON格式正確
|
||||
|
||||
高價值判定邏輯:
|
||||
- 學習者程度: {userLevel}
|
||||
- 高價值範圍: {CEFRLevelService.GetTargetLevelRange(userLevel)}
|
||||
- 太簡單的詞彙(≤{userLevel})不要標記為高價值
|
||||
- 太難的詞彙謹慎標記
|
||||
- 重點關注適合學習者程度的詞彙
|
||||
";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## **📋 Phase 3: Controller 層整合 (第4天) - 🔄 簡化版**
|
||||
|
||||
### **3.1 更新 AnalyzeSentence API**
|
||||
**檔案**: `/backend/DramaLing.Api/Controllers/AIController.cs`
|
||||
**位置**: 第501行的 `AnalyzeSentence` 方法
|
||||
|
||||
**🔄 簡化版用戶程度取得邏輯** (在第538行 AI 調用前新增):
|
||||
```csharp
|
||||
// 取得用戶英語程度
|
||||
string userLevel = request.UserLevel ?? "A2";
|
||||
|
||||
// 🔄 簡化版:暫不從資料庫讀取,先使用 API 參數或預設值
|
||||
if (string.IsNullOrEmpty(userLevel))
|
||||
{
|
||||
userLevel = "A2"; // 預設程度
|
||||
}
|
||||
|
||||
_logger.LogInformation("Using user level for analysis: {UserLevel}", userLevel);
|
||||
```
|
||||
|
||||
**🔄 更新 AI 調用** (當前約第540行):
|
||||
```csharp
|
||||
// 原本:
|
||||
// var aiAnalysis = await _geminiService.AnalyzeSentenceAsync(request.InputText);
|
||||
|
||||
// 修改為:
|
||||
var aiAnalysis = await _geminiService.AnalyzeSentenceAsync(request.InputText, userLevel);
|
||||
```
|
||||
|
||||
### **3.2 回應資料增強** 🔄 適配無快取版本
|
||||
**位置**: 約第550行的 baseResponseData 物件
|
||||
|
||||
```csharp
|
||||
var baseResponseData = new
|
||||
{
|
||||
AnalysisId = Guid.NewGuid(),
|
||||
InputText = request.InputText,
|
||||
UserLevel = userLevel, // 🆕 新增:顯示使用的程度
|
||||
HighValueCriteria = CEFRLevelService.GetTargetLevelRange(userLevel), // 🆕 新增
|
||||
GrammarCorrection = aiAnalysis.GrammarCorrection,
|
||||
SentenceMeaning = new
|
||||
{
|
||||
Translation = aiAnalysis.Translation // 🔄 已移除 Explanation
|
||||
},
|
||||
FinalAnalysisText = finalText ?? request.InputText,
|
||||
WordAnalysis = aiAnalysis.WordAnalysis,
|
||||
HighValueWords = aiAnalysis.HighValueWords,
|
||||
PhrasesDetected = new object[0]
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## **📋 Phase 4: 前端個人化體驗 (第5-7天) - ✅ 基本無變動**
|
||||
|
||||
### **4.1 建立用戶程度設定頁面** ✅ 原計劃可直接使用
|
||||
**新檔案**: `/frontend/app/settings/page.tsx`
|
||||
(完整代碼與原計劃相同,已針對無 explanation 優化)
|
||||
|
||||
### **4.2 更新導航選單** ✅ 無變動
|
||||
**檔案**: `/frontend/components/Navigation.tsx`
|
||||
|
||||
### **4.3 修改句子分析頁面** 🔄 微調
|
||||
**檔案**: `/frontend/app/generate/page.tsx`
|
||||
**修改位置**: 第28行的 `handleAnalyzeSentence` 函數 (行數已更新)
|
||||
|
||||
### **4.4 個人化詞彙標記顯示** ✅ 基本無變動
|
||||
(原計劃的 WordAnalysisCard 組件可直接使用)
|
||||
|
||||
---
|
||||
|
||||
## **🔄 主要調整說明**
|
||||
|
||||
### **1. 移除過時的快取相關邏輯**
|
||||
```diff
|
||||
- 原計劃: 修改快取檢查和存入邏輯
|
||||
+ 更新版: 已無快取機制,直接修改 AI 調用
|
||||
```
|
||||
|
||||
### **2. 適配簡化的回應格式**
|
||||
```diff
|
||||
- 原計劃: SentenceMeaning { Translation, Explanation }
|
||||
+ 更新版: SentenceMeaning { Translation } // 已移除 explanation
|
||||
```
|
||||
|
||||
### **3. 簡化錯誤處理**
|
||||
```diff
|
||||
- 原計劃: 複雜的快取錯誤處理
|
||||
+ 更新版: 簡化的 AI 錯誤處理
|
||||
```
|
||||
|
||||
### **4. 更新行數引用**
|
||||
```diff
|
||||
- 原計劃: 基於舊版本的行數
|
||||
+ 更新版: 基於當前優化後的行數
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## **⏰ 更新版開發時程**
|
||||
|
||||
| 天數 | 階段 | 主要任務 | 預計工時 | 變化 |
|
||||
|------|------|----------|----------|------|
|
||||
| Day 1 | **資料模型** | User 實體擴充、API 擴充、資料庫遷移 | 8h | -4h (簡化) |
|
||||
| Day 2-3 | **Service 層** | CEFRLevelService、GeminiService 個人化 | 12h | -4h (無快取) |
|
||||
| Day 4 | **Controller 整合** | 簡化版 API 邏輯整合 | 6h | -4h (已優化) |
|
||||
| Day 5-6 | **前端設定頁** | 程度設定介面、導航整合 | 12h | 無變動 |
|
||||
| Day 7 | **前端分析整合** | generate 頁面修改、個人化顯示 | 6h | -2h (簡化) |
|
||||
| Day 8-9 | **測試開發** | 單元測試、整合測試 | 8h | -4h (簡化) |
|
||||
| Day 10 | **優化除錯** | 性能調整、UI 優化 | 4h | -2h |
|
||||
|
||||
**總計**: 56 工時 (約1.5週) - **節省 26 工時!**
|
||||
|
||||
---
|
||||
|
||||
## **🎯 實施優勢分析**
|
||||
|
||||
### **🚀 當前架構的優勢**
|
||||
1. **代碼更乾淨**: 移除冗餘後更容易擴充
|
||||
2. **邏輯更清晰**: 無快取干擾,邏輯線性化
|
||||
3. **Service 層完整**: GeminiService 架構良好
|
||||
4. **API 簡潔**: 統一的錯誤處理
|
||||
|
||||
### **💡 實施建議**
|
||||
|
||||
#### **立即可開始的項目**
|
||||
1. **User 實體擴充** - 完全 ready
|
||||
2. **CEFRLevelService 建立** - 獨立功能
|
||||
3. **前端設定頁面** - 無依賴
|
||||
|
||||
#### **需要小幅調整的項目**
|
||||
1. **GeminiService Prompt** - 適配無 explanation
|
||||
2. **Controller 行數** - 更新引用位置
|
||||
|
||||
---
|
||||
|
||||
## **📋 風險評估更新**
|
||||
|
||||
### **🟢 降低的風險**
|
||||
- ✅ **複雜度降低**: 無快取邏輯干擾
|
||||
- ✅ **測試簡化**: 線性邏輯更易測試
|
||||
- ✅ **維護容易**: 代碼結構清晰
|
||||
|
||||
### **🟡 保持的風險**
|
||||
- ⚠️ **AI Prompt 複雜化**: 仍需謹慎測試
|
||||
- ⚠️ **用戶理解度**: CEFR 概念對用戶的理解
|
||||
|
||||
### **🔴 新增風險**
|
||||
- ⚠️ **AI 成本**: 無快取後每次都調用 AI (但您已選擇此方向)
|
||||
|
||||
---
|
||||
|
||||
## **🎯 執行建議**
|
||||
|
||||
### **🚀 立即開始**
|
||||
建議從 **Phase 1** 開始,因為:
|
||||
- ✅ 完全獨立,無依賴
|
||||
- ✅ 為後續階段打基礎
|
||||
- ✅ 可以快速看到成果
|
||||
|
||||
### **🔄 調整重點**
|
||||
1. **更新所有行數引用**
|
||||
2. **移除 explanation 相關邏輯**
|
||||
3. **簡化快取相關的修改步驟**
|
||||
|
||||
### **📊 成功機率**
|
||||
**95%** - 當前架構非常適合個人化功能實施
|
||||
|
||||
---
|
||||
|
||||
## **💡 額外建議**
|
||||
|
||||
### **漸進式實施**
|
||||
可以考慮分階段發佈:
|
||||
1. **MVP版**: 僅前端本地存儲用戶程度
|
||||
2. **完整版**: 後端資料庫 + 完整個人化
|
||||
|
||||
### **測試策略**
|
||||
由於代碼已大幅簡化,測試工作量也相應減少
|
||||
|
||||
---
|
||||
|
||||
**結論: 這個計劃不僅可行,而且由於當前代碼優化,實施會比原計劃更簡單快速!** 🎉
|
||||
|
||||
**© 2025 DramaLing Development Team**
|
||||
**更新基於**: 當前代碼狀況 (commit 1b937f8)
|
||||
**主要改善**: 適配優化後的簡潔架構
|
||||
|
|
@ -0,0 +1,478 @@
|
|||
# 🗄️ 詞彙快取機制技術規格書
|
||||
|
||||
**專案**: DramaLing 英語學習平台
|
||||
**功能**: 詞彙分析快取系統
|
||||
**文檔版本**: v1.0
|
||||
**建立日期**: 2025-01-18
|
||||
**分析範圍**: 前端快取 + 後端 API 快取
|
||||
|
||||
---
|
||||
|
||||
## 📋 **快取系統概述**
|
||||
|
||||
DramaLing 詞彙快取系統包含**三層快取結構**:
|
||||
1. **前端頁面快取** - 當前頁面的詞彙分析資料
|
||||
2. **後端句子快取** - 24小時的句子分析結果快取
|
||||
3. **假資料快取** - 開發階段的模擬詞彙資料
|
||||
|
||||
---
|
||||
|
||||
## 🎯 **Layer 1: 前端頁面快取**
|
||||
|
||||
### **📁 實現位置**
|
||||
**檔案**: `/frontend/app/generate/page.tsx`
|
||||
**狀態管理**: `const [sentenceAnalysis, setSentenceAnalysis] = useState<any>(null)`
|
||||
|
||||
### **🔄 快取行為分析**
|
||||
|
||||
#### **初始化** (第84行)
|
||||
```typescript
|
||||
setSentenceAnalysis(result.data.wordAnalysis || result.data.WordAnalysis || {})
|
||||
```
|
||||
|
||||
**行為**: **完全覆蓋式更新**
|
||||
|
||||
#### **動態擴展** (第405-412行)
|
||||
```typescript
|
||||
onNewWordAnalysis={(word, newAnalysis) => {
|
||||
setSentenceAnalysis((prev: any) => ({
|
||||
...prev, // 保留現有資料
|
||||
[word]: newAnalysis // 新增單字分析
|
||||
}))
|
||||
}}
|
||||
```
|
||||
|
||||
**行為**: **累積式更新**
|
||||
|
||||
### **📊 完整的詞彙資料流程**
|
||||
|
||||
#### **場景測試: "The apple" → "The orange"**
|
||||
|
||||
```
|
||||
📍 步驟1: 分析 "The apple"
|
||||
API 回應: { "apple": {...} }
|
||||
前端狀態: { "apple": {...} }
|
||||
結果: "The" = 灰框 (無預存資料)
|
||||
|
||||
📍 步驟2: 點擊 "The"
|
||||
API 調用: POST /api/ai/query-word {"word": "the", ...}
|
||||
前端狀態: { "apple": {...}, "the": {...} }
|
||||
結果: "The" = 藍框 (有預存資料)
|
||||
|
||||
📍 步驟3: 換新句子 "The orange"
|
||||
API 回應: { "orange": {...} }
|
||||
前端狀態: { "orange": {...} } ❌ "the" 被清空!
|
||||
結果: "The" = 灰框 (又變成無預存資料)
|
||||
```
|
||||
|
||||
### **🚨 當前問題**
|
||||
|
||||
| 操作 | 預期行為 | 實際行為 | 問題 |
|
||||
|------|----------|----------|------|
|
||||
| 查詢過的詞彙 | 保持快取,下次直接顯示 | 換句子後被清空 | ❌ 覆蓋式更新 |
|
||||
| 跨句子學習 | 累積詞彙庫,提升效率 | 每次重新開始 | ❌ 浪費 AI 資源 |
|
||||
| 用戶體驗 | 學過的詞彙有記憶 | 需要重複查詢 | ❌ 體驗差 |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 **Layer 2: 後端句子快取**
|
||||
|
||||
### **📁 實現位置**
|
||||
**檔案**: `/backend/DramaLing.Api/Controllers/AIController.cs`
|
||||
**服務**: `IAnalysisCacheService _cacheService`
|
||||
**資料表**: `SentenceAnalysisCache`
|
||||
|
||||
### **💾 快取機制**
|
||||
|
||||
#### **存入快取** (第589-602行)
|
||||
```csharp
|
||||
await _cacheService.SetCachedAnalysisAsync(
|
||||
request.InputText, // 快取鍵:句子文本
|
||||
baseResponseData, // 完整分析結果
|
||||
TimeSpan.FromHours(24) // TTL: 24小時
|
||||
);
|
||||
```
|
||||
|
||||
#### **快取檢索** (第533-561行)
|
||||
```csharp
|
||||
var cachedAnalysis = await _cacheService.GetCachedAnalysisAsync(request.InputText);
|
||||
if (cachedAnalysis != null && !request.ForceRefresh) {
|
||||
// 返回快取結果,標記為 cached: true
|
||||
}
|
||||
```
|
||||
|
||||
### **📊 快取資料結構**
|
||||
```sql
|
||||
CREATE TABLE SentenceAnalysisCache (
|
||||
Id UNIQUEIDENTIFIER PRIMARY KEY,
|
||||
InputText NVARCHAR(1000) NOT NULL, -- 原句 (快取鍵)
|
||||
InputTextHash NVARCHAR(64) NOT NULL, -- 句子雜湊值
|
||||
AnalysisResult NVARCHAR(MAX) NOT NULL, -- JSON 分析結果
|
||||
ExpiresAt DATETIME2 NOT NULL, -- 過期時間
|
||||
CreatedAt DATETIME2 NOT NULL, -- 建立時間
|
||||
LastAccessedAt DATETIME2, -- 最後存取時間
|
||||
AccessCount INT NOT NULL DEFAULT 0 -- 存取次數
|
||||
);
|
||||
```
|
||||
|
||||
### **🔄 快取邏輯流程**
|
||||
```
|
||||
用戶輸入: "Hello world"
|
||||
↓
|
||||
檢查快取: SELECT * FROM SentenceAnalysisCache WHERE InputTextHash = HASH("Hello world")
|
||||
↓
|
||||
如果命中: 返回快取結果 (cached: true, cacheHit: true)
|
||||
如果錯失: 調用 AI → 存入快取 → 返回結果 (cached: false, usingAI: true)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 **Layer 3: 單字查詢快取 (目前為假資料)**
|
||||
|
||||
### **📁 實現位置**
|
||||
**檔案**: `/backend/DramaLing.Api/Controllers/AIController.cs:1019-1037`
|
||||
|
||||
### **⚠️ 當前狀態: 混合實現 (需要進一步確認)**
|
||||
|
||||
**根據後端日誌證據,系統確實在調用真實的 Gemini AI**:
|
||||
```
|
||||
info: Calling Gemini AI for text: Learning is fun and exciting
|
||||
POST https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash-latest:generateContent?key=AIza...
|
||||
```
|
||||
|
||||
**但程式碼顯示模擬實現**:
|
||||
```csharp
|
||||
private async Task<object> AnalyzeLowValueWord(string word, string sentence)
|
||||
{
|
||||
// 模擬即時AI分析
|
||||
await Task.Delay(200); // 模擬延遲
|
||||
|
||||
return new {
|
||||
word = word,
|
||||
translation = "即時分析的翻譯", // ⚠️ 疑似固定回應
|
||||
definition = "即時分析的定義",
|
||||
// ...
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### **📊 API 回應速度分析**
|
||||
|
||||
| API 端點 | 速度 | 實現狀態 | 證據 |
|
||||
|----------|------|----------|------|
|
||||
| `/analyze-sentence` | ~3-5秒 | ✅ 確認真實 AI | 日誌顯示 Gemini 調用 |
|
||||
| `/query-word` | ~200ms-1s | ❓ **需要確認** | 程式碼顯示模擬,但可能有其他路徑 |
|
||||
|
||||
### **🔍 需要進一步調查**
|
||||
1. `AnalyzeLowValueWord` 是否有其他版本的實現
|
||||
2. 是否存在條件分支調用真實 AI
|
||||
3. 固定回應 "即時分析的翻譯" 是否為測試資料
|
||||
|
||||
---
|
||||
|
||||
## 📋 **詳細的預存機制規格**
|
||||
|
||||
### **🔍 您的測試場景分析**
|
||||
|
||||
#### **場景**: "The apple" → 點擊 "The" → "The orange"
|
||||
|
||||
```
|
||||
🟦 第1步: 分析 "The apple"
|
||||
├─ API: POST /analyze-sentence {"inputText": "The apple"}
|
||||
├─ AI 回應: {"wordAnalysis": {"apple": {...}}} // 不包含 "the"
|
||||
├─ 前端狀態: sentenceAnalysis = {"apple": {...}}
|
||||
└─ 視覺: "The"=灰框, "apple"=綠框
|
||||
|
||||
🟦 第2步: 點擊 "The"
|
||||
├─ 觸發: queryWordWithAI("the")
|
||||
├─ API: POST /query-word {"word": "the", "sentence": "The apple"}
|
||||
├─ 模擬回應: {"word": "the", "translation": "即時分析的翻譯", ...}
|
||||
├─ 前端狀態: sentenceAnalysis = {"apple": {...}, "the": {...}}
|
||||
└─ 視覺: "The"=藍框, "apple"=綠框
|
||||
|
||||
🟦 第3步: 分析 "The orange"
|
||||
├─ API: POST /analyze-sentence {"inputText": "The orange"}
|
||||
├─ AI 回應: {"wordAnalysis": {"orange": {...}}}
|
||||
├─ 前端狀態: sentenceAnalysis = {"orange": {...}} ❌ "the" 被覆蓋清空!
|
||||
└─ 視覺: "The"=灰框, "orange"=綠框
|
||||
|
||||
🟦 第4步: 再次點擊 "The"
|
||||
├─ 發現: sentenceAnalysis["the"] = undefined
|
||||
├─ 觸發: queryWordWithAI("the") again ❌ 重複查詢!
|
||||
└─ 結果: 浪費 AI 資源,用戶體驗差
|
||||
```
|
||||
|
||||
### **📊 當前快取機制的優缺點**
|
||||
|
||||
#### ✅ **優點**
|
||||
1. **句子級快取**: 相同句子 24 小時內不重複分析
|
||||
2. **動態擴展**: 點擊的詞彙會加入當前分析
|
||||
3. **記憶體效率**: 不會無限累積資料
|
||||
|
||||
#### ❌ **缺點**
|
||||
1. **跨句子遺失**: 換句子後之前查詢的詞彙被清空
|
||||
2. **重複查詢**: 相同詞彙在不同句子中需要重複查詢
|
||||
3. **假資料問題**: query-word 目前不是真實 AI 查詢
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ **改善方案規格**
|
||||
|
||||
### **方案1: 全域詞彙快取 (推薦)**
|
||||
|
||||
#### **前端實現**
|
||||
```typescript
|
||||
// 新增全域詞彙快取
|
||||
const [globalWordCache, setGlobalWordCache] = useState<Record<string, any>>({})
|
||||
|
||||
// 修改句子分析更新邏輯
|
||||
setSentenceAnalysis(prev => ({
|
||||
...globalWordCache, // 保留全域快取
|
||||
...prev, // 保留當前分析
|
||||
...result.data.wordAnalysis // 新增句子分析
|
||||
}))
|
||||
|
||||
// 修改詞彙查詢邏輯
|
||||
onNewWordAnalysis={(word, newAnalysis) => {
|
||||
// 同時更新兩個快取
|
||||
setGlobalWordCache(prev => ({ ...prev, [word]: newAnalysis }))
|
||||
setSentenceAnalysis(prev => ({ ...prev, [word]: newAnalysis }))
|
||||
}}
|
||||
```
|
||||
|
||||
#### **本地存儲持久化**
|
||||
```typescript
|
||||
// 保存到 localStorage
|
||||
useEffect(() => {
|
||||
const cached = localStorage.getItem('dramalingWordCache')
|
||||
if (cached) {
|
||||
setGlobalWordCache(JSON.parse(cached))
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('dramalingWordCache', JSON.stringify(globalWordCache))
|
||||
}, [globalWordCache])
|
||||
```
|
||||
|
||||
### **方案2: 真實 AI 查詢實現**
|
||||
|
||||
#### **後端修改**
|
||||
```csharp
|
||||
private async Task<object> AnalyzeLowValueWord(string word, string sentence)
|
||||
{
|
||||
try {
|
||||
// 🆕 真實調用 Gemini AI
|
||||
var prompt = $@"
|
||||
請分析單字 ""{word}"" 在句子 ""{sentence}"" 中的詳細資訊:
|
||||
|
||||
請以JSON格式回應:
|
||||
{{
|
||||
""word"": ""{word}"",
|
||||
""translation"": ""繁體中文翻譯"",
|
||||
""definition"": ""英文定義"",
|
||||
""partOfSpeech"": ""詞性"",
|
||||
""pronunciation"": ""IPA音標"",
|
||||
""difficultyLevel"": ""CEFR等級"",
|
||||
""contextMeaning"": ""在此句子中的具體含義"",
|
||||
""isHighValue"": false
|
||||
}}
|
||||
";
|
||||
|
||||
var response = await _geminiService.CallGeminiApiAsync(prompt);
|
||||
return _geminiService.ParseWordAnalysisResponse(response);
|
||||
}
|
||||
catch {
|
||||
// 回退到模擬資料
|
||||
await Task.Delay(200);
|
||||
return CreateMockWordAnalysis(word);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### **方案3: 後端詞彙快取資料表**
|
||||
|
||||
#### **新資料表設計**
|
||||
```sql
|
||||
CREATE TABLE WordAnalysisCache (
|
||||
Id UNIQUEIDENTIFIER PRIMARY KEY,
|
||||
Word NVARCHAR(100) NOT NULL, -- 詞彙 (快取鍵)
|
||||
WordLowercase NVARCHAR(100) NOT NULL, -- 小寫版本 (查詢用)
|
||||
Translation NVARCHAR(200) NOT NULL, -- 翻譯
|
||||
Definition NVARCHAR(500) NOT NULL, -- 定義
|
||||
PartOfSpeech NVARCHAR(50), -- 詞性
|
||||
Pronunciation NVARCHAR(100), -- 發音
|
||||
DifficultyLevel NVARCHAR(10), -- CEFR 等級
|
||||
IsHighValue BIT DEFAULT 0, -- 是否高價值
|
||||
Synonyms NVARCHAR(500), -- 同義詞 (JSON)
|
||||
ExampleSentences NVARCHAR(MAX), -- 例句 (JSON)
|
||||
CreatedAt DATETIME2 NOT NULL, -- 建立時間
|
||||
UpdatedAt DATETIME2 NOT NULL, -- 更新時間
|
||||
AccessCount INT DEFAULT 0, -- 存取次數
|
||||
|
||||
INDEX IX_WordAnalysisCache_WordLowercase (WordLowercase)
|
||||
);
|
||||
```
|
||||
|
||||
#### **後端查詢邏輯**
|
||||
```csharp
|
||||
public async Task<WordAnalysisResult> QueryWordAsync(string word, string sentence)
|
||||
{
|
||||
var wordLower = word.ToLower();
|
||||
|
||||
// 1. 檢查詞彙快取
|
||||
var cached = await _context.WordAnalysisCache
|
||||
.FirstOrDefaultAsync(w => w.WordLowercase == wordLower);
|
||||
|
||||
if (cached != null) {
|
||||
// 更新存取統計
|
||||
cached.AccessCount++;
|
||||
cached.UpdatedAt = DateTime.UtcNow;
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return MapToWordAnalysisResult(cached);
|
||||
}
|
||||
|
||||
// 2. 快取錯失,調用 AI
|
||||
var aiResult = await CallGeminiForWordAnalysis(word, sentence);
|
||||
|
||||
// 3. 存入快取
|
||||
var cacheEntry = new WordAnalysisCache {
|
||||
Word = word,
|
||||
WordLowercase = wordLower,
|
||||
Translation = aiResult.Translation,
|
||||
// ... 其他欄位
|
||||
};
|
||||
|
||||
_context.WordAnalysisCache.Add(cacheEntry);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return aiResult;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 **三種快取策略比較**
|
||||
|
||||
| 策略 | 持久性 | 效能 | 實現複雜度 | AI 成本 | 用戶體驗 |
|
||||
|------|--------|------|-----------|---------|----------|
|
||||
| **目前 (頁面級)** | ❌ 換句子清空 | 🟡 中等 | 🟢 簡單 | 🔴 高 (重複查詢) | 🔴 差 |
|
||||
| **方案1 (前端全域)** | 🟡 瀏覽器重啟清空 | 🟢 高 | 🟡 中等 | 🟢 低 | 🟢 好 |
|
||||
| **方案2 (後端資料庫)** | ✅ 永久保存 | 🟢 高 | 🔴 複雜 | 🟢 極低 | ✅ 極佳 |
|
||||
|
||||
---
|
||||
|
||||
## 🔧 **當前 query-word API 的實現細節**
|
||||
|
||||
### **📍 速度快的真相**
|
||||
|
||||
**檔案**: `/backend/DramaLing.Api/Controllers/AIController.cs:1019-1037`
|
||||
|
||||
```csharp
|
||||
private async Task<object> AnalyzeLowValueWord(string word, string sentence)
|
||||
{
|
||||
// 🚨 這只是模擬實現!
|
||||
await Task.Delay(200); // 假延遲
|
||||
|
||||
return new {
|
||||
word = word,
|
||||
translation = "即時分析的翻譯", // 🚨 所有詞彙都一樣
|
||||
definition = "即時分析的定義", // 🚨 所有詞彙都一樣
|
||||
partOfSpeech = "noun", // 🚨 所有詞彙都一樣
|
||||
pronunciation = "/example/", // 🚨 所有詞彙都一樣
|
||||
// ...
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### **🧪 驗證測試**
|
||||
|
||||
#### **測試1: 查詢不同詞彙**
|
||||
```bash
|
||||
# 查詢 "hello"
|
||||
curl -X POST http://localhost:5000/api/ai/query-word \
|
||||
-d '{"word": "hello", "sentence": "Hello world"}'
|
||||
# 結果: translation = "即時分析的翻譯"
|
||||
|
||||
# 查詢 "amazing"
|
||||
curl -X POST http://localhost:5000/api/ai/query-word \
|
||||
-d '{"word": "amazing", "sentence": "Amazing day"}'
|
||||
# 結果: translation = "即時分析的翻譯" ❌ 完全相同!
|
||||
```
|
||||
|
||||
#### **測試2: 檢查是否真的調用 AI**
|
||||
```bash
|
||||
# 查看後端日誌
|
||||
grep -i "gemini\|ai\|query" backend_logs.txt
|
||||
# 結果: 沒有真實的 AI API 調用記錄
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 **建議的改善優先級**
|
||||
|
||||
### **🔥 高優先級 (立即修復)**
|
||||
1. **實現真實的詞彙 AI 查詢**
|
||||
- 替換假資料為真實 Gemini API 調用
|
||||
- 提供準確的詞彙分析
|
||||
|
||||
2. **前端全域詞彙快取**
|
||||
- 避免重複查詢相同詞彙
|
||||
- 提升用戶體驗
|
||||
|
||||
### **⚡ 中優先級 (2週內)**
|
||||
3. **後端詞彙快取資料表**
|
||||
- 永久保存查詢過的詞彙
|
||||
- 跨用戶共享常用詞彙分析
|
||||
|
||||
4. **智能快取策略**
|
||||
- 基於詞彙頻率的快取優先級
|
||||
- 自動清理低價值快取項目
|
||||
|
||||
### **💡 低優先級 (未來功能)**
|
||||
5. **跨設備同步**
|
||||
- 用戶詞彙學習記錄雲端同步
|
||||
- 個人化詞彙掌握程度追蹤
|
||||
|
||||
---
|
||||
|
||||
## 🎯 **回答您的問題**
|
||||
|
||||
### **當前實際行為**:
|
||||
|
||||
**場景**: "The apple" → 點擊 "The" → "The orange"
|
||||
|
||||
```
|
||||
1. 分析 "The apple" → "The" 無預存資料 (灰框)
|
||||
2. 點擊 "The" → 假 AI 查詢 → 加入前端快取 → "The" 變藍框
|
||||
3. 分析 "The orange" → 前端快取被覆蓋清空 → "The" 又變灰框 ❌
|
||||
4. 點擊 "The" → 重新假 AI 查詢 → 重複步驟2 ❌
|
||||
```
|
||||
|
||||
### **問題總結**:
|
||||
- ❌ **不會在預存裡**: 換句子後快取被清空
|
||||
- ❌ **重複假查詢**: 每次都返回相同的假資料
|
||||
- ❌ **浪費資源**: 用戶以為是真實 AI 查詢
|
||||
|
||||
### **建議修復**:
|
||||
1. **立即**: 修改前端為累積式快取
|
||||
2. **短期**: 實現真實的詞彙 AI 查詢
|
||||
3. **長期**: 建立後端詞彙快取資料表
|
||||
|
||||
---
|
||||
|
||||
## 📞 **技術支援**
|
||||
|
||||
**相關檔案**:
|
||||
- 前端快取: `/frontend/app/generate/page.tsx:84, 405-412`
|
||||
- 後端假查詢: `/backend/DramaLing.Api/Controllers/AIController.cs:1019-1037`
|
||||
- 後端句子快取: `/backend/DramaLing.Api/Services/AnalysisCacheService.cs`
|
||||
|
||||
**建議優先修復**: 前端累積式快取 + 真實 AI 查詢實現
|
||||
|
||||
---
|
||||
|
||||
**© 2025 DramaLing Development Team**
|
||||
**文檔建立**: 2025-01-18
|
||||
**分析基於**: 當前系統 commit e940d86
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -30,6 +30,30 @@ public class CardSetsController : ControllerBase
|
|||
throw new UnauthorizedAccessException("Invalid user ID");
|
||||
}
|
||||
|
||||
private async Task EnsureDefaultCardSetAsync(Guid userId)
|
||||
{
|
||||
// 檢查用戶是否已有預設卡組
|
||||
var hasDefaultCardSet = await _context.CardSets
|
||||
.AnyAsync(cs => cs.UserId == userId && cs.IsDefault);
|
||||
|
||||
if (!hasDefaultCardSet)
|
||||
{
|
||||
// 創建預設「未分類」卡組
|
||||
var defaultCardSet = new CardSet
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
Name = "未分類",
|
||||
Description = "系統預設卡組,用於存放尚未分類的詞卡",
|
||||
Color = "bg-slate-700",
|
||||
IsDefault = true
|
||||
};
|
||||
|
||||
_context.CardSets.Add(defaultCardSet);
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult> GetCardSets()
|
||||
{
|
||||
|
|
@ -37,9 +61,13 @@ public class CardSetsController : ControllerBase
|
|||
{
|
||||
var userId = GetUserId();
|
||||
|
||||
// 確保用戶有預設卡組
|
||||
await EnsureDefaultCardSetAsync(userId);
|
||||
|
||||
var cardSets = await _context.CardSets
|
||||
.Where(cs => cs.UserId == userId)
|
||||
.OrderByDescending(cs => cs.CreatedAt)
|
||||
.OrderBy(cs => cs.IsDefault ? 0 : 1) // 預設卡組排在最前面
|
||||
.ThenByDescending(cs => cs.CreatedAt)
|
||||
.Select(cs => new
|
||||
{
|
||||
cs.Id,
|
||||
|
|
@ -49,6 +77,7 @@ public class CardSetsController : ControllerBase
|
|||
cs.CardCount,
|
||||
cs.CreatedAt,
|
||||
cs.UpdatedAt,
|
||||
cs.IsDefault,
|
||||
// 計算進度 (簡化版)
|
||||
Progress = cs.CardCount > 0 ?
|
||||
_context.Flashcards
|
||||
|
|
@ -186,6 +215,10 @@ public class CardSetsController : ControllerBase
|
|||
if (cardSet == null)
|
||||
return NotFound(new { Success = false, Error = "Card set not found" });
|
||||
|
||||
// 防止刪除預設卡組
|
||||
if (cardSet.IsDefault)
|
||||
return BadRequest(new { Success = false, Error = "Cannot delete default card set" });
|
||||
|
||||
_context.CardSets.Remove(cardSet);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
|
|
@ -209,6 +242,43 @@ public class CardSetsController : ControllerBase
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("ensure-default")]
|
||||
public async Task<ActionResult> EnsureDefaultCardSet()
|
||||
{
|
||||
try
|
||||
{
|
||||
var userId = GetUserId();
|
||||
await EnsureDefaultCardSetAsync(userId);
|
||||
|
||||
// 返回預設卡組
|
||||
var defaultCardSet = await _context.CardSets
|
||||
.FirstOrDefaultAsync(cs => cs.UserId == userId && cs.IsDefault);
|
||||
|
||||
if (defaultCardSet == null)
|
||||
return StatusCode(500, new { Success = false, Error = "Failed to create default card set" });
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Success = true,
|
||||
Data = defaultCardSet,
|
||||
Message = "Default card set ensured"
|
||||
});
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return Unauthorized(new { Success = false, Error = "Unauthorized" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new
|
||||
{
|
||||
Success = false,
|
||||
Error = "Failed to ensure default card set",
|
||||
Timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Request DTOs
|
||||
|
|
|
|||
|
|
@ -30,6 +30,31 @@ public class FlashcardsController : ControllerBase
|
|||
throw new UnauthorizedAccessException("Invalid user ID");
|
||||
}
|
||||
|
||||
private async Task<Guid> GetOrCreateDefaultCardSetAsync(Guid userId)
|
||||
{
|
||||
// 嘗試找到預設卡組
|
||||
var defaultCardSet = await _context.CardSets
|
||||
.FirstOrDefaultAsync(cs => cs.UserId == userId && cs.IsDefault);
|
||||
|
||||
if (defaultCardSet != null)
|
||||
return defaultCardSet.Id;
|
||||
|
||||
// 如果沒有預設卡組,創建一個
|
||||
var newDefaultCardSet = new CardSet
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
Name = "未分類",
|
||||
Description = "系統預設卡組,用於存放尚未分類的詞卡",
|
||||
Color = "bg-slate-700",
|
||||
IsDefault = true
|
||||
};
|
||||
|
||||
_context.CardSets.Add(newDefaultCardSet);
|
||||
await _context.SaveChangesAsync();
|
||||
return newDefaultCardSet.Id;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult> GetFlashcards(
|
||||
[FromQuery] Guid? setId,
|
||||
|
|
@ -116,18 +141,30 @@ public class FlashcardsController : ControllerBase
|
|||
{
|
||||
var userId = GetUserId();
|
||||
|
||||
// 驗證卡組是否屬於用戶
|
||||
var cardSet = await _context.CardSets
|
||||
.FirstOrDefaultAsync(cs => cs.Id == request.CardSetId && cs.UserId == userId);
|
||||
// 確定要使用的卡組ID
|
||||
Guid cardSetId;
|
||||
if (request.CardSetId.HasValue)
|
||||
{
|
||||
// 如果指定了卡組,驗證是否屬於用戶
|
||||
var cardSet = await _context.CardSets
|
||||
.FirstOrDefaultAsync(cs => cs.Id == request.CardSetId.Value && cs.UserId == userId);
|
||||
|
||||
if (cardSet == null)
|
||||
return NotFound(new { Success = false, Error = "Card set not found" });
|
||||
if (cardSet == null)
|
||||
return NotFound(new { Success = false, Error = "Card set not found" });
|
||||
|
||||
cardSetId = request.CardSetId.Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
// 如果沒有指定卡組,使用或創建預設卡組
|
||||
cardSetId = await GetOrCreateDefaultCardSetAsync(userId);
|
||||
}
|
||||
|
||||
var flashcard = new Flashcard
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
CardSetId = request.CardSetId,
|
||||
CardSetId = cardSetId,
|
||||
Word = request.Word.Trim(),
|
||||
Translation = request.Translation.Trim(),
|
||||
Definition = request.Definition.Trim(),
|
||||
|
|
@ -290,7 +327,7 @@ public class FlashcardsController : ControllerBase
|
|||
// DTOs
|
||||
public class CreateFlashcardRequest
|
||||
{
|
||||
public Guid CardSetId { get; set; }
|
||||
public Guid? CardSetId { get; set; }
|
||||
public string Word { get; set; } = string.Empty;
|
||||
public string Translation { get; set; } = string.Empty;
|
||||
public string Definition { get; set; } = string.Empty;
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ public class DramaLingDbContext : DbContext
|
|||
public DbSet<StudyRecord> StudyRecords { get; set; }
|
||||
public DbSet<ErrorReport> ErrorReports { get; set; }
|
||||
public DbSet<DailyStats> DailyStats { get; set; }
|
||||
public DbSet<SentenceAnalysisCache> SentenceAnalysisCache { get; set; }
|
||||
public DbSet<WordQueryUsageStats> WordQueryUsageStats { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
|
|
@ -72,6 +74,13 @@ public class DramaLingDbContext : DbContext
|
|||
.HasConversion(
|
||||
v => System.Text.Json.JsonSerializer.Serialize(v, (System.Text.Json.JsonSerializerOptions)null),
|
||||
v => System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, object>>(v, (System.Text.Json.JsonSerializerOptions)null) ?? new Dictionary<string, object>());
|
||||
|
||||
// 新增個人化欄位映射
|
||||
userEntity.Property(u => u.EnglishLevel).HasColumnName("english_level");
|
||||
userEntity.Property(u => u.LevelUpdatedAt).HasColumnName("level_updated_at");
|
||||
userEntity.Property(u => u.IsLevelVerified).HasColumnName("is_level_verified");
|
||||
userEntity.Property(u => u.LevelNotes).HasColumnName("level_notes");
|
||||
|
||||
userEntity.Property(u => u.CreatedAt).HasColumnName("created_at");
|
||||
userEntity.Property(u => u.UpdatedAt).HasColumnName("updated_at");
|
||||
|
||||
|
|
@ -242,5 +251,34 @@ public class DramaLingDbContext : DbContext
|
|||
.WithMany(u => u.DailyStats)
|
||||
.HasForeignKey(ds => ds.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// Sentence analysis cache configuration
|
||||
modelBuilder.Entity<SentenceAnalysisCache>()
|
||||
.HasIndex(sac => sac.InputTextHash)
|
||||
.HasDatabaseName("IX_SentenceAnalysisCache_Hash");
|
||||
|
||||
modelBuilder.Entity<SentenceAnalysisCache>()
|
||||
.HasIndex(sac => sac.ExpiresAt)
|
||||
.HasDatabaseName("IX_SentenceAnalysisCache_Expires");
|
||||
|
||||
modelBuilder.Entity<SentenceAnalysisCache>()
|
||||
.HasIndex(sac => new { sac.InputTextHash, sac.ExpiresAt })
|
||||
.HasDatabaseName("IX_SentenceAnalysisCache_Hash_Expires");
|
||||
|
||||
// Word query usage stats configuration
|
||||
modelBuilder.Entity<WordQueryUsageStats>()
|
||||
.HasOne(wq => wq.User)
|
||||
.WithMany()
|
||||
.HasForeignKey(wq => wq.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
modelBuilder.Entity<WordQueryUsageStats>()
|
||||
.HasIndex(wq => new { wq.UserId, wq.Date })
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_WordQueryUsageStats_UserDate");
|
||||
|
||||
modelBuilder.Entity<WordQueryUsageStats>()
|
||||
.HasIndex(wq => wq.CreatedAt)
|
||||
.HasDatabaseName("IX_WordQueryUsageStats_CreatedAt");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,23 +1,24 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.20" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.10" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.10" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.8" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.10" />
|
||||
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.2" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Cors" Version="2.2.0" />
|
||||
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.10" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>d32b5470-3ba2-442c-8352-dd968fd406d8</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.20" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.10" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.10" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.8" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.10" />
|
||||
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.2" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Cors" Version="2.2.0" />
|
||||
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.10" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
807
backend/DramaLing.Api/Migrations/20250917130019_AddSentenceAnalysisCache.Designer.cs
generated
Normal file
807
backend/DramaLing.Api/Migrations/20250917130019_AddSentenceAnalysisCache.Designer.cs
generated
Normal file
|
|
@ -0,0 +1,807 @@
|
|||
// <auto-generated />
|
||||
using System;
|
||||
using DramaLing.Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DramaLing.Api.Migrations
|
||||
{
|
||||
[DbContext(typeof(DramaLingDbContext))]
|
||||
[Migration("20250917130019_AddSentenceAnalysisCache")]
|
||||
partial class AddSentenceAnalysisCache
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "8.0.10");
|
||||
|
||||
modelBuilder.Entity("DramaLing.Api.Models.Entities.CardSet", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CardCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Color")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsDefault")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("card_sets", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DramaLing.Api.Models.Entities.DailyStats", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("AiApiCalls")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("ai_api_calls");
|
||||
|
||||
b.Property<int>("CardsGenerated")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("cards_generated");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("SessionCount")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("session_count");
|
||||
|
||||
b.Property<int>("StudyTimeSeconds")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("study_time_seconds");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.Property<int>("WordsCorrect")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("words_correct");
|
||||
|
||||
b.Property<int>("WordsStudied")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("words_studied");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId", "Date")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("daily_stats", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DramaLing.Api.Models.Entities.ErrorReport", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("AdminNotes")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("admin_notes");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("FlashcardId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("flashcard_id");
|
||||
|
||||
b.Property<string>("ReportType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("report_type");
|
||||
|
||||
b.Property<DateTime?>("ResolvedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("resolved_at");
|
||||
|
||||
b.Property<Guid?>("ResolvedBy")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("resolved_by");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("StudyMode")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("study_mode");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("FlashcardId");
|
||||
|
||||
b.HasIndex("ResolvedBy");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("error_reports", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DramaLing.Api.Models.Entities.Flashcard", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("CardSetId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("card_set_id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("Definition")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DifficultyLevel")
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("difficulty_level");
|
||||
|
||||
b.Property<float>("EasinessFactor")
|
||||
.HasColumnType("REAL")
|
||||
.HasColumnName("easiness_factor");
|
||||
|
||||
b.Property<string>("Example")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ExampleTranslation")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("example_translation");
|
||||
|
||||
b.Property<int>("IntervalDays")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("interval_days");
|
||||
|
||||
b.Property<bool>("IsArchived")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("is_archived");
|
||||
|
||||
b.Property<bool>("IsFavorite")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("is_favorite");
|
||||
|
||||
b.Property<DateTime?>("LastReviewedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("last_reviewed_at");
|
||||
|
||||
b.Property<int>("MasteryLevel")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("mastery_level");
|
||||
|
||||
b.Property<DateTime>("NextReviewDate")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("next_review_date");
|
||||
|
||||
b.Property<string>("PartOfSpeech")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("part_of_speech");
|
||||
|
||||
b.Property<string>("Pronunciation")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Repetitions")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("TimesCorrect")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("times_correct");
|
||||
|
||||
b.Property<int>("TimesReviewed")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("times_reviewed");
|
||||
|
||||
b.Property<string>("Translation")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.Property<string>("Word")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CardSetId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("flashcards", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DramaLing.Api.Models.Entities.FlashcardTag", b =>
|
||||
{
|
||||
b.Property<Guid>("FlashcardId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("flashcard_id");
|
||||
|
||||
b.Property<Guid>("TagId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("tag_id");
|
||||
|
||||
b.HasKey("FlashcardId", "TagId");
|
||||
|
||||
b.HasIndex("TagId");
|
||||
|
||||
b.ToTable("flashcard_tags", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DramaLing.Api.Models.Entities.SentenceAnalysisCache", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("AccessCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AnalysisResult")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CorrectedText")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("ExpiresAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("GrammarCorrections")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("HasGrammarErrors")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("HighValueWords")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("InputText")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("InputTextHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("LastAccessedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PhrasesDetected")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ExpiresAt")
|
||||
.HasDatabaseName("IX_SentenceAnalysisCache_Expires");
|
||||
|
||||
b.HasIndex("InputTextHash")
|
||||
.HasDatabaseName("IX_SentenceAnalysisCache_Hash");
|
||||
|
||||
b.HasIndex("InputTextHash", "ExpiresAt")
|
||||
.HasDatabaseName("IX_SentenceAnalysisCache_Hash_Expires");
|
||||
|
||||
b.ToTable("SentenceAnalysisCache");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DramaLing.Api.Models.Entities.StudyRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("FlashcardId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("flashcard_id");
|
||||
|
||||
b.Property<bool>("IsCorrect")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("is_correct");
|
||||
|
||||
b.Property<float>("NewEasinessFactor")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<int>("NewIntervalDays")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("NewRepetitions")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("NextReviewDate")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<float>("PreviousEasinessFactor")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<int>("PreviousIntervalDays")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("PreviousRepetitions")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("QualityRating")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("quality_rating");
|
||||
|
||||
b.Property<int?>("ResponseTimeMs")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("response_time_ms");
|
||||
|
||||
b.Property<Guid>("SessionId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_id");
|
||||
|
||||
b.Property<DateTime>("StudiedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("studied_at");
|
||||
|
||||
b.Property<string>("StudyMode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("study_mode");
|
||||
|
||||
b.Property<string>("UserAnswer")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("user_answer");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("FlashcardId");
|
||||
|
||||
b.HasIndex("SessionId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("study_records", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DramaLing.Api.Models.Entities.StudySession", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("AverageResponseTimeMs")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("average_response_time_ms");
|
||||
|
||||
b.Property<int>("CorrectCount")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("correct_count");
|
||||
|
||||
b.Property<int>("DurationSeconds")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("duration_seconds");
|
||||
|
||||
b.Property<DateTime?>("EndedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("ended_at");
|
||||
|
||||
b.Property<string>("SessionType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_type");
|
||||
|
||||
b.Property<DateTime>("StartedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("started_at");
|
||||
|
||||
b.Property<int>("TotalCards")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("total_cards");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("study_sessions", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DramaLing.Api.Models.Entities.Tag", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Color")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("UsageCount")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("usage_count");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("tags", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DramaLing.Api.Models.Entities.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("AvatarUrl")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("avatar_url");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("display_name");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("email");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("password_hash");
|
||||
|
||||
b.Property<string>("Preferences")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("preferences");
|
||||
|
||||
b.Property<string>("SubscriptionType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("subscription_type");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("username");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Email")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("Username")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("user_profiles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DramaLing.Api.Models.Entities.UserSettings", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("AutoPlayAudio")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("DailyGoal")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("DifficultyPreference")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("ReminderEnabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<TimeOnly>("ReminderTime")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("ShowPronunciation")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("user_settings", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DramaLing.Api.Models.Entities.CardSet", b =>
|
||||
{
|
||||
b.HasOne("DramaLing.Api.Models.Entities.User", "User")
|
||||
.WithMany("CardSets")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DramaLing.Api.Models.Entities.DailyStats", b =>
|
||||
{
|
||||
b.HasOne("DramaLing.Api.Models.Entities.User", "User")
|
||||
.WithMany("DailyStats")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DramaLing.Api.Models.Entities.ErrorReport", b =>
|
||||
{
|
||||
b.HasOne("DramaLing.Api.Models.Entities.Flashcard", "Flashcard")
|
||||
.WithMany("ErrorReports")
|
||||
.HasForeignKey("FlashcardId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DramaLing.Api.Models.Entities.User", "ResolvedByUser")
|
||||
.WithMany()
|
||||
.HasForeignKey("ResolvedBy")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("DramaLing.Api.Models.Entities.User", "User")
|
||||
.WithMany("ErrorReports")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Flashcard");
|
||||
|
||||
b.Navigation("ResolvedByUser");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DramaLing.Api.Models.Entities.Flashcard", b =>
|
||||
{
|
||||
b.HasOne("DramaLing.Api.Models.Entities.CardSet", "CardSet")
|
||||
.WithMany("Flashcards")
|
||||
.HasForeignKey("CardSetId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DramaLing.Api.Models.Entities.User", "User")
|
||||
.WithMany("Flashcards")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("CardSet");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DramaLing.Api.Models.Entities.FlashcardTag", b =>
|
||||
{
|
||||
b.HasOne("DramaLing.Api.Models.Entities.Flashcard", "Flashcard")
|
||||
.WithMany("FlashcardTags")
|
||||
.HasForeignKey("FlashcardId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DramaLing.Api.Models.Entities.Tag", "Tag")
|
||||
.WithMany("FlashcardTags")
|
||||
.HasForeignKey("TagId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Flashcard");
|
||||
|
||||
b.Navigation("Tag");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DramaLing.Api.Models.Entities.StudyRecord", b =>
|
||||
{
|
||||
b.HasOne("DramaLing.Api.Models.Entities.Flashcard", "Flashcard")
|
||||
.WithMany("StudyRecords")
|
||||
.HasForeignKey("FlashcardId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DramaLing.Api.Models.Entities.StudySession", "Session")
|
||||
.WithMany("StudyRecords")
|
||||
.HasForeignKey("SessionId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DramaLing.Api.Models.Entities.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Flashcard");
|
||||
|
||||
b.Navigation("Session");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DramaLing.Api.Models.Entities.StudySession", b =>
|
||||
{
|
||||
b.HasOne("DramaLing.Api.Models.Entities.User", "User")
|
||||
.WithMany("StudySessions")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DramaLing.Api.Models.Entities.Tag", b =>
|
||||
{
|
||||
b.HasOne("DramaLing.Api.Models.Entities.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DramaLing.Api.Models.Entities.UserSettings", b =>
|
||||
{
|
||||
b.HasOne("DramaLing.Api.Models.Entities.User", "User")
|
||||
.WithOne("Settings")
|
||||
.HasForeignKey("DramaLing.Api.Models.Entities.UserSettings", "UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DramaLing.Api.Models.Entities.CardSet", b =>
|
||||
{
|
||||
b.Navigation("Flashcards");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DramaLing.Api.Models.Entities.Flashcard", b =>
|
||||
{
|
||||
b.Navigation("ErrorReports");
|
||||
|
||||
b.Navigation("FlashcardTags");
|
||||
|
||||
b.Navigation("StudyRecords");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DramaLing.Api.Models.Entities.StudySession", b =>
|
||||
{
|
||||
b.Navigation("StudyRecords");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DramaLing.Api.Models.Entities.Tag", b =>
|
||||
{
|
||||
b.Navigation("FlashcardTags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DramaLing.Api.Models.Entities.User", b =>
|
||||
{
|
||||
b.Navigation("CardSets");
|
||||
|
||||
b.Navigation("DailyStats");
|
||||
|
||||
b.Navigation("ErrorReports");
|
||||
|
||||
b.Navigation("Flashcards");
|
||||
|
||||
b.Navigation("Settings");
|
||||
|
||||
b.Navigation("StudySessions");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,471 @@
|
|||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DramaLing.Api.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSentenceAnalysisCache : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SentenceAnalysisCache",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
InputTextHash = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
|
||||
InputText = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: false),
|
||||
CorrectedText = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: true),
|
||||
HasGrammarErrors = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
GrammarCorrections = table.Column<string>(type: "TEXT", nullable: true),
|
||||
AnalysisResult = table.Column<string>(type: "TEXT", nullable: false),
|
||||
HighValueWords = table.Column<string>(type: "TEXT", nullable: true),
|
||||
PhrasesDetected = table.Column<string>(type: "TEXT", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
ExpiresAt = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
AccessCount = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
LastAccessedAt = table.Column<DateTime>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SentenceAnalysisCache", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "user_profiles",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
username = table.Column<string>(type: "TEXT", maxLength: 50, nullable: false),
|
||||
email = table.Column<string>(type: "TEXT", maxLength: 255, nullable: false),
|
||||
password_hash = table.Column<string>(type: "TEXT", maxLength: 255, nullable: false),
|
||||
display_name = table.Column<string>(type: "TEXT", maxLength: 100, nullable: true),
|
||||
avatar_url = table.Column<string>(type: "TEXT", nullable: true),
|
||||
subscription_type = table.Column<string>(type: "TEXT", maxLength: 20, nullable: false),
|
||||
preferences = table.Column<string>(type: "TEXT", nullable: false),
|
||||
created_at = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
updated_at = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_user_profiles", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "card_sets",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
Name = table.Column<string>(type: "TEXT", maxLength: 255, nullable: false),
|
||||
Description = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Color = table.Column<string>(type: "TEXT", maxLength: 50, nullable: false),
|
||||
CardCount = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
IsDefault = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_card_sets", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_card_sets_user_profiles_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "user_profiles",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "daily_stats",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
user_id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
Date = table.Column<DateOnly>(type: "TEXT", nullable: false),
|
||||
words_studied = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
words_correct = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
study_time_seconds = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
session_count = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
cards_generated = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
ai_api_calls = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
created_at = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_daily_stats", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_daily_stats_user_profiles_user_id",
|
||||
column: x => x.user_id,
|
||||
principalTable: "user_profiles",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "study_sessions",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
user_id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
session_type = table.Column<string>(type: "TEXT", maxLength: 50, nullable: false),
|
||||
started_at = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
ended_at = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
total_cards = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
correct_count = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
duration_seconds = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
average_response_time_ms = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_study_sessions", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_study_sessions_user_profiles_user_id",
|
||||
column: x => x.user_id,
|
||||
principalTable: "user_profiles",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "tags",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
user_id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
Name = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
|
||||
Color = table.Column<string>(type: "TEXT", maxLength: 50, nullable: false),
|
||||
usage_count = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
created_at = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_tags", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_tags_user_profiles_user_id",
|
||||
column: x => x.user_id,
|
||||
principalTable: "user_profiles",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "user_settings",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
DailyGoal = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
ReminderTime = table.Column<TimeOnly>(type: "TEXT", nullable: false),
|
||||
ReminderEnabled = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
DifficultyPreference = table.Column<string>(type: "TEXT", maxLength: 20, nullable: false),
|
||||
AutoPlayAudio = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
ShowPronunciation = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_user_settings", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_user_settings_user_profiles_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "user_profiles",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "flashcards",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
user_id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
card_set_id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
Word = table.Column<string>(type: "TEXT", maxLength: 255, nullable: false),
|
||||
Translation = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Definition = table.Column<string>(type: "TEXT", nullable: false),
|
||||
part_of_speech = table.Column<string>(type: "TEXT", maxLength: 50, nullable: true),
|
||||
Pronunciation = table.Column<string>(type: "TEXT", maxLength: 255, nullable: true),
|
||||
Example = table.Column<string>(type: "TEXT", nullable: true),
|
||||
example_translation = table.Column<string>(type: "TEXT", nullable: true),
|
||||
easiness_factor = table.Column<float>(type: "REAL", nullable: false),
|
||||
Repetitions = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
interval_days = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
next_review_date = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
mastery_level = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
times_reviewed = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
times_correct = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
last_reviewed_at = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
is_favorite = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
is_archived = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
difficulty_level = table.Column<string>(type: "TEXT", maxLength: 10, nullable: true),
|
||||
created_at = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
updated_at = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_flashcards", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_flashcards_card_sets_card_set_id",
|
||||
column: x => x.card_set_id,
|
||||
principalTable: "card_sets",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_flashcards_user_profiles_user_id",
|
||||
column: x => x.user_id,
|
||||
principalTable: "user_profiles",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "error_reports",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
user_id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
flashcard_id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
report_type = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
|
||||
Description = table.Column<string>(type: "TEXT", nullable: true),
|
||||
study_mode = table.Column<string>(type: "TEXT", maxLength: 50, nullable: true),
|
||||
Status = table.Column<string>(type: "TEXT", maxLength: 50, nullable: false),
|
||||
admin_notes = table.Column<string>(type: "TEXT", nullable: true),
|
||||
resolved_at = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
resolved_by = table.Column<Guid>(type: "TEXT", nullable: true),
|
||||
created_at = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_error_reports", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_error_reports_flashcards_flashcard_id",
|
||||
column: x => x.flashcard_id,
|
||||
principalTable: "flashcards",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_error_reports_user_profiles_resolved_by",
|
||||
column: x => x.resolved_by,
|
||||
principalTable: "user_profiles",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "FK_error_reports_user_profiles_user_id",
|
||||
column: x => x.user_id,
|
||||
principalTable: "user_profiles",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "flashcard_tags",
|
||||
columns: table => new
|
||||
{
|
||||
flashcard_id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
tag_id = table.Column<Guid>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_flashcard_tags", x => new { x.flashcard_id, x.tag_id });
|
||||
table.ForeignKey(
|
||||
name: "FK_flashcard_tags_flashcards_flashcard_id",
|
||||
column: x => x.flashcard_id,
|
||||
principalTable: "flashcards",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_flashcard_tags_tags_tag_id",
|
||||
column: x => x.tag_id,
|
||||
principalTable: "tags",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "study_records",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
user_id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
flashcard_id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
session_id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
study_mode = table.Column<string>(type: "TEXT", maxLength: 50, nullable: false),
|
||||
quality_rating = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
response_time_ms = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
user_answer = table.Column<string>(type: "TEXT", nullable: true),
|
||||
is_correct = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
PreviousEasinessFactor = table.Column<float>(type: "REAL", nullable: false),
|
||||
NewEasinessFactor = table.Column<float>(type: "REAL", nullable: false),
|
||||
PreviousIntervalDays = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
NewIntervalDays = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
PreviousRepetitions = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
NewRepetitions = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
NextReviewDate = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
studied_at = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_study_records", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_study_records_flashcards_flashcard_id",
|
||||
column: x => x.flashcard_id,
|
||||
principalTable: "flashcards",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_study_records_study_sessions_session_id",
|
||||
column: x => x.session_id,
|
||||
principalTable: "study_sessions",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_study_records_user_profiles_user_id",
|
||||
column: x => x.user_id,
|
||||
principalTable: "user_profiles",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_card_sets_UserId",
|
||||
table: "card_sets",
|
||||
column: "UserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_daily_stats_user_id_Date",
|
||||
table: "daily_stats",
|
||||
columns: new[] { "user_id", "Date" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_error_reports_flashcard_id",
|
||||
table: "error_reports",
|
||||
column: "flashcard_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_error_reports_resolved_by",
|
||||
table: "error_reports",
|
||||
column: "resolved_by");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_error_reports_user_id",
|
||||
table: "error_reports",
|
||||
column: "user_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_flashcard_tags_tag_id",
|
||||
table: "flashcard_tags",
|
||||
column: "tag_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_flashcards_card_set_id",
|
||||
table: "flashcards",
|
||||
column: "card_set_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_flashcards_user_id",
|
||||
table: "flashcards",
|
||||
column: "user_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SentenceAnalysisCache_Expires",
|
||||
table: "SentenceAnalysisCache",
|
||||
column: "ExpiresAt");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SentenceAnalysisCache_Hash",
|
||||
table: "SentenceAnalysisCache",
|
||||
column: "InputTextHash");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SentenceAnalysisCache_Hash_Expires",
|
||||
table: "SentenceAnalysisCache",
|
||||
columns: new[] { "InputTextHash", "ExpiresAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_study_records_flashcard_id",
|
||||
table: "study_records",
|
||||
column: "flashcard_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_study_records_session_id",
|
||||
table: "study_records",
|
||||
column: "session_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_study_records_user_id",
|
||||
table: "study_records",
|
||||
column: "user_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_study_sessions_user_id",
|
||||
table: "study_sessions",
|
||||
column: "user_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_tags_user_id",
|
||||
table: "tags",
|
||||
column: "user_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_user_profiles_email",
|
||||
table: "user_profiles",
|
||||
column: "email",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_user_profiles_username",
|
||||
table: "user_profiles",
|
||||
column: "username",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_user_settings_UserId",
|
||||
table: "user_settings",
|
||||
column: "UserId",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "daily_stats");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "error_reports");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "flashcard_tags");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "SentenceAnalysisCache");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "study_records");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "user_settings");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "tags");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "flashcards");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "study_sessions");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "card_sets");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "user_profiles");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,804 @@
|
|||
// <auto-generated />
|
||||
using System;
|
||||
using DramaLing.Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DramaLing.Api.Migrations
|
||||
{
|
||||
[DbContext(typeof(DramaLingDbContext))]
|
||||
partial class DramaLingDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "8.0.10");
|
||||
|
||||
modelBuilder.Entity("DramaLing.Api.Models.Entities.CardSet", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CardCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Color")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsDefault")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("card_sets", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DramaLing.Api.Models.Entities.DailyStats", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("AiApiCalls")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("ai_api_calls");
|
||||
|
||||
b.Property<int>("CardsGenerated")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("cards_generated");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("SessionCount")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("session_count");
|
||||
|
||||
b.Property<int>("StudyTimeSeconds")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("study_time_seconds");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.Property<int>("WordsCorrect")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("words_correct");
|
||||
|
||||
b.Property<int>("WordsStudied")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("words_studied");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId", "Date")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("daily_stats", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DramaLing.Api.Models.Entities.ErrorReport", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("AdminNotes")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("admin_notes");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("FlashcardId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("flashcard_id");
|
||||
|
||||
b.Property<string>("ReportType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("report_type");
|
||||
|
||||
b.Property<DateTime?>("ResolvedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("resolved_at");
|
||||
|
||||
b.Property<Guid?>("ResolvedBy")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("resolved_by");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("StudyMode")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("study_mode");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("FlashcardId");
|
||||
|
||||
b.HasIndex("ResolvedBy");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("error_reports", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DramaLing.Api.Models.Entities.Flashcard", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("CardSetId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("card_set_id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("Definition")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DifficultyLevel")
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("difficulty_level");
|
||||
|
||||
b.Property<float>("EasinessFactor")
|
||||
.HasColumnType("REAL")
|
||||
.HasColumnName("easiness_factor");
|
||||
|
||||
b.Property<string>("Example")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ExampleTranslation")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("example_translation");
|
||||
|
||||
b.Property<int>("IntervalDays")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("interval_days");
|
||||
|
||||
b.Property<bool>("IsArchived")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("is_archived");
|
||||
|
||||
b.Property<bool>("IsFavorite")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("is_favorite");
|
||||
|
||||
b.Property<DateTime?>("LastReviewedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("last_reviewed_at");
|
||||
|
||||
b.Property<int>("MasteryLevel")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("mastery_level");
|
||||
|
||||
b.Property<DateTime>("NextReviewDate")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("next_review_date");
|
||||
|
||||
b.Property<string>("PartOfSpeech")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("part_of_speech");
|
||||
|
||||
b.Property<string>("Pronunciation")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Repetitions")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("TimesCorrect")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("times_correct");
|
||||
|
||||
b.Property<int>("TimesReviewed")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("times_reviewed");
|
||||
|
||||
b.Property<string>("Translation")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.Property<string>("Word")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CardSetId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("flashcards", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DramaLing.Api.Models.Entities.FlashcardTag", b =>
|
||||
{
|
||||
b.Property<Guid>("FlashcardId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("flashcard_id");
|
||||
|
||||
b.Property<Guid>("TagId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("tag_id");
|
||||
|
||||
b.HasKey("FlashcardId", "TagId");
|
||||
|
||||
b.HasIndex("TagId");
|
||||
|
||||
b.ToTable("flashcard_tags", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DramaLing.Api.Models.Entities.SentenceAnalysisCache", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("AccessCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AnalysisResult")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CorrectedText")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("ExpiresAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("GrammarCorrections")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("HasGrammarErrors")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("HighValueWords")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("InputText")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("InputTextHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("LastAccessedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PhrasesDetected")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ExpiresAt")
|
||||
.HasDatabaseName("IX_SentenceAnalysisCache_Expires");
|
||||
|
||||
b.HasIndex("InputTextHash")
|
||||
.HasDatabaseName("IX_SentenceAnalysisCache_Hash");
|
||||
|
||||
b.HasIndex("InputTextHash", "ExpiresAt")
|
||||
.HasDatabaseName("IX_SentenceAnalysisCache_Hash_Expires");
|
||||
|
||||
b.ToTable("SentenceAnalysisCache");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DramaLing.Api.Models.Entities.StudyRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("FlashcardId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("flashcard_id");
|
||||
|
||||
b.Property<bool>("IsCorrect")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("is_correct");
|
||||
|
||||
b.Property<float>("NewEasinessFactor")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<int>("NewIntervalDays")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("NewRepetitions")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("NextReviewDate")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<float>("PreviousEasinessFactor")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<int>("PreviousIntervalDays")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("PreviousRepetitions")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("QualityRating")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("quality_rating");
|
||||
|
||||
b.Property<int?>("ResponseTimeMs")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("response_time_ms");
|
||||
|
||||
b.Property<Guid>("SessionId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_id");
|
||||
|
||||
b.Property<DateTime>("StudiedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("studied_at");
|
||||
|
||||
b.Property<string>("StudyMode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("study_mode");
|
||||
|
||||
b.Property<string>("UserAnswer")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("user_answer");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("FlashcardId");
|
||||
|
||||
b.HasIndex("SessionId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("study_records", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DramaLing.Api.Models.Entities.StudySession", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("AverageResponseTimeMs")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("average_response_time_ms");
|
||||
|
||||
b.Property<int>("CorrectCount")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("correct_count");
|
||||
|
||||
b.Property<int>("DurationSeconds")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("duration_seconds");
|
||||
|
||||
b.Property<DateTime?>("EndedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("ended_at");
|
||||
|
||||
b.Property<string>("SessionType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_type");
|
||||
|
||||
b.Property<DateTime>("StartedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("started_at");
|
||||
|
||||
b.Property<int>("TotalCards")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("total_cards");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("study_sessions", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DramaLing.Api.Models.Entities.Tag", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Color")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("UsageCount")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("usage_count");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("tags", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DramaLing.Api.Models.Entities.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("AvatarUrl")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("avatar_url");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("display_name");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("email");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("password_hash");
|
||||
|
||||
b.Property<string>("Preferences")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("preferences");
|
||||
|
||||
b.Property<string>("SubscriptionType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("subscription_type");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("username");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Email")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("Username")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("user_profiles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DramaLing.Api.Models.Entities.UserSettings", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("AutoPlayAudio")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("DailyGoal")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("DifficultyPreference")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("ReminderEnabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<TimeOnly>("ReminderTime")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("ShowPronunciation")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("user_settings", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DramaLing.Api.Models.Entities.CardSet", b =>
|
||||
{
|
||||
b.HasOne("DramaLing.Api.Models.Entities.User", "User")
|
||||
.WithMany("CardSets")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DramaLing.Api.Models.Entities.DailyStats", b =>
|
||||
{
|
||||
b.HasOne("DramaLing.Api.Models.Entities.User", "User")
|
||||
.WithMany("DailyStats")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DramaLing.Api.Models.Entities.ErrorReport", b =>
|
||||
{
|
||||
b.HasOne("DramaLing.Api.Models.Entities.Flashcard", "Flashcard")
|
||||
.WithMany("ErrorReports")
|
||||
.HasForeignKey("FlashcardId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DramaLing.Api.Models.Entities.User", "ResolvedByUser")
|
||||
.WithMany()
|
||||
.HasForeignKey("ResolvedBy")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("DramaLing.Api.Models.Entities.User", "User")
|
||||
.WithMany("ErrorReports")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Flashcard");
|
||||
|
||||
b.Navigation("ResolvedByUser");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DramaLing.Api.Models.Entities.Flashcard", b =>
|
||||
{
|
||||
b.HasOne("DramaLing.Api.Models.Entities.CardSet", "CardSet")
|
||||
.WithMany("Flashcards")
|
||||
.HasForeignKey("CardSetId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DramaLing.Api.Models.Entities.User", "User")
|
||||
.WithMany("Flashcards")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("CardSet");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DramaLing.Api.Models.Entities.FlashcardTag", b =>
|
||||
{
|
||||
b.HasOne("DramaLing.Api.Models.Entities.Flashcard", "Flashcard")
|
||||
.WithMany("FlashcardTags")
|
||||
.HasForeignKey("FlashcardId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DramaLing.Api.Models.Entities.Tag", "Tag")
|
||||
.WithMany("FlashcardTags")
|
||||
.HasForeignKey("TagId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Flashcard");
|
||||
|
||||
b.Navigation("Tag");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DramaLing.Api.Models.Entities.StudyRecord", b =>
|
||||
{
|
||||
b.HasOne("DramaLing.Api.Models.Entities.Flashcard", "Flashcard")
|
||||
.WithMany("StudyRecords")
|
||||
.HasForeignKey("FlashcardId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DramaLing.Api.Models.Entities.StudySession", "Session")
|
||||
.WithMany("StudyRecords")
|
||||
.HasForeignKey("SessionId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DramaLing.Api.Models.Entities.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Flashcard");
|
||||
|
||||
b.Navigation("Session");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DramaLing.Api.Models.Entities.StudySession", b =>
|
||||
{
|
||||
b.HasOne("DramaLing.Api.Models.Entities.User", "User")
|
||||
.WithMany("StudySessions")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DramaLing.Api.Models.Entities.Tag", b =>
|
||||
{
|
||||
b.HasOne("DramaLing.Api.Models.Entities.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DramaLing.Api.Models.Entities.UserSettings", b =>
|
||||
{
|
||||
b.HasOne("DramaLing.Api.Models.Entities.User", "User")
|
||||
.WithOne("Settings")
|
||||
.HasForeignKey("DramaLing.Api.Models.Entities.UserSettings", "UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DramaLing.Api.Models.Entities.CardSet", b =>
|
||||
{
|
||||
b.Navigation("Flashcards");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DramaLing.Api.Models.Entities.Flashcard", b =>
|
||||
{
|
||||
b.Navigation("ErrorReports");
|
||||
|
||||
b.Navigation("FlashcardTags");
|
||||
|
||||
b.Navigation("StudyRecords");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DramaLing.Api.Models.Entities.StudySession", b =>
|
||||
{
|
||||
b.Navigation("StudyRecords");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DramaLing.Api.Models.Entities.Tag", b =>
|
||||
{
|
||||
b.Navigation("FlashcardTags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DramaLing.Api.Models.Entities.User", b =>
|
||||
{
|
||||
b.Navigation("CardSets");
|
||||
|
||||
b.Navigation("DailyStats");
|
||||
|
||||
b.Navigation("ErrorReports");
|
||||
|
||||
b.Navigation("Flashcards");
|
||||
|
||||
b.Navigation("Settings");
|
||||
|
||||
b.Navigation("StudySessions");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -19,6 +19,8 @@ public class CardSet
|
|||
|
||||
public int CardCount { get; set; } = 0;
|
||||
|
||||
public bool IsDefault { get; set; } = false;
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DramaLing.Api.Models.Entities;
|
||||
|
||||
public class SentenceAnalysisCache
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
[Required]
|
||||
[StringLength(64)]
|
||||
public string InputTextHash { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[StringLength(1000)]
|
||||
public string InputText { get; set; } = string.Empty;
|
||||
|
||||
[StringLength(1000)]
|
||||
public string? CorrectedText { get; set; }
|
||||
|
||||
public bool HasGrammarErrors { get; set; } = false;
|
||||
|
||||
public string? GrammarCorrections { get; set; } // JSON 格式
|
||||
|
||||
[Required]
|
||||
public string AnalysisResult { get; set; } = string.Empty; // JSON 格式
|
||||
|
||||
public string? HighValueWords { get; set; } // JSON 格式,高價值詞彙列表
|
||||
|
||||
public string? PhrasesDetected { get; set; } // JSON 格式,檢測到的片語
|
||||
|
||||
[Required]
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
[Required]
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
|
||||
public int AccessCount { get; set; } = 0;
|
||||
|
||||
public DateTime? LastAccessedAt { get; set; }
|
||||
}
|
||||
|
|
@ -29,6 +29,16 @@ public class User
|
|||
|
||||
public Dictionary<string, object> Preferences { get; set; } = new();
|
||||
|
||||
[MaxLength(10)]
|
||||
public string EnglishLevel { get; set; } = "A2"; // A1, A2, B1, B2, C1, C2
|
||||
|
||||
public DateTime LevelUpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public bool IsLevelVerified { get; set; } = false; // 是否通過測試驗證
|
||||
|
||||
[MaxLength(500)]
|
||||
public string? LevelNotes { get; set; } // 程度設定備註
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DramaLing.Api.Models.Entities;
|
||||
|
||||
public class WordQueryUsageStats
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
[Required]
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
[Required]
|
||||
public DateOnly Date { get; set; }
|
||||
|
||||
public int SentenceAnalysisCount { get; set; } = 0; // 句子分析次數
|
||||
|
||||
public int HighValueWordClicks { get; set; } = 0; // 高價值詞彙點擊(免費)
|
||||
|
||||
public int LowValueWordClicks { get; set; } = 0; // 低價值詞彙點擊(收費)
|
||||
|
||||
public int TotalApiCalls { get; set; } = 0; // 總 API 調用次數
|
||||
|
||||
public int UniqueWordsQueried { get; set; } = 0; // 查詢的獨特詞彙數
|
||||
|
||||
[Required]
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
[Required]
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
|
||||
// Navigation properties
|
||||
public User User { get; set; } = null!;
|
||||
}
|
||||
|
|
@ -36,6 +36,11 @@ else
|
|||
// Custom Services
|
||||
builder.Services.AddScoped<IAuthService, AuthService>();
|
||||
builder.Services.AddHttpClient<IGeminiService, GeminiService>();
|
||||
builder.Services.AddScoped<IAnalysisCacheService, AnalysisCacheService>();
|
||||
builder.Services.AddScoped<IUsageTrackingService, UsageTrackingService>();
|
||||
|
||||
// Background Services
|
||||
builder.Services.AddHostedService<CacheCleanupService>();
|
||||
|
||||
// Authentication - 從環境變數讀取 JWT 配置
|
||||
var supabaseUrl = Environment.GetEnvironmentVariable("DRAMALING_SUPABASE_URL")
|
||||
|
|
@ -66,7 +71,7 @@ builder.Services.AddCors(options =>
|
|||
{
|
||||
options.AddPolicy("AllowFrontend", policy =>
|
||||
{
|
||||
policy.WithOrigins("http://localhost:3000", "http://localhost:3001")
|
||||
policy.WithOrigins("http://localhost:3000", "http://localhost:3001", "http://localhost:3002")
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod()
|
||||
.AllowCredentials()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,204 @@
|
|||
using Microsoft.EntityFrameworkCore;
|
||||
using DramaLing.Api.Data;
|
||||
using DramaLing.Api.Models.Entities;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace DramaLing.Api.Services;
|
||||
|
||||
public interface IAnalysisCacheService
|
||||
{
|
||||
Task<SentenceAnalysisCache?> GetCachedAnalysisAsync(string inputText);
|
||||
Task<string> SetCachedAnalysisAsync(string inputText, object analysisResult, TimeSpan ttl);
|
||||
Task<bool> InvalidateCacheAsync(string textHash);
|
||||
Task<int> GetCacheHitCountAsync();
|
||||
Task CleanExpiredCacheAsync();
|
||||
}
|
||||
|
||||
public class AnalysisCacheService : IAnalysisCacheService
|
||||
{
|
||||
private readonly DramaLingDbContext _context;
|
||||
private readonly ILogger<AnalysisCacheService> _logger;
|
||||
|
||||
public AnalysisCacheService(DramaLingDbContext context, ILogger<AnalysisCacheService> logger)
|
||||
{
|
||||
_context = context;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 獲取快取的分析結果
|
||||
/// </summary>
|
||||
public async Task<SentenceAnalysisCache?> GetCachedAnalysisAsync(string inputText)
|
||||
{
|
||||
try
|
||||
{
|
||||
var textHash = GenerateTextHash(inputText);
|
||||
var cached = await _context.SentenceAnalysisCache
|
||||
.FirstOrDefaultAsync(c => c.InputTextHash == textHash && c.ExpiresAt > DateTime.UtcNow);
|
||||
|
||||
if (cached != null)
|
||||
{
|
||||
// 更新訪問統計
|
||||
cached.AccessCount++;
|
||||
cached.LastAccessedAt = DateTime.UtcNow;
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Cache hit for text hash: {TextHash}", textHash);
|
||||
return cached;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Cache miss for text hash: {TextHash}", textHash);
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting cached analysis for text: {InputText}", inputText);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 設定快取分析結果
|
||||
/// </summary>
|
||||
public async Task<string> SetCachedAnalysisAsync(string inputText, object analysisResult, TimeSpan ttl)
|
||||
{
|
||||
try
|
||||
{
|
||||
var textHash = GenerateTextHash(inputText);
|
||||
var expiresAt = DateTime.UtcNow.Add(ttl);
|
||||
|
||||
// 檢查是否已存在
|
||||
var existing = await _context.SentenceAnalysisCache
|
||||
.FirstOrDefaultAsync(c => c.InputTextHash == textHash);
|
||||
|
||||
if (existing != null)
|
||||
{
|
||||
// 更新現有快取 - 使用一致的命名策略
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
existing.AnalysisResult = JsonSerializer.Serialize(analysisResult, options);
|
||||
existing.ExpiresAt = expiresAt;
|
||||
existing.AccessCount++;
|
||||
existing.LastAccessedAt = DateTime.UtcNow;
|
||||
}
|
||||
else
|
||||
{
|
||||
// 創建新快取項目
|
||||
var cacheItem = new SentenceAnalysisCache
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
InputTextHash = textHash,
|
||||
InputText = inputText,
|
||||
AnalysisResult = JsonSerializer.Serialize(analysisResult, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
}),
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
ExpiresAt = expiresAt,
|
||||
AccessCount = 1,
|
||||
LastAccessedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_context.SentenceAnalysisCache.Add(cacheItem);
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Cached analysis for text hash: {TextHash}, expires at: {ExpiresAt}",
|
||||
textHash, expiresAt);
|
||||
|
||||
return textHash;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error setting cached analysis for text: {InputText}", inputText);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使快取失效
|
||||
/// </summary>
|
||||
public async Task<bool> InvalidateCacheAsync(string textHash)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cached = await _context.SentenceAnalysisCache
|
||||
.FirstOrDefaultAsync(c => c.InputTextHash == textHash);
|
||||
|
||||
if (cached != null)
|
||||
{
|
||||
_context.SentenceAnalysisCache.Remove(cached);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Invalidated cache for text hash: {TextHash}", textHash);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error invalidating cache for text hash: {TextHash}", textHash);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 獲取快取命中次數
|
||||
/// </summary>
|
||||
public async Task<int> GetCacheHitCountAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _context.SentenceAnalysisCache
|
||||
.Where(c => c.ExpiresAt > DateTime.UtcNow)
|
||||
.SumAsync(c => c.AccessCount);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting cache hit count");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清理過期的快取
|
||||
/// </summary>
|
||||
public async Task CleanExpiredCacheAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var expiredItems = await _context.SentenceAnalysisCache
|
||||
.Where(c => c.ExpiresAt <= DateTime.UtcNow)
|
||||
.ToListAsync();
|
||||
|
||||
if (expiredItems.Any())
|
||||
{
|
||||
_context.SentenceAnalysisCache.RemoveRange(expiredItems);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Cleaned {Count} expired cache items", expiredItems.Count);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error cleaning expired cache");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生成文本哈希值
|
||||
/// </summary>
|
||||
private string GenerateTextHash(string inputText)
|
||||
{
|
||||
using var sha256 = SHA256.Create();
|
||||
var bytes = Encoding.UTF8.GetBytes(inputText.Trim().ToLower());
|
||||
var hash = sha256.ComputeHash(bytes);
|
||||
return Convert.ToHexString(hash).ToLower();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
using System;
|
||||
|
||||
namespace DramaLing.Api.Services;
|
||||
|
||||
public static class CEFRLevelService
|
||||
{
|
||||
private static readonly string[] Levels = { "A1", "A2", "B1", "B2", "C1", "C2" };
|
||||
|
||||
/// <summary>
|
||||
/// 取得 CEFR 等級的數字索引
|
||||
/// </summary>
|
||||
public static int GetLevelIndex(string level)
|
||||
{
|
||||
if (string.IsNullOrEmpty(level)) return 1; // 預設 A2
|
||||
return Array.IndexOf(Levels, level.ToUpper());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判定詞彙對特定用戶是否為高價值
|
||||
/// 規則:比用戶程度高 1-2 級的詞彙為高價值
|
||||
/// </summary>
|
||||
public static bool IsHighValueForUser(string wordLevel, string userLevel)
|
||||
{
|
||||
var userIndex = GetLevelIndex(userLevel);
|
||||
var wordIndex = GetLevelIndex(wordLevel);
|
||||
|
||||
// 無效等級處理
|
||||
if (userIndex == -1 || wordIndex == -1) return false;
|
||||
|
||||
// 高價值 = 比用戶程度高 1-2 級
|
||||
return wordIndex >= userIndex + 1 && wordIndex <= userIndex + 2;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 取得用戶的目標學習等級範圍
|
||||
/// </summary>
|
||||
public static string GetTargetLevelRange(string userLevel)
|
||||
{
|
||||
var userIndex = GetLevelIndex(userLevel);
|
||||
if (userIndex == -1) return "B1-B2";
|
||||
|
||||
var targetMin = Levels[Math.Min(userIndex + 1, Levels.Length - 1)];
|
||||
var targetMax = Levels[Math.Min(userIndex + 2, Levels.Length - 1)];
|
||||
|
||||
return targetMin == targetMax ? targetMin : $"{targetMin}-{targetMax}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 取得下一個等級
|
||||
/// </summary>
|
||||
public static string GetNextLevel(string currentLevel, int steps = 1)
|
||||
{
|
||||
var currentIndex = GetLevelIndex(currentLevel);
|
||||
if (currentIndex == -1) return "B1";
|
||||
|
||||
var nextIndex = Math.Min(currentIndex + steps, Levels.Length - 1);
|
||||
return Levels[nextIndex];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
namespace DramaLing.Api.Services;
|
||||
|
||||
public class CacheCleanupService : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ILogger<CacheCleanupService> _logger;
|
||||
private readonly TimeSpan _cleanupInterval = TimeSpan.FromHours(1); // 每小時清理一次
|
||||
|
||||
public CacheCleanupService(IServiceProvider serviceProvider, ILogger<CacheCleanupService> logger)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("Cache cleanup service started");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var cacheService = scope.ServiceProvider.GetRequiredService<IAnalysisCacheService>();
|
||||
|
||||
_logger.LogInformation("Starting cache cleanup...");
|
||||
await cacheService.CleanExpiredCacheAsync();
|
||||
_logger.LogInformation("Cache cleanup completed");
|
||||
|
||||
await Task.Delay(_cleanupInterval, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 正常的服務停止
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error during cache cleanup");
|
||||
// 出錯時等待較短時間後重試
|
||||
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Cache cleanup service stopped");
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,8 @@ public interface IGeminiService
|
|||
{
|
||||
Task<List<GeneratedCard>> GenerateCardsAsync(string inputText, string extractionType, int cardCount);
|
||||
Task<ValidationResult> ValidateCardAsync(Flashcard card);
|
||||
Task<SentenceAnalysisResponse> AnalyzeSentenceAsync(string inputText, string userLevel = "A2");
|
||||
Task<WordAnalysisResult> AnalyzeWordAsync(string word, string sentence);
|
||||
}
|
||||
|
||||
public class GeminiService : IGeminiService
|
||||
|
|
@ -30,7 +32,7 @@ public class GeminiService : IGeminiService
|
|||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(_apiKey))
|
||||
if (string.IsNullOrEmpty(_apiKey) || _apiKey == "your-gemini-api-key-here")
|
||||
{
|
||||
throw new InvalidOperationException("Gemini API key not configured");
|
||||
}
|
||||
|
|
@ -47,11 +49,133 @@ public class GeminiService : IGeminiService
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 真正的句子分析和翻譯 - 調用 Gemini AI
|
||||
/// </summary>
|
||||
public async Task<SentenceAnalysisResponse> AnalyzeSentenceAsync(string inputText, string userLevel = "A2")
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(_apiKey) || _apiKey == "your-gemini-api-key-here")
|
||||
{
|
||||
throw new InvalidOperationException("Gemini API key not configured");
|
||||
}
|
||||
|
||||
var targetRange = CEFRLevelService.GetTargetLevelRange(userLevel);
|
||||
|
||||
var prompt = $@"
|
||||
請分析以下英文句子,提供翻譯和個人化詞彙分析:
|
||||
|
||||
句子:{inputText}
|
||||
學習者程度:{userLevel}
|
||||
|
||||
請按照以下JSON格式回應,不要包含任何其他文字:
|
||||
|
||||
{{
|
||||
""translation"": ""自然流暢的繁體中文翻譯"",
|
||||
""grammarCorrection"": {{
|
||||
""hasErrors"": false,
|
||||
""originalText"": ""{inputText}"",
|
||||
""correctedText"": null,
|
||||
""corrections"": []
|
||||
}},
|
||||
""highValueWords"": [""重要詞彙1"", ""重要詞彙2""],
|
||||
""wordAnalysis"": {{
|
||||
""單字"": {{
|
||||
""translation"": ""中文翻譯"",
|
||||
""definition"": ""英文定義"",
|
||||
""partOfSpeech"": ""詞性"",
|
||||
""pronunciation"": ""音標"",
|
||||
""isHighValue"": true,
|
||||
""difficultyLevel"": ""CEFR等級""
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
|
||||
要求:
|
||||
1. 翻譯要自然流暢,符合中文語法
|
||||
2. **基於學習者程度({userLevel}),標記 {targetRange} 等級的詞彙為重點學習**
|
||||
3. 如有語法錯誤請指出並修正
|
||||
4. 確保JSON格式正確
|
||||
|
||||
重點學習判定邏輯:
|
||||
- 學習者程度: {userLevel}
|
||||
- 重點學習範圍: {targetRange}
|
||||
- 太簡單的詞彙(≤{userLevel})不要標記為重點學習
|
||||
- 太難的詞彙謹慎標記
|
||||
- 重點關注適合學習者程度的詞彙
|
||||
";
|
||||
|
||||
var response = await CallGeminiApiAsync(prompt);
|
||||
return ParseSentenceAnalysisResponse(response);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error analyzing sentence with Gemini API");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<WordAnalysisResult> AnalyzeWordAsync(string word, string sentence)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(_apiKey) || _apiKey == "your-gemini-api-key-here")
|
||||
{
|
||||
throw new InvalidOperationException("Gemini API key not configured");
|
||||
}
|
||||
|
||||
var prompt = $@"
|
||||
請分析單字 ""{word}"" 在句子 ""{sentence}"" 中的詳細資訊:
|
||||
|
||||
單字: {word}
|
||||
語境: {sentence}
|
||||
|
||||
請以JSON格式回應,不要包含任何其他文字:
|
||||
{{
|
||||
""word"": ""{word}"",
|
||||
""translation"": ""繁體中文翻譯"",
|
||||
""definition"": ""英文定義"",
|
||||
""partOfSpeech"": ""詞性(n./v./adj./adv.等)"",
|
||||
""pronunciation"": ""IPA音標"",
|
||||
""difficultyLevel"": ""CEFR等級(A1/A2/B1/B2/C1/C2)"",
|
||||
""isHighValue"": false,
|
||||
""contextMeaning"": ""在此句子中的具體含義""
|
||||
}}
|
||||
|
||||
要求:
|
||||
1. 翻譯要準確自然
|
||||
2. 定義要簡潔易懂
|
||||
3. 音標使用標準IPA格式
|
||||
4. 根據語境判斷詞性和含義
|
||||
";
|
||||
|
||||
var response = await CallGeminiApiAsync(prompt);
|
||||
return ParseWordAnalysisResponse(response, word);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error analyzing word with Gemini API");
|
||||
|
||||
// 回退到基本資料
|
||||
return new WordAnalysisResult
|
||||
{
|
||||
Word = word,
|
||||
Translation = $"{word} (AI 暫時不可用)",
|
||||
Definition = $"Definition of {word} (service temporarily unavailable)",
|
||||
PartOfSpeech = "unknown",
|
||||
Pronunciation = $"/{word}/",
|
||||
DifficultyLevel = "unknown",
|
||||
IsHighValue = false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ValidationResult> ValidateCardAsync(Flashcard card)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(_apiKey))
|
||||
if (string.IsNullOrEmpty(_apiKey) || _apiKey == "your-gemini-api-key-here")
|
||||
{
|
||||
throw new InvalidOperationException("Gemini API key not configured");
|
||||
}
|
||||
|
|
@ -239,6 +363,129 @@ public class GeminiService : IGeminiService
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析 Gemini AI 詞彙分析響應
|
||||
/// </summary>
|
||||
private WordAnalysisResult ParseWordAnalysisResponse(string response, string word)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cleanText = response.Trim().Replace("```json", "").Replace("```", "").Trim();
|
||||
var jsonResponse = JsonSerializer.Deserialize<JsonElement>(cleanText);
|
||||
|
||||
return new WordAnalysisResult
|
||||
{
|
||||
Word = GetStringProperty(jsonResponse, "word") ?? word,
|
||||
Translation = GetStringProperty(jsonResponse, "translation") ?? $"{word} 的翻譯",
|
||||
Definition = GetStringProperty(jsonResponse, "definition") ?? $"Definition of {word}",
|
||||
PartOfSpeech = GetStringProperty(jsonResponse, "partOfSpeech") ?? "unknown",
|
||||
Pronunciation = GetStringProperty(jsonResponse, "pronunciation") ?? $"/{word}/",
|
||||
DifficultyLevel = GetStringProperty(jsonResponse, "difficultyLevel") ?? "A1",
|
||||
IsHighValue = jsonResponse.TryGetProperty("isHighValue", out var isHighValueElement) && isHighValueElement.GetBoolean()
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to parse word analysis response");
|
||||
|
||||
return new WordAnalysisResult
|
||||
{
|
||||
Word = word,
|
||||
Translation = $"{word} (解析失敗)",
|
||||
Definition = $"Definition of {word} (parsing failed)",
|
||||
PartOfSpeech = "unknown",
|
||||
Pronunciation = $"/{word}/",
|
||||
DifficultyLevel = "unknown",
|
||||
IsHighValue = false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析 Gemini AI 句子分析響應
|
||||
/// </summary>
|
||||
private SentenceAnalysisResponse ParseSentenceAnalysisResponse(string response)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cleanText = response.Trim().Replace("```json", "").Replace("```", "").Trim();
|
||||
|
||||
if (!cleanText.StartsWith("{"))
|
||||
{
|
||||
var jsonStart = cleanText.IndexOf("{");
|
||||
if (jsonStart >= 0)
|
||||
{
|
||||
cleanText = cleanText[jsonStart..];
|
||||
}
|
||||
}
|
||||
|
||||
var jsonResponse = JsonSerializer.Deserialize<JsonElement>(cleanText);
|
||||
|
||||
return new SentenceAnalysisResponse
|
||||
{
|
||||
Translation = GetStringProperty(jsonResponse, "translation"),
|
||||
Explanation = GetStringProperty(jsonResponse, "explanation"),
|
||||
HighValueWords = GetArrayProperty(jsonResponse, "highValueWords"),
|
||||
WordAnalysis = ParseWordAnalysisFromJson(jsonResponse),
|
||||
GrammarCorrection = ParseGrammarCorrectionFromJson(jsonResponse)
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error parsing sentence analysis response: {Response}", response);
|
||||
throw new InvalidOperationException($"Failed to parse AI sentence analysis: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private Dictionary<string, WordAnalysisResult> ParseWordAnalysisFromJson(JsonElement jsonResponse)
|
||||
{
|
||||
var result = new Dictionary<string, WordAnalysisResult>();
|
||||
|
||||
if (jsonResponse.TryGetProperty("wordAnalysis", out var wordAnalysisElement))
|
||||
{
|
||||
foreach (var property in wordAnalysisElement.EnumerateObject())
|
||||
{
|
||||
var word = property.Name;
|
||||
var analysis = property.Value;
|
||||
|
||||
result[word] = new WordAnalysisResult
|
||||
{
|
||||
Word = word,
|
||||
Translation = GetStringProperty(analysis, "translation"),
|
||||
Definition = GetStringProperty(analysis, "definition"),
|
||||
PartOfSpeech = GetStringProperty(analysis, "partOfSpeech"),
|
||||
Pronunciation = GetStringProperty(analysis, "pronunciation"),
|
||||
IsHighValue = analysis.TryGetProperty("isHighValue", out var isHighValueElement) && isHighValueElement.GetBoolean(),
|
||||
DifficultyLevel = GetStringProperty(analysis, "difficultyLevel")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private GrammarCorrectionResult ParseGrammarCorrectionFromJson(JsonElement jsonResponse)
|
||||
{
|
||||
if (jsonResponse.TryGetProperty("grammarCorrection", out var grammarElement))
|
||||
{
|
||||
return new GrammarCorrectionResult
|
||||
{
|
||||
HasErrors = grammarElement.TryGetProperty("hasErrors", out var hasErrorsElement) && hasErrorsElement.GetBoolean(),
|
||||
OriginalText = GetStringProperty(grammarElement, "originalText"),
|
||||
CorrectedText = GetStringProperty(grammarElement, "correctedText"),
|
||||
Corrections = new List<GrammarCorrection>() // 簡化
|
||||
};
|
||||
}
|
||||
|
||||
return new GrammarCorrectionResult
|
||||
{
|
||||
HasErrors = false,
|
||||
OriginalText = "",
|
||||
CorrectedText = null,
|
||||
Corrections = new List<GrammarCorrection>()
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetStringProperty(JsonElement element, string propertyName)
|
||||
{
|
||||
return element.TryGetProperty(propertyName, out var prop) ? prop.GetString() ?? "" : "";
|
||||
|
|
@ -351,4 +598,41 @@ public class ValidationIssue
|
|||
public string Corrected { get; set; } = string.Empty;
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
public string Severity { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
// 新增句子分析相關類型
|
||||
public class SentenceAnalysisResponse
|
||||
{
|
||||
public string Translation { get; set; } = string.Empty;
|
||||
public string Explanation { get; set; } = string.Empty;
|
||||
public List<string> HighValueWords { get; set; } = new();
|
||||
public Dictionary<string, WordAnalysisResult> WordAnalysis { get; set; } = new();
|
||||
public GrammarCorrectionResult GrammarCorrection { get; set; } = new();
|
||||
}
|
||||
|
||||
public class WordAnalysisResult
|
||||
{
|
||||
public string Word { get; set; } = string.Empty;
|
||||
public string Translation { get; set; } = string.Empty;
|
||||
public string Definition { get; set; } = string.Empty;
|
||||
public string PartOfSpeech { get; set; } = string.Empty;
|
||||
public string Pronunciation { get; set; } = string.Empty;
|
||||
public bool IsHighValue { get; set; }
|
||||
public string DifficultyLevel { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class GrammarCorrectionResult
|
||||
{
|
||||
public bool HasErrors { get; set; }
|
||||
public string OriginalText { get; set; } = string.Empty;
|
||||
public string? CorrectedText { get; set; }
|
||||
public List<GrammarCorrection> Corrections { get; set; } = new();
|
||||
}
|
||||
|
||||
public class GrammarCorrection
|
||||
{
|
||||
public string ErrorType { get; set; } = string.Empty;
|
||||
public string Original { get; set; } = string.Empty;
|
||||
public string Corrected { get; set; } = string.Empty;
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
}
|
||||
|
|
@ -0,0 +1,255 @@
|
|||
using Microsoft.EntityFrameworkCore;
|
||||
using DramaLing.Api.Data;
|
||||
using DramaLing.Api.Models.Entities;
|
||||
|
||||
namespace DramaLing.Api.Services;
|
||||
|
||||
public interface IUsageTrackingService
|
||||
{
|
||||
Task<bool> CheckUsageLimitAsync(Guid userId, bool isPremium = false);
|
||||
Task RecordSentenceAnalysisAsync(Guid userId);
|
||||
Task RecordWordQueryAsync(Guid userId, bool wasHighValue);
|
||||
Task<UserUsageStats> GetUsageStatsAsync(Guid userId);
|
||||
}
|
||||
|
||||
public class UsageTrackingService : IUsageTrackingService
|
||||
{
|
||||
private readonly DramaLingDbContext _context;
|
||||
private readonly ILogger<UsageTrackingService> _logger;
|
||||
|
||||
// 免費用戶限制
|
||||
private const int FREE_USER_ANALYSIS_LIMIT = 5;
|
||||
private const int FREE_USER_RESET_HOURS = 3;
|
||||
|
||||
public UsageTrackingService(DramaLingDbContext context, ILogger<UsageTrackingService> logger)
|
||||
{
|
||||
_context = context;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 檢查用戶使用限制
|
||||
/// </summary>
|
||||
public async Task<bool> CheckUsageLimitAsync(Guid userId, bool isPremium = false)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (isPremium)
|
||||
{
|
||||
return true; // 付費用戶無限制
|
||||
}
|
||||
|
||||
var resetTime = DateTime.UtcNow.AddHours(-FREE_USER_RESET_HOURS);
|
||||
var recentUsage = await _context.WordQueryUsageStats
|
||||
.Where(stats => stats.UserId == userId && stats.CreatedAt >= resetTime)
|
||||
.SumAsync(stats => stats.SentenceAnalysisCount + stats.LowValueWordClicks);
|
||||
|
||||
var canUse = recentUsage < FREE_USER_ANALYSIS_LIMIT;
|
||||
|
||||
_logger.LogInformation("Usage check for user {UserId}: {RecentUsage}/{Limit}, Can use: {CanUse}",
|
||||
userId, recentUsage, FREE_USER_ANALYSIS_LIMIT, canUse);
|
||||
|
||||
return canUse;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error checking usage limit for user {UserId}", userId);
|
||||
return false; // 出錯時拒絕使用
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 記錄句子分析使用
|
||||
/// </summary>
|
||||
public async Task RecordSentenceAnalysisAsync(Guid userId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var stats = await GetOrCreateTodayStatsAsync(userId, today);
|
||||
|
||||
stats.SentenceAnalysisCount++;
|
||||
stats.TotalApiCalls++;
|
||||
stats.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Recorded sentence analysis for user {UserId}, total today: {Count}",
|
||||
userId, stats.SentenceAnalysisCount);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error recording sentence analysis for user {UserId}", userId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 記錄單字查詢使用
|
||||
/// </summary>
|
||||
public async Task RecordWordQueryAsync(Guid userId, bool wasHighValue)
|
||||
{
|
||||
try
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var stats = await GetOrCreateTodayStatsAsync(userId, today);
|
||||
|
||||
if (wasHighValue)
|
||||
{
|
||||
stats.HighValueWordClicks++;
|
||||
}
|
||||
else
|
||||
{
|
||||
stats.LowValueWordClicks++;
|
||||
stats.TotalApiCalls++; // 低價值詞彙需要API調用
|
||||
}
|
||||
|
||||
stats.UniqueWordsQueried++; // 簡化:每次查詢都算一個獨特詞彙
|
||||
stats.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Recorded word query for user {UserId}, high value: {IsHighValue}",
|
||||
userId, wasHighValue);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error recording word query for user {UserId}", userId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 獲取用戶使用統計
|
||||
/// </summary>
|
||||
public async Task<UserUsageStats> GetUsageStatsAsync(Guid userId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var resetTime = DateTime.UtcNow.AddHours(-FREE_USER_RESET_HOURS);
|
||||
|
||||
// 今日統計
|
||||
var todayStats = await _context.WordQueryUsageStats
|
||||
.FirstOrDefaultAsync(stats => stats.UserId == userId && stats.Date == today)
|
||||
?? new WordQueryUsageStats { UserId = userId, Date = today };
|
||||
|
||||
// 最近3小時使用量(用於限制檢查)
|
||||
var recentUsage = await _context.WordQueryUsageStats
|
||||
.Where(stats => stats.UserId == userId && stats.CreatedAt >= resetTime)
|
||||
.SumAsync(stats => stats.SentenceAnalysisCount + stats.LowValueWordClicks);
|
||||
|
||||
// 本週統計
|
||||
var weekStart = DateTime.Today.AddDays(-(int)DateTime.Today.DayOfWeek);
|
||||
var weekStats = await _context.WordQueryUsageStats
|
||||
.Where(stats => stats.UserId == userId && stats.Date >= DateOnly.FromDateTime(weekStart))
|
||||
.GroupBy(stats => 1)
|
||||
.Select(g => new
|
||||
{
|
||||
TotalAnalysis = g.Sum(s => s.SentenceAnalysisCount),
|
||||
TotalWordClicks = g.Sum(s => s.HighValueWordClicks + s.LowValueWordClicks),
|
||||
TotalApiCalls = g.Sum(s => s.TotalApiCalls),
|
||||
UniqueWords = g.Sum(s => s.UniqueWordsQueried)
|
||||
})
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
return new UserUsageStats
|
||||
{
|
||||
UserId = userId,
|
||||
Today = new DailyUsageStats
|
||||
{
|
||||
Date = today,
|
||||
SentenceAnalysisCount = todayStats.SentenceAnalysisCount,
|
||||
HighValueWordClicks = todayStats.HighValueWordClicks,
|
||||
LowValueWordClicks = todayStats.LowValueWordClicks,
|
||||
TotalApiCalls = todayStats.TotalApiCalls,
|
||||
UniqueWordsQueried = todayStats.UniqueWordsQueried
|
||||
},
|
||||
RecentUsage = new UsageLimitInfo
|
||||
{
|
||||
UsedInWindow = recentUsage,
|
||||
WindowLimit = FREE_USER_ANALYSIS_LIMIT,
|
||||
WindowHours = FREE_USER_RESET_HOURS,
|
||||
ResetTime = DateTime.UtcNow.AddHours(FREE_USER_RESET_HOURS -
|
||||
((DateTime.UtcNow - resetTime).TotalHours % FREE_USER_RESET_HOURS))
|
||||
},
|
||||
ThisWeek = weekStats != null ? new WeeklyUsageStats
|
||||
{
|
||||
StartDate = DateOnly.FromDateTime(weekStart),
|
||||
EndDate = DateOnly.FromDateTime(weekStart.AddDays(6)),
|
||||
TotalSentenceAnalysis = weekStats.TotalAnalysis,
|
||||
TotalWordClicks = weekStats.TotalWordClicks,
|
||||
TotalApiCalls = weekStats.TotalApiCalls,
|
||||
UniqueWordsQueried = weekStats.UniqueWords
|
||||
} : new WeeklyUsageStats()
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting usage stats for user {UserId}", userId);
|
||||
return new UserUsageStats { UserId = userId };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 獲取或創建今日統計記錄
|
||||
/// </summary>
|
||||
private async Task<WordQueryUsageStats> GetOrCreateTodayStatsAsync(Guid userId, DateOnly date)
|
||||
{
|
||||
var stats = await _context.WordQueryUsageStats
|
||||
.FirstOrDefaultAsync(s => s.UserId == userId && s.Date == date);
|
||||
|
||||
if (stats == null)
|
||||
{
|
||||
stats = new WordQueryUsageStats
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
Date = date,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_context.WordQueryUsageStats.Add(stats);
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
}
|
||||
|
||||
// 回應用的 DTO 類別
|
||||
public class UserUsageStats
|
||||
{
|
||||
public Guid UserId { get; set; }
|
||||
public DailyUsageStats Today { get; set; } = new();
|
||||
public UsageLimitInfo RecentUsage { get; set; } = new();
|
||||
public WeeklyUsageStats ThisWeek { get; set; } = new();
|
||||
}
|
||||
|
||||
public class DailyUsageStats
|
||||
{
|
||||
public DateOnly Date { get; set; }
|
||||
public int SentenceAnalysisCount { get; set; }
|
||||
public int HighValueWordClicks { get; set; }
|
||||
public int LowValueWordClicks { get; set; }
|
||||
public int TotalApiCalls { get; set; }
|
||||
public int UniqueWordsQueried { get; set; }
|
||||
}
|
||||
|
||||
public class UsageLimitInfo
|
||||
{
|
||||
public int UsedInWindow { get; set; }
|
||||
public int WindowLimit { get; set; }
|
||||
public int WindowHours { get; set; }
|
||||
public DateTime ResetTime { get; set; }
|
||||
public bool CanUse => UsedInWindow < WindowLimit;
|
||||
public int Remaining => Math.Max(0, WindowLimit - UsedInWindow);
|
||||
}
|
||||
|
||||
public class WeeklyUsageStats
|
||||
{
|
||||
public DateOnly StartDate { get; set; }
|
||||
public DateOnly EndDate { get; set; }
|
||||
public int TotalSentenceAnalysis { get; set; }
|
||||
public int TotalWordClicks { get; set; }
|
||||
public int TotalApiCalls { get; set; }
|
||||
public int UniqueWordsQueried { get; set; }
|
||||
}
|
||||
|
|
@ -63,22 +63,48 @@
|
|||
- 難度選擇:A1, A2, B1, B2, C1, C2
|
||||
|
||||
#### 1.2.2 AI 生成規格
|
||||
- **生成方式**
|
||||
1. 原始例句類型
|
||||
- 影劇截圖(訂閱功能, phase2)
|
||||
- 手動輸入
|
||||
2. 詞彙萃取:把每個單字拿去查詢字典API,並標記CEFR
|
||||
3. 智能萃取(訂閱功能):將原始例句拿去問AI有無常用片語或俚語,並直接生成相關詞彙內容
|
||||
- **原始例句輸入**
|
||||
- 輸入方式
|
||||
1. 影劇截圖(訂閱功能, phase2)
|
||||
2. 手動輸入
|
||||
- 輸入資料
|
||||
- 可接受多句子
|
||||
- 字數限制規則:
|
||||
- 若為手動輸入,則限定300字以內,在前端畫面做阻擋
|
||||
- 若為影劇截圖,則無300字限制
|
||||
|
||||
- **生成數量**
|
||||
- 預設:10個詞卡
|
||||
- 範圍:5-20個(用戶可調)
|
||||
- 免費用戶:
|
||||
- 無法自行生成例句圖,但若系統中匹配到現成例句圖,可直接使用
|
||||
- 每日學習數量無限制
|
||||
- 訂閱用戶:每天最多生成50張例句圖
|
||||
- **互動式單字查詢(低成本設計)**
|
||||
1. 預分析機制
|
||||
- 用戶輸入句子後,AI 一次性分析整句內容
|
||||
- 獲取原始例句意思
|
||||
- 識別具備高學習價值的片語/俚語/單字,並標記為高價值,並於當次直接生成具標記的項目內容詳情(參考「生成內容詳情」)
|
||||
- 分析結果存儲於快取中(避免重複 API 調用)
|
||||
- 當次操作扣除使用次數一次
|
||||
|
||||
2. 點擊查詢體驗
|
||||
- 句子顯示為可點擊的單字
|
||||
- 點擊對象
|
||||
- 若為高價值標記,則即時顯示意思(無延遲,讀取預分析資料),不扣除使用次數
|
||||
- 若非高價值標記,則拿當前點擊單字及當前句子,給AI分析並生成內容詳情,扣除使用次數一次
|
||||
- 片語/俚語特殊高亮顯示
|
||||
- 智能提醒:當單字屬於片語/俚語時,優先顯示片語意思並提醒
|
||||
- 若出現多筆片語/俚語需標記時,請使用不同顏色區分
|
||||
|
||||
3. 成本優化策略
|
||||
- **核心原則**:一句一次 API 調用,多次查詢零成本
|
||||
- 相同句子分析結果快取(24小時)
|
||||
- 常用單字基礎資訊本地快取
|
||||
- 預估 API 成本降低 80-95%
|
||||
|
||||
4. 收費策略(phase 2):
|
||||
- 免費用戶:5次/3小時
|
||||
- 付費用戶:無限制
|
||||
|
||||
- **生成內容詳情**
|
||||
- **原始例句**
|
||||
- 整體意思:不論原始例句是多句、一句、片段,就是將原始例句整體意思描述出來
|
||||
- 修正語法錯誤:若原始例句有語法錯誤,則進行修正,並說明修正原因,且後續學習內容皆以正確的版本進行
|
||||
|
||||
- **單字/片語**
|
||||
- 原形展示
|
||||
- 詞性標註(n./v./adj./adv./phrase/slang)
|
||||
|
|
@ -100,6 +126,9 @@
|
|||
- 例句中文翻譯
|
||||
- 重點標示(highlight目標詞)
|
||||
- 例句圖
|
||||
- 收費策略(phase 2):
|
||||
- 免費用戶:無法自行生成例句圖,但若系統中匹配到現成例句圖,可直接使用
|
||||
- 訂閱用戶:每天最多生成50張例句圖
|
||||
- 例句發音
|
||||
|
||||
- **生成後處理**
|
||||
|
|
@ -438,6 +467,11 @@
|
|||
**目標**:提升用戶體驗
|
||||
- ✅ 標籤系統
|
||||
- ✅ 搜尋篩選
|
||||
- ⬜ **互動式單字查詢系統**
|
||||
- 句子預分析 API 端點
|
||||
- 可點擊文字組件
|
||||
- 片語/俚語智能提醒
|
||||
- 快取機制實現
|
||||
- ✅ 進階統計圖表
|
||||
- ✅ 成就系統
|
||||
- ✅ 學習提醒
|
||||
|
|
@ -481,7 +515,9 @@
|
|||
- 內容版權問題
|
||||
|
||||
### 7.3 緩解措施
|
||||
- 實施 API 快取機制
|
||||
- 實施 API 快取機制(重點:單字查詢預分析快取)
|
||||
- 準備備用 AI 服務
|
||||
- 建立用戶反饋循環
|
||||
- 確保內容合規性
|
||||
- 確保內容合規性
|
||||
- 監控 AI API 使用量並設定預算警告
|
||||
- 實現降級機制:API 配額用盡時使用離線字典
|
||||
|
|
@ -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 說明,為前端開發和第三方整合提供了詳細的參考文檔。
|
||||
|
|
@ -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
|
||||
|
|
@ -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 導師指導詞彙學習
|
||||
|
|
@ -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 線框圖設計涵蓋了完整的用戶介面規劃,包括主要頁面、互動流程、響應式適配和無障礙設計,為開發團隊提供了詳細的實現指南。
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
{
|
||||
"pages": {
|
||||
"/layout": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/css/app/layout.css",
|
||||
"static/chunks/app/layout.js"
|
||||
],
|
||||
"/learn/page": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/learn/page.js"
|
||||
],
|
||||
"/page": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/page.js"
|
||||
],
|
||||
"/register/page": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/register/page.js"
|
||||
],
|
||||
"/dashboard/page": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/dashboard/page.js"
|
||||
],
|
||||
"/login/page": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/login/page.js"
|
||||
],
|
||||
"/flashcards/page": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/flashcards/page.js"
|
||||
],
|
||||
"/generate/page": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/generate/page.js"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
{
|
||||
"polyfillFiles": [
|
||||
"static/chunks/polyfills.js"
|
||||
],
|
||||
"devFiles": [
|
||||
"static/chunks/react-refresh.js"
|
||||
],
|
||||
"ampDevFiles": [],
|
||||
"lowPriorityFiles": [
|
||||
"static/development/_buildManifest.js",
|
||||
"static/development/_ssgManifest.js"
|
||||
],
|
||||
"rootMainFiles": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js"
|
||||
],
|
||||
"rootMainFilesTree": {},
|
||||
"pages": {
|
||||
"/_app": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main.js",
|
||||
"static/chunks/pages/_app.js"
|
||||
],
|
||||
"/_error": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main.js",
|
||||
"static/chunks/pages/_error.js"
|
||||
]
|
||||
},
|
||||
"ampFirstPages": []
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"encryption.key":"vtRMAhpqMDvdooQranzjEWxOMG1mIJjg+R6fpKxANE8=","encryption.expire_at":1759221816718}
|
||||
|
|
@ -1 +0,0 @@
|
|||
84c93a70-dbda-4599-bced-8d2c4bb9293b
|
||||
|
|
@ -1 +0,0 @@
|
|||
{}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1,31 +0,0 @@
|
|||
{
|
||||
"polyfillFiles": [
|
||||
"static/chunks/polyfills.js"
|
||||
],
|
||||
"devFiles": [
|
||||
"static/chunks/fallback/react-refresh.js"
|
||||
],
|
||||
"ampDevFiles": [
|
||||
"static/chunks/fallback/webpack.js",
|
||||
"static/chunks/fallback/amp.js"
|
||||
],
|
||||
"lowPriorityFiles": [],
|
||||
"rootMainFiles": [
|
||||
"static/chunks/fallback/webpack.js",
|
||||
"static/chunks/fallback/main-app.js"
|
||||
],
|
||||
"rootMainFilesTree": {},
|
||||
"pages": {
|
||||
"/_app": [
|
||||
"static/chunks/fallback/webpack.js",
|
||||
"static/chunks/fallback/main.js",
|
||||
"static/chunks/fallback/pages/_app.js"
|
||||
],
|
||||
"/_error": [
|
||||
"static/chunks/fallback/webpack.js",
|
||||
"static/chunks/fallback/main.js",
|
||||
"static/chunks/fallback/pages/_error.js"
|
||||
]
|
||||
},
|
||||
"ampFirstPages": []
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"type": "commonjs"}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"version": 4,
|
||||
"routes": {},
|
||||
"dynamicRoutes": {},
|
||||
"notFoundRoutes": [],
|
||||
"preview": {
|
||||
"previewModeId": "aa74c82558a74dea33a7e6459579eda7",
|
||||
"previewModeSigningKey": "b1bbb9a9237204029f84fcac8af2150171a76407c1fb924618b1336e1faf265d",
|
||||
"previewModeEncryptionKey": "a030c93e576b09039ab5861447b99555ce133486c547bbe0f6d2d61647f4c034"
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
{}
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"version":3,"caseSensitive":false,"basePath":"","rewrites":{"beforeFiles":[],"afterFiles":[],"fallback":[]},"redirects":[{"source":"/:path+/","destination":"/:path+","permanent":true,"internal":true,"regex":"^(?:\\/((?:[^\\/]+?)(?:\\/(?:[^\\/]+?))*))\\/$"}],"headers":[]}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
{
|
||||
"/login/page": "app/login/page.js",
|
||||
"/flashcards/page": "app/flashcards/page.js",
|
||||
"/generate/page": "app/generate/page.js",
|
||||
"/dashboard/page": "app/dashboard/page.js",
|
||||
"/learn/page": "app/learn/page.js"
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1 +0,0 @@
|
|||
self.__INTERCEPTION_ROUTE_REWRITE_MANIFEST="[]"
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
globalThis.__BUILD_MANIFEST = {
|
||||
"polyfillFiles": [
|
||||
"static/chunks/polyfills.js"
|
||||
],
|
||||
"devFiles": [
|
||||
"static/chunks/react-refresh.js"
|
||||
],
|
||||
"ampDevFiles": [],
|
||||
"lowPriorityFiles": [],
|
||||
"rootMainFiles": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js"
|
||||
],
|
||||
"rootMainFilesTree": {},
|
||||
"pages": {
|
||||
"/_app": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main.js",
|
||||
"static/chunks/pages/_app.js"
|
||||
],
|
||||
"/_error": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main.js",
|
||||
"static/chunks/pages/_error.js"
|
||||
]
|
||||
},
|
||||
"ampFirstPages": []
|
||||
};
|
||||
globalThis.__BUILD_MANIFEST.lowPriorityFiles = [
|
||||
"/static/" + process.env.__NEXT_BUILD_ID + "/_buildManifest.js",
|
||||
,"/static/" + process.env.__NEXT_BUILD_ID + "/_ssgManifest.js",
|
||||
|
||||
];
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
"version": 3,
|
||||
"middleware": {},
|
||||
"functions": {},
|
||||
"sortedMiddleware": []
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
self.__REACT_LOADABLE_MANIFEST="{}"
|
||||
|
|
@ -1 +0,0 @@
|
|||
self.__NEXT_FONT_MANIFEST="{\"pages\":{},\"app\":{\"/Users/jettcheng1018/code/dramaling-vocab-learning/frontend/app/layout\":[\"static/media/e4af272ccee01ff0-s.p.woff2\"]},\"appUsingSizeAdjust\":true,\"pagesUsingSizeAdjust\":false}"
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"pages":{},"app":{"/Users/jettcheng1018/code/dramaling-vocab-learning/frontend/app/layout":["static/media/e4af272ccee01ff0-s.p.woff2"]},"appUsingSizeAdjust":true,"pagesUsingSizeAdjust":false}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
{
|
||||
"/_app": "pages/_app.js",
|
||||
"/_error": "pages/_error.js",
|
||||
"/_document": "pages/_document.js"
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
"use strict";
|
||||
/*
|
||||
* ATTENTION: An "eval-source-map" devtool has been used.
|
||||
* This devtool is neither made for production nor for readable output files.
|
||||
* It uses "eval()" calls to create a separate source file with attached SourceMaps in the browser devtools.
|
||||
* If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/)
|
||||
* or disable the default devtool with "devtool: false".
|
||||
* If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/).
|
||||
*/
|
||||
(() => {
|
||||
var exports = {};
|
||||
exports.id = "pages/_app";
|
||||
exports.ids = ["pages/_app"];
|
||||
exports.modules = {
|
||||
|
||||
/***/ "react":
|
||||
/*!************************!*\
|
||||
!*** external "react" ***!
|
||||
\************************/
|
||||
/***/ ((module) => {
|
||||
|
||||
module.exports = require("react");
|
||||
|
||||
/***/ }),
|
||||
|
||||
/***/ "react/jsx-runtime":
|
||||
/*!************************************!*\
|
||||
!*** external "react/jsx-runtime" ***!
|
||||
\************************************/
|
||||
/***/ ((module) => {
|
||||
|
||||
module.exports = require("react/jsx-runtime");
|
||||
|
||||
/***/ })
|
||||
|
||||
};
|
||||
;
|
||||
|
||||
// load runtime
|
||||
var __webpack_require__ = require("../webpack-runtime.js");
|
||||
__webpack_require__.C(exports);
|
||||
var __webpack_exec__ = (moduleId) => (__webpack_require__(__webpack_require__.s = moduleId))
|
||||
var __webpack_exports__ = __webpack_require__.X(0, ["vendor-chunks/next","vendor-chunks/@swc"], () => (__webpack_exec__("(pages-dir-node)/./node_modules/next/dist/pages/_app.js")));
|
||||
module.exports = __webpack_exports__;
|
||||
|
||||
})();
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
"use strict";
|
||||
/*
|
||||
* ATTENTION: An "eval-source-map" devtool has been used.
|
||||
* This devtool is neither made for production nor for readable output files.
|
||||
* It uses "eval()" calls to create a separate source file with attached SourceMaps in the browser devtools.
|
||||
* If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/)
|
||||
* or disable the default devtool with "devtool: false".
|
||||
* If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/).
|
||||
*/
|
||||
(() => {
|
||||
var exports = {};
|
||||
exports.id = "pages/_document";
|
||||
exports.ids = ["pages/_document"];
|
||||
exports.modules = {
|
||||
|
||||
/***/ "next/dist/compiled/next-server/pages.runtime.dev.js":
|
||||
/*!**********************************************************************!*\
|
||||
!*** external "next/dist/compiled/next-server/pages.runtime.dev.js" ***!
|
||||
\**********************************************************************/
|
||||
/***/ ((module) => {
|
||||
|
||||
module.exports = require("next/dist/compiled/next-server/pages.runtime.dev.js");
|
||||
|
||||
/***/ }),
|
||||
|
||||
/***/ "path":
|
||||
/*!***********************!*\
|
||||
!*** external "path" ***!
|
||||
\***********************/
|
||||
/***/ ((module) => {
|
||||
|
||||
module.exports = require("path");
|
||||
|
||||
/***/ }),
|
||||
|
||||
/***/ "react":
|
||||
/*!************************!*\
|
||||
!*** external "react" ***!
|
||||
\************************/
|
||||
/***/ ((module) => {
|
||||
|
||||
module.exports = require("react");
|
||||
|
||||
/***/ }),
|
||||
|
||||
/***/ "react/jsx-runtime":
|
||||
/*!************************************!*\
|
||||
!*** external "react/jsx-runtime" ***!
|
||||
\************************************/
|
||||
/***/ ((module) => {
|
||||
|
||||
module.exports = require("react/jsx-runtime");
|
||||
|
||||
/***/ })
|
||||
|
||||
};
|
||||
;
|
||||
|
||||
// load runtime
|
||||
var __webpack_require__ = require("../webpack-runtime.js");
|
||||
__webpack_require__.C(exports);
|
||||
var __webpack_exec__ = (moduleId) => (__webpack_require__(__webpack_require__.s = moduleId))
|
||||
var __webpack_exports__ = __webpack_require__.X(0, ["vendor-chunks/next","vendor-chunks/@swc"], () => (__webpack_exec__("(pages-dir-node)/./node_modules/next/dist/pages/_document.js")));
|
||||
module.exports = __webpack_exports__;
|
||||
|
||||
})();
|
||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue