readloops 2.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- app/__init__.py +0 -0
- app/api/__init__.py +0 -0
- app/api/articles.py +97 -0
- app/api/reading.py +157 -0
- app/api/stats.py +254 -0
- app/api/words.py +210 -0
- app/cli.py +185 -0
- app/config.py +58 -0
- app/database.py +204 -0
- app/main.py +63 -0
- app/models.py +35 -0
- app/services/__init__.py +0 -0
- app/services/ai.py +571 -0
- app/services/corpus.py +78 -0
- app/services/dict_import.py +236 -0
- app/services/similarity.py +209 -0
- app/services/smart_test.py +345 -0
- app/services/srs.py +154 -0
- app/web/css/style.css +2338 -0
- app/web/icon.svg +14 -0
- app/web/index.html +179 -0
- app/web/js/app.js +1923 -0
- app/web/manifest.json +24 -0
- readloops-2.3.0.dist-info/METADATA +272 -0
- readloops-2.3.0.dist-info/RECORD +29 -0
- readloops-2.3.0.dist-info/WHEEL +5 -0
- readloops-2.3.0.dist-info/entry_points.txt +2 -0
- readloops-2.3.0.dist-info/licenses/LICENSE +21 -0
- readloops-2.3.0.dist-info/top_level.txt +1 -0
app/__init__.py
ADDED
|
File without changes
|
app/api/__init__.py
ADDED
|
File without changes
|
app/api/articles.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""文章路由:生成、列表、详情。"""
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
from fastapi import APIRouter, HTTPException
|
|
5
|
+
|
|
6
|
+
from app.database import get_db
|
|
7
|
+
from app.services.ai import generate_article
|
|
8
|
+
|
|
9
|
+
router = APIRouter(prefix="/api/articles", tags=["articles"])
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@router.post("/generate")
|
|
13
|
+
async def generate():
|
|
14
|
+
"""生成一篇新文章。"""
|
|
15
|
+
article = generate_article()
|
|
16
|
+
if not article:
|
|
17
|
+
raise HTTPException(status_code=500, detail="无法生成文章,请检查 AI 配置")
|
|
18
|
+
return {
|
|
19
|
+
"id": article.id,
|
|
20
|
+
"title": article.title,
|
|
21
|
+
"content": article.content,
|
|
22
|
+
"source": article.source,
|
|
23
|
+
"word_count": article.word_count,
|
|
24
|
+
"new_word_count": article.new_word_count,
|
|
25
|
+
"target_words": json.loads(article.target_words) if article.target_words else [],
|
|
26
|
+
"created_at": article.created_at,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@router.get("/")
|
|
31
|
+
async def list_articles(limit: int = 30):
|
|
32
|
+
"""获取文章列表。"""
|
|
33
|
+
with get_db() as conn:
|
|
34
|
+
rows = conn.execute(
|
|
35
|
+
"SELECT id, title, source, word_count, new_word_count, reading_time_seconds, created_at "
|
|
36
|
+
"FROM articles ORDER BY created_at DESC LIMIT ?",
|
|
37
|
+
(limit,),
|
|
38
|
+
).fetchall()
|
|
39
|
+
return [
|
|
40
|
+
{
|
|
41
|
+
"id": r["id"],
|
|
42
|
+
"title": r["title"],
|
|
43
|
+
"source": r["source"],
|
|
44
|
+
"word_count": r["word_count"],
|
|
45
|
+
"new_word_count": r["new_word_count"],
|
|
46
|
+
"reading_time_seconds": r["reading_time_seconds"],
|
|
47
|
+
"created_at": r["created_at"],
|
|
48
|
+
}
|
|
49
|
+
for r in rows
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@router.get("/{article_id}")
|
|
54
|
+
async def get_article(article_id: int):
|
|
55
|
+
"""获取文章详情。"""
|
|
56
|
+
with get_db() as conn:
|
|
57
|
+
row = conn.execute(
|
|
58
|
+
"SELECT * FROM articles WHERE id = ?", (article_id,)
|
|
59
|
+
).fetchone()
|
|
60
|
+
if not row:
|
|
61
|
+
raise HTTPException(status_code=404, detail="文章不存在")
|
|
62
|
+
return {
|
|
63
|
+
"id": row["id"],
|
|
64
|
+
"title": row["title"],
|
|
65
|
+
"content": row["content"],
|
|
66
|
+
"source": row["source"],
|
|
67
|
+
"word_count": row["word_count"],
|
|
68
|
+
"new_word_count": row["new_word_count"],
|
|
69
|
+
"target_words": json.loads(row["target_words"]) if row["target_words"] else [],
|
|
70
|
+
"difficulty_score": row["difficulty_score"],
|
|
71
|
+
"reading_time_seconds": row["reading_time_seconds"],
|
|
72
|
+
"created_at": row["created_at"],
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@router.get("/{article_id}/details")
|
|
77
|
+
async def get_article_details(article_id: int):
|
|
78
|
+
"""获取文章增强详情:高频词、目标生词、真题相似度。"""
|
|
79
|
+
from app.services.similarity import get_article_details
|
|
80
|
+
details = get_article_details(article_id)
|
|
81
|
+
if not details:
|
|
82
|
+
raise HTTPException(status_code=404, detail="文章不存在")
|
|
83
|
+
return details
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@router.delete("/{article_id}")
|
|
87
|
+
async def delete_article(article_id: int):
|
|
88
|
+
"""删除文章。"""
|
|
89
|
+
with get_db() as conn:
|
|
90
|
+
row = conn.execute("SELECT id FROM articles WHERE id = ?", (article_id,)).fetchone()
|
|
91
|
+
if not row:
|
|
92
|
+
raise HTTPException(status_code=404, detail="文章不存在")
|
|
93
|
+
# 先删除关联数据,再删除文章本身(避免外键约束失败)
|
|
94
|
+
conn.execute("DELETE FROM highlights WHERE article_id = ?", (article_id,))
|
|
95
|
+
conn.execute("DELETE FROM reading_sessions WHERE article_id = ?", (article_id,))
|
|
96
|
+
conn.execute("DELETE FROM articles WHERE id = ?", (article_id,))
|
|
97
|
+
return {"status": "ok", "message": "文章已删除"}
|
app/api/reading.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""阅读行为路由:查词记录、高亮、阅读会话。"""
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
from fastapi import APIRouter
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from app.database import get_db
|
|
8
|
+
|
|
9
|
+
router = APIRouter(prefix="/api/reading", tags=["reading"])
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LookupRequest(BaseModel):
|
|
13
|
+
word_id: int = 0
|
|
14
|
+
article_id: int = 0
|
|
15
|
+
context: str = ""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class HighlightRequest(BaseModel):
|
|
19
|
+
article_id: int
|
|
20
|
+
text: str
|
|
21
|
+
word_id: int = 0
|
|
22
|
+
color: str = "yellow"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SessionRequest(BaseModel):
|
|
26
|
+
article_id: int
|
|
27
|
+
duration_seconds: int = 0
|
|
28
|
+
lookups: int = 0
|
|
29
|
+
highlights: int = 0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@router.post("/lookup")
|
|
33
|
+
async def record_lookup(word_id: int = 0, article_id: int = 0, context: str = ""):
|
|
34
|
+
"""记录一次查词。
|
|
35
|
+
算法:
|
|
36
|
+
- 第1次查词:只记录
|
|
37
|
+
- 第2次查词:标记为 target(后续文章优先包含,测验记忆)
|
|
38
|
+
- 第3次及以上:自动加入生词本(learning)
|
|
39
|
+
"""
|
|
40
|
+
now = int(time.time())
|
|
41
|
+
with get_db() as conn:
|
|
42
|
+
if word_id:
|
|
43
|
+
conn.execute(
|
|
44
|
+
"INSERT INTO word_encounters (word_id, article_id, context, action, created_at) "
|
|
45
|
+
"VALUES (?, ?, ?, 'lookup', ?)",
|
|
46
|
+
(word_id, article_id or None, context, now),
|
|
47
|
+
)
|
|
48
|
+
# 获取当前 lookup_count(更新前)
|
|
49
|
+
row = conn.execute("SELECT lookup_count, status FROM words WHERE id=?", (word_id,)).fetchone()
|
|
50
|
+
if row:
|
|
51
|
+
current_count = row["lookup_count"]
|
|
52
|
+
new_count = current_count + 1
|
|
53
|
+
# 根据查词次数更新状态
|
|
54
|
+
if new_count >= 3 and row["status"] != "learning":
|
|
55
|
+
# 第3次查词:自动加入生词本
|
|
56
|
+
new_status = "learning"
|
|
57
|
+
elif new_count == 2 and row["status"] == "new":
|
|
58
|
+
# 第2次查词:标记为目标词
|
|
59
|
+
new_status = "target"
|
|
60
|
+
else:
|
|
61
|
+
new_status = row["status"]
|
|
62
|
+
conn.execute(
|
|
63
|
+
"UPDATE words SET lookup_count = ?, status = ?, updated_at = ? WHERE id=?",
|
|
64
|
+
(new_count, new_status, now, word_id),
|
|
65
|
+
)
|
|
66
|
+
return {"status": "ok", "lookup_count": new_count, "word_status": new_status}
|
|
67
|
+
return {"status": "ok"}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@router.get("/lookups")
|
|
71
|
+
async def get_lookup_history(limit: int = 50):
|
|
72
|
+
"""获取查词历史(最近查过的词,去重)。"""
|
|
73
|
+
with get_db() as conn:
|
|
74
|
+
rows = conn.execute(
|
|
75
|
+
"""
|
|
76
|
+
SELECT w.id, w.text, w.phonetic, w.meaning, MAX(we.created_at) as last_lookup,
|
|
77
|
+
COUNT(we.id) as lookup_count
|
|
78
|
+
FROM word_encounters we
|
|
79
|
+
JOIN words w ON w.id = we.word_id
|
|
80
|
+
WHERE we.action = 'lookup'
|
|
81
|
+
GROUP BY w.id
|
|
82
|
+
ORDER BY last_lookup DESC
|
|
83
|
+
LIMIT ?
|
|
84
|
+
""",
|
|
85
|
+
(limit,),
|
|
86
|
+
).fetchall()
|
|
87
|
+
return [
|
|
88
|
+
{
|
|
89
|
+
"word_id": r["id"],
|
|
90
|
+
"text": r["text"],
|
|
91
|
+
"phonetic": r["phonetic"] or "",
|
|
92
|
+
"meaning": r["meaning"] or "",
|
|
93
|
+
"last_lookup": r["last_lookup"],
|
|
94
|
+
"lookup_count": r["lookup_count"],
|
|
95
|
+
}
|
|
96
|
+
for r in rows
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@router.post("/highlight")
|
|
101
|
+
async def record_highlight(req: HighlightRequest):
|
|
102
|
+
"""记录一次高亮。"""
|
|
103
|
+
now = int(time.time())
|
|
104
|
+
with get_db() as conn:
|
|
105
|
+
conn.execute(
|
|
106
|
+
"INSERT INTO highlights (article_id, text, word_id, color, created_at) "
|
|
107
|
+
"VALUES (?, ?, ?, ?, ?)",
|
|
108
|
+
(req.article_id, req.text, req.word_id or None, req.color, now),
|
|
109
|
+
)
|
|
110
|
+
if req.word_id:
|
|
111
|
+
conn.execute(
|
|
112
|
+
"UPDATE words SET encounter_count = encounter_count + 1 WHERE id=?",
|
|
113
|
+
(req.word_id,),
|
|
114
|
+
)
|
|
115
|
+
return {"status": "ok"}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@router.delete("/highlight")
|
|
119
|
+
async def delete_highlight(article_id: int, text: str):
|
|
120
|
+
"""删除一次高亮。"""
|
|
121
|
+
with get_db() as conn:
|
|
122
|
+
result = conn.execute(
|
|
123
|
+
"DELETE FROM highlights WHERE article_id=? AND text=?",
|
|
124
|
+
(article_id, text),
|
|
125
|
+
)
|
|
126
|
+
return {"status": "ok", "deleted": result.rowcount}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@router.post("/session")
|
|
130
|
+
async def record_session(req: SessionRequest):
|
|
131
|
+
"""记录一次阅读会话。"""
|
|
132
|
+
now = int(time.time())
|
|
133
|
+
with get_db() as conn:
|
|
134
|
+
# 先检查文章是否存在,避免外键约束失败
|
|
135
|
+
article = conn.execute("SELECT id FROM articles WHERE id=?", (req.article_id,)).fetchone()
|
|
136
|
+
if article:
|
|
137
|
+
conn.execute(
|
|
138
|
+
"INSERT INTO reading_sessions (article_id, start_time, end_time, duration_seconds, lookups, highlights) "
|
|
139
|
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
140
|
+
(req.article_id, now - req.duration_seconds, now, req.duration_seconds, req.lookups, req.highlights),
|
|
141
|
+
)
|
|
142
|
+
conn.execute(
|
|
143
|
+
"UPDATE articles SET reading_time_seconds = reading_time_seconds + ? WHERE id=?",
|
|
144
|
+
(req.duration_seconds, req.article_id),
|
|
145
|
+
)
|
|
146
|
+
return {"status": "ok"}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@router.get("/highlights/{article_id}")
|
|
150
|
+
async def get_highlights(article_id: int):
|
|
151
|
+
"""获取文章的高亮。"""
|
|
152
|
+
with get_db() as conn:
|
|
153
|
+
rows = conn.execute(
|
|
154
|
+
"SELECT text, color, created_at FROM highlights WHERE article_id=? ORDER BY created_at",
|
|
155
|
+
(article_id,),
|
|
156
|
+
).fetchall()
|
|
157
|
+
return [{"text": r["text"], "color": r["color"], "created_at": r["created_at"]} for r in rows]
|
app/api/stats.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""统计、测试、设置路由。"""
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
from fastapi import APIRouter
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
from app.database import get_db
|
|
9
|
+
|
|
10
|
+
router = APIRouter(prefix="/api", tags=["stats"])
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SettingsRequest(BaseModel):
|
|
14
|
+
ai_base_url: str = ""
|
|
15
|
+
ai_api_key: str = ""
|
|
16
|
+
ai_model: str = ""
|
|
17
|
+
theme: str = ""
|
|
18
|
+
font_size: str = ""
|
|
19
|
+
line_height: str = ""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TestApiRequest(BaseModel):
|
|
23
|
+
ai_base_url: str = ""
|
|
24
|
+
ai_api_key: str = ""
|
|
25
|
+
ai_model: str = ""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@router.get("/stats/overview")
|
|
29
|
+
async def stats_overview():
|
|
30
|
+
"""总览统计。"""
|
|
31
|
+
with get_db() as conn:
|
|
32
|
+
articles_read = conn.execute("SELECT COUNT(*) as c FROM articles").fetchone()["c"]
|
|
33
|
+
total_words = conn.execute("SELECT COALESCE(SUM(word_count),0) as s FROM articles").fetchone()["s"]
|
|
34
|
+
words_learning = conn.execute(
|
|
35
|
+
"SELECT COUNT(*) as c FROM words WHERE status='learning'"
|
|
36
|
+
).fetchone()["c"]
|
|
37
|
+
words_known = conn.execute(
|
|
38
|
+
"SELECT COUNT(*) as c FROM words WHERE status='known'"
|
|
39
|
+
).fetchone()["c"]
|
|
40
|
+
words_target = conn.execute(
|
|
41
|
+
"SELECT COUNT(*) as c FROM words WHERE status='target'"
|
|
42
|
+
).fetchone()["c"]
|
|
43
|
+
total_time = conn.execute(
|
|
44
|
+
"SELECT COALESCE(SUM(duration_seconds),0) as s FROM reading_sessions"
|
|
45
|
+
).fetchone()["s"]
|
|
46
|
+
total_lookups = conn.execute(
|
|
47
|
+
"SELECT COUNT(*) as c FROM word_encounters WHERE action='lookup'"
|
|
48
|
+
).fetchone()["c"]
|
|
49
|
+
|
|
50
|
+
# 连续学习天数(streak)
|
|
51
|
+
streak = 0
|
|
52
|
+
days = conn.execute(
|
|
53
|
+
"SELECT DISTINCT date(created_at, 'unixepoch', 'localtime') as d "
|
|
54
|
+
"FROM articles ORDER BY d DESC LIMIT 30"
|
|
55
|
+
).fetchall()
|
|
56
|
+
if days:
|
|
57
|
+
from datetime import datetime, timedelta
|
|
58
|
+
today = datetime.now().date()
|
|
59
|
+
day_set = set(d["d"] for d in days)
|
|
60
|
+
# 从今天或昨天开始算
|
|
61
|
+
check = today
|
|
62
|
+
if str(check) not in day_set:
|
|
63
|
+
check = today - timedelta(days=1)
|
|
64
|
+
while str(check) in day_set:
|
|
65
|
+
streak += 1
|
|
66
|
+
check -= timedelta(days=1)
|
|
67
|
+
|
|
68
|
+
# 词汇量估算(已掌握 + 学习中 * 0.5 + 测试正确率加权)
|
|
69
|
+
test_correct = conn.execute(
|
|
70
|
+
"SELECT COALESCE(SUM(correct_count),0) as s FROM words"
|
|
71
|
+
).fetchone()["s"]
|
|
72
|
+
test_total = conn.execute(
|
|
73
|
+
"SELECT COALESCE(SUM(correct_count + wrong_count),0) as s FROM words"
|
|
74
|
+
).fetchone()["s"]
|
|
75
|
+
vocab_estimate = words_known + int(words_learning * 0.5) + 2000 # 基础2000 + 已掌握 + 学习中一半
|
|
76
|
+
|
|
77
|
+
# FSRS 统计
|
|
78
|
+
from app.services.srs import get_review_stats
|
|
79
|
+
srs = get_review_stats(conn)
|
|
80
|
+
|
|
81
|
+
# 查词热词 TOP10
|
|
82
|
+
hot_words = conn.execute(
|
|
83
|
+
"SELECT w.text, w.lookup_count FROM words w "
|
|
84
|
+
"WHERE w.lookup_count > 0 ORDER BY w.lookup_count DESC LIMIT 10"
|
|
85
|
+
).fetchall()
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
"articles_read": articles_read,
|
|
89
|
+
"total_words": total_words,
|
|
90
|
+
"words_learning": words_learning,
|
|
91
|
+
"words_known": words_known,
|
|
92
|
+
"words_target": words_target,
|
|
93
|
+
"total_reading_seconds": total_time,
|
|
94
|
+
"total_lookups": total_lookups,
|
|
95
|
+
"streak_days": streak,
|
|
96
|
+
"vocab_estimate": vocab_estimate,
|
|
97
|
+
"test_accuracy": round(test_correct / test_total * 100, 1) if test_total > 0 else 0,
|
|
98
|
+
"srs": srs,
|
|
99
|
+
"hot_words": [{"text": r["text"], "count": r["lookup_count"]} for r in hot_words],
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@router.get("/stats/recent")
|
|
104
|
+
async def stats_recent(days: int = 7):
|
|
105
|
+
"""最近统计。"""
|
|
106
|
+
since = int(time.time()) - days * 86400
|
|
107
|
+
with get_db() as conn:
|
|
108
|
+
rows = conn.execute(
|
|
109
|
+
"SELECT date(created_at, 'unixepoch', 'localtime') as day, COUNT(*) as cnt "
|
|
110
|
+
"FROM articles WHERE created_at >= ? GROUP BY day ORDER BY day",
|
|
111
|
+
(since,),
|
|
112
|
+
).fetchall()
|
|
113
|
+
return [{"date": r["day"], "count": r["cnt"]} for r in rows]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@router.get("/stats/activity")
|
|
117
|
+
async def stats_activity(weeks: int = 52):
|
|
118
|
+
"""每日活动热力图数据(最近 N 周)。"""
|
|
119
|
+
from datetime import datetime, timedelta
|
|
120
|
+
today = datetime.now().date()
|
|
121
|
+
# 计算起始日期(对齐到周一)
|
|
122
|
+
start = today - timedelta(days=weeks * 7 + today.weekday())
|
|
123
|
+
|
|
124
|
+
with get_db() as conn:
|
|
125
|
+
# 文章数按天
|
|
126
|
+
articles_by_day = {}
|
|
127
|
+
for r in conn.execute(
|
|
128
|
+
"SELECT date(created_at, 'unixepoch', 'localtime') as d, COUNT(*) as c "
|
|
129
|
+
"FROM articles WHERE created_at >= ? GROUP BY d",
|
|
130
|
+
(int(datetime.combine(start, datetime.min.time()).timestamp()),),
|
|
131
|
+
).fetchall():
|
|
132
|
+
articles_by_day[r["d"]] = r["c"]
|
|
133
|
+
|
|
134
|
+
# 阅读时长按天
|
|
135
|
+
time_by_day = {}
|
|
136
|
+
for r in conn.execute(
|
|
137
|
+
"SELECT date(start_time, 'unixepoch', 'localtime') as d, "
|
|
138
|
+
"COALESCE(SUM(duration_seconds),0) as s FROM reading_sessions "
|
|
139
|
+
"WHERE start_time >= ? GROUP BY d",
|
|
140
|
+
(int(datetime.combine(start, datetime.min.time()).timestamp()),),
|
|
141
|
+
).fetchall():
|
|
142
|
+
time_by_day[r["d"]] = r["s"]
|
|
143
|
+
|
|
144
|
+
# 查词数按天
|
|
145
|
+
lookup_by_day = {}
|
|
146
|
+
for r in conn.execute(
|
|
147
|
+
"SELECT date(created_at, 'unixepoch', 'localtime') as d, COUNT(*) as c "
|
|
148
|
+
"FROM word_encounters WHERE action='lookup' AND created_at >= ? GROUP BY d",
|
|
149
|
+
(int(datetime.combine(start, datetime.min.time()).timestamp()),),
|
|
150
|
+
).fetchall():
|
|
151
|
+
lookup_by_day[r["d"]] = r["c"]
|
|
152
|
+
|
|
153
|
+
# 生成完整日期序列
|
|
154
|
+
days = []
|
|
155
|
+
d = start
|
|
156
|
+
while d <= today:
|
|
157
|
+
ds = str(d)
|
|
158
|
+
articles = articles_by_day.get(ds, 0)
|
|
159
|
+
seconds = time_by_day.get(ds, 0)
|
|
160
|
+
lookups = lookup_by_day.get(ds, 0)
|
|
161
|
+
# 活动等级:0-4(基于文章数+阅读分钟+查词数综合)
|
|
162
|
+
score = articles * 2 + min(seconds / 300, 3) + min(lookups / 5, 2)
|
|
163
|
+
level = 0 if score == 0 else min(4, int(score) + 1)
|
|
164
|
+
days.append({
|
|
165
|
+
"date": ds,
|
|
166
|
+
"articles": articles,
|
|
167
|
+
"reading_seconds": seconds,
|
|
168
|
+
"lookups": lookups,
|
|
169
|
+
"level": level,
|
|
170
|
+
})
|
|
171
|
+
d += timedelta(days=1)
|
|
172
|
+
|
|
173
|
+
return {"days": days, "total_days": len(days), "active_days": sum(1 for d in days if d["level"] > 0)}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@router.post("/stats/test/start")
|
|
177
|
+
async def test_start():
|
|
178
|
+
"""开始一次词汇测试。"""
|
|
179
|
+
now = int(time.time())
|
|
180
|
+
with get_db() as conn:
|
|
181
|
+
cur = conn.execute(
|
|
182
|
+
"INSERT INTO tests (type, status, created_at) VALUES ('vocab', 'in_progress', ?)",
|
|
183
|
+
(now,),
|
|
184
|
+
)
|
|
185
|
+
test_id = cur.lastrowid
|
|
186
|
+
return {"test_id": test_id}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@router.post("/stats/test/{test_id}/submit")
|
|
190
|
+
async def test_submit(test_id: int, correct: int = 0, total: int = 0):
|
|
191
|
+
"""提交测试结果。"""
|
|
192
|
+
now = int(time.time())
|
|
193
|
+
with get_db() as conn:
|
|
194
|
+
conn.execute(
|
|
195
|
+
"UPDATE tests SET status='completed', score=?, total=?, completed_at=? WHERE id=?",
|
|
196
|
+
(correct, total, now, test_id),
|
|
197
|
+
)
|
|
198
|
+
return {"test_id": test_id, "score": correct, "total": total}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@router.get("/settings")
|
|
202
|
+
async def get_settings():
|
|
203
|
+
"""获取设置。"""
|
|
204
|
+
with get_db() as conn:
|
|
205
|
+
rows = conn.execute("SELECT key, value FROM settings").fetchall()
|
|
206
|
+
return {r["key"]: r["value"] for r in rows}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@router.post("/settings")
|
|
210
|
+
async def update_settings(req: SettingsRequest):
|
|
211
|
+
"""更新设置。"""
|
|
212
|
+
with get_db() as conn:
|
|
213
|
+
for key, value in req.model_dump().items():
|
|
214
|
+
if value:
|
|
215
|
+
conn.execute(
|
|
216
|
+
"INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
|
|
217
|
+
(key, value),
|
|
218
|
+
)
|
|
219
|
+
return {"status": "ok"}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@router.post("/settings/test")
|
|
223
|
+
async def test_api_connection(req: TestApiRequest):
|
|
224
|
+
"""测试 AI API 连接。"""
|
|
225
|
+
base_url = req.ai_base_url.rstrip("/")
|
|
226
|
+
api_key = req.ai_api_key
|
|
227
|
+
model = req.ai_model
|
|
228
|
+
if not base_url or not api_key:
|
|
229
|
+
return {"ok": False, "error": "请填写 Base URL 和 API Key"}
|
|
230
|
+
try:
|
|
231
|
+
async with httpx.AsyncClient(timeout=10) as client:
|
|
232
|
+
resp = await client.get(
|
|
233
|
+
f"{base_url}/models",
|
|
234
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
235
|
+
)
|
|
236
|
+
if resp.status_code == 200:
|
|
237
|
+
data = resp.json()
|
|
238
|
+
models = [m.get("id", "") for m in data.get("data", [])]
|
|
239
|
+
return {"ok": True, "model": model or (models[0] if models else "未知")}
|
|
240
|
+
resp2 = await client.post(
|
|
241
|
+
f"{base_url}/chat/completions",
|
|
242
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
243
|
+
json={
|
|
244
|
+
"model": model or "gpt-3.5-turbo",
|
|
245
|
+
"messages": [{"role": "user", "content": "hi"}],
|
|
246
|
+
"max_tokens": 5,
|
|
247
|
+
"thinking": {"type": "disabled"},
|
|
248
|
+
},
|
|
249
|
+
)
|
|
250
|
+
if resp2.status_code == 200:
|
|
251
|
+
return {"ok": True, "model": model}
|
|
252
|
+
return {"ok": False, "error": f"HTTP {resp2.status_code}: {resp2.text[:100]}"}
|
|
253
|
+
except Exception as e:
|
|
254
|
+
return {"ok": False, "error": str(e)[:100]}
|