From f3359b17fde62ebfe32179071e7ae579431fd0cd Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Fri, 9 Oct 2026 10:07:37 +0200 Subject: [PATCH] =?UTF-8?q?feat(examples):=20manual-pick=20=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E6=94=B9=E7=94=B1=20web-src/=20=E6=9E=84=E5=BB=BA?= =?UTF-8?q?=EF=BC=8C=E8=A1=A5=E5=85=85=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 插件市场要求带前端的插件以 web-src/ 源码提交、至少有 1 个测试。ab-plugin pack 不再打包 web-src/,运行时只需要构建出的 web/。 --- backend/src/ab_sdk/cli.py | 2 +- examples/plugins/manual-pick/.gitignore | 1 + examples/plugins/manual-pick/README.md | 16 + examples/plugins/manual-pick/pyproject.toml | 13 + .../manual-pick/tests/test_manual_pick.py | 37 ++ .../plugins/manual-pick/web-src/package.json | 14 + .../manual-pick/web-src/pnpm-lock.yaml | 339 ++++++++++++++++++ .../plugins/manual-pick/web-src/src/env.d.ts | 1 + .../plugins/manual-pick/web-src/src/index.ts | 127 +++++++ .../manual-pick/web-src/vite.config.ts | 23 ++ examples/plugins/manual-pick/web/index.js | 112 +++--- 11 files changed, 617 insertions(+), 68 deletions(-) create mode 100644 examples/plugins/manual-pick/.gitignore create mode 100644 examples/plugins/manual-pick/pyproject.toml create mode 100644 examples/plugins/manual-pick/tests/test_manual_pick.py create mode 100644 examples/plugins/manual-pick/web-src/package.json create mode 100644 examples/plugins/manual-pick/web-src/pnpm-lock.yaml create mode 100644 examples/plugins/manual-pick/web-src/src/env.d.ts create mode 100644 examples/plugins/manual-pick/web-src/src/index.ts create mode 100644 examples/plugins/manual-pick/web-src/vite.config.ts diff --git a/backend/src/ab_sdk/cli.py b/backend/src/ab_sdk/cli.py index 9a5c41e9..06a23a89 100644 --- a/backend/src/ab_sdk/cli.py +++ b/backend/src/ab_sdk/cli.py @@ -15,7 +15,7 @@ from . import SDK_VERSION, points from .manifest import ID_RE, MANIFEST_NAME, TOOLING_DIRS, PluginManifest, check # 打包时排除的目录名与文件后缀:开发产物、测试与作者侧的工程文件 -_PACK_EXCLUDE_DIRS = TOOLING_DIRS | {"tests"} +_PACK_EXCLUDE_DIRS = TOOLING_DIRS | {"tests", "web-src"} # web-src 是前端源码,运行时只用构建出的 web/ _PACK_EXCLUDE_FILES = frozenset({".DS_Store", "pyproject.toml", "uv.lock"}) _ZIP_EPOCH = (1980, 1, 1, 0, 0, 0) # 固定时间戳,同样的内容打出同样的 sha256 diff --git a/examples/plugins/manual-pick/.gitignore b/examples/plugins/manual-pick/.gitignore new file mode 100644 index 00000000..df9afd46 --- /dev/null +++ b/examples/plugins/manual-pick/.gitignore @@ -0,0 +1 @@ +web-src/node_modules diff --git a/examples/plugins/manual-pick/README.md b/examples/plugins/manual-pick/README.md index aecfdaa8..3935b52d 100644 --- a/examples/plugins/manual-pick/README.md +++ b/examples/plugins/manual-pick/README.md @@ -9,6 +9,22 @@ - 宿主的只读 API(`/api/v1/bangumi//torrents`,经 `host.api.get`) - 事件:路由发布 `manual-pick.picked`,组件用 `host.events.on` 订阅并刷新 +## 构建前端 + +前端源码在 `web-src/`(TypeScript,Vite 库模式,模板来自 `webui/packages/plugin-ui/template/`),产物写到 `web/index.js`,即清单 `[[plugin.ui]]` 的 `entry`。`web/` 提交在仓库里,供本地开发和 `ab-plugin validate` 使用;插件仓库的 CI 会丢弃它并重新构建。 + +```bash +cd web-src && pnpm install && pnpm build +``` + +`@autobangumi/plugin-ui` 以 `link:` 指向仓库内的 `webui/packages/plugin-ui`(只提供类型)。 + +## 测试 + +```bash +uv run pytest -q -c pyproject.toml --rootdir . tests # 在 backend/ 目录下运行 +``` + ## 安装 ```bash diff --git a/examples/plugins/manual-pick/pyproject.toml b/examples/plugins/manual-pick/pyproject.toml new file mode 100644 index 00000000..a568eced --- /dev/null +++ b/examples/plugins/manual-pick/pyproject.toml @@ -0,0 +1,13 @@ +# 只用于本地开发与测试,不会被打进插件包(ab-plugin pack 排除它) +[project] +name = "ab-plugin-manual-pick" +version = "0.1.0" +requires-python = ">=3.13" +dependencies = ["autobangumi-sdk"] + +[dependency-groups] +dev = ["pytest>=8.0.0"] + +[tool.pytest.ini_options] +pythonpath = ["."] +testpaths = ["tests"] diff --git a/examples/plugins/manual-pick/tests/test_manual_pick.py b/examples/plugins/manual-pick/tests/test_manual_pick.py new file mode 100644 index 00000000..b2fa5024 --- /dev/null +++ b/examples/plugins/manual-pick/tests/test_manual_pick.py @@ -0,0 +1,37 @@ +from fastapi import FastAPI +from fastapi.testclient import TestClient +from manual_pick import ManualPick, Picked + +from ab_sdk.testing import create_plugin + + +def client(): + plugin, ctx = create_plugin(ManualPick) + app = FastAPI() + app.include_router(plugin.api()) + return TestClient(app), ctx + + +def test_get_pick_nothing_picked_returns_null(): + http, _ = client() + assert http.get("/picks/1").json() == {"torrent_id": None} + + +def test_put_pick_saves_selection_per_bangumi(): + http, ctx = client() + assert http.put("/picks/1", json={"torrent_id": 7}).json() == {"torrent_id": 7} + assert http.get("/picks/1").json() == {"torrent_id": 7} + assert http.get("/picks/2").json() == {"torrent_id": None} + assert ctx.kv.data == {"pick:1": 7} + + +def test_put_pick_publishes_picked_event(): + http, ctx = client() + http.put("/picks/3", json={"torrent_id": 9}) + assert ctx.bus.published == [Picked(bangumi_id=3, torrent_id=9)] + + +def test_put_pick_invalid_body_returns_422_and_publishes_nothing(): + http, ctx = client() + assert http.put("/picks/1", json={"torrent_id": "x"}).status_code == 422 + assert ctx.bus.published == [] diff --git a/examples/plugins/manual-pick/web-src/package.json b/examples/plugins/manual-pick/web-src/package.json new file mode 100644 index 00000000..5157a1de --- /dev/null +++ b/examples/plugins/manual-pick/web-src/package.json @@ -0,0 +1,14 @@ +{ + "name": "ab-plugin-manual-pick-web", + "type": "module", + "version": "0.0.0", + "private": true, + "scripts": { + "build": "vite build" + }, + "devDependencies": { + "@autobangumi/plugin-ui": "link:../../../../webui/packages/plugin-ui", + "typescript": "^4.9.5", + "vite": "^4.5.5" + } +} diff --git a/examples/plugins/manual-pick/web-src/pnpm-lock.yaml b/examples/plugins/manual-pick/web-src/pnpm-lock.yaml new file mode 100644 index 00000000..4c609cd6 --- /dev/null +++ b/examples/plugins/manual-pick/web-src/pnpm-lock.yaml @@ -0,0 +1,339 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@autobangumi/plugin-ui': + specifier: link:../../../../webui/packages/plugin-ui + version: link:../../../../webui/packages/plugin-ui + typescript: + specifier: ^4.9.5 + version: 4.9.5 + vite: + specifier: ^4.5.5 + version: 4.5.14 + +packages: + + '@esbuild/android-arm64@0.18.20': + resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.18.20': + resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.18.20': + resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.18.20': + resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.18.20': + resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.18.20': + resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.18.20': + resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.18.20': + resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.18.20': + resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.18.20': + resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.18.20': + resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.18.20': + resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.18.20': + resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.18.20': + resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.18.20': + resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.18.20': + resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.18.20': + resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.18.20': + resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.18.20': + resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.18.20': + resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.18.20': + resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.18.20': + resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + esbuild@0.18.20: + resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} + engines: {node: '>=12'} + hasBin: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + postcss@8.5.29: + resolution: {integrity: sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==} + engines: {node: ^10 || ^12 || >=14} + + rollup@3.30.0: + resolution: {integrity: sha512-kQvGasUgN+AlWGliFn2POSajRQEsULVYFGTvOZmK06d7vCD+YhZztt70kGk3qaeAXeWYL5eO7zx+rAubBc55eA==} + engines: {node: '>=14.18.0', npm: '>=8.0.0'} + hasBin: true + + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + + typescript@4.9.5: + resolution: {integrity: sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==} + engines: {node: '>=4.2.0'} + hasBin: true + + vite@4.5.14: + resolution: {integrity: sha512-+v57oAaoYNnO3hIu5Z/tJRZjq5aHM2zDve9YZ8HngVHbhk66RStobhb1sqPMIPEleV6cNKYK4eGrAbE9Ulbl2g==} + engines: {node: ^14.18.0 || >=16.0.0} + hasBin: true + peerDependencies: + '@types/node': '>= 14' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + +snapshots: + + '@esbuild/android-arm64@0.18.20': + optional: true + + '@esbuild/android-arm@0.18.20': + optional: true + + '@esbuild/android-x64@0.18.20': + optional: true + + '@esbuild/darwin-arm64@0.18.20': + optional: true + + '@esbuild/darwin-x64@0.18.20': + optional: true + + '@esbuild/freebsd-arm64@0.18.20': + optional: true + + '@esbuild/freebsd-x64@0.18.20': + optional: true + + '@esbuild/linux-arm64@0.18.20': + optional: true + + '@esbuild/linux-arm@0.18.20': + optional: true + + '@esbuild/linux-ia32@0.18.20': + optional: true + + '@esbuild/linux-loong64@0.18.20': + optional: true + + '@esbuild/linux-mips64el@0.18.20': + optional: true + + '@esbuild/linux-ppc64@0.18.20': + optional: true + + '@esbuild/linux-riscv64@0.18.20': + optional: true + + '@esbuild/linux-s390x@0.18.20': + optional: true + + '@esbuild/linux-x64@0.18.20': + optional: true + + '@esbuild/netbsd-x64@0.18.20': + optional: true + + '@esbuild/openbsd-x64@0.18.20': + optional: true + + '@esbuild/sunos-x64@0.18.20': + optional: true + + '@esbuild/win32-arm64@0.18.20': + optional: true + + '@esbuild/win32-ia32@0.18.20': + optional: true + + '@esbuild/win32-x64@0.18.20': + optional: true + + esbuild@0.18.20: + optionalDependencies: + '@esbuild/android-arm': 0.18.20 + '@esbuild/android-arm64': 0.18.20 + '@esbuild/android-x64': 0.18.20 + '@esbuild/darwin-arm64': 0.18.20 + '@esbuild/darwin-x64': 0.18.20 + '@esbuild/freebsd-arm64': 0.18.20 + '@esbuild/freebsd-x64': 0.18.20 + '@esbuild/linux-arm': 0.18.20 + '@esbuild/linux-arm64': 0.18.20 + '@esbuild/linux-ia32': 0.18.20 + '@esbuild/linux-loong64': 0.18.20 + '@esbuild/linux-mips64el': 0.18.20 + '@esbuild/linux-ppc64': 0.18.20 + '@esbuild/linux-riscv64': 0.18.20 + '@esbuild/linux-s390x': 0.18.20 + '@esbuild/linux-x64': 0.18.20 + '@esbuild/netbsd-x64': 0.18.20 + '@esbuild/openbsd-x64': 0.18.20 + '@esbuild/sunos-x64': 0.18.20 + '@esbuild/win32-arm64': 0.18.20 + '@esbuild/win32-ia32': 0.18.20 + '@esbuild/win32-x64': 0.18.20 + + fsevents@2.3.3: + optional: true + + nanoid@3.3.20: {} + + picocolors@1.1.1: {} + + postcss@8.5.29: + dependencies: + nanoid: 3.3.20 + picocolors: 1.1.1 + source-map-js: 1.2.2 + + rollup@3.30.0: + optionalDependencies: + fsevents: 2.3.3 + + source-map-js@1.2.2: {} + + typescript@4.9.5: {} + + vite@4.5.14: + dependencies: + esbuild: 0.18.20 + postcss: 8.5.29 + rollup: 3.30.0 + optionalDependencies: + fsevents: 2.3.3 diff --git a/examples/plugins/manual-pick/web-src/src/env.d.ts b/examples/plugins/manual-pick/web-src/src/env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/examples/plugins/manual-pick/web-src/src/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/plugins/manual-pick/web-src/src/index.ts b/examples/plugins/manual-pick/web-src/src/index.ts new file mode 100644 index 00000000..febe1a64 --- /dev/null +++ b/examples/plugins/manual-pick/web-src/src/index.ts @@ -0,0 +1,127 @@ +// 手动选种:番剧详情页标签。宿主在包裹元素的 Shadow DOM 中注入 +// --ab-* 主题变量,这里直接使用,不内联 tokens.css。 +import type { AbPluginElement } from '@autobangumi/plugin-ui'; + +interface Torrent { + id: number; + name: string; +} + +const TEXT = { + 'zh-CN': { + empty: '这条规则还没有种子记录', + pick: '选用', + picked: '已选', + failed: '保存选择失败', + }, + 'en-US': { + empty: 'This rule has no torrent records yet', + pick: 'Pick', + picked: 'Picked', + failed: 'Failed to save the pick', + }, +}; + +class ManualPick + extends HTMLElement + implements AbPluginElement<'bangumi.detail.tab'> +{ + // 宿主在插入文档前设置;用 declare 不生成类字段,否则构造时会被重置为 undefined + declare host: AbPluginElement['host']; + declare context: AbPluginElement<'bangumi.detail.tab'>['context']; + + private text = TEXT['en-US']; + private bangumiId = 0; + private body!: HTMLDivElement; + private unsubscribe?: () => void; + + async connectedCallback() { + this.text = + TEXT[this.host.i18n.locale as keyof typeof TEXT] ?? TEXT['en-US']; + this.bangumiId = this.context.bangumiId; + const root = this.shadowRoot ?? this.attachShadow({ mode: 'open' }); + root.innerHTML = ` + +
+ `; + this.body = root.querySelector('div')!; + // 别的会话或标签页选种后,打开着的详情页也会刷新 + this.unsubscribe = this.host.events.on('manual-pick.picked', (payload) => { + if ((payload as { bangumi_id?: number } | null)?.bangumi_id === this.bangumiId) + this.load(); + }); + await this.load(); + } + + disconnectedCallback() { + this.unsubscribe?.(); + } + + private async load() { + const [torrents, pick] = await Promise.all([ + // 宿主的只读 API:以 /api/v1/ 开头的路径只允许 GET + this.host.api.get(`/api/v1/bangumi/${this.bangumiId}/torrents`), + this.host.api.get<{ torrent_id: number | null }>(`picks/${this.bangumiId}`), + ]); + this.render(torrents, pick.torrent_id); + } + + private render(torrents: Torrent[], pickedId: number | null) { + this.body.replaceChildren(); + if (!torrents.length) { + const empty = document.createElement('p'); + empty.textContent = this.text.empty; + this.body.append(empty); + return; + } + const list = document.createElement('ul'); + for (const torrent of torrents) { + const item = document.createElement('li'); + const name = document.createElement('span'); + name.className = 'name'; + name.textContent = torrent.name; + item.append(name); + if (torrent.id === pickedId) { + const mark = document.createElement('span'); + mark.className = 'mark'; + mark.title = this.text.picked; + item.append(mark); + } else { + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = this.text.pick; + button.addEventListener('click', () => this.pick(torrent.id)); + item.append(button); + } + list.append(item); + } + this.body.append(list); + } + + private async pick(torrentId: number) { + try { + await this.host.api.put(`picks/${this.bangumiId}`, { torrent_id: torrentId }); + } catch { + this.host.toast(this.text.failed, 'error'); + } + } +} + +if (!customElements.get('ab-plugin-manual-pick')) { + customElements.define('ab-plugin-manual-pick', ManualPick); +} diff --git a/examples/plugins/manual-pick/web-src/vite.config.ts b/examples/plugins/manual-pick/web-src/vite.config.ts new file mode 100644 index 00000000..4eb234c1 --- /dev/null +++ b/examples/plugins/manual-pick/web-src/vite.config.ts @@ -0,0 +1,23 @@ +// 前端插件组件的 Vite 库模式模板。 +// +// 把本目录复制到插件根目录下(如 my-plugin/web-src/),把 package.json 中 +// @autobangumi/plugin-ui 的 link: 路径改为指向 webui/packages/plugin-ui, +// 然后 `pnpm install && pnpm build`,产物写到插件的 web/index.js, +// 清单中对应写 `entry = "web/index.js"`。 +// +// 产物必须是单个自包含的 ES module:浏览器里没有裸模块名解析,CSP +// (script-src 'self')也不允许 import map 等内联脚本与远程脚本, +// 所以依赖全部打包进来,不设 external。 +import { defineConfig } from 'vite'; + +export default defineConfig({ + build: { + lib: { + entry: 'src/index.ts', + formats: ['es'], + fileName: () => 'index.js', + }, + outDir: '../web', + emptyOutDir: true, + }, +}); diff --git a/examples/plugins/manual-pick/web/index.js b/examples/plugins/manual-pick/web/index.js index 1e6f7089..d8f38f71 100644 --- a/examples/plugins/manual-pick/web/index.js +++ b/examples/plugins/manual-pick/web/index.js @@ -1,26 +1,25 @@ -// 手动选种:番剧详情页标签。无需构建;宿主在包裹元素的 Shadow DOM 中注入 -// --ab-* 主题变量,这里直接使用。 -const TEXT = { - 'zh-CN': { - empty: '这条规则还没有种子记录', - pick: '选用', - picked: '已选', - failed: '保存选择失败', - }, - 'en-US': { - empty: 'This rule has no torrent records yet', - pick: 'Pick', - picked: 'Picked', - failed: 'Failed to save the pick', +const s = { + "zh-CN": { + empty: "这条规则还没有种子记录", + pick: "选用", + picked: "已选", + failed: "保存选择失败" }, + "en-US": { + empty: "This rule has no torrent records yet", + pick: "Pick", + picked: "Picked", + failed: "Failed to save the pick" + } }; - -class ManualPick extends HTMLElement { +class c extends HTMLElement { + constructor() { + super(...arguments), this.text = s["en-US"], this.bangumiId = 0; + } async connectedCallback() { - this.text = TEXT[this.host.i18n.locale] ?? TEXT['en-US']; - this.bangumiId = this.context.bangumiId; - const root = this.shadowRoot ?? this.attachShadow({ mode: 'open' }); - root.innerHTML = ` + this.text = s[this.host.i18n.locale] ?? s["en-US"], this.bangumiId = this.context.bangumiId; + const t = this.shadowRoot ?? this.attachShadow({ mode: "open" }); + t.innerHTML = `
- `; - this.body = root.querySelector('div'); - // 别的会话或标签页选种后,打开着的详情页也会刷新 - this.unsubscribe = this.host.events.on('manual-pick.picked', (payload) => { - if (payload?.bangumi_id === this.bangumiId) this.load(); - }); - await this.load(); + `, this.body = t.querySelector("div"), this.unsubscribe = this.host.events.on("manual-pick.picked", (i) => { + (i == null ? void 0 : i.bangumi_id) === this.bangumiId && this.load(); + }), await this.load(); } - disconnectedCallback() { - this.unsubscribe?.(); + var t; + (t = this.unsubscribe) == null || t.call(this); } - async load() { - const [torrents, pick] = await Promise.all([ + const [t, i] = await Promise.all([ // 宿主的只读 API:以 /api/v1/ 开头的路径只允许 GET this.host.api.get(`/api/v1/bangumi/${this.bangumiId}/torrents`), - this.host.api.get(`picks/${this.bangumiId}`), + this.host.api.get(`picks/${this.bangumiId}`) ]); - this.render(torrents, pick.torrent_id); + this.render(t, i.torrent_id); } - - render(torrents, pickedId) { - this.body.replaceChildren(); - if (!torrents.length) { - const empty = document.createElement('p'); - empty.textContent = this.text.empty; - this.body.append(empty); + render(t, i) { + if (this.body.replaceChildren(), !t.length) { + const n = document.createElement("p"); + n.textContent = this.text.empty, this.body.append(n); return; } - const list = document.createElement('ul'); - for (const torrent of torrents) { - const item = document.createElement('li'); - const name = document.createElement('span'); - name.className = 'name'; - name.textContent = torrent.name; - item.append(name); - if (torrent.id === pickedId) { - const mark = document.createElement('span'); - mark.className = 'mark'; - mark.title = this.text.picked; - item.append(mark); + const r = document.createElement("ul"); + for (const n of t) { + const a = document.createElement("li"), o = document.createElement("span"); + if (o.className = "name", o.textContent = n.name, a.append(o), n.id === i) { + const e = document.createElement("span"); + e.className = "mark", e.title = this.text.picked, a.append(e); } else { - const button = document.createElement('button'); - button.type = 'button'; - button.textContent = this.text.pick; - button.addEventListener('click', () => this.pick(torrent.id)); - item.append(button); + const e = document.createElement("button"); + e.type = "button", e.textContent = this.text.pick, e.addEventListener("click", () => this.pick(n.id)), a.append(e); } - list.append(item); + r.append(a); } - this.body.append(list); + this.body.append(r); } - - async pick(torrentId) { + async pick(t) { try { - await this.host.api.put(`picks/${this.bangumiId}`, { torrent_id: torrentId }); + await this.host.api.put(`picks/${this.bangumiId}`, { torrent_id: t }); } catch { - this.host.toast(this.text.failed, 'error'); + this.host.toast(this.text.failed, "error"); } } } - -if (!customElements.get('ab-plugin-manual-pick')) { - customElements.define('ab-plugin-manual-pick', ManualPick); -} +customElements.get("ab-plugin-manual-pick") || customElements.define("ab-plugin-manual-pick", c);