readloops 2.3.0__tar.gz → 2.3.1__tar.gz
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.
- {readloops-2.3.0 → readloops-2.3.1}/PKG-INFO +16 -3
- {readloops-2.3.0 → readloops-2.3.1}/README.md +16 -3
- {readloops-2.3.0 → readloops-2.3.1}/app/api/words.py +17 -4
- {readloops-2.3.0 → readloops-2.3.1}/app/main.py +25 -3
- {readloops-2.3.0 → readloops-2.3.1}/app/services/ai.py +42 -26
- readloops-2.3.1/app/web/apple-touch-icon.png +0 -0
- {readloops-2.3.0 → readloops-2.3.1}/app/web/css/style.css +157 -11
- readloops-2.3.1/app/web/favicon-16.png +0 -0
- readloops-2.3.1/app/web/favicon-32.png +0 -0
- readloops-2.3.1/app/web/favicon.ico +0 -0
- readloops-2.3.1/app/web/icon-192.png +0 -0
- readloops-2.3.1/app/web/icon-512.png +0 -0
- readloops-2.3.1/app/web/icon-maskable-512.png +0 -0
- readloops-2.3.1/app/web/icon.svg +12 -0
- {readloops-2.3.0 → readloops-2.3.1}/app/web/index.html +19 -5
- {readloops-2.3.0 → readloops-2.3.1}/app/web/js/app.js +74 -19
- readloops-2.3.1/app/web/manifest.json +36 -0
- {readloops-2.3.0 → readloops-2.3.1}/pyproject.toml +1 -1
- {readloops-2.3.0 → readloops-2.3.1}/readloops.egg-info/PKG-INFO +16 -3
- {readloops-2.3.0 → readloops-2.3.1}/readloops.egg-info/SOURCES.txt +7 -0
- readloops-2.3.0/app/web/icon.svg +0 -14
- readloops-2.3.0/app/web/manifest.json +0 -24
- {readloops-2.3.0 → readloops-2.3.1}/LICENSE +0 -0
- {readloops-2.3.0 → readloops-2.3.1}/app/__init__.py +0 -0
- {readloops-2.3.0 → readloops-2.3.1}/app/api/__init__.py +0 -0
- {readloops-2.3.0 → readloops-2.3.1}/app/api/articles.py +0 -0
- {readloops-2.3.0 → readloops-2.3.1}/app/api/reading.py +0 -0
- {readloops-2.3.0 → readloops-2.3.1}/app/api/stats.py +0 -0
- {readloops-2.3.0 → readloops-2.3.1}/app/cli.py +0 -0
- {readloops-2.3.0 → readloops-2.3.1}/app/config.py +0 -0
- {readloops-2.3.0 → readloops-2.3.1}/app/database.py +0 -0
- {readloops-2.3.0 → readloops-2.3.1}/app/models.py +0 -0
- {readloops-2.3.0 → readloops-2.3.1}/app/services/__init__.py +0 -0
- {readloops-2.3.0 → readloops-2.3.1}/app/services/corpus.py +0 -0
- {readloops-2.3.0 → readloops-2.3.1}/app/services/dict_import.py +0 -0
- {readloops-2.3.0 → readloops-2.3.1}/app/services/similarity.py +0 -0
- {readloops-2.3.0 → readloops-2.3.1}/app/services/smart_test.py +0 -0
- {readloops-2.3.0 → readloops-2.3.1}/app/services/srs.py +0 -0
- {readloops-2.3.0 → readloops-2.3.1}/readloops.egg-info/dependency_links.txt +0 -0
- {readloops-2.3.0 → readloops-2.3.1}/readloops.egg-info/entry_points.txt +0 -0
- {readloops-2.3.0 → readloops-2.3.1}/readloops.egg-info/requires.txt +0 -0
- {readloops-2.3.0 → readloops-2.3.1}/readloops.egg-info/top_level.txt +0 -0
- {readloops-2.3.0 → readloops-2.3.1}/setup.cfg +0 -0
- {readloops-2.3.0 → readloops-2.3.1}/tests/test_api_endpoints.py +0 -0
- {readloops-2.3.0 → readloops-2.3.1}/tests/test_cli.py +0 -0
- {readloops-2.3.0 → readloops-2.3.1}/tests/test_database.py +0 -0
- {readloops-2.3.0 → readloops-2.3.1}/tests/test_similarity.py +0 -0
- {readloops-2.3.0 → readloops-2.3.1}/tests/test_smoke.py +0 -0
- {readloops-2.3.0 → readloops-2.3.1}/tests/test_srs.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: readloops
|
|
3
|
-
Version: 2.3.
|
|
3
|
+
Version: 2.3.1
|
|
4
4
|
Summary: AI 驱动的英语阅读训练器:可理解输入 + FSRS 间隔重复
|
|
5
5
|
Author: Fangyuan2711
|
|
6
6
|
License: MIT
|
|
@@ -38,9 +38,22 @@ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
|
38
38
|
Requires-Dist: pre-commit>=3.7; extra == "dev"
|
|
39
39
|
Dynamic: license-file
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
<p align="center">
|
|
42
|
+
<img src="https://raw.githubusercontent.com/pekoqq/ReadLoops/main/assets/logo.png" width="104" alt="ReadLoops logo">
|
|
43
|
+
</p>
|
|
42
44
|
|
|
43
|
-
>
|
|
45
|
+
<h1 align="center">ReadLoops</h1>
|
|
46
|
+
|
|
47
|
+
<p align="center">
|
|
48
|
+
<a href="https://pypi.org/project/readloops/"><img alt="PyPI" src="https://img.shields.io/pypi/v/readloops"></a>
|
|
49
|
+
<a href="https://pypi.org/project/readloops/"><img alt="Python versions" src="https://img.shields.io/pypi/pyversions/readloops"></a>
|
|
50
|
+
<a href="LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-green"></a>
|
|
51
|
+
<a href="https://github.com/pekoqq/ReadLoops/releases"><img alt="Release" src="https://img.shields.io/github/v/release/pekoqq/ReadLoops"></a>
|
|
52
|
+
</p>
|
|
53
|
+
|
|
54
|
+
<p align="center">
|
|
55
|
+
AI 驱动的英语阅读训练器。基于「可理解输入(i+1)」+ 阅读驱动的 FSRS 间隔重复,面向大学英语四级(进阶六级)。
|
|
56
|
+
</p>
|
|
44
57
|
|
|
45
58
|
## 它解决什么问题
|
|
46
59
|
|
|
@@ -1,6 +1,19 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
>
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/pekoqq/ReadLoops/main/assets/logo.png" width="104" alt="ReadLoops logo">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<h1 align="center">ReadLoops</h1>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<a href="https://pypi.org/project/readloops/"><img alt="PyPI" src="https://img.shields.io/pypi/v/readloops"></a>
|
|
9
|
+
<a href="https://pypi.org/project/readloops/"><img alt="Python versions" src="https://img.shields.io/pypi/pyversions/readloops"></a>
|
|
10
|
+
<a href="LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-green"></a>
|
|
11
|
+
<a href="https://github.com/pekoqq/ReadLoops/releases"><img alt="Release" src="https://img.shields.io/github/v/release/pekoqq/ReadLoops"></a>
|
|
12
|
+
</p>
|
|
13
|
+
|
|
14
|
+
<p align="center">
|
|
15
|
+
AI 驱动的英语阅读训练器。基于「可理解输入(i+1)」+ 阅读驱动的 FSRS 间隔重复,面向大学英语四级(进阶六级)。
|
|
16
|
+
</p>
|
|
4
17
|
|
|
5
18
|
## 它解决什么问题
|
|
6
19
|
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"""单词路由:查词、生词本、批量添加。"""
|
|
2
|
+
import asyncio
|
|
2
3
|
import time
|
|
3
4
|
|
|
4
5
|
from fastapi import APIRouter
|
|
6
|
+
from fastapi.responses import JSONResponse
|
|
5
7
|
from pydantic import BaseModel
|
|
6
8
|
|
|
7
9
|
from app.database import get_db
|
|
@@ -42,11 +44,22 @@ class TranslateRequest(BaseModel):
|
|
|
42
44
|
async def translate(req: TranslateRequest):
|
|
43
45
|
"""翻译句子 / 短语。
|
|
44
46
|
|
|
45
|
-
划词选中多个单词时走这里 ——
|
|
46
|
-
|
|
47
|
+
划词选中多个单词时走这里 —— 词典查不到整句,需要调 AI。
|
|
48
|
+
AI 调用是同步阻塞的,放到线程池避免卡住事件循环;失败返回 502 与原因,
|
|
49
|
+
前端据此提示「点击重试」而不是笼统的「翻译失败」。
|
|
47
50
|
"""
|
|
48
|
-
|
|
49
|
-
|
|
51
|
+
text = (req.text or "").strip()
|
|
52
|
+
if not text:
|
|
53
|
+
return {"text": "", "translation": "", "ok": False, "error": "选中文本为空"}
|
|
54
|
+
try:
|
|
55
|
+
translation = await asyncio.to_thread(translate_sentence, text)
|
|
56
|
+
except Exception as exc:
|
|
57
|
+
return JSONResponse(
|
|
58
|
+
status_code=502,
|
|
59
|
+
content={"text": text, "translation": "", "ok": False,
|
|
60
|
+
"error": f"AI 服务暂时不可用:{str(exc)[:160]}"},
|
|
61
|
+
)
|
|
62
|
+
return {"text": text, "translation": translation, "ok": bool(translation)}
|
|
50
63
|
|
|
51
64
|
|
|
52
65
|
@router.post("/add")
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""FastAPI 入口。"""
|
|
2
2
|
import re
|
|
3
|
+
from importlib import metadata
|
|
3
4
|
|
|
4
5
|
from fastapi import FastAPI
|
|
5
6
|
from fastapi.responses import FileResponse, HTMLResponse
|
|
@@ -8,7 +9,12 @@ from fastapi.staticfiles import StaticFiles
|
|
|
8
9
|
from app.api import articles, reading, stats, words
|
|
9
10
|
from app.config import WEB_DIR
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
try:
|
|
13
|
+
__version__ = metadata.version('readloops')
|
|
14
|
+
except metadata.PackageNotFoundError: # 源码直接运行、未 pip install 时
|
|
15
|
+
__version__ = '0.0.0.dev'
|
|
16
|
+
|
|
17
|
+
app = FastAPI(title="ReadLoops", version=__version__)
|
|
12
18
|
|
|
13
19
|
# 注册路由
|
|
14
20
|
app.include_router(articles.router)
|
|
@@ -53,9 +59,25 @@ async def manifest():
|
|
|
53
59
|
return FileResponse(str(WEB_DIR / "manifest.json"))
|
|
54
60
|
|
|
55
61
|
|
|
56
|
-
@app.get("/icon.svg")
|
|
62
|
+
@app.get("/icon.svg", include_in_schema=False)
|
|
57
63
|
async def icon():
|
|
58
|
-
return FileResponse(str(WEB_DIR / "icon.svg"))
|
|
64
|
+
return FileResponse(str(WEB_DIR / "icon.svg"), headers={"Cache-Control": "no-cache"})
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# favicon / iOS 主屏 / PWA 各尺寸 PNG(与 manifest.json 中的路径对应)
|
|
68
|
+
_ICON_ROUTES = {
|
|
69
|
+
'/favicon.ico': 'favicon.ico',
|
|
70
|
+
'/favicon-16.png': 'favicon-16.png',
|
|
71
|
+
'/favicon-32.png': 'favicon-32.png',
|
|
72
|
+
'/apple-touch-icon.png': 'apple-touch-icon.png',
|
|
73
|
+
'/icon-192.png': 'icon-192.png',
|
|
74
|
+
'/icon-512.png': 'icon-512.png',
|
|
75
|
+
'/icon-maskable-512.png': 'icon-maskable-512.png',
|
|
76
|
+
}
|
|
77
|
+
for _url, _fname in _ICON_ROUTES.items():
|
|
78
|
+
def _icon_endpoint(_fname: str = _fname):
|
|
79
|
+
return FileResponse(str(WEB_DIR / _fname), headers={"Cache-Control": "no-cache"})
|
|
80
|
+
app.get(_url, include_in_schema=False)(_icon_endpoint)
|
|
59
81
|
|
|
60
82
|
|
|
61
83
|
@app.get("/api/health")
|
|
@@ -22,29 +22,49 @@ def _get_settings():
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
|
|
25
|
-
def _chat(messages, max_tokens=1500, temperature=0.7):
|
|
26
|
-
"""调用 AI 聊天接口,关闭思考模式。
|
|
25
|
+
def _chat(messages, max_tokens=1500, temperature=0.7, retries=2):
|
|
26
|
+
"""调用 AI 聊天接口,关闭思考模式。
|
|
27
|
+
|
|
28
|
+
deepseek-flash 等模型偶发:① 网络抖动 / 5xx;② 思考没被关住,正文为空、
|
|
29
|
+
内容落到 reasoning_content。两类都按可恢复错误处理,最多重试 ``retries`` 次。
|
|
30
|
+
"""
|
|
27
31
|
cfg = _get_settings()
|
|
28
32
|
if not cfg["api_key"]:
|
|
29
33
|
raise ValueError("未配置 API Key")
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
34
|
+
last_exc = None
|
|
35
|
+
for attempt in range(retries + 1):
|
|
36
|
+
try:
|
|
37
|
+
resp = httpx.post(
|
|
38
|
+
f"{cfg['base_url'].rstrip('/')}/chat/completions",
|
|
39
|
+
headers={
|
|
40
|
+
"Authorization": f"Bearer {cfg['api_key']}",
|
|
41
|
+
"Content-Type": "application/json",
|
|
42
|
+
},
|
|
43
|
+
json={
|
|
44
|
+
"model": cfg["model"],
|
|
45
|
+
"messages": messages,
|
|
46
|
+
"max_tokens": max_tokens,
|
|
47
|
+
"temperature": temperature,
|
|
48
|
+
"thinking": {"type": "disabled"},
|
|
49
|
+
},
|
|
50
|
+
timeout=60,
|
|
51
|
+
)
|
|
52
|
+
resp.raise_for_status()
|
|
53
|
+
data = resp.json()
|
|
54
|
+
message = data["choices"][0]["message"]
|
|
55
|
+
content = (message.get("content") or "").strip()
|
|
56
|
+
if not content:
|
|
57
|
+
# 正文为空通常是思考泄漏到 reasoning_content —— 关思考没生效,值得重试
|
|
58
|
+
reasoning = (message.get("reasoning_content") or "").strip()
|
|
59
|
+
raise ValueError(f"AI 返回内容为空(reasoning_content {len(reasoning)} 字)")
|
|
60
|
+
return content
|
|
61
|
+
except Exception as exc: # 网络 / HTTP / 空内容都走重试
|
|
62
|
+
last_exc = exc
|
|
63
|
+
if attempt < retries:
|
|
64
|
+
time.sleep(0.6 * (attempt + 1))
|
|
65
|
+
continue
|
|
66
|
+
raise
|
|
67
|
+
raise last_exc
|
|
48
68
|
|
|
49
69
|
|
|
50
70
|
def _extract_json(text):
|
|
@@ -563,9 +583,5 @@ def translate_sentence(text):
|
|
|
563
583
|
Return ONLY the translation itself — no explanation, no quotes, no pinyin.
|
|
564
584
|
|
|
565
585
|
Text: {text}"""
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
return (result or "").strip().strip('"').strip("'")
|
|
569
|
-
except Exception as e:
|
|
570
|
-
print(f"翻译失败: {e}")
|
|
571
|
-
return ""
|
|
586
|
+
result = _chat([{"role": "user", "content": prompt}], max_tokens=500, temperature=0.3)
|
|
587
|
+
return (result or "").strip().strip('"').strip("'")
|
|
Binary file
|
|
@@ -51,6 +51,8 @@
|
|
|
51
51
|
玻璃会显得很平;加入极淡光斑后,模糊才产生真正的层次感。 */
|
|
52
52
|
--ambient-1: rgba(140,130,255,0.07);
|
|
53
53
|
--ambient-2: rgba(255,160,100,0.045);
|
|
54
|
+
/* 专注模式:四周收暗的暗角色(把视线收向中央阅读卡),各主题单独配浓度 */
|
|
55
|
+
--focus-shade: rgba(0,0,0,0.42);
|
|
54
56
|
|
|
55
57
|
/* ===== 按钮设计系统 =====
|
|
56
58
|
全站按钮共用这套 token。之前每个按钮各写各的,
|
|
@@ -217,6 +219,7 @@
|
|
|
217
219
|
--ripple-color: rgba(60,45,20,0.12);
|
|
218
220
|
--ambient-1: rgba(190,150,70,0.12);
|
|
219
221
|
--ambient-2: rgba(130,95,50,0.07);
|
|
222
|
+
--focus-shade: rgba(58,42,18,0.26);
|
|
220
223
|
}
|
|
221
224
|
|
|
222
225
|
/* 明亮 — 柔和米白,不刺眼 */
|
|
@@ -236,6 +239,7 @@
|
|
|
236
239
|
--ripple-color: rgba(0,0,0,0.11);
|
|
237
240
|
--ambient-1: rgba(120,140,255,0.08);
|
|
238
241
|
--ambient-2: rgba(255,180,120,0.07);
|
|
242
|
+
--focus-shade: rgba(35,38,52,0.15);
|
|
239
243
|
}
|
|
240
244
|
|
|
241
245
|
/* 纯黑 */
|
|
@@ -255,6 +259,7 @@
|
|
|
255
259
|
--ripple-color: rgba(255,255,255,0.08);
|
|
256
260
|
--ambient-1: rgba(140,130,255,0.09);
|
|
257
261
|
--ambient-2: rgba(255,160,100,0.055);
|
|
262
|
+
--focus-shade: rgba(0,0,0,0.58);
|
|
258
263
|
}
|
|
259
264
|
|
|
260
265
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
@@ -270,17 +275,67 @@ body {
|
|
|
270
275
|
font-size: 13px;
|
|
271
276
|
}
|
|
272
277
|
|
|
273
|
-
/*
|
|
274
|
-
|
|
278
|
+
/* 氛围光层:两团极淡光斑固定在最底层,为毛玻璃提供可折射内容。
|
|
279
|
+
拆成两个独立伪元素、用 transform 平移 —— CSS 渐变的圆心坐标无法平滑过渡,
|
|
280
|
+
只有 transform 能让光源「滑动」。
|
|
281
|
+
日常:左上冷光 + 右下暖光的对角光;进入专注:两团光平滑上移、在顶部汇聚成顶光。
|
|
282
|
+
过渡延迟 .3s、持续 3s,与水纹涟漪(setTimeout 300 + rippleWave 3s)同时起落。 */
|
|
283
|
+
body::before, body::after {
|
|
275
284
|
content: '';
|
|
285
|
+
position: fixed;
|
|
286
|
+
left: 0;
|
|
287
|
+
top: 0;
|
|
288
|
+
width: 64vw;
|
|
289
|
+
height: 64vh;
|
|
290
|
+
z-index: -1; /* 严格压在所有内容之下、body 底色之上的背景光层 */
|
|
291
|
+
pointer-events: none;
|
|
292
|
+
will-change: transform, filter, opacity;
|
|
293
|
+
filter: brightness(1);
|
|
294
|
+
opacity: 1;
|
|
295
|
+
transition: transform 0.6s var(--ease-out), filter 0.6s var(--ease-out),
|
|
296
|
+
opacity 0.6s var(--ease-out); /* 退出时跟手回位 */
|
|
297
|
+
}
|
|
298
|
+
body::before {
|
|
299
|
+
background: radial-gradient(closest-side, var(--ambient-1), transparent 72%);
|
|
300
|
+
transform: translate(-16vw, -22vh); /* 光斑中心≈视口 16% / 10% */
|
|
301
|
+
}
|
|
302
|
+
body::after {
|
|
303
|
+
background: radial-gradient(closest-side, var(--ambient-2), transparent 72%);
|
|
304
|
+
transform: translate(54vw, 58vh); /* 光斑中心≈视口 86% / 90% */
|
|
305
|
+
}
|
|
306
|
+
/* 进入专注:只留一整团统一的顶光居中铺在上方(放大一圈),另一团对角光淡出,
|
|
307
|
+
避免顶部出现两个冷暖异色的「球」。延迟 .3s、时长 3s,与水纹涟漪同时落定。 */
|
|
308
|
+
body.focus-mode::before {
|
|
309
|
+
transform: translate(18vw, -38vh) scale(1.7);
|
|
310
|
+
filter: brightness(2.6);
|
|
311
|
+
transition: transform 3s 0.3s cubic-bezier(0.16, 1, 0.3, 1),
|
|
312
|
+
filter 3s 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
|
313
|
+
}
|
|
314
|
+
body.focus-mode::after {
|
|
315
|
+
opacity: 0; /* 第二团光淡出,顶部只剩一团 */
|
|
316
|
+
transition: opacity 2.4s 0.3s var(--ease-out);
|
|
317
|
+
}
|
|
318
|
+
@media (prefers-reduced-motion: reduce) {
|
|
319
|
+
body.focus-mode::before, body.focus-mode::after { transition-duration: 0.01s; transition-delay: 0s; }
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/* 专注聚焦暗角:中央(阅读卡所在)保持通透,向四周、底部轻微压暗,收束视线。
|
|
323
|
+
层级:背景光 -1 < 暗角 0 < 应用内容 .app(1)。 */
|
|
324
|
+
.focus-shade {
|
|
276
325
|
position: fixed;
|
|
277
326
|
inset: 0;
|
|
278
327
|
z-index: 0;
|
|
279
328
|
pointer-events: none;
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
329
|
+
opacity: 0;
|
|
330
|
+
background: radial-gradient(118% 82% at 50% 38%, transparent 50%, var(--focus-shade) 100%);
|
|
331
|
+
transition: opacity 0.6s var(--ease-out);
|
|
332
|
+
}
|
|
333
|
+
body.focus-mode .focus-shade {
|
|
334
|
+
opacity: 1;
|
|
335
|
+
transition: opacity 3s 0.3s cubic-bezier(0.16, 1, 0.3, 1); /* 与涟漪、顶光同时落定 */
|
|
336
|
+
}
|
|
337
|
+
@media (prefers-reduced-motion: reduce) {
|
|
338
|
+
body.focus-mode .focus-shade { transition-duration: 0.01s; transition-delay: 0s; }
|
|
284
339
|
}
|
|
285
340
|
|
|
286
341
|
/* 主题切换过渡:只在切换的那 420ms 内生效(JS 临时加类),
|
|
@@ -299,6 +354,8 @@ body.theme-transition *::after {
|
|
|
299
354
|
/* 布局 */
|
|
300
355
|
.app {
|
|
301
356
|
display: flex;
|
|
357
|
+
position: relative;
|
|
358
|
+
z-index: 1; /* 抬到专注暗角(.focus-shade z:0)与背景光(z:-1)之上 */
|
|
302
359
|
height: 100vh;
|
|
303
360
|
transform: translateZ(0);
|
|
304
361
|
transition: background 0.5s cubic-bezier(0.32, 0.72, 0, 1);
|
|
@@ -337,7 +394,9 @@ body.theme-transition *::after {
|
|
|
337
394
|
}
|
|
338
395
|
|
|
339
396
|
.sidebar-header {
|
|
340
|
-
display:
|
|
397
|
+
display: flex;
|
|
398
|
+
align-items: center;
|
|
399
|
+
gap: 9px;
|
|
341
400
|
padding: 18px 18px 12px;
|
|
342
401
|
font-size: 13px;
|
|
343
402
|
font-weight: 700;
|
|
@@ -348,11 +407,33 @@ body.theme-transition *::after {
|
|
|
348
407
|
color: inherit;
|
|
349
408
|
text-decoration: none;
|
|
350
409
|
cursor: pointer;
|
|
351
|
-
transition: opacity 0.2s var(--ease-out)
|
|
410
|
+
transition: opacity 0.2s var(--ease-out);
|
|
352
411
|
}
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
412
|
+
/* logo 单色跟随当前主题文字色,不引入额外颜色 */
|
|
413
|
+
.sidebar-logo {
|
|
414
|
+
width: 20px;
|
|
415
|
+
height: 20px;
|
|
416
|
+
flex: none;
|
|
417
|
+
display: inline-flex;
|
|
418
|
+
transition: transform 0.18s var(--ease-out);
|
|
419
|
+
}
|
|
420
|
+
.sidebar-logo svg { width: 100%; height: 100%; display: block; }
|
|
421
|
+
.sidebar-title { transition: letter-spacing 0.25s var(--ease-out); }
|
|
422
|
+
.sidebar-header:hover { opacity: 0.85; }
|
|
423
|
+
.sidebar-header:hover .sidebar-title { letter-spacing: 0.11em; }
|
|
424
|
+
/* hover 时循环环顺时针转一圈并轻微放大,点题 ReadLoops;
|
|
425
|
+
用 animation(而非 transition)避免鼠标离开时整圈倒转 */
|
|
426
|
+
.sidebar-header:hover .sidebar-logo svg {
|
|
427
|
+
animation: logoCycle 0.85s var(--ease-damped) forwards;
|
|
428
|
+
}
|
|
429
|
+
.sidebar-header:active .sidebar-logo { transform: scale(0.86); }
|
|
430
|
+
@keyframes logoCycle {
|
|
431
|
+
0% { transform: rotate(0deg) scale(1); }
|
|
432
|
+
70% { transform: rotate(310deg) scale(1.14); }
|
|
433
|
+
100% { transform: rotate(360deg) scale(1.06); }
|
|
434
|
+
}
|
|
435
|
+
@media (prefers-reduced-motion: reduce) {
|
|
436
|
+
.sidebar-header:hover .sidebar-logo svg { animation: none; }
|
|
356
437
|
}
|
|
357
438
|
.sidebar-nav { padding: 8px 10px; flex: 1; overflow-y: auto; }
|
|
358
439
|
.nav-section {
|
|
@@ -720,6 +801,23 @@ body.theme-transition *::after {
|
|
|
720
801
|
/* 句子翻译模式:整句不该进生词本,隐藏该按钮 */
|
|
721
802
|
.lookup-panel.sentence-mode #lpAdd { display: none; }
|
|
722
803
|
.lookup-panel.sentence-mode { max-width: 380px; }
|
|
804
|
+
/* 整句翻译失败的提示与重试 */
|
|
805
|
+
.lookup-panel .translate-hint { font-size: 12px; color: var(--text-tertiary); margin-bottom: 8px; }
|
|
806
|
+
.lookup-panel .translate-retry-btn {
|
|
807
|
+
height: var(--btn-h-sm);
|
|
808
|
+
padding: var(--btn-pad-sm);
|
|
809
|
+
border-radius: var(--btn-radius);
|
|
810
|
+
border: 1px solid var(--glass-border-strong);
|
|
811
|
+
background: var(--glass);
|
|
812
|
+
color: var(--text-secondary);
|
|
813
|
+
cursor: pointer;
|
|
814
|
+
font-size: 11px;
|
|
815
|
+
font-weight: 500;
|
|
816
|
+
font-family: var(--mono);
|
|
817
|
+
transition: background 0.2s var(--ease-out), color 0.2s var(--ease-out), transform 0.18s var(--ease-spring);
|
|
818
|
+
}
|
|
819
|
+
.lookup-panel .translate-retry-btn:hover { background: var(--glass-strong); color: var(--text); transform: translateY(-1px); }
|
|
820
|
+
.lookup-panel .translate-retry-btn:active { transform: scale(0.97); }
|
|
723
821
|
.lookup-panel .action-btn {
|
|
724
822
|
height: var(--btn-h-sm);
|
|
725
823
|
padding: var(--btn-pad-sm);
|
|
@@ -1862,6 +1960,54 @@ body.theme-transition *::after {
|
|
|
1862
1960
|
.right-panel.show {
|
|
1863
1961
|
transform: translateX(0);
|
|
1864
1962
|
}
|
|
1963
|
+
/* 新手引导箭头:指向右侧抽屉的浮动小玻璃圆钮,触发抽屉后由 JS 加 .dismiss 淡出 */
|
|
1964
|
+
.right-guide-arrow {
|
|
1965
|
+
position: fixed;
|
|
1966
|
+
right: 18px;
|
|
1967
|
+
top: 50%;
|
|
1968
|
+
margin-top: -19px; /* 高 38 的一半:垂直居中;transform 留给显隐/退场动画 */
|
|
1969
|
+
width: 38px;
|
|
1970
|
+
height: 38px;
|
|
1971
|
+
z-index: 55; /* 在右边缘触发带(50)之上、抽屉面板(60)之下 */
|
|
1972
|
+
display: flex;
|
|
1973
|
+
align-items: center;
|
|
1974
|
+
justify-content: center;
|
|
1975
|
+
border-radius: 50%;
|
|
1976
|
+
background: var(--glass-strong);
|
|
1977
|
+
backdrop-filter: var(--glass-liquid-strong);
|
|
1978
|
+
-webkit-backdrop-filter: var(--glass-liquid-strong);
|
|
1979
|
+
border: 1px solid var(--glass-border-strong);
|
|
1980
|
+
box-shadow: var(--glass-inner-shadow), 0 6px 20px rgba(0,0,0,0.25);
|
|
1981
|
+
color: var(--text);
|
|
1982
|
+
opacity: 0;
|
|
1983
|
+
transform: translateX(10px) scale(0.9);
|
|
1984
|
+
transition: opacity 0.4s var(--ease-out), transform 0.45s var(--ease-damped);
|
|
1985
|
+
pointer-events: none;
|
|
1986
|
+
}
|
|
1987
|
+
.right-guide-arrow svg { width: 20px; height: 20px; }
|
|
1988
|
+
.right-guide-arrow.show {
|
|
1989
|
+
opacity: 1;
|
|
1990
|
+
transform: translateX(0) scale(1);
|
|
1991
|
+
}
|
|
1992
|
+
/* 箭头本体持续向右轻推,提示「右边有东西」;显隐交给外层 transform,互不打架 */
|
|
1993
|
+
.right-guide-arrow.show svg {
|
|
1994
|
+
animation: guideNudge 2.2s ease-in-out infinite;
|
|
1995
|
+
}
|
|
1996
|
+
.right-guide-arrow.dismiss {
|
|
1997
|
+
opacity: 0;
|
|
1998
|
+
transform: translateX(16px) scale(0.8);
|
|
1999
|
+
animation: none;
|
|
2000
|
+
}
|
|
2001
|
+
.right-guide-arrow.dismiss svg { animation: none; }
|
|
2002
|
+
@keyframes guideNudge {
|
|
2003
|
+
0%, 100% { transform: translateX(0); }
|
|
2004
|
+
50% { transform: translateX(3px); }
|
|
2005
|
+
}
|
|
2006
|
+
/* 专注模式追求极简,隐藏引导 */
|
|
2007
|
+
.app.focus-mode .right-guide-arrow { display: none; }
|
|
2008
|
+
@media (prefers-reduced-motion: reduce) {
|
|
2009
|
+
.right-guide-arrow.show svg { animation: none; }
|
|
2010
|
+
}
|
|
1865
2011
|
.right-panel.show .right-panel-header {
|
|
1866
2012
|
animation: slideInRight 0.4s 0.1s cubic-bezier(0.16, 1, 0.3, 1) both;
|
|
1867
2013
|
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="ReadLoops">
|
|
2
|
+
<rect width="512" height="512" rx="104" fill="#1a1818"/>
|
|
3
|
+
<defs>
|
|
4
|
+
<linearGradient id="trail" gradientUnits="userSpaceOnUse" x1="382.9" y1="179.8" x2="299.3" y2="114.5">
|
|
5
|
+
<stop offset="0%" stop-color="#fdfcfc" stop-opacity="0.32"/>
|
|
6
|
+
<stop offset="55%" stop-color="#fdfcfc" stop-opacity="0.72"/>
|
|
7
|
+
<stop offset="100%" stop-color="#fdfcfc" stop-opacity="1"/>
|
|
8
|
+
</linearGradient>
|
|
9
|
+
</defs>
|
|
10
|
+
<path d="M 382.86 179.77 A 148 148 0 1 1 299.27 114.47" fill="none" stroke="url(#trail)" stroke-width="38" stroke-linecap="round"/>
|
|
11
|
+
<path d="M 331.61 124.35 L 291.96 138.37 L 306.58 90.56 Z" fill="#fdfcfc" stroke-linejoin="round"/>
|
|
12
|
+
</svg>
|
|
@@ -5,12 +5,15 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
6
|
<title>ReadLoops</title>
|
|
7
7
|
<link rel="manifest" href="/manifest.json">
|
|
8
|
-
<meta name="theme-color" content="#
|
|
8
|
+
<meta name="theme-color" content="#1a1818">
|
|
9
9
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
10
10
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
11
|
+
<link rel="icon" href="/favicon.ico" sizes="32x32">
|
|
11
12
|
<link rel="icon" type="image/svg+xml" href="/icon.svg">
|
|
12
|
-
<link rel="
|
|
13
|
-
<link rel="
|
|
13
|
+
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png">
|
|
14
|
+
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png">
|
|
15
|
+
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
|
16
|
+
<link rel="stylesheet" href="/static/css/style.css?v=42">
|
|
14
17
|
</head>
|
|
15
18
|
<body>
|
|
16
19
|
<!-- 液态玻璃 SVG 滤镜(边缘折射效果) -->
|
|
@@ -33,7 +36,10 @@
|
|
|
33
36
|
</svg>
|
|
34
37
|
<div class="app">
|
|
35
38
|
<aside class="sidebar" id="sidebar">
|
|
36
|
-
<a class="sidebar-header" id="projectHomeLink" href="https://github.com/pekoqq/ReadLoops" target="_blank" rel="noopener noreferrer" title="在 GitHub 上查看项目">
|
|
39
|
+
<a class="sidebar-header" id="projectHomeLink" href="https://github.com/pekoqq/ReadLoops" target="_blank" rel="noopener noreferrer" title="在 GitHub 上查看项目">
|
|
40
|
+
<span class="sidebar-logo" aria-hidden="true"><svg viewBox="0 0 512 512" fill="none"><path d="M 382.86 179.77 A 148 148 0 1 1 299.27 114.47" stroke="currentColor" stroke-width="38" stroke-linecap="round"/><path d="M 331.79 124.41 L 291.96 138.37 L 306.58 90.56 Z" fill="currentColor" stroke-linejoin="round"/></svg></span>
|
|
41
|
+
<span class="sidebar-title">ReadLoops</span>
|
|
42
|
+
</a>
|
|
37
43
|
<nav class="sidebar-nav">
|
|
38
44
|
<div class="nav-section">阅读</div>
|
|
39
45
|
<div class="nav-item active" data-view="reader">阅读</div>
|
|
@@ -174,6 +180,14 @@
|
|
|
174
180
|
</div>
|
|
175
181
|
</div>
|
|
176
182
|
|
|
177
|
-
|
|
183
|
+
<!-- 专注聚焦遮罩:四周轻微收暗,把视线收向中央阅读卡(body.focus-mode 时淡入) -->
|
|
184
|
+
<div class="focus-shade" aria-hidden="true"></div>
|
|
185
|
+
|
|
186
|
+
<!-- 新手引导:浮动小玻璃箭头,指向右侧查词记录抽屉,触发一次抽屉后消失 -->
|
|
187
|
+
<div class="right-guide-arrow" id="rightGuideArrow" aria-hidden="true">
|
|
188
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M9 6l6 6-6 6"/></svg>
|
|
189
|
+
</div>
|
|
190
|
+
|
|
191
|
+
<script src="/static/js/app.js?v=40"></script>
|
|
178
192
|
</body>
|
|
179
193
|
</html>
|
|
@@ -247,10 +247,14 @@ function toggleFocusMode() {
|
|
|
247
247
|
}
|
|
248
248
|
focusMode = !focusMode;
|
|
249
249
|
document.querySelector('.app').classList.toggle('focus-mode', focusMode);
|
|
250
|
+
// body 同步切类:驱动氛围光层上移 / 回位(过渡与水纹涟漪对齐)
|
|
251
|
+
document.body.classList.toggle('focus-mode', focusMode);
|
|
250
252
|
if (focusMode) {
|
|
251
253
|
focusStartTime = Date.now();
|
|
252
254
|
$('#focusBtn').textContent = '退出专注';
|
|
253
255
|
$('#focusBtn').classList.add('active');
|
|
256
|
+
// 进入专注 = 开始沉浸阅读:有文章且计时未跑时同步开始计时
|
|
257
|
+
if (currentArticle && !timerRunning) startTimer();
|
|
254
258
|
toast('已进入专注模式');
|
|
255
259
|
// 触发水纹扩散引导
|
|
256
260
|
setTimeout(triggerRipple, 300);
|
|
@@ -259,6 +263,10 @@ function toggleFocusMode() {
|
|
|
259
263
|
focusTotalTime += duration;
|
|
260
264
|
$('#focusBtn').textContent = '专注';
|
|
261
265
|
$('#focusBtn').classList.remove('active');
|
|
266
|
+
// 退出专注 = 暂停阅读:同步暂停计时(再次进入会从当前读数继续)
|
|
267
|
+
if (timerRunning) pauseTimer();
|
|
268
|
+
// 已非专注,计时按钮回到「开始(即进入专注)」语义,而不是「继续」
|
|
269
|
+
$('#timerBtn').textContent = '开始 [空格]';
|
|
262
270
|
const mins = Math.floor(duration / 60);
|
|
263
271
|
const secs = duration % 60;
|
|
264
272
|
toast(`本次专注 ${mins}分${secs}秒`);
|
|
@@ -369,11 +377,6 @@ document.addEventListener('keydown', (e) => {
|
|
|
369
377
|
toggleFocusMode();
|
|
370
378
|
return;
|
|
371
379
|
}
|
|
372
|
-
// Space 暂停/继续计时(不在输入框中时)
|
|
373
|
-
if (e.key === ' ' && !['INPUT','TEXTAREA'].includes(e.target.tagName)) {
|
|
374
|
-
e.preventDefault();
|
|
375
|
-
toggleTimer();
|
|
376
|
-
}
|
|
377
380
|
});
|
|
378
381
|
|
|
379
382
|
// 计时器
|
|
@@ -383,6 +386,8 @@ function formatTime(s) {
|
|
|
383
386
|
return `${m}:${sec}`;
|
|
384
387
|
}
|
|
385
388
|
function startTimer() {
|
|
389
|
+
// 计时器与专注模式一体:非专注状态下一律不计时(根防线,任何入口都绕不过)
|
|
390
|
+
if (!focusMode) return;
|
|
386
391
|
if (timerRunning) return;
|
|
387
392
|
timerRunning = true;
|
|
388
393
|
$('#timerBtn').textContent = '暂停 [空格]';
|
|
@@ -396,12 +401,22 @@ function pauseTimer() {
|
|
|
396
401
|
clearInterval(timerInterval);
|
|
397
402
|
$('#timerBtn').textContent = '继续 [空格]';
|
|
398
403
|
}
|
|
399
|
-
|
|
404
|
+
// 计时与专注一体:非专注时「开始计时」即进入专注模式;已在专注中则只做暂停 / 继续
|
|
405
|
+
function toggleTimerWithFocus() {
|
|
406
|
+
if (!focusMode) {
|
|
407
|
+
const view = document.querySelector('.nav-item.active')?.dataset.view;
|
|
408
|
+
if (view !== 'reader' || !currentArticle) return; // 非阅读页 / 还没文章:不启动
|
|
409
|
+
toggleFocusMode(); // 进入专注,其进入分支会负责 startTimer
|
|
410
|
+
} else {
|
|
411
|
+
timerRunning ? pauseTimer() : startTimer();
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
$('#timerBtn').addEventListener('click', toggleTimerWithFocus);
|
|
400
415
|
|
|
401
416
|
document.addEventListener('keydown', (e) => {
|
|
402
417
|
if (e.code === 'Space' && !['INPUT','SELECT','TEXTAREA'].includes(e.target.tagName)) {
|
|
403
418
|
e.preventDefault();
|
|
404
|
-
|
|
419
|
+
toggleTimerWithFocus();
|
|
405
420
|
}
|
|
406
421
|
if (e.key === 'Escape') { hideLookupPanel(); closeSettings(); }
|
|
407
422
|
});
|
|
@@ -669,7 +684,8 @@ document.addEventListener('mouseup', (e) => {
|
|
|
669
684
|
if (lookupPanel.contains(e.target)) return;
|
|
670
685
|
const sel = window.getSelection();
|
|
671
686
|
const text = sel.toString().trim();
|
|
672
|
-
|
|
687
|
+
// 上限 280:整句翻译需要容纳 1~2 个完整句子(原 60 稍长一点的句子直接不弹面板)
|
|
688
|
+
if (!text || text.length > 280 || !/[a-zA-Z]/.test(text)) { hideLookupPanel(); return; }
|
|
673
689
|
if (!$('#reader').contains(sel.anchorNode)) { hideLookupPanel(); return; }
|
|
674
690
|
currentSelection = text;
|
|
675
691
|
showLookupPanel(e.clientX, e.clientY, text);
|
|
@@ -682,18 +698,14 @@ async function showLookupPanel(x, y, word) {
|
|
|
682
698
|
$('#lpWord').textContent = word;
|
|
683
699
|
$('#lpPhonetic').textContent = '';
|
|
684
700
|
$('#lpMeaning').textContent = '查询中...';
|
|
685
|
-
|
|
701
|
+
// 计时由「划词自动进入专注」统一带动(mouseup → tryAutoFocus → 进专注即计时),
|
|
702
|
+
// 这里不再单独 startTimer,避免出现非专注计时
|
|
686
703
|
|
|
687
704
|
// 选中多个单词 → 翻译整句;单个单词 → 查词典
|
|
688
705
|
if (/\s/.test(word.trim())) {
|
|
689
706
|
lookupPanel.classList.add('sentence-mode');
|
|
690
707
|
lookupPanel.dataset.wordId = '';
|
|
691
|
-
|
|
692
|
-
const res = await api.post('/api/words/translate', { text: word });
|
|
693
|
-
$('#lpMeaning').textContent = res.translation || '翻译失败';
|
|
694
|
-
} catch (err) {
|
|
695
|
-
$('#lpMeaning').textContent = '翻译失败';
|
|
696
|
-
}
|
|
708
|
+
requestSentenceTranslate(word);
|
|
697
709
|
return;
|
|
698
710
|
}
|
|
699
711
|
lookupPanel.classList.remove('sentence-mode');
|
|
@@ -713,6 +725,34 @@ async function showLookupPanel(x, y, word) {
|
|
|
713
725
|
}
|
|
714
726
|
} catch (err) { $('#lpMeaning').textContent = '查询失败'; }
|
|
715
727
|
}
|
|
728
|
+
// 整句翻译:独立函数,失败时在面板内给出「点击重试」而不是一句死掉的「翻译失败」
|
|
729
|
+
async function requestSentenceTranslate(word) {
|
|
730
|
+
const meaning = $('#lpMeaning');
|
|
731
|
+
meaning.textContent = '翻译中...';
|
|
732
|
+
try {
|
|
733
|
+
const res = await api.post('/api/words/translate', { text: word });
|
|
734
|
+
if (res && res.ok && res.translation) {
|
|
735
|
+
meaning.textContent = res.translation;
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
renderTranslateRetry(word, res && res.error);
|
|
739
|
+
} catch (err) {
|
|
740
|
+
renderTranslateRetry(word, String(err));
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
function renderTranslateRetry(word, reason) {
|
|
744
|
+
if (reason) console.warn('整句翻译失败:', reason);
|
|
745
|
+
const meaning = $('#lpMeaning');
|
|
746
|
+
meaning.innerHTML = '';
|
|
747
|
+
const hint = document.createElement('div');
|
|
748
|
+
hint.className = 'translate-hint';
|
|
749
|
+
hint.textContent = '翻译失败(网络或 AI 服务波动)';
|
|
750
|
+
const btn = document.createElement('button');
|
|
751
|
+
btn.className = 'translate-retry-btn';
|
|
752
|
+
btn.textContent = '点击重试';
|
|
753
|
+
btn.addEventListener('click', () => requestSentenceTranslate(word));
|
|
754
|
+
meaning.append(hint, btn);
|
|
755
|
+
}
|
|
716
756
|
function hideLookupPanel() { lookupPanel.classList.remove('show'); }
|
|
717
757
|
|
|
718
758
|
$('#lpPronounce').addEventListener('click', () => {
|
|
@@ -954,9 +994,9 @@ $$('.nav-item').forEach(item => {
|
|
|
954
994
|
<h2>ReadLoops</h2>
|
|
955
995
|
<p>点击「生成文章」开始阅读训练</p>
|
|
956
996
|
<div class="empty-hints">
|
|
957
|
-
<span><kbd>空格</kbd
|
|
997
|
+
<span><kbd>空格</kbd>开始专注计时 · 暂停继续</span>
|
|
958
998
|
<span><kbd>选中</kbd>查词 · 选整句翻译</span>
|
|
959
|
-
<span><kbd>F</kbd
|
|
999
|
+
<span><kbd>F</kbd>切换专注模式</span>
|
|
960
1000
|
</div>
|
|
961
1001
|
</div>`;
|
|
962
1002
|
}
|
|
@@ -1187,9 +1227,9 @@ async function loadVocab() {
|
|
|
1187
1227
|
<h2>ReadLoops</h2>
|
|
1188
1228
|
<p>点击「生成文章」开始阅读训练</p>
|
|
1189
1229
|
<div class="empty-hints">
|
|
1190
|
-
<span><kbd>空格</kbd
|
|
1230
|
+
<span><kbd>空格</kbd>开始专注计时 · 暂停继续</span>
|
|
1191
1231
|
<span><kbd>选中</kbd>查词 · 选整句翻译</span>
|
|
1192
|
-
<span><kbd>F</kbd
|
|
1232
|
+
<span><kbd>F</kbd>切换专注模式</span>
|
|
1193
1233
|
</div>
|
|
1194
1234
|
</div>`;
|
|
1195
1235
|
}
|
|
@@ -1791,6 +1831,19 @@ const rightPanelTrigger = $('#rightPanelTrigger');
|
|
|
1791
1831
|
let panelHoverTimer = null;
|
|
1792
1832
|
let panelPinned = false; // 工具栏按钮打开时固定显示
|
|
1793
1833
|
|
|
1834
|
+
// 新手引导箭头:没触发过右侧抽屉才显示,首次把抽屉唤出后永久记住(不再打扰)
|
|
1835
|
+
const guideArrow = $('#rightGuideArrow');
|
|
1836
|
+
const GUIDE_SEEN_KEY = 'readloops_right_guide_seen';
|
|
1837
|
+
if (guideArrow && !localStorage.getItem(GUIDE_SEEN_KEY)) {
|
|
1838
|
+
setTimeout(() => guideArrow.classList.add('show'), 900);
|
|
1839
|
+
}
|
|
1840
|
+
function dismissGuideArrow() {
|
|
1841
|
+
if (!guideArrow || guideArrow.classList.contains('dismiss')) return;
|
|
1842
|
+
guideArrow.classList.remove('show');
|
|
1843
|
+
guideArrow.classList.add('dismiss');
|
|
1844
|
+
localStorage.setItem(GUIDE_SEEN_KEY, '1');
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1794
1847
|
// 鼠标移入右侧边缘触发(带防抖,避免快速划过误触)
|
|
1795
1848
|
rightPanelTrigger.addEventListener('mouseenter', () => {
|
|
1796
1849
|
clearTimeout(panelHoverTimer);
|
|
@@ -1798,6 +1851,7 @@ rightPanelTrigger.addEventListener('mouseenter', () => {
|
|
|
1798
1851
|
if (!panelPinned) {
|
|
1799
1852
|
rightPanel.classList.add('show');
|
|
1800
1853
|
loadLookupHistory();
|
|
1854
|
+
dismissGuideArrow(); // 用户已发现右侧抽屉,引导箭头退场
|
|
1801
1855
|
}
|
|
1802
1856
|
}, 120);
|
|
1803
1857
|
});
|
|
@@ -1817,6 +1871,7 @@ rightPanel.addEventListener('mouseleave', () => {
|
|
|
1817
1871
|
$('#lookupHistoryBtn').addEventListener('click', () => {
|
|
1818
1872
|
rightPanel.classList.add('show');
|
|
1819
1873
|
loadLookupHistory();
|
|
1874
|
+
dismissGuideArrow();
|
|
1820
1875
|
});
|
|
1821
1876
|
|
|
1822
1877
|
async function loadLookupHistory() {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ReadLoops",
|
|
3
|
+
"short_name": "ReadLoops",
|
|
4
|
+
"description": "AI 驱动的英语阅读训练器",
|
|
5
|
+
"start_url": "/",
|
|
6
|
+
"display": "standalone",
|
|
7
|
+
"background_color": "#1a1818",
|
|
8
|
+
"theme_color": "#1a1818",
|
|
9
|
+
"orientation": "any",
|
|
10
|
+
"icons": [
|
|
11
|
+
{
|
|
12
|
+
"src": "/icon-192.png",
|
|
13
|
+
"sizes": "192x192",
|
|
14
|
+
"type": "image/png",
|
|
15
|
+
"purpose": "any"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"src": "/icon-512.png",
|
|
19
|
+
"sizes": "512x512",
|
|
20
|
+
"type": "image/png",
|
|
21
|
+
"purpose": "any"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"src": "/icon-maskable-512.png",
|
|
25
|
+
"sizes": "512x512",
|
|
26
|
+
"type": "image/png",
|
|
27
|
+
"purpose": "maskable"
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"src": "/icon.svg",
|
|
31
|
+
"sizes": "any",
|
|
32
|
+
"type": "image/svg+xml",
|
|
33
|
+
"purpose": "any"
|
|
34
|
+
}
|
|
35
|
+
]
|
|
36
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: readloops
|
|
3
|
-
Version: 2.3.
|
|
3
|
+
Version: 2.3.1
|
|
4
4
|
Summary: AI 驱动的英语阅读训练器:可理解输入 + FSRS 间隔重复
|
|
5
5
|
Author: Fangyuan2711
|
|
6
6
|
License: MIT
|
|
@@ -38,9 +38,22 @@ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
|
38
38
|
Requires-Dist: pre-commit>=3.7; extra == "dev"
|
|
39
39
|
Dynamic: license-file
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
<p align="center">
|
|
42
|
+
<img src="https://raw.githubusercontent.com/pekoqq/ReadLoops/main/assets/logo.png" width="104" alt="ReadLoops logo">
|
|
43
|
+
</p>
|
|
42
44
|
|
|
43
|
-
>
|
|
45
|
+
<h1 align="center">ReadLoops</h1>
|
|
46
|
+
|
|
47
|
+
<p align="center">
|
|
48
|
+
<a href="https://pypi.org/project/readloops/"><img alt="PyPI" src="https://img.shields.io/pypi/v/readloops"></a>
|
|
49
|
+
<a href="https://pypi.org/project/readloops/"><img alt="Python versions" src="https://img.shields.io/pypi/pyversions/readloops"></a>
|
|
50
|
+
<a href="LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-green"></a>
|
|
51
|
+
<a href="https://github.com/pekoqq/ReadLoops/releases"><img alt="Release" src="https://img.shields.io/github/v/release/pekoqq/ReadLoops"></a>
|
|
52
|
+
</p>
|
|
53
|
+
|
|
54
|
+
<p align="center">
|
|
55
|
+
AI 驱动的英语阅读训练器。基于「可理解输入(i+1)」+ 阅读驱动的 FSRS 间隔重复,面向大学英语四级(进阶六级)。
|
|
56
|
+
</p>
|
|
44
57
|
|
|
45
58
|
## 它解决什么问题
|
|
46
59
|
|
|
@@ -19,6 +19,13 @@ app/services/dict_import.py
|
|
|
19
19
|
app/services/similarity.py
|
|
20
20
|
app/services/smart_test.py
|
|
21
21
|
app/services/srs.py
|
|
22
|
+
app/web/apple-touch-icon.png
|
|
23
|
+
app/web/favicon-16.png
|
|
24
|
+
app/web/favicon-32.png
|
|
25
|
+
app/web/favicon.ico
|
|
26
|
+
app/web/icon-192.png
|
|
27
|
+
app/web/icon-512.png
|
|
28
|
+
app/web/icon-maskable-512.png
|
|
22
29
|
app/web/icon.svg
|
|
23
30
|
app/web/index.html
|
|
24
31
|
app/web/manifest.json
|
readloops-2.3.0/app/web/icon.svg
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
|
2
|
-
<defs>
|
|
3
|
-
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
4
|
-
<stop offset="0%" style="stop-color:#2d2d30"/>
|
|
5
|
-
<stop offset="100%" style="stop-color:#1a1a1c"/>
|
|
6
|
-
</linearGradient>
|
|
7
|
-
</defs>
|
|
8
|
-
<rect width="512" height="512" rx="96" fill="url(#bg)"/>
|
|
9
|
-
<g transform="translate(256, 256)">
|
|
10
|
-
<path d="M -90 -60 A 110 110 0 1 1 -90 60" fill="none" stroke="white" stroke-width="22" stroke-linecap="round" opacity="0.9"/>
|
|
11
|
-
<polygon points="-90,80 -60,55 -120,55" fill="white" opacity="0.9"/>
|
|
12
|
-
<path d="M 30 -30 L 30 30 M 30 30 L 70 0" fill="none" stroke="white" stroke-width="16" stroke-linecap="round" stroke-linejoin="round" opacity="0.7"/>
|
|
13
|
-
</g>
|
|
14
|
-
</svg>
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "ReadLoops",
|
|
3
|
-
"short_name": "ReadLoops",
|
|
4
|
-
"description": "AI 驱动的英语阅读训练器",
|
|
5
|
-
"start_url": "/",
|
|
6
|
-
"display": "standalone",
|
|
7
|
-
"background_color": "#1a1a1c",
|
|
8
|
-
"theme_color": "#1a1a1c",
|
|
9
|
-
"orientation": "any",
|
|
10
|
-
"icons": [
|
|
11
|
-
{
|
|
12
|
-
"src": "/icon.svg",
|
|
13
|
-
"sizes": "any",
|
|
14
|
-
"type": "image/svg+xml",
|
|
15
|
-
"purpose": "any"
|
|
16
|
-
},
|
|
17
|
-
{
|
|
18
|
-
"src": "/icon.svg",
|
|
19
|
-
"sizes": "any",
|
|
20
|
-
"type": "image/svg+xml",
|
|
21
|
-
"purpose": "maskable"
|
|
22
|
-
}
|
|
23
|
-
]
|
|
24
|
-
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|