feat(examples): manual-pick 前端改由 web-src/ 构建,补充测试

插件市场要求带前端的插件以 web-src/ 源码提交、至少有 1 个测试。ab-plugin pack
不再打包 web-src/,运行时只需要构建出的 web/。
This commit is contained in:
Estrella Pan
2026-10-09 10:07:37 +02:00
parent a421814ab8
commit f3359b17fd
11 changed files with 617 additions and 68 deletions

View File

@@ -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

View File

@@ -0,0 +1 @@
web-src/node_modules

View File

@@ -9,6 +9,22 @@
- 宿主的只读 API(`/api/v1/bangumi/<id>/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

View File

@@ -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"]

View File

@@ -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 == []

View File

@@ -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"
}
}

View File

@@ -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

View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -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 = `
<style>
:host { display: block; }
ul { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 4px; }
li {
display: flex; align-items: center; gap: 12px; padding: 8px 12px;
background: var(--ab-color-surface-2); border-radius: var(--ab-radius-sm);
}
.name { flex: 1; min-width: 0; overflow-wrap: anywhere; font-size: 13px; font-family: var(--ab-font-mono); }
.mark { width: 8px; height: 8px; border-radius: 2px; background: var(--ab-color-primary); }
button {
padding: 4px 10px; border: 1px solid var(--ab-color-border); border-radius: var(--ab-radius-sm);
background: var(--ab-color-surface); color: var(--ab-color-text); font: inherit; font-size: 12px; cursor: pointer;
}
button:focus-visible { outline: 2px solid var(--ab-color-primary); outline-offset: 2px; }
p { margin: 0; font-size: 13px; color: var(--ab-color-text-secondary); }
</style>
<div></div>
`;
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<Torrent[]>(`/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);
}

View File

@@ -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,
},
});

View File

@@ -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 = `
<style>
:host { display: block; }
ul { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 4px; }
@@ -38,69 +37,48 @@ class ManualPick extends HTMLElement {
p { margin: 0; font-size: 13px; color: var(--ab-color-text-secondary); }
</style>
<div></div>
`;
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);