From 23a0c2e968eea71fd27bbed49c9778a14af6867c Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Wed, 21 Oct 2026 08:47:35 +0200 Subject: [PATCH 1/7] =?UTF-8?q?fix(backend):=20=E6=90=9C=E7=B4=A2=E8=AE=A2?= =?UTF-8?q?=E9=98=85=E5=85=B3=E9=94=AE=E8=AF=8D=E5=8E=BB=E6=8E=89=E9=9B=86?= =?UTF-8?q?=E5=8F=B7=20(#1138)=E3=80=81=E4=B8=8D=E9=87=8D=E5=91=BD?= =?UTF-8?q?=E5=90=8D=E6=97=B6=E5=AE=8C=E6=88=90=E9=80=9A=E7=9F=A5=E5=8F=AA?= =?UTF-8?q?=E5=8F=91=E4=B8=80=E6=AC=A1=20(#1082)=E3=80=81=E5=81=8F?= =?UTF-8?q?=E7=A7=BB=E8=AF=AF=E6=8A=A5=E5=9C=A8=E5=A4=8D=E6=89=AB=E6=97=A0?= =?UTF-8?q?=E5=81=8F=E7=A7=BB=E6=97=B6=E8=87=AA=E5=8A=A8=E6=B8=85=E9=99=A4?= =?UTF-8?q?=E5=B9=B6=E8=AE=A9=E8=BF=9E=E8=BD=BD=E7=95=AA=E5=89=A7=E7=9A=84?= =?UTF-8?q?=20TMDB=20=E7=BC=93=E5=AD=98=2024=20=E5=B0=8F=E6=97=B6=E8=BF=87?= =?UTF-8?q?=E6=9C=9F=20(#1072)=E3=80=81=E6=8C=89=E7=95=AA=E5=89=A7?= =?UTF-8?q?=E6=89=B9=E9=87=8F=E5=88=A0=E9=99=A4=E7=A7=8D=E5=AD=90=20(#1113?= =?UTF-8?q?)=E3=80=81/posters=20=E6=94=B9=E7=94=A8=E4=B8=A5=E6=A0=BC?= =?UTF-8?q?=E7=9A=84=E7=9B=AE=E5=BD=95=E5=8C=85=E5=90=AB=E5=88=A4=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/main.py | 2 +- backend/src/module/core/offset_scanner.py | 24 ++++++--- .../src/module/database/rename_operation.py | 5 ++ backend/src/module/database/torrent.py | 17 +++--- backend/src/module/manager/renamer.py | 3 ++ backend/src/module/manager/revision_saga.py | 16 ++++++ .../src/module/parser/analyser/tmdb_parser.py | 14 +++-- backend/src/module/searcher/searcher.py | 13 ++++- backend/src/test/test_database.py | 24 +++++++++ .../test/test_database_rename_operation.py | 20 +++++++ backend/src/test/test_main_posters.py | 27 ++++++++++ backend/src/test/test_offset_scanner.py | 53 +++++++++++++++++++ backend/src/test/test_renamer.py | 12 +++++ backend/src/test/test_searcher.py | 44 +++++++++++++++ backend/src/test/test_tmdb.py | 27 ++++++++++ 15 files changed, 280 insertions(+), 21 deletions(-) create mode 100644 backend/src/test/test_main_posters.py diff --git a/backend/src/main.py b/backend/src/main.py index 6d3976e0..f36bbca9 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -151,7 +151,7 @@ _POSTERS_BASE = Path("data/posters").resolve() @app.get("/posters/{path:path}", tags=["posters"]) def posters(path: str): resolved = (_POSTERS_BASE / path).resolve() - if not str(resolved).startswith(str(_POSTERS_BASE)): + if not resolved.is_relative_to(_POSTERS_BASE): return HTMLResponse(status_code=403) return FileResponse(str(resolved)) diff --git a/backend/src/module/core/offset_scanner.py b/backend/src/module/core/offset_scanner.py index 5a772606..86f79bd9 100644 --- a/backend/src/module/core/offset_scanner.py +++ b/backend/src/module/core/offset_scanner.py @@ -54,11 +54,6 @@ class OffsetScanner: An event describing the flag if the bangumi was flagged for review, None otherwise. """ - # Skip if already needs review - if bangumi.needs_review: - logger.debug(f"Skipping {bangumi.official_title}: already needs review") - return None - # OVA/OAD/SP are persisted as Bangumi for library compatibility, but # they are not weekly episode streams and must never drive offsets. if bangumi.episode_type != "episode": @@ -96,7 +91,24 @@ class OffsetScanner: tmdb_info=tmdb_info, ) - if suggestion and suggestion.confidence in ("high", "medium"): + flagged = suggestion is not None and suggestion.confidence in ( + "high", + "medium", + ) + # 已标记的番剧也要重新扫描:TMDB 集数更新或解析修正后,误报不会自己消失 + # (#1072)。needs_review 只由本扫描器设置;用户忽略或应用后标记已清除, + # 手动配了偏移的在上面已跳过,这里清除的只会是扫描器自己的旧标记 + if bangumi.needs_review: + if not flagged: + async with Database() as db: + await db.bangumi.clear_needs_review(bangumi.id) + logger.info( + "Cleared review flag for %s: no mismatch on rescan", + bangumi.official_title, + ) + return None + + if suggestion and flagged: async with Database() as db: await db.bangumi.set_needs_review( bangumi.id, diff --git a/backend/src/module/database/rename_operation.py b/backend/src/module/database/rename_operation.py index 9a6a24e2..f4808084 100644 --- a/backend/src/module/database/rename_operation.py +++ b/backend/src/module/database/rename_operation.py @@ -390,6 +390,11 @@ class RenameOperationDatabase: .where( col(RenameOperation.state) == "done", col(RenameOperation.updated_at) < before, + # 路径未变的记录(重命名方式 none)不清理:种子不打 ab:renamed、 + # 每轮都会再处理,删掉后会重建记录并再发一次下载完成通知(#1082) + # ponytail: 这类记录随已删除的种子累积,量级同 torrent 表; + # 需要时按下载器里已不存在的任务清理 + col(RenameOperation.source_path) != col(RenameOperation.target_path), ) .execution_options(synchronize_session=False) ) diff --git a/backend/src/module/database/torrent.py b/backend/src/module/database/torrent.py index 130a4e2a..5615d26c 100644 --- a/backend/src/module/database/torrent.py +++ b/backend/src/module/database/torrent.py @@ -3,7 +3,7 @@ from collections import defaultdict from sqlalchemy import delete, func from sqlalchemy.ext.asyncio import AsyncSession -from sqlmodel import and_, select, true +from sqlmodel import and_, col, select, true from module.models import Bangumi, RSSItem, Torrent @@ -122,14 +122,15 @@ class TorrentDatabase: Returns the number of deleted records. """ - statement = select(Torrent).where(Torrent.bangumi_id == bangumi_id) - result = await self.session.execute(statement) - torrents = list(result.scalars().all()) - count = len(torrents) - for t in torrents: - await self.session.delete(t) + # 单条 DELETE:逐行 ORM 删除会拉长写事务,与其他写入争 SQLite 锁(#1113) + result = await self.session.execute( + delete(Torrent) + .where(col(Torrent.bangumi_id) == bangumi_id) + .execution_options(synchronize_session=False) + ) + await self.session.commit() + count = result.rowcount # type: ignore[attr-defined] if count > 0: - await self.session.commit() logger.debug( "Deleted %s torrent records for bangumi_id %s.", count, bangumi_id ) diff --git a/backend/src/module/manager/renamer.py b/backend/src/module/manager/renamer.py index 1f065f10..07025c7a 100644 --- a/backend/src/module/manager/renamer.py +++ b/backend/src/module/manager/renamer.py @@ -561,6 +561,9 @@ class Renamer(RevisionSaga): identity=identity, bangumi_name=bangumi_name, episode_offset=episode_offset, + # 只认用户选定的 none:策略未登记而回退到 none 时,恢复后会真正改名 + # 并通知,这里再通知就重复了 + notify_unchanged=settings.plugins.slots.rename_strategy == NO_RENAME, ) def _finish_torrent( diff --git a/backend/src/module/manager/revision_saga.py b/backend/src/module/manager/revision_saga.py index cceb1609..452050f5 100644 --- a/backend/src/module/manager/revision_saga.py +++ b/backend/src/module/manager/revision_saga.py @@ -110,11 +110,25 @@ class RevisionSaga: bangumi_name: str, _hash: str, episode_offset: int, + notify_unchanged: bool = False, ) -> MediaRenameReport: if prepared.source_path == prepared.target_path: + # 重命名方式为 none 时路径从不改变,这里是唯一能发下载完成通知的地方; + # 操作记录随后置为 done,之后各轮直接返回,通知只发一次(#1082) return MediaRenameReport( result=RenameResult(RenameOutcome.ALREADY_APPLIED), prepared=prepared, + notification=( + Notification( + official_title=bangumi_name, + season=prepared.episode.season, + episode=self._adjust_episode( + prepared.episode.episode, episode_offset + ), + ) + if notify_unchanged + else None + ), ) result = await self.client.rename_torrent_file( _hash=_hash, @@ -837,6 +851,7 @@ class RevisionSaga: identity: RevisionIdentity | None, bangumi_name: str, episode_offset: int, + notify_unchanged: bool = False, ) -> MediaRenameReport: """Claim and execute one non-replacement rename exactly once at a time.""" @@ -968,6 +983,7 @@ class RevisionSaga: bangumi_name=bangumi_name, _hash=info["hash"], episode_offset=episode_offset, + notify_unchanged=notify_unchanged, ) if report.result.succeeded: await self._set_operation_state(claimed, "done") diff --git a/backend/src/module/parser/analyser/tmdb_parser.py b/backend/src/module/parser/analyser/tmdb_parser.py index 9917aa45..6df846aa 100644 --- a/backend/src/module/parser/analyser/tmdb_parser.py +++ b/backend/src/module/parser/analyser/tmdb_parser.py @@ -30,6 +30,9 @@ _TMDB_CACHE_MAX = 512 # 否定/回退结果(无匹配、回退到电影)的存活时间:既避免对同一个查不到的标题 # 反复请求 TMDB,又不让一次临时故障把标题永久毒化 _TMDB_NEGATIVE_TTL = 6 * 3600.0 +# 连载中剧集的各季集数每周都在变;永久缓存会让偏移扫描一直拿旧集数误报, +# 也无法在 TMDB 更新后自动清除标记(#1072) +_TMDB_AIRING_TTL = 24 * 3600.0 # 值为 (过期的 monotonic 时刻, 结果);inf 表示不过期 _tmdb_cache: OrderedDict[str, "tuple[float, TMDBInfo | None]"] = OrderedDict() @@ -265,11 +268,12 @@ async def tmdb_parser( logger.warning("TMDB request failed for %s: %s", title, e) return None # 剧集结果的 season 非空;None 与电影回退(season=[])都按否定结果限时缓存 - expires = ( - time.monotonic() + _TMDB_NEGATIVE_TTL - if result is None or not result.season - else float("inf") - ) + if result is None or not result.season: + expires = time.monotonic() + _TMDB_NEGATIVE_TTL + elif result.series_status == "Returning Series": + expires = time.monotonic() + _TMDB_AIRING_TTL + else: + expires = float("inf") _tmdb_cache[cache_key] = (expires, result) _tmdb_cache.move_to_end(cache_key) if len(_tmdb_cache) > _TMDB_CACHE_MAX: diff --git a/backend/src/module/searcher/searcher.py b/backend/src/module/searcher/searcher.py index cf79d2bf..ad288854 100644 --- a/backend/src/module/searcher/searcher.py +++ b/backend/src/module/searcher/searcher.py @@ -1,5 +1,6 @@ import json import logging +import re from collections import OrderedDict from module.conf import settings @@ -21,6 +22,12 @@ SEARCH_KEY = [ "dpi", ] +# season_raw 取自「S03E02」「Season 3 Episode 2」这类季集合写时会带上集数, +# 只保留季的部分,否则订阅关键词锁死在单集(#1138) +_SEASON_WITH_EPISODE = re.compile( + r"^(S\d{1,2}|Season\s+\d{1,2})\s*E(?:P|pisodes?)?\.?\s*\d.*$", re.I +) + # Cache for TMDB preview lookups by official_title. Bounded (LRU-ish, # oldest-evicted) like _tmdb_cache/_mikan_cache — was previously a plain dict @@ -103,7 +110,11 @@ class SearchTorrent: @staticmethod def special_url(data: Bangumi | Movie, site: str) -> RSSItem: - keywords = [value for key in SEARCH_KEY if (value := getattr(data, key, None))] + keywords = [ + _SEASON_WITH_EPISODE.sub(r"\1", value) if key == "season_raw" else value + for key in SEARCH_KEY + if (value := getattr(data, key, None)) + ] url = search_url(site, keywords) return url diff --git a/backend/src/test/test_database.py b/backend/src/test/test_database.py index df43401f..e3e2b51c 100644 --- a/backend/src/test/test_database.py +++ b/backend/src/test/test_database.py @@ -859,6 +859,30 @@ class TestDeleteByBangumiId: assert count == 3 assert len(await db.search_all()) == 0 + async def test_deletes_with_single_statement(self, db_session, db_engine): + """一条 DELETE 删完,不先 SELECT 再逐行删,缩短写事务(#1113)。""" + from sqlalchemy import event + + db = TorrentDatabase(db_session) + await _ensure_bangumi(db_session, 11) + for i in range(3): + await db.add( + Torrent(name=f"t{i}", url=f"https://example.com/b{i}", refer_id=11) + ) + statements: list[str] = [] + + def _record(conn, cursor, statement, parameters, context, executemany): + if "torrent" in statement.lower(): + statements.append(statement.split()[0].upper()) + + event.listen(db_engine.sync_engine, "before_cursor_execute", _record) + try: + assert await db.delete_by_bangumi_id(11) == 3 + finally: + event.remove(db_engine.sync_engine, "before_cursor_execute", _record) + + assert statements == ["DELETE"] + async def test_leaves_other_bangumi_torrents(self, db_session): db = TorrentDatabase(db_session) await _ensure_bangumi(db_session, 20) diff --git a/backend/src/test/test_database_rename_operation.py b/backend/src/test/test_database_rename_operation.py index 32c59b1c..c4d666c0 100644 --- a/backend/src/test/test_database_rename_operation.py +++ b/backend/src/test/test_database_rename_operation.py @@ -264,6 +264,26 @@ async def test_conflict_query_and_done_pruning(db_session): assert await repo.prune_done(before=now - timedelta(days=30)) == 1 +async def test_prune_done_keeps_unchanged_path_rows(db_session): + """路径未变(重命名方式 none)的 done 记录是只通知一次的依据,不能被清理(#1082)。""" + repo = RenameOperationDatabase(db_session) + now = datetime.now(timezone.utc) + row, _ = await repo.get_or_create( + _operation( + kind="conflict", + state="done", + source_path="Anime - 01.mkv", + target_path="Anime - 01.mkv", + ) + ) + row.updated_at = now - timedelta(days=31) + db_session.add(row) + await db_session.commit() + + assert await repo.prune_done(before=now - timedelta(days=30)) == 0 + assert await repo.get(row.id) is not None + + async def test_database_facade_exposes_rename_operation_repository(db_engine): async with Database(engine=db_engine) as db: row, created = await db.rename_operation.get_or_create(_operation()) diff --git a/backend/src/test/test_main_posters.py b/backend/src/test/test_main_posters.py new file mode 100644 index 00000000..998f12da --- /dev/null +++ b/backend/src/test/test_main_posters.py @@ -0,0 +1,27 @@ +"""/posters 路由只能读海报目录内的文件。""" + +import pytest +from fastapi.responses import FileResponse + +import main + + +@pytest.mark.parametrize( + ("path", "allowed"), + [ + ("a.jpg", True), + # 同名前缀的兄弟目录:按字符串前缀检查时会被放行 + ("../posters_x/secret.jpg", False), + ("../secret.jpg", False), + ], +) +def test_posters_rejects_paths_outside_base(tmp_path, monkeypatch, path, allowed): + base = tmp_path / "posters" + base.mkdir() + monkeypatch.setattr(main, "_POSTERS_BASE", base) + + resp = main.posters(path) + + assert isinstance(resp, FileResponse) is allowed + if not allowed: + assert resp.status_code == 403 diff --git a/backend/src/test/test_offset_scanner.py b/backend/src/test/test_offset_scanner.py index 77c05184..71601d21 100644 --- a/backend/src/test/test_offset_scanner.py +++ b/backend/src/test/test_offset_scanner.py @@ -358,3 +358,56 @@ class TestCheckBangumiUsesRealSignal: assert updated.needs_review_reason is not None assert "13" in updated.needs_review_reason assert "12" in updated.needs_review_reason + + +class TestRescanFlaggedBangumi: + """已标记的番剧重新扫描:误报自动清除,其余情况保留标记(#1072)。""" + + @pytest.mark.parametrize( + ("episode", "season_offset", "tmdb_found", "still_flagged"), + [ + (6, 0, True, False), # TMDB 已更新 / 解析已修正:不再不匹配,清除 + (13, 0, True, True), # 仍然不匹配:保留原标记 + (6, -1, True, True), # 用户已手动配置偏移:不动 + (6, 0, False, True), # TMDB 暂时查不到:没有新结论,不动 + ], + ids=["no-mismatch", "still-mismatch", "user-offset", "tmdb-miss"], + ) + async def test_rescan_clears_only_stale_scanner_flag( + self, episode, season_offset, tmdb_found, still_flagged + ): + scanner = OffsetScanner() + reason = "调整后集数13超出TMDB该季的12集(正在放送中,TMDB可能未更新)" + async with Database() as db: + bangumi = make_bangumi( + title_raw="Rescan Anime", + season=1, + season_offset=season_offset, + episode_offset=0, + ) + await db.bangumi.add(bangumi) + await db.bangumi.set_needs_review( + bangumi.id, reason, suggested_season_offset=0 + ) + await db.torrent.add( + Torrent( + name=f"[TestGroup] Rescan Anime - {episode:02d} [1080p].mkv", + url="https://example.com/rescan", + refer_id=bangumi.id, + ) + ) + bangumi = await db.bangumi.search_id(bangumi.id) + + tmdb_info = TestCheckBangumiUsesRealSignal._ended_twelve_episode_season() + with patch( + "module.core.offset_scanner.tmdb_parser", + new=AsyncMock(return_value=tmdb_info if tmdb_found else None), + ): + event = await scanner._check_bangumi(bangumi) + + # 已标记过的番剧不再重复发送检查通知 + assert event is None + async with Database() as db: + updated = await db.bangumi.search_id(bangumi.id) + assert updated.needs_review is still_flagged + assert updated.needs_review_reason == (reason if still_flagged else None) diff --git a/backend/src/test/test_renamer.py b/backend/src/test/test_renamer.py index a279fefb..825806c6 100644 --- a/backend/src/test/test_renamer.py +++ b/backend/src/test/test_renamer.py @@ -504,6 +504,18 @@ class TestRenameSingleFile: renamer.client.client.torrents_rename_file.assert_not_awaited() renamer.client.client.add_tag.assert_not_awaited() + async def test_none_method_notifies_once_across_loops(self, renamer): + """none 方法不改名也要发下载完成通知;种子每轮都会再处理,通知只发一次(#1082)。""" + ep = EpisodeFile( + media_path="old.mkv", title="My Anime", season=1, episode=5, suffix=".mkv" + ) + first = await self._rename(renamer, ep, method="none", _hash="hash-none") + second = await self._rename(renamer, ep, method="none", _hash="hash-none") + + assert first == [Notification(official_title="My Anime", season=1, episode=5)] + assert second == [] + renamer.client.client.torrents_rename_file.assert_not_awaited() + @pytest.mark.parametrize("remove_bad_torrent", [False, True]) async def test_parse_fails(self, renamer, remove_bad_torrent): """解析失败时不返回通知;仅 remove_bad_torrent=True 时删除种子。""" diff --git a/backend/src/test/test_searcher.py b/backend/src/test/test_searcher.py index 9782d58c..5bbaf42f 100644 --- a/backend/src/test/test_searcher.py +++ b/backend/src/test/test_searcher.py @@ -173,6 +173,50 @@ class TestSpecialUrl: # Only title_raw should be in the URL assert "Test" in result.url + @pytest.mark.parametrize( + ("title", "keyword"), + [ + ( + "[ToonsHub] The Apothecary Diaries S03E02 Multi Subs NF WEB-DL 1080p", + "ToonsHub+The+Apothecary+Diaries+S03+Multi+Subs+NF+1080p", + ), + ( + "[ToonsHub] The Apothecary Diaries S03 E02 1080p NF WEB-DL", + "ToonsHub+The+Apothecary+Diaries+S03+NF+1080p", + ), + ( + "[ANi] 葬送的芙莉莲 第二季 - 05 [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4]", + "ANi+葬送的芙莉莲+第二季+CHT+Baha+1080P", + ), + ( + "[Lilith-Raws] Kusuriya no Hitorigoto S2 - 05 [Baha][WebDL 1080p AVC AAC][CHT]", + "Lilith+Raws+Kusuriya+no+Hitorigoto+S2+CHT+Baha+1080p", + ), + ( + "[SubsPlease] Spy x Family Season 2 - 05 (1080p) [ABCDEF12].mkv", + "SubsPlease+Spy+x+Family+Season+2+1080p", + ), + ( + "[Erai-raws] Mushoku Tensei 2nd Season - 05 [1080p][Multiple Subtitle]", + "Erai+raws+Mushoku+Tensei+2nd+Season+Multiple+Subtitle+1080p", + ), + ], + ) + async def test_keyword_keeps_season_but_drops_episode(self, title, keyword): + """搜索结果生成订阅时关键词保留季、去掉集数,订阅才能收到后续集(#1138)。""" + from module.parser.title_parser import TitleParser + from module.searcher.searcher import SearchTorrent + + bangumi = await TitleParser.raw_parser(title) + assert isinstance(bangumi, Bangumi) + with patch( + "module.searcher.provider.get_provider", + return_value={"nyaa": {"url": "https://nyaa.si/?q=%s", "parser": "tmdb"}}, + ): + result = SearchTorrent.special_url(bangumi, "nyaa") + + assert result.url == f"https://nyaa.si/?q={keyword}" + def test_supports_movie_without_season_fields(self): """Movie search results do not expose Bangumi-only season fields.""" from module.searcher.searcher import SearchTorrent diff --git a/backend/src/test/test_tmdb.py b/backend/src/test/test_tmdb.py index 3bf6dfdb..f6eaeb25 100644 --- a/backend/src/test/test_tmdb.py +++ b/backend/src/test/test_tmdb.py @@ -344,6 +344,33 @@ async def test_tmdb_parser_negative_result_is_cached_until_ttl_expires(mocker): assert len(requests) == 2 * first +@pytest.mark.parametrize( + ("status", "refetched"), [("Ended", False), ("Returning Series", True)] +) +async def test_tmdb_parser_airing_show_cache_expires(mocker, status, refetched): + """连载中剧集的集数会变,缓存过期后重新查询;已完结的永久缓存(#1072)。""" + requests: list[str] = [] + + async def fake_get_json(url: str) -> dict: + requests.append(url) + if "/search/tv" in url or "/season/" in url: + return await _fake_get_json(url) + return {**_SHOW_INFO, "status": status} + + mocker.patch.object( + tmdb_parser_module.RequestContent, "get_json", side_effect=fake_get_json + ) + now = mocker.patch.object(tmdb_parser_module.time, "monotonic", return_value=0.0) + tmdb_parser_module._tmdb_cache.clear() + + await tmdb_parser("海盗战记", "zh", test=True) + first = len(requests) + now.return_value = tmdb_parser_module._TMDB_AIRING_TTL + 1 + await tmdb_parser("海盗战记", "zh", test=True) + + assert len(requests) == (2 * first if refetched else first) + + async def test_tmdb_parser_cache_hit_refreshes_lru_order(mocker): """命中的条目移到队尾,满员淘汰的是最久未用而非最早写入的条目。""" mocker.patch.object(tmdb_parser_module, "_TMDB_CACHE_MAX", 2) From 6e6461febedbc583247a3ff50c285a92a6c4564a Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Wed, 21 Oct 2026 08:47:35 +0200 Subject: [PATCH 2/7] =?UTF-8?q?fix(webui):=20=E5=89=A9=E4=BD=99=20carbon?= =?UTF-8?q?=20=E5=9B=BE=E6=A0=87=E6=94=B9=E4=B8=BA=20Lucide=E3=80=81?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E9=A1=B5=E5=AF=BC=E8=88=AA=E9=AB=98=E4=BA=AE?= =?UTF-8?q?=E4=B8=8E=E6=BB=9A=E5=8A=A8=E4=BD=8D=E7=BD=AE=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=EF=BC=88=E5=8F=82=E8=80=83=20#1119=EF=BC=89=E3=80=81HTTP=20?= =?UTF-8?q?=E4=B8=8B=E5=A4=8D=E5=88=B6=E4=BB=A4=E7=89=8C=E5=9B=9E=E9=80=80?= =?UTF-8?q?=E5=88=B0=E5=85=BC=E5=AE=B9=E6=96=B9=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/setting/config-access.vue | 6 +- .../setting/config-notification.vue | 76 ++++++++++--------- webui/src/pages/index/config.vue | 51 ++++++++++--- webui/src/pages/index/rss.vue | 24 ++---- webui/src/utils/__tests__/scroll-spy.test.ts | 26 +++++++ webui/src/utils/scroll-spy.ts | 27 +++++++ webui/types/dts/auto-imports.d.ts | 6 ++ 7 files changed, 150 insertions(+), 66 deletions(-) create mode 100644 webui/src/utils/__tests__/scroll-spy.test.ts create mode 100644 webui/src/utils/scroll-spy.ts diff --git a/webui/src/components/setting/config-access.vue b/webui/src/components/setting/config-access.vue index 97987bab..c940b0fc 100644 --- a/webui/src/components/setting/config-access.vue +++ b/webui/src/components/setting/config-access.vue @@ -7,6 +7,7 @@ import { ref, } from 'vue'; import { storeToRefs } from 'pinia'; +import { useClipboard } from '@vueuse/core'; import type { ApiTokenPublic, ApiTokenStatus, @@ -137,8 +138,11 @@ async function handleCreateToken(): Promise { } } +// legacy: 非安全上下文(HTTP)下 navigator.clipboard 不可用,回落到 execCommand +const { copy } = useClipboard({ legacy: true }); + async function copyIssuedToken(): Promise { - await navigator.clipboard.writeText(issuedToken.value); + await copy(issuedToken.value); message.success(t('access.token_copied')); } diff --git a/webui/src/components/setting/config-notification.vue b/webui/src/components/setting/config-notification.vue index b6464bc5..5609597f 100644 --- a/webui/src/components/setting/config-notification.vue +++ b/webui/src/components/setting/config-notification.vue @@ -1,4 +1,18 @@