mirror of
https://github.com/jxxghp/MoviePilot-Plugins.git
synced 2026-06-13 07:26:50 +00:00
Compare commits
89 Commits
InvitesSig
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8dd0783b4a | ||
|
|
afc28446f5 | ||
|
|
e89047a95f | ||
|
|
ad8137a65a | ||
|
|
1ececcb410 | ||
|
|
8490668f5d | ||
|
|
1fb6b4845e | ||
|
|
b63e74b680 | ||
|
|
2b575d9e8d | ||
|
|
85ea5cba61 | ||
|
|
0665cfb71b | ||
|
|
692205095c | ||
|
|
465ce39f6f | ||
|
|
949fced655 | ||
|
|
2e352a1845 | ||
|
|
295e49311f | ||
|
|
613b1f2604 | ||
|
|
088b9e6d98 | ||
|
|
43e080839b | ||
|
|
9105a95bb9 | ||
|
|
6203fa69c2 | ||
|
|
34895bc520 | ||
|
|
3e6ad20d1b | ||
|
|
96ab3c73a7 | ||
|
|
b564a71203 | ||
|
|
75925415a3 | ||
|
|
60451c9b7f | ||
|
|
c1270840e4 | ||
|
|
c2dbaf5c5e | ||
|
|
45829c05e5 | ||
|
|
c5eac77128 | ||
|
|
1b948cab0c | ||
|
|
fa2be65bc6 | ||
|
|
08a32a009d | ||
|
|
aa502fc5fc | ||
|
|
7722ce3406 | ||
|
|
3bd0964209 | ||
|
|
157a1053b1 | ||
|
|
154f996dbe | ||
|
|
5ed9ee9793 | ||
|
|
bb3c392e62 | ||
|
|
0398af971b | ||
|
|
a5237c6a5b | ||
|
|
b048106d2e | ||
|
|
ec0c8cc521 | ||
|
|
52cd5b96e1 | ||
|
|
0d7be2b58c | ||
|
|
e29a710a33 | ||
|
|
de83b88ad1 | ||
|
|
96ac52041a | ||
|
|
287ccf50b2 | ||
|
|
08faed6ff0 | ||
|
|
944867f96e | ||
|
|
5f7c342b78 | ||
|
|
7aa0f188c7 | ||
|
|
3a7afe5047 | ||
|
|
9c87f5e51b | ||
|
|
35b010c03d | ||
|
|
74926ca150 | ||
|
|
51c18cbb19 | ||
|
|
02b3d61c04 | ||
|
|
13c12392d0 | ||
|
|
9332e17e6c | ||
|
|
8e5bfd58c0 | ||
|
|
a52e8ad0ed | ||
|
|
21ebda74b1 | ||
|
|
50b4d2558c | ||
|
|
94e14d86d7 | ||
|
|
a6f5d3a75b | ||
|
|
ab8e7c99b7 | ||
|
|
41663d5a27 | ||
|
|
d475578bcd | ||
|
|
c6a6877ff7 | ||
|
|
eb13d0ec62 | ||
|
|
59486dbf01 | ||
|
|
c6d91a74f2 | ||
|
|
9b476c61d3 | ||
|
|
b74a36bbe2 | ||
|
|
2fd3e1e37e | ||
|
|
a6030ad068 | ||
|
|
6327c89a78 | ||
|
|
a2be00a423 | ||
|
|
230cbc2094 | ||
|
|
5e364d9535 | ||
|
|
6692937c44 | ||
|
|
1df37a5149 | ||
|
|
718323f781 | ||
|
|
0705372054 | ||
|
|
9357638adc |
67
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
67
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,67 @@
|
||||
name: 问题反馈
|
||||
description: File a bug report
|
||||
title: "[错误报告]: 请在此处简单描述你的问题"
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
请确认以下信息:
|
||||
1. 请按此模板提交 issues,不按模板提交的问题将直接关闭。
|
||||
2. 如果你的问题可以直接在以往 issue 或者 Telegram频道 中找到,那么你的 issue 将会被直接关闭。
|
||||
3. **$\color{red}{提交问题务必描述清楚、附上日志}$**,描述不清导致无法理解和分析的问题会被直接关闭。
|
||||
4. 此仓库为**官方插件仓库**,如果是**主程序问题**请在 [MoviePilot 主仓库](https://github.com/jxxghp/MoviePilot/issues) 提 issue,如果是**前端 WebUI 问题**请在 [前端仓库](https://github.com/jxxghp/MoviePilot-Frontend) 提 issue。
|
||||
5. **$\color{red}{不要通过 issues 来寻求解决你的环境问题、配置安装类问题、咨询类问题}$**,否则直接关闭并加入用户 $\color{red}{黑名单}$ !实在没有精力陪一波又一波的伸手党玩。
|
||||
- type: checkboxes
|
||||
id: ensure
|
||||
attributes:
|
||||
label: 确认
|
||||
description: 在提交 issue 之前,请确认你已经阅读并确认以下内容
|
||||
options:
|
||||
- label: 我的版本是最新版本,我的版本号与 [version](https://github.com/jxxghp/MoviePilot/releases/latest) 相同。
|
||||
required: true
|
||||
- label: 我已经 [issue](https://github.com/jxxghp/MoviePilot-Plugins/issues) 中搜索过,确认我的问题没有被提出过。
|
||||
required: true
|
||||
- label: 我已经 [Telegram频道](https://t.me/moviepilot_channel) 中搜索过,确认我的问题没有被提出过。
|
||||
required: true
|
||||
- label: 我已经修改标题,将标题中的 描述 替换为我遇到的问题。
|
||||
required: true
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: 当前程序版本
|
||||
description: 遇到问题时 MoviePilot 主程序所在的版本号
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: plugin
|
||||
attributes:
|
||||
label: 涉及插件名称及版本
|
||||
description: 如果是插件问题,请填写插件名称及版本号(如不清楚可留空)
|
||||
placeholder: "例如:ChatGPT v3.0.4"
|
||||
validations:
|
||||
required: false
|
||||
- type: dropdown
|
||||
id: environment
|
||||
attributes:
|
||||
label: 运行环境
|
||||
description: 当前程序运行环境
|
||||
options:
|
||||
- Docker
|
||||
- Windows
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: 问题描述
|
||||
description: 请详细描述你碰到的问题
|
||||
placeholder: "问题描述"
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: 发生问题时系统日志和配置文件
|
||||
description: 问题出现时,程序运行日志请复制到这里。
|
||||
render: bash
|
||||
11
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
11
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 项目讨论
|
||||
url: https://github.com/jxxghp/MoviePilot/discussions/new/choose
|
||||
about: discussion
|
||||
- name: Telegram 频道
|
||||
url: https://t.me/moviepilot_channel
|
||||
about: 更新日志
|
||||
- name: Telegram 交流群
|
||||
url: https://t.me/moviepilot_official
|
||||
about: 交流互助
|
||||
40
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
40
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
name: 功能改进
|
||||
description: Feature Request
|
||||
title: "[Feature Request]: "
|
||||
labels: ["feature request"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
请说明你希望添加的功能。
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: 当前程序版本
|
||||
description: 目前使用的 MoviePilot 程序版本
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: environment
|
||||
attributes:
|
||||
label: 运行环境
|
||||
description: 当前程序运行环境
|
||||
options:
|
||||
- Docker
|
||||
- Windows
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: feature-request
|
||||
attributes:
|
||||
label: 功能改进
|
||||
description: 请详细描述需要改进或者添加的功能。
|
||||
placeholder: "功能改进"
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: references
|
||||
attributes:
|
||||
label: 参考资料
|
||||
description: 可以列举一些参考资料,但是不要引用同类但商业化软件的任何内容。
|
||||
placeholder: "参考资料"
|
||||
144
.github/workflows/issues.yml
vendored
Normal file
144
.github/workflows/issues.yml
vendored
Normal file
@@ -0,0 +1,144 @@
|
||||
name: Close inactive issues
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
issues:
|
||||
types: [opened, edited]
|
||||
|
||||
schedule:
|
||||
- cron: "0 18 * * *"
|
||||
|
||||
jobs:
|
||||
label-opened-issue:
|
||||
if: github.event_name == 'issues'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const issue = context.payload.issue;
|
||||
const title = issue.title || '';
|
||||
const body = issue.body || '';
|
||||
const currentLabels = (issue.labels || []).map((label) => label.name);
|
||||
|
||||
// 网页 Issue Form 已经会自动带模板 labels;这里只兜底处理
|
||||
// API 创建或异常路径产生的无 label issue,避免重复补标。
|
||||
if (currentLabels.length > 0) {
|
||||
core.info(`Issue #${issue.number} already has labels: ${currentLabels.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const hasAllMarkers = (markers) => markers.every((marker) => body.includes(marker));
|
||||
const labelRules = [
|
||||
{
|
||||
label: 'bug',
|
||||
titlePrefix: '[错误报告]:',
|
||||
markers: ['### 当前程序版本', '### 运行环境', '### 问题描述'],
|
||||
},
|
||||
{
|
||||
label: 'feature request',
|
||||
titlePrefix: '[Feature Request]:',
|
||||
markers: ['### 当前程序版本', '### 运行环境', '### 功能改进'],
|
||||
},
|
||||
];
|
||||
|
||||
const matched = labelRules.find((rule) => (
|
||||
title.startsWith(rule.titlePrefix) || hasAllMarkers(rule.markers)
|
||||
));
|
||||
|
||||
if (!matched) {
|
||||
core.info(`Issue #${issue.number} does not match known issue templates.`);
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: [matched.label],
|
||||
});
|
||||
core.info(`Added label "${matched.label}" to issue #${issue.number}.`);
|
||||
|
||||
label-unlabeled-issues:
|
||||
if: github.event_name != 'issues'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const labelRules = [
|
||||
{
|
||||
label: 'bug',
|
||||
titlePrefix: '[错误报告]:',
|
||||
markers: ['### 当前程序版本', '### 运行环境', '### 问题描述'],
|
||||
},
|
||||
{
|
||||
label: 'feature request',
|
||||
titlePrefix: '[Feature Request]:',
|
||||
markers: ['### 当前程序版本', '### 运行环境', '### 功能改进'],
|
||||
},
|
||||
];
|
||||
|
||||
const hasAllMarkers = (body, markers) => markers.every((marker) => body.includes(marker));
|
||||
const getMatchedRule = (issue) => {
|
||||
const title = issue.title || '';
|
||||
const body = issue.body || '';
|
||||
return labelRules.find((rule) => (
|
||||
title.startsWith(rule.titlePrefix) || hasAllMarkers(body, rule.markers)
|
||||
));
|
||||
};
|
||||
|
||||
// Search API 支持 no:label 查询;issues.listForRepo 的 labels=none
|
||||
// 会被当作名为 none 的标签,不能用于扫描无 label issue。
|
||||
const query = `repo:${context.repo.owner}/${context.repo.repo} is:issue is:open no:label`;
|
||||
for await (const response of github.paginate.iterator(github.rest.search.issuesAndPullRequests, {
|
||||
q: query,
|
||||
per_page: 100,
|
||||
})) {
|
||||
for (const issue of response.data) {
|
||||
if (issue.pull_request) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const matched = getMatchedRule(issue);
|
||||
if (!matched) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: [matched.label],
|
||||
});
|
||||
core.info(`Added label "${matched.label}" to issue #${issue.number}.`);
|
||||
}
|
||||
}
|
||||
|
||||
close-issues:
|
||||
if: github.event_name != 'issues'
|
||||
needs: label-unlabeled-issues
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@v5
|
||||
with:
|
||||
# 标记 stale 标签时间
|
||||
days-before-issue-stale: 30
|
||||
# 关闭 issues 标签时间
|
||||
days-before-issue-close: 14
|
||||
# 自定义标签名
|
||||
stale-issue-label: "stale"
|
||||
stale-issue-message: "此问题已过时,因为它已打开 30 天且没有任何活动。"
|
||||
close-issue-message: "此问题已关闭,因为它在标记为 stale 后,已处于无更新状态 14 天。"
|
||||
# 忽略所有的 Pull Request,只处理 Issue
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
operations-per-run: 500
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -10,7 +10,6 @@ __pycache__/
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
@@ -70,6 +69,7 @@ instance/
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
docs/superpowers/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
@@ -160,4 +160,4 @@ cython_debug/
|
||||
#.idea/
|
||||
|
||||
.idea/
|
||||
.vscode/
|
||||
.vscode/
|
||||
|
||||
@@ -42,6 +42,7 @@ MoviePilot-Plugins/
|
||||
- MoviePilot 会优先读取 `package.v2.json` 中与当前版本标识匹配的插件定义。
|
||||
- 如果某个插件不在 `package.v2.json` 中,但其 `package.json` 条目声明了 `"v2": true`,则会作为“兼容 V2 的默认插件”继续显示和安装。
|
||||
- `package.v2.json` 中的插件代码通常放在 `plugins.v2/<plugin_id_lower>/`;`package.json` 中的插件代码通常放在 `plugins/<plugin_id_lower>/`。
|
||||
- 插件如果依赖特定主系统版本,可在条目中增加 `system_version`,格式参考 pip 依赖版本范围,例如 `">=2.12.0,<3"`;未定义该字段时不做主系统版本检查。
|
||||
- 插件目录名必须是插件类名的小写形式,插件主类必须定义在对应目录的 `__init__.py` 中。
|
||||
- 插件市场里看到的版本、图标、作者、权限级别,都来自 `package.json` / `package.v2.json`;运行时真正生效的类属性来自插件代码中的 `plugin_*` 字段,两者必须保持同步。
|
||||
|
||||
@@ -68,6 +69,8 @@ MoviePilot-Plugins/
|
||||
### 4. 依赖
|
||||
- 可在插件目录中放置 `requirements.txt` 文件声明额外依赖,MoviePilot 安装插件时会自动安装。
|
||||
- 依赖尽量保持最小化,优先复用主程序已提供的公共能力,例如下载器、媒体服务器、通知渠道、缓存、链式处理等封装。
|
||||
- 插件依赖安装在 MoviePilot 的共享 Python 运行环境中,不是每个插件独立虚拟环境。不要在插件 `requirements.txt` 中重新声明或覆盖主程序已经提供的依赖版本。
|
||||
- MoviePilot 会在安装前保护主程序依赖图:核心依赖会严格锁定当前版本,其他主程序依赖也不能被插件要求降级或改成不兼容版本;发生冲突时插件安装会被拒绝。
|
||||
- 如果插件还依赖 Vue 远程组件,请将前端依赖放在独立的前端工程中构建后再产出到插件目录,不要把前端源码直接混入主插件包。
|
||||
|
||||
### 5. 界面开发
|
||||
@@ -82,7 +85,7 @@ MoviePilot-Plugins/
|
||||
- 详情页面和仪表板可通过 `events` 发起 API 调用。
|
||||
- Vue 联邦模式说明:
|
||||
- 插件后端需要实现 `get_render_mode()` 并返回 `("vue", "dist/assets")`。
|
||||
- 如果需要在主界面左侧导航新增入口,还需要实现 `get_sidebar_nav()`。
|
||||
- `get_page()` 对应插件管理中的详情弹窗;如果需要在主界面左侧导航新增全页入口,还需要实现 `get_sidebar_nav()` 并暴露 `AppPage` 远程组件。
|
||||
- 远程组件的构建、暴露名约定、侧栏多入口、静态资源打包方式,请参考 [模块联邦开发指南](https://github.com/jxxghp/MoviePilot-Frontend/blob/v2/docs/module-federation-guide.md)。
|
||||
|
||||
### 6. 开发与校验建议
|
||||
@@ -100,6 +103,7 @@ MoviePilot-Plugins/
|
||||
- `package.json` / `package.v2.json` 中的 `version` 必须与插件类中的 `plugin_version` 保持一致,否则用户会看到错误的升级提示。
|
||||
- `name`、`description`、`icon`、`author`、`level` 建议与插件类属性保持一致,避免插件市场展示与实际运行信息不一致。
|
||||
- `history` 用于展示插件更新日志,建议每次发布都补齐一条可读变更说明。
|
||||
- `system_version` 用于声明插件可安装的 MoviePilot 主系统版本范围,格式参考 pip 依赖版本约束;例如插件依赖 v2.12.0 新增能力时填写 `">=2.12.0"`。
|
||||
- 需要走 GitHub Release 压缩包分发的插件,请在对应索引条目中增加 `"release": true`,并确保仓库中的发布工作流能够定位到对应目录。
|
||||
|
||||
|
||||
@@ -123,6 +127,8 @@ MoviePilot-Plugins/
|
||||
- [14. 如何在插件中通过消息持续与用户交互?](./docs/faq/14-message-interaction.md)
|
||||
- [15. 如何在插件中使用系统级统一缓存?](./docs/faq/15-use-system-cache.md)
|
||||
- [16. 如何在插件中注册智能体工具?](./docs/faq/16-register-agent-tools.md)
|
||||
- [17. 如何将插件页面注册到主界面左侧导航栏?](./docs/faq/17-register-plugin-sidebar-nav.md)
|
||||
- [18. 如何限定插件可安装的 MoviePilot 主系统版本?](./docs/faq/18-limit-moviepilot-version.md)
|
||||
|
||||
## 版本发布
|
||||
|
||||
|
||||
@@ -18,3 +18,5 @@
|
||||
- [14. 如何在插件中通过消息持续与用户交互?](./faq/14-message-interaction.md)
|
||||
- [15. 如何在插件中使用系统级统一缓存?](./faq/15-use-system-cache.md)
|
||||
- [16. 如何在插件中注册智能体工具?](./faq/16-register-agent-tools.md)
|
||||
- [17. 如何将插件页面注册到主界面左侧导航栏?](./faq/17-register-plugin-sidebar-nav.md)
|
||||
- [18. 如何限定插件可安装的 MoviePilot 主系统版本?](./faq/18-limit-moviepilot-version.md)
|
||||
|
||||
@@ -90,6 +90,7 @@ V2 优先插件索引文件。MoviePilot 在 V2 环境下会优先读取这里
|
||||
- `icon`:图标文件名或完整 HTTP URL
|
||||
- `author`:作者
|
||||
- `level`:用户可见级别
|
||||
- `system_version`:可安装的 MoviePilot 主系统版本范围,格式参考 pip 依赖版本约束,例如 `">=2.12.0,<3"`
|
||||
- `history`:更新日志
|
||||
- `release`:是否使用 GitHub Release 压缩包发布
|
||||
- `v2`:默认索引中的插件是否兼容 V2
|
||||
@@ -104,12 +105,14 @@ MoviePilot 当前的插件版本选择逻辑可以概括为:
|
||||
2. 优先检查 `package.v2.json` 中是否存在该插件
|
||||
3. 若不存在,再检查 `package.json`
|
||||
4. 只有当 `package.json` 中对应条目显式声明 `"v2": true` 时,才会作为 V2 兼容插件继续使用
|
||||
5. 如果条目声明了 `system_version`,安装、更新检测和本地插件同步会继续检查当前 MoviePilot 主程序版本是否落在该范围内;未声明则不检查
|
||||
|
||||
这意味着:
|
||||
|
||||
- 同一个插件若在 `package.v2.json` 中已有专用实现,就不要再依赖 `package.json` 中的兼容声明做“隐式覆盖”。
|
||||
- 新写的 V2 专用插件,优先放 `plugins.v2/`,并把元数据写入 `package.v2.json`。
|
||||
- 真正跨版本共用一套实现时,再使用 `package.json + "v2": true` 的方式。
|
||||
- 依赖宿主新增能力的插件需要同步声明 `system_version`,否则旧版 MoviePilot 仍可能看到更新入口但安装后无法加载。
|
||||
|
||||
## 5. 与宿主仓库的协作边界
|
||||
|
||||
|
||||
@@ -177,11 +177,14 @@ class MyPlugin(_PluginBase):
|
||||
"version": "1.0.0",
|
||||
"icon": "Moviepilot_A.png",
|
||||
"author": "your-name",
|
||||
"system_version": ">=2.12.0",
|
||||
"level": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`system_version` 是可选字段。插件依赖某个 MoviePilot 主系统版本才提供的能力时再声明,格式参考 pip 依赖版本范围;不声明时宿主不会做主系统版本检查。
|
||||
|
||||
## 4. `_PluginBase` 的核心能力
|
||||
|
||||
V2 插件的核心宿主基类是 `MoviePilot/app/plugins/__init__.py` 中的 `_PluginBase`。开发时需要优先理解它暴露出来的扩展点。
|
||||
|
||||
147
docs/faq/17-register-plugin-sidebar-nav.md
Normal file
147
docs/faq/17-register-plugin-sidebar-nav.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# 17. 如何将插件页面注册到主界面左侧导航栏?
|
||||
|
||||
返回 [README](../../README.md) | [FAQ 索引](../FAQ.md)
|
||||
|
||||
插件进入左侧导航栏走的是 **Vue 远程组件全页入口**,不是 `get_page()` 的详情弹窗。完整链路是:插件后端声明 Vue 渲染模式和侧栏入口,MoviePilot 后端通过 `GET /api/v1/plugin/sidebar_nav` 聚合,前端把入口插入对应分组并跳转到 `#/plugin-app/<PluginID>/<nav_key>`,再加载插件暴露的 `AppPage` 组件。
|
||||
|
||||
## 1. 后端插件要做什么?
|
||||
|
||||
插件必须同时满足这些条件:
|
||||
|
||||
- 插件已启用,`get_state()` 返回 `True`。
|
||||
- `get_render_mode()` 返回 `("vue", "dist/assets")` 或你的实际构建产物目录。
|
||||
- 实现 `get_sidebar_nav()` 并返回一个列表。
|
||||
- 插件目录下存在前端构建产物,至少包含 `remoteEntry.js` 和被暴露的组件文件。
|
||||
|
||||
示例:
|
||||
|
||||
```python
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
|
||||
def get_render_mode(self) -> Tuple[str, str]:
|
||||
"""
|
||||
声明插件使用 Vue 远程组件渲染,并指定构建产物目录。
|
||||
"""
|
||||
return "vue", "dist/assets"
|
||||
|
||||
|
||||
def get_sidebar_nav(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
声明插件在主界面左侧导航栏中的全页入口。
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"nav_key": "main",
|
||||
"title": "我的插件",
|
||||
"icon": "mdi-puzzle",
|
||||
"section": "system",
|
||||
"permission": "manage",
|
||||
"order": 10,
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
| 字段 | 是否必填 | 说明 |
|
||||
|------|----------|------|
|
||||
| `nav_key` | 否 | 当前插件内的入口标识,默认 `main`;会进入 URL 路径段 |
|
||||
| `title` | 否 | 侧栏显示标题,未填时使用插件名称 |
|
||||
| `icon` | 否 | MDI 图标名,未填时使用 `mdi-puzzle` |
|
||||
| `section` | 否 | 侧栏分组:`start` / `discovery` / `subscribe` / `organize` / `system`,无效值会归入 `system` |
|
||||
| `permission` | 否 | 菜单权限:`subscribe` / `discovery` / `search` / `manage` / `admin`,未填则不额外限制 |
|
||||
| `order` | 否 | 同组内排序,数值越小越靠前 |
|
||||
|
||||
注意:
|
||||
|
||||
- `nav_key` 不能包含 `/`、`?`、`#`、空格;建议使用 `main`、`settings`、`history`、`my_tool` 这类稳定值。
|
||||
- `get_page()` 只影响插件管理里的详情弹窗;要出现在主界面左侧导航,必须实现 `get_sidebar_nav()`。
|
||||
- 如果插件依赖这个新前端能力,建议在 `package.json` / `package.v2.json` 中用 `system_version` 限定最低 MoviePilot 版本。
|
||||
|
||||
## 2. 前端远程组件要暴露什么?
|
||||
|
||||
前端工程需要在模块联邦里暴露全页组件:
|
||||
|
||||
```typescript
|
||||
federation({
|
||||
name: 'MyPlugin',
|
||||
filename: 'remoteEntry.js',
|
||||
exposes: {
|
||||
'./AppPage': './src/components/AppPage.vue',
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
`AppPage.vue` 会收到主应用传入的 `api`、`pluginId`、`navKey`:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const props = defineProps({
|
||||
api: { type: Object, default: () => ({}) },
|
||||
pluginId: { type: String, default: '' },
|
||||
navKey: { type: String, default: 'main' },
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pa-4">
|
||||
{{ props.pluginId }} / {{ props.navKey }}
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
如果页面需要调用插件后端 API,后端 `get_api()` 建议使用 `auth: "bear"`,前端通过传入的 `api` 调用:
|
||||
|
||||
```typescript
|
||||
const rows = await props.api.get(`plugin/${props.pluginId}/history`)
|
||||
```
|
||||
|
||||
## 3. 多个导航入口怎么做?
|
||||
|
||||
`get_sidebar_nav()` 可以返回多条记录:
|
||||
|
||||
```python
|
||||
def get_sidebar_nav(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
声明同一插件的多个左侧导航入口。
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"nav_key": "main",
|
||||
"title": "处理面板",
|
||||
"icon": "mdi-view-dashboard",
|
||||
"section": "organize",
|
||||
"permission": "manage",
|
||||
"order": 20,
|
||||
},
|
||||
{
|
||||
"nav_key": "settings",
|
||||
"title": "处理设置",
|
||||
"icon": "mdi-cog",
|
||||
"section": "system",
|
||||
"permission": "manage",
|
||||
"order": 21,
|
||||
},
|
||||
]
|
||||
```
|
||||
|
||||
前端加载规则:
|
||||
|
||||
| `nav_key` | 依次尝试的联邦暴露名 |
|
||||
|-----------|----------------------|
|
||||
| `main` 或省略 | `./AppPage` -> `./Page` |
|
||||
| 其它,例如 `settings` | `./AppPageSettings` -> `./AppPage` -> `./Page` |
|
||||
| 其它,例如 `my_tool` | `./AppPageMyTool` -> `./AppPage` -> `./Page` |
|
||||
|
||||
也就是说你可以只暴露一个 `./AppPage`,在组件内根据 `navKey` 分支渲染;也可以为不同入口分别暴露 `./AppPageSettings`、`./AppPageHistory` 等组件。
|
||||
|
||||
## 4. 排查清单
|
||||
|
||||
- `GET /api/v1/plugin/sidebar_nav` 是否能看到你的插件入口。
|
||||
- `GET /api/v1/plugin/remotes?token=moviepilot` 是否能看到你的插件远程组件入口。
|
||||
- 插件是否启用,且 `get_render_mode()` 是否返回 `vue`。
|
||||
- `dist/assets/remoteEntry.js` 是否实际安装到了插件运行目录。
|
||||
- `nav_key` 是否包含非法字符,或和前端暴露名不匹配。
|
||||
- 当前用户是否有 `permission` 声明的权限;超级用户默认拥有全部权限。
|
||||
- 前端侧栏会缓存 `plugin/sidebar_nav` 结果,插件启停或变更入口后建议刷新页面重新加载。
|
||||
26
docs/faq/18-limit-moviepilot-version.md
Normal file
26
docs/faq/18-limit-moviepilot-version.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# 18. 如何限定插件可安装的 MoviePilot 主系统版本?
|
||||
|
||||
如果插件依赖某个 MoviePilot 主程序版本才提供的后端接口、前端能力、事件字段或运行时模块,应在对应的 `package.json` / `package.v2.json` 条目中增加 `system_version` 字段。
|
||||
|
||||
```json
|
||||
{
|
||||
"MyPlugin": {
|
||||
"name": "我的插件",
|
||||
"version": "1.0.0",
|
||||
"system_version": ">=2.12.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
字段规则:
|
||||
|
||||
- `system_version` 的格式参考 pip 依赖版本范围,也就是 PEP 440 specifier,例如 `">=2.12.0"`、`">=2.12.0,<3"`、`"~=2.12"`。
|
||||
- MoviePilot 当前版本号带 `v` 前缀也可以正常比较,因此 `>=v2.12.0` 和 `>=2.12.0` 都能解析;文档和索引中推荐写不带 `v` 的形式。
|
||||
- 未定义 `system_version` 时,宿主不做主系统版本检查,保持旧插件兼容。
|
||||
- 如果当前主系统版本不满足范围,插件市场会显示不兼容提示,安装和更新都会被拒绝。
|
||||
|
||||
发布插件时,建议在以下场景补充该字段:
|
||||
|
||||
- 插件调用了新版本才存在的后端 API、helper、chain、module 或事件字段。
|
||||
- 插件的 Vue 远程组件依赖新版本前端才支持的加载、侧栏、仪表板或渲染行为。
|
||||
- 插件换用了新版本 MoviePilot 才内置或才稳定可用的系统能力,例如浏览器运行时、统一缓存、智能体工具注册等。
|
||||
BIN
icons/Oidcauth_A.png
Normal file
BIN
icons/Oidcauth_A.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.3 KiB |
BIN
icons/Oidcauth_B.png
Normal file
BIN
icons/Oidcauth_B.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.4 KiB |
33
package.json
33
package.json
@@ -114,11 +114,13 @@
|
||||
"name": "目录监控",
|
||||
"description": "监控目录文件发生变化时实时整理到媒体库。",
|
||||
"labels": "文件整理",
|
||||
"version": "2.4",
|
||||
"version": "2.5.1",
|
||||
"icon": "directory.png",
|
||||
"author": "jxxghp",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v2.5.1": "过滤下载临时文件和不可整理文件,避免目录监控误触发整理",
|
||||
"v2.5": "目录监控改用watchfiles,移除旧监控依赖",
|
||||
"v2.4": "修复目录监控不使用ChatGPT辅助识别问题",
|
||||
"v2.3": "特殊场景下补充转移成功历史记录",
|
||||
"v2.2": "更新目录设置说明",
|
||||
@@ -251,11 +253,12 @@
|
||||
"name": "媒体库服务器通知",
|
||||
"description": "发送Emby/Jellyfin/Plex服务器的播放、入库等通知消息。",
|
||||
"labels": "消息通知,媒体库",
|
||||
"version": "1.3",
|
||||
"version": "1.4",
|
||||
"icon": "mediaplay.png",
|
||||
"author": "jxxghp",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v1.4": "兼容Jellyfin ItemAdded入库Webhook事件",
|
||||
"v1.3": "兼容处理Emby部分客户端暂停重复推送停止播放webhook的场景",
|
||||
"v1.2": "播放通知增加超链接跳转(需要v1.9.4+)"
|
||||
}
|
||||
@@ -325,11 +328,13 @@
|
||||
"name": "IYUU自动辅种",
|
||||
"description": "基于IYUU官方Api实现自动辅种。",
|
||||
"labels": "做种,IYUU",
|
||||
"version": "1.9.12",
|
||||
"version": "1.9.14",
|
||||
"icon": "IYUU.png",
|
||||
"author": "jxxghp",
|
||||
"level": 2,
|
||||
"history": {
|
||||
"v1.9.14": "修复由于站点哈希值过期导致辅种失败的问题,并优化代码逻辑",
|
||||
"v1.9.13": "限制辅种缓存大小并重置运行期校验队列,避免长期运行缓存无限增长",
|
||||
"v1.9.12": "修复海豹不能辅种的问题",
|
||||
"v1.9.11": "修复馒头不能辅种的问题",
|
||||
"v1.9.10": "Revert 辅种结束后,一起开始所有辅种后暂停的种子(排除了出错的种子)",
|
||||
@@ -355,11 +360,12 @@
|
||||
"name": "青蛙辅种助手",
|
||||
"description": "参考ReseedPuppy和IYUU辅种插件实现自动辅种,支持站点:青蛙、AGSVPT、麒麟、UBits、聆音、憨憨等。",
|
||||
"labels": "做种",
|
||||
"version": "2.4",
|
||||
"version": "2.4.1",
|
||||
"icon": "qingwa.png",
|
||||
"author": "233@qingwa",
|
||||
"level": 2,
|
||||
"history": {
|
||||
"v2.4.1": "限制辅种缓存大小并重置运行期校验队列,避免长期运行缓存无限增长",
|
||||
"v2.4": "支持qbittorrent 5",
|
||||
"v2.2": "站点停用后会同步暂停对该站点的辅种",
|
||||
"v2.3": "站点辅种支持代理"
|
||||
@@ -369,11 +375,13 @@
|
||||
"name": "整理VCB动漫压制组作品",
|
||||
"description": "一款辅助整理&提高识别VCB-Stuido动漫压制组作品的插件",
|
||||
"labels": "文件整理,识别",
|
||||
"version": "1.8.2.1",
|
||||
"version": "1.8.2.4",
|
||||
"icon": "vcbmonitor.png",
|
||||
"author": "pixel@qingwa",
|
||||
"level": 2,
|
||||
"history": {
|
||||
"v1.8.2.4": "过滤下载临时文件和不可整理文件,避免目录监控误触发整理",
|
||||
"v1.8.2.3": "目录监控改用watchfiles,移除旧监控依赖",
|
||||
"v1.8.2.1": "修复日志输出&同步目录监控插件功能",
|
||||
"v1.8.2": "提高识别率",
|
||||
"v1.8.1": "重构插件,测试版",
|
||||
@@ -470,12 +478,13 @@
|
||||
"name": "药丸签到",
|
||||
"description": "药丸论坛签到。",
|
||||
"labels": "站点",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"icon": "invites.png",
|
||||
"author": "thsrite",
|
||||
"level": 2,
|
||||
"release": true,
|
||||
"history": {
|
||||
"v2.0.4": "切换药丸真实签到接口并校验站点实时签到状态,修复失败误报成功问题",
|
||||
"v2.0.3": "增加启用浏览器仿真功能发送请求",
|
||||
"v2.0.2": "增加签到检测机制防止重复签到,增强代码健壮性。",
|
||||
"v2.0.1": "尝试修复签到失败问题,新增使用代理、Cookie自动更新功能",
|
||||
@@ -637,12 +646,14 @@
|
||||
"name": "清理硬链接",
|
||||
"description": "监控目录内文件被删除时,同步删除监控目录内所有和它硬链接的文件",
|
||||
"labels": "文件整理",
|
||||
"version": "2.2",
|
||||
"version": "2.3.1",
|
||||
"icon": "Ombi_A.png",
|
||||
"author": "DzAvril",
|
||||
"level": 1,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"v2.3.1": "优化下载临时文件过滤,避免清理硬链接处理未完成文件",
|
||||
"v2.3": "目录监控改用watchfiles,移除旧监控依赖",
|
||||
"v2.2": "修复直接删除文件夹导致的插件崩溃的bug",
|
||||
"v2.1": "联动删除历史记录",
|
||||
"v2.0": "联动删除种子,需安装插件[下载器助手]并打开监听源文件事件",
|
||||
@@ -656,12 +667,14 @@
|
||||
"name": "实时硬链接",
|
||||
"description": "监控目录文件变化,实时硬链接。",
|
||||
"labels": "文件整理",
|
||||
"version": "1.6",
|
||||
"version": "1.7.1",
|
||||
"icon": "Linkace_C.png",
|
||||
"author": "jxxghp",
|
||||
"level": 1,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"v1.7.1": "过滤下载临时文件,避免实时硬链接处理未完成文件",
|
||||
"v1.7": "目录监控改用watchfiles,移除旧监控依赖",
|
||||
"v1.6": "增强API安全性"
|
||||
}
|
||||
},
|
||||
@@ -867,12 +880,14 @@
|
||||
"name": "MoviePilot服务器监控",
|
||||
"description": "在仪表板中实时显示MoviePilot公共服务器状态。",
|
||||
"labels": "仪表板",
|
||||
"version": "1.2",
|
||||
"version": "1.4",
|
||||
"icon": "Duplicati_A.png",
|
||||
"author": "jxxghp",
|
||||
"level": 1,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"v1.4": "重新设计仪表板,仅展示响应延迟、连接状态和速率等关键卡片",
|
||||
"v1.3": "增加HTTP/DNS/TLS探测、请求速率、连接占比和异常兜底展示",
|
||||
"v1.2": "优化数量示",
|
||||
"v1.1": "增加详情界面显示"
|
||||
}
|
||||
|
||||
237
package.v2.json
237
package.v2.json
@@ -44,12 +44,14 @@
|
||||
"name": "站点自动签到",
|
||||
"description": "自动模拟登录、签到站点。",
|
||||
"labels": "站点",
|
||||
"version": "2.8.2",
|
||||
"version": "2.9.1",
|
||||
"icon": "signin.png",
|
||||
"author": "thsrite",
|
||||
"level": 2,
|
||||
"release": true,
|
||||
"history": {
|
||||
"v2.9.1": "修复详情页历史记录部分站点只显示站点ID的问题",
|
||||
"v2.9.0": "优化插件详情页,改为紧凑状态矩阵展示签到和登录情况",
|
||||
"v2.8.2": "优化站点 Rousi Pro 签到失败提示信息",
|
||||
"v2.8.1": "更新站点 Rousi Pro 签到接口",
|
||||
"v2.8": "适配站点 Rousi Pro",
|
||||
@@ -97,11 +99,12 @@
|
||||
"name": "媒体库服务器通知",
|
||||
"description": "发送Emby/Jellyfin/Plex服务器的播放、入库等通知消息。",
|
||||
"labels": "消息通知,媒体库",
|
||||
"version": "1.8.2.2",
|
||||
"version": "1.8.2.3",
|
||||
"icon": "mediaplay.png",
|
||||
"author": "jxxghp",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v1.8.2.3": "兼容Jellyfin ItemAdded入库Webhook事件",
|
||||
"v1.8.2.2": "修复emby多条相同新入库消息推送多次的问题",
|
||||
"v1.8.2.1": "修复多集时有概率图片获取失败的问题;修复emby测试通知类型接收失败的问题",
|
||||
"v1.8.1": "修复单集剧情信息有概率获取失败的问题",
|
||||
@@ -115,13 +118,20 @@
|
||||
},
|
||||
"ChatGPT": {
|
||||
"name": "ChatGPT",
|
||||
"description": "消息交互支持与ChatGPT对话。",
|
||||
"labels": "消息通知,识别",
|
||||
"version": "2.1.9",
|
||||
"description": "使用系统智能助手或 Agent Tokens 管理插件的 LLM 配置增强媒体名称识别。",
|
||||
"labels": "AI,识别,LLM,媒体识别,Agent Tokens",
|
||||
"version": "3.0.5",
|
||||
"icon": "Chatgpt_A.png",
|
||||
"author": "jxxghp",
|
||||
"level": 1,
|
||||
"system_version": ">=2.13.2",
|
||||
"history": {
|
||||
"v3.0.5": "新增识别调用 Token 用量统计,详情页按千分位展示并支持清除统计,同时优化缓存说明文案。",
|
||||
"v3.0.4": "重构缓存管理 UI,缓存数量改从数据库读取,新增插件详情页管理缓存。",
|
||||
"v3.0.3": "添加识别结果持久化缓存,避免相同标题重复调用 LLM API,支持手动清除缓存并显示缓存数量。",
|
||||
"v3.0.2": "修复插件入口误打开空详情页,并统一配置表单行列布局。",
|
||||
"v3.0.1": "兼容系统智能助手和 Agent Tokens 供应商的 LLM 代理开关配置。",
|
||||
"v3.0": "移除聊天功能,仅保留媒体识别增强;模型来源改为系统智能助手设置或 Agent Tokens 管理插件;重写英文识别提示词并优化配置界面。",
|
||||
"v2.1.9": "更新依赖库",
|
||||
"v2.1.8": "修复 OpenAI API >=1.0.0 兼容性问题",
|
||||
"v2.1.7": "独立安装OpenAi SDK依赖",
|
||||
@@ -185,11 +195,13 @@
|
||||
"name": "媒体库刮削",
|
||||
"description": "定时对媒体库进行刮削,补齐缺失元数据和图片。",
|
||||
"labels": "刮削",
|
||||
"version": "2.1.1",
|
||||
"version": "2.1.3",
|
||||
"icon": "scraper.png",
|
||||
"author": "jxxghp",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v2.1.3": "修复分类路径下按文件标记识别及强制类型跨库扫描问题",
|
||||
"v2.1.2": "修复分类目录被误识别导致下级媒体未刮削的问题",
|
||||
"v2.1.1": "调整目录计算方法,以支持更多重命名格式",
|
||||
"v2.1": "优化执行周期输入,需要MoviePilot v2.2.1+",
|
||||
"v2.0": "兼容MoviePilot V2 版本",
|
||||
@@ -201,11 +213,14 @@
|
||||
"name": "演职人员刮削",
|
||||
"description": "刮削演职人员图片以及中文名称。",
|
||||
"labels": "媒体库,刮削",
|
||||
"version": "2.2.2",
|
||||
"version": "2.2.4",
|
||||
"icon": "actor.png",
|
||||
"author": "jxxghp",
|
||||
"level": 1,
|
||||
"system_version": ">2.12.4",
|
||||
"history": {
|
||||
"v2.2.4": "改为监听元数据刮削事件并增加实时防重,修复媒体服务器过滤和空人物保存问题",
|
||||
"v2.2.3": "简繁转换依赖改用 zhconv-rs,需要 MoviePilot >2.12.4",
|
||||
"v2.2.2": "修复异常日志问题",
|
||||
"v2.2.1": "优化错误数据兼容处理",
|
||||
"v2.2": "修改使用自定义图片域名时无法下载图片的问题",
|
||||
@@ -261,11 +276,13 @@
|
||||
"name": "IYUU自动辅种",
|
||||
"description": "基于IYUU官方Api实现自动辅种。",
|
||||
"labels": "做种,IYUU",
|
||||
"version": "2.15",
|
||||
"version": "2.17",
|
||||
"icon": "IYUU.png",
|
||||
"author": "jxxghp,CKun",
|
||||
"level": 2,
|
||||
"history": {
|
||||
"v2.17": "修复由于站点哈希值过期导致辅种失败的问题,并优化代码逻辑",
|
||||
"v2.16": "限制辅种缓存大小并重置运行期校验队列,避免长期运行缓存无限增长",
|
||||
"v2.15": "修复海豹不能辅种的问题",
|
||||
"v2.14": "修复馒头不能辅种的问题",
|
||||
"v2.13": "开启跳过校验后需手动开启自动开始",
|
||||
@@ -288,11 +305,12 @@
|
||||
"name": "青蛙辅种助手",
|
||||
"description": "参考ReseedPuppy和IYUU辅种插件实现自动辅种,支持站点:青蛙、AGSVPT、麒麟、UBits、聆音、憨憨等。",
|
||||
"labels": "做种",
|
||||
"version": "3.0.2",
|
||||
"version": "3.0.3",
|
||||
"icon": "qingwa.png",
|
||||
"author": "233@qingwa",
|
||||
"level": 2,
|
||||
"history": {
|
||||
"v3.0.3": "限制辅种缓存大小并重置运行期校验队列,避免长期运行缓存无限增长",
|
||||
"v3.0.2": "更新依赖库",
|
||||
"v3.0.1": "遗漏了一个私有属性",
|
||||
"v3.0": "兼容MoviePilot V2 版本"
|
||||
@@ -479,11 +497,13 @@
|
||||
"name": "IMDb源",
|
||||
"description": "让探索,推荐和媒体识别支持IMDb数据源。",
|
||||
"labels": "探索",
|
||||
"version": "1.6.8",
|
||||
"version": "1.6.9",
|
||||
"icon": "IMDb_IOS-OSX_App.png",
|
||||
"author": "wumode",
|
||||
"level": 1,
|
||||
"system_version": ">2.12.4",
|
||||
"history": {
|
||||
"v1.6.9": "简繁转换依赖改用 zhconv-rs,需要 MoviePilot >2.12.4",
|
||||
"v1.6.8": "兼容 MoviePilot v2.11.0 识别链新增 share_meta 参数,修复辅助识别模式下刮削报错",
|
||||
"v1.6.7": "优化界面显示; 增加榜单排名显示; 添加制作公司过滤项",
|
||||
"v1.6.6": "优化主页组件链接跳转",
|
||||
@@ -519,12 +539,13 @@
|
||||
"name": "Clash Rule Provider",
|
||||
"description": "随时为Clash添加一些额外的规则。",
|
||||
"labels": "工具",
|
||||
"version": "2.1.6",
|
||||
"version": "2.1.7",
|
||||
"icon": "Mihomo_Meta_A.png",
|
||||
"author": "wumode",
|
||||
"level": 1,
|
||||
"release": true,
|
||||
"history": {
|
||||
"v2.1.7": "移除全局 TLS 指纹配置,请在代理节点中直接设置 client-fingerprint",
|
||||
"v2.1.6": "修复依赖冲突",
|
||||
"v2.1.5": "优化仪表盘连接鉴权;优化订阅更新提示",
|
||||
"v2.1.4": "支持 xhttp 协议",
|
||||
@@ -567,11 +588,12 @@
|
||||
"name": "美剧生词标注",
|
||||
"description": "根据CEFR等级,为英语影视剧标注高级词汇。",
|
||||
"labels": "英语",
|
||||
"version": "1.2.5",
|
||||
"version": "1.2.6",
|
||||
"icon": "LexiAnnot.png",
|
||||
"author": "wumode",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v1.2.6": "适配 MoviePilot 新版 LLM 助手",
|
||||
"v1.2.5": "langchain 1.x 兼容 (主程序版本需高于 2.9.17)",
|
||||
"v1.2.4": "增强数据校验",
|
||||
"v1.2.3": "优化提示词",
|
||||
@@ -603,11 +625,13 @@
|
||||
"name": "Bug反馈",
|
||||
"description": "自动上报异常,协助开发者发现和解决问题。",
|
||||
"labels": "开发",
|
||||
"version": "1.3",
|
||||
"version": "1.5.1",
|
||||
"icon": "Alist_encrypt_A.png",
|
||||
"author": "jxxghp",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v1.5": "更换上报端点",
|
||||
"v1.4": "仅上报包含异常堆栈的错误,普通日志不再上报",
|
||||
"v1.3": "减少网络异常信息上送",
|
||||
"v1.2": "优化上报信息量",
|
||||
"v1.1": "加强脱敏处理"
|
||||
@@ -638,6 +662,70 @@
|
||||
"v2.0.0": "首次发布:可配置报告模块、历史记录页面、下载器通用化、存储路径可配置"
|
||||
}
|
||||
},
|
||||
"DynamicWeChat": {
|
||||
"name": "动态企微可信IP",
|
||||
"description": "修改企微应用可信IP,支持Srever酱等第三方通知。验证码以?结尾发送到企业微信应用",
|
||||
"labels": "消息通知",
|
||||
"version": "2.1.2",
|
||||
"icon": "Wecom_A.png",
|
||||
"author": "RamenRa",
|
||||
"level": 2,
|
||||
"system_version": ">=2.12.0",
|
||||
"history": {
|
||||
"v2.1.2": "修复本地扫码获取不到验证码的问题" ,
|
||||
"v2.1.1": "优化MP/Nas关闭期间IP变动检测不到的现象。支持IYUU通知移除AnPush v2支持在微信通知失效时用第三方发送通知 支持||Q修改IP时不发送通知 使用全局AI助手需使用/wxcode 510010的格式发送验证码",
|
||||
"v2.0.1": "修复企业微信后台页面语言未稳定切换为中文导致无法匹配配置按钮的问题。",
|
||||
"v2.0.0": "V2 专用大版本改用 CloakBrowser 启动企业微信浏览器流程,默认插件不再声明 V2 兼容。",
|
||||
"v1.7.3": "修复检测登录的元素",
|
||||
"v1.7.2": "||wan参数细分,修复使用||wan时立即检测一次实际不生效,修复v1第三方备用通知可能无效,调整验证码获取",
|
||||
"v1.7.1": "允许使用'||wan2'选项及无法使用'立即检测一次'",
|
||||
"v1.7.0": "使用第三方通知时可IP变动后通知,拟支持多网络出口检查。",
|
||||
"v1.6.0": "忽略因网络波动导致获取ip错误。自定义的类合并为helper.py。后续核心功能没问题将不再更新",
|
||||
"v1.5.2": "可以从指定url获取ip,修复不使用cc时cookie失效过快,v1可配置第三方为备用通知,server酱可以将文本发送到server3,二维码给服务号",
|
||||
"v1.5.1": "修复v2微信通知,可以指定微信通知ID",
|
||||
"v1.5.0": "支持企微应用通知和第Serve酱等第三方推送。按要求修改插件名称",
|
||||
"v1.4.1": "完善面板说明",
|
||||
"v1.4.0": "修复强制更改IP时配置面板延时过长的问题。庆祝v2进入正式版,显示了一个没用的参数"
|
||||
}
|
||||
},
|
||||
"InvitesSignin": {
|
||||
"name": "药丸签到",
|
||||
"description": "药丸论坛签到。",
|
||||
"labels": "站点",
|
||||
"version": "3.0.1",
|
||||
"icon": "invites.png",
|
||||
"author": "thsrite",
|
||||
"level": 2,
|
||||
"release": true,
|
||||
"system_version": ">=2.12.0",
|
||||
"history": {
|
||||
"v3.0.1": "切换药丸真实签到接口并校验站点实时签到状态,修复失败误报成功问题",
|
||||
"v3.0.0": "V2 专用大版本的浏览器仿真改用 CloakBrowser 获取页面,默认插件不再声明 V2 兼容。",
|
||||
"v2.0.3": "增加启用浏览器仿真功能发送请求",
|
||||
"v2.0.2": "增加签到检测机制防止重复签到,增强代码健壮性。",
|
||||
"v2.0.1": "尝试修复签到失败问题,新增使用代理、Cookie自动更新功能",
|
||||
"v2.0.0": "修复签到失败问题,新增账户登录签到功能、新增签到失败重试机制,美化界面UI",
|
||||
"v1.4.1": "更新签到域名前缀",
|
||||
"v1.4": "自定义保留消息天数"
|
||||
}
|
||||
},
|
||||
"ContractCheck": {
|
||||
"name": "契约检查",
|
||||
"description": "定时检查保种契约达成情况。",
|
||||
"labels": "做种",
|
||||
"version": "2.0.0",
|
||||
"icon": "contract.png",
|
||||
"author": "DzAvril",
|
||||
"level": 1,
|
||||
"system_version": ">=2.12.0",
|
||||
"history": {
|
||||
"v2.0.0": "V2 专用大版本的渲染模式改用 CloakBrowser 获取站点页面,默认插件不再声明 V2 兼容。",
|
||||
"v1.4.1": "增加站点猪猪",
|
||||
"v1.4": "支持仪表板组件显示",
|
||||
"v1.3": "修复观众做种数据异常问题",
|
||||
"v1.2": "修复契约检查无数据返回的问题"
|
||||
}
|
||||
},
|
||||
"TvFirstWatch": {
|
||||
"name": "首播试看",
|
||||
"description": "定时抓取RSS,只下载电视剧前N集,自动跳过合集和过大文件。",
|
||||
@@ -654,12 +742,13 @@
|
||||
"name": "WechatClawBot消息推送",
|
||||
"description": "支持使用微信(通过ClawBot)发送消息通知。",
|
||||
"labels": "消息通知,微信",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.2",
|
||||
"icon": "Wechat_A.png",
|
||||
"author": "mijjjj",
|
||||
"level": 1,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"v0.2.2": "修复重复推送消息问题,增加入站消息去重并修正轮询更新提取逻辑",
|
||||
"v0.2.1": "修复详情页状态信息换行显示问题",
|
||||
"v0.2.0": "优化配置页UI布局,修复回复消息携带多余类型前缀的问题",
|
||||
"v0.1.0": "初始版本"
|
||||
@@ -693,11 +782,13 @@
|
||||
"name": "Agent影视助手",
|
||||
"description": "龙虾agent稳定控制 MP:飞书入口、盘搜/影巢搜索、115/夸克转存、智能评分推荐。",
|
||||
"labels": "Agent,影巢,HDHive,115,夸克,Quark,智能体,转存,解锁",
|
||||
"version": "0.2.72",
|
||||
"version": "0.2.73",
|
||||
"icon": "agentresourceofficer.png",
|
||||
"author": "liuyuexi1987",
|
||||
"level": 1,
|
||||
"system_version": ">2.12.4",
|
||||
"history": {
|
||||
"0.2.73": "整理历史标题分词依赖改用 jieba-next,需要 MoviePilot >2.12.4。",
|
||||
"0.2.72": "影巢自动登录兜底流程改用 CloakBrowser,移除插件对 Playwright 浏览器调用的直接依赖。",
|
||||
"0.2.71": "新增流媒体推荐:聚合 Netflix、Disney+、Apple TV+、Prime Video 四大平台,基于 TMDB discover 按热度/评分推荐本月上新、近期热门电影和剧集;结果页改为只读列表,仅支持显式前缀触发。",
|
||||
"0.2.70": "最后一轮主线收口:取消标题级云盘转存/云盘搜索入口,统一保留前缀搜索与编号续接;修复 PT 指定集/最新集筛选、下载路径透传、分页与旧别名拦截,并同步外部智能体 Skill/命令文档。",
|
||||
@@ -971,61 +1062,79 @@
|
||||
"v1.0.1": "修复定时任务重复触发问题"
|
||||
}
|
||||
},
|
||||
"DynamicWeChat": {
|
||||
"name": "动态企微可信IP",
|
||||
"description": "修改企微应用可信IP,支持Srever酱等第三方通知。验证码以?结尾发送到企业微信应用",
|
||||
"labels": "消息通知",
|
||||
"version": "2.0.0",
|
||||
"icon": "Wecom_A.png",
|
||||
"author": "RamenRa",
|
||||
"level": 2,
|
||||
"history": {
|
||||
"v2.0.0": "V2 专用大版本改用 CloakBrowser 启动企业微信浏览器流程,默认插件不再声明 V2 兼容。",
|
||||
"v1.7.3": "修复检测登录的元素",
|
||||
"v1.7.2": "||wan参数细分,修复使用||wan时立即检测一次实际不生效,修复v1第三方备用通知可能无效,调整验证码获取",
|
||||
"v1.7.1": "允许使用'||wan2'选项及无法使用'立即检测一次'",
|
||||
"v1.7.0": "使用第三方通知时可IP变动后通知,拟支持多网络出口检查。",
|
||||
"v1.6.0": "忽略因网络波动导致获取ip错误。自定义的类合并为helper.py。后续核心功能没问题将不再更新",
|
||||
"v1.5.2": "可以从指定url获取ip,修复不使用cc时cookie失效过快,v1可配置第三方为备用通知,server酱可以将文本发送到server3,二维码给服务号",
|
||||
"v1.5.1": "修复v2微信通知,可以指定微信通知ID",
|
||||
"v1.5.0": "支持企微应用通知和第Serve酱等第三方推送。按要求修改插件名称",
|
||||
"v1.4.1": "完善面板说明",
|
||||
"v1.4.0": "修复强制更改IP时配置面板延时过长的问题。庆祝v2进入正式版,显示了一个没用的参数"
|
||||
}
|
||||
},
|
||||
"InvitesSignin": {
|
||||
"name": "药丸签到",
|
||||
"description": "药丸论坛签到。",
|
||||
"labels": "站点",
|
||||
"version": "3.0.0",
|
||||
"icon": "invites.png",
|
||||
"author": "thsrite",
|
||||
"level": 2,
|
||||
"OidcAuth": {
|
||||
"name": "OIDC 认证",
|
||||
"description": "通过 OpenID Connect Provider 为 MoviePilot 提供插件化登录与账号绑定。",
|
||||
"labels": "认证,OIDC,SSO",
|
||||
"version": "0.3.1",
|
||||
"icon": "Oidcauth_A.png",
|
||||
"author": "ui-beam-9,jxxghp",
|
||||
"level": 1,
|
||||
"system_version": ">=2.13.5",
|
||||
"release": true,
|
||||
"history": {
|
||||
"v3.0.0": "V2 专用大版本的浏览器仿真改用 CloakBrowser 获取页面,默认插件不再声明 V2 兼容。",
|
||||
"v2.0.3": "增加启用浏览器仿真功能发送请求",
|
||||
"v2.0.2": "增加签到检测机制防止重复签到,增强代码健壮性。",
|
||||
"v2.0.1": "尝试修复签到失败问题,新增使用代理、Cookie自动更新功能",
|
||||
"v2.0.0": "修复签到失败问题,新增账户登录签到功能、新增签到失败重试机制,美化界面UI",
|
||||
"v1.4.1": "更新签到域名前缀",
|
||||
"v1.4": "自定义保留消息天数"
|
||||
"v0.1.0": "新增插件化 OIDC 登录、账号绑定、Provider 配置与联邦认证界面。",
|
||||
"v0.2.0": "AuthPage 自动跳转 OIDC 授权,新增加载动画与错误重试;修复弹窗拦截提示及 PROXY_HOST 空值崩溃,补充配置表单指南。",
|
||||
"v0.3.0": "重构双栏布局与动态背景,支持深浅主题自适应;新增绑定可视化、详情卡片及解绑确认;升级通信机制,新增特性介绍与底部信息栏,统一图标风格。",
|
||||
"v0.3.1": "修复回调事件类型不匹配导致前端错误提示不准确;移除解绑方法多余检查,允许 OIDC 关闭状态下正常解绑。"
|
||||
}
|
||||
},
|
||||
"ContractCheck": {
|
||||
"name": "契约检查",
|
||||
"description": "定时检查保种契约达成情况。",
|
||||
"labels": "做种",
|
||||
"version": "2.0.0",
|
||||
"icon": "contract.png",
|
||||
"author": "DzAvril",
|
||||
"AgentTokens": {
|
||||
"name": "Agent Tokens 管理",
|
||||
"description": "管理多平台免费 Token 配额,按优先级自动切换 Agent LLM 供应商。",
|
||||
"labels": "Agent,AI,系统",
|
||||
"version": "1.0.12",
|
||||
"icon": "agentresourceofficer.png",
|
||||
"author": "jxxghp",
|
||||
"level": 1,
|
||||
"system_version": ">=2.13.2",
|
||||
"release": true,
|
||||
"history": {
|
||||
"v1.0.12": "优化仪表板组件主题 token 适配和手动调整大小后的紧凑自适应布局",
|
||||
"v1.0.11": "重设计仪表板组件,改为紧凑卡片式配额概览并对齐主仪表板视觉风格",
|
||||
"v1.0.10": "新增供应商使用代理服务器配置,分配 Agent LLM 供应商时按配置传递代理开关",
|
||||
"v1.0.9": "统一配置页和管理页内容,新增总使用进度图表卡片并优化大小屏布局",
|
||||
"v1.0.8": "支持为 Agent LLM 供应商配置并传递 User-Agent",
|
||||
"v1.0.7": "禁用VWindow触摸滑动,修复表格内滑动触发tab切换问题",
|
||||
"v1.0.6": "优化标题样式并对齐站点管理页面风格,修复弹窗标题截断问题",
|
||||
"v1.0.5": "优化UI布局,修复页面标题和按钮滚动问题",
|
||||
"v1.0.4": "补充分配模型信息及更新用量的运行日志",
|
||||
"v1.0.3": "修复因组件导出导致的界面空白问题、统一图标样式并去除全局样式污染",
|
||||
"v1.0.2": "修复UI界面显示不全及前端路由报错问题",
|
||||
"v1.0.1": "新增 Agent Tokens 配额管理、供应商优先级切换和用量展示"
|
||||
}
|
||||
},
|
||||
"TraktCleaner": {
|
||||
"name": "Trakt 观看清理",
|
||||
"description": "根据 Trakt 播放记录,自动清理下载器中已观看的种子。",
|
||||
"labels": "Trakt,清理",
|
||||
"version": "1.0",
|
||||
"icon": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/trakt.png",
|
||||
"author": "Guoyin-Wen",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v2.0.0": "V2 专用大版本的渲染模式改用 CloakBrowser 获取站点页面,默认插件不再声明 V2 兼容。",
|
||||
"v1.4.1": "增加站点猪猪",
|
||||
"v1.4": "支持仪表板组件显示",
|
||||
"v1.3": "修复观众做种数据异常问题",
|
||||
"v1.2": "修复契约检查无数据返回的问题"
|
||||
"v1.0": "初始版本:根据 Trakt 播放记录自动清理下载器中已观看的种子"
|
||||
}
|
||||
},
|
||||
"UpdateWeChatIp": {
|
||||
"name": "动态企微可信IP",
|
||||
"description": "修改企微应用可信IP,可本地扫码刷新Cookie,直接调用接口更稳定",
|
||||
"labels": "消息通知",
|
||||
"version": "1.0.8",
|
||||
"icon": "Wecom_A.png",
|
||||
"author": "书小白",
|
||||
"level": 2,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"1.0.8": "完善日志输出",
|
||||
"1.0.7": "插件初始化时调用一下check确定登录状态",
|
||||
"1.0.6": "修复未登录时_party_cache_data为空导致UI崩溃的BUG\n图片地址优先使用MP_DOMAIN获取,如果未配置使用127.0.0.1地址\n回调解析qrcode_key时判断是否存在,不存在发送错误\n优化请求企微接口的参数",
|
||||
"1.0.5": "根据Code Review结果优化代码",
|
||||
"1.0.4": "增加IP更新记录查询",
|
||||
"1.0.3": "cookie保活输出返回值",
|
||||
"1.0.2": "支持多个应用ID",
|
||||
"1.0.1": "IP更新时发送通知,增加API接口,指定更新的IP",
|
||||
"1.0.0": "初始化"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ class AgentResourceOfficer(_PluginBase):
|
||||
plugin_name = "Agent影视助手"
|
||||
plugin_desc = "龙虾agent稳定控制 MP:飞书入口、盘搜/影巢搜索、115/夸克转存、智能评分推荐。"
|
||||
plugin_icon = "https://raw.githubusercontent.com/liuyuexi1987/MoviePilot-Plugins/main/icons/agentresourceofficer.png"
|
||||
plugin_version = "0.2.72"
|
||||
plugin_version = "0.2.73"
|
||||
request_templates_schema_version = "request_templates.v1"
|
||||
plugin_author = "liuyuexi1987"
|
||||
plugin_level = 1
|
||||
|
||||
@@ -15,9 +15,9 @@ from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
try:
|
||||
import jieba
|
||||
from jieba_next import cut as jieba_cut
|
||||
except Exception:
|
||||
jieba = None
|
||||
jieba_cut = None
|
||||
|
||||
for _site_path in (
|
||||
"/usr/local/lib/python3.12/site-packages",
|
||||
@@ -33,7 +33,7 @@ except Exception:
|
||||
|
||||
_LARK_IMPORT_LOCK = threading.Lock()
|
||||
_LARK_AUTO_INSTALL_ATTEMPTED = False
|
||||
_LARK_PACKAGE_SPEC = "lark-oapi==1.5.3"
|
||||
_LARK_PACKAGE_SPEC = "lark-oapi>=1.4.0"
|
||||
|
||||
try:
|
||||
from app.chain.download import DownloadChain
|
||||
@@ -1350,9 +1350,9 @@ class FeishuChannel:
|
||||
status_bool = self._transfer_status_bool(status)
|
||||
title_text = str(title or "").strip()
|
||||
search_text = title_text
|
||||
if title_text and jieba is not None:
|
||||
if title_text and jieba_cut is not None:
|
||||
try:
|
||||
search_text = "%".join(jieba.cut(title_text, HMM=False))
|
||||
search_text = "%".join(jieba_cut(title_text, HMM=False))
|
||||
except Exception:
|
||||
search_text = title_text
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
requests
|
||||
cloudscraper
|
||||
lark-oapi==1.5.3
|
||||
lark-oapi>=1.4.0
|
||||
p115client==0.0.8.4.8
|
||||
|
||||
2
plugins.v2/agenttokens/.gitignore
vendored
Normal file
2
plugins.v2/agenttokens/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
package-lock.json
|
||||
node_modules/
|
||||
493
plugins.v2/agenttokens/__init__.py
Normal file
493
plugins.v2/agenttokens/__init__.py
Normal file
@@ -0,0 +1,493 @@
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from fastapi import Body
|
||||
|
||||
from app import schemas
|
||||
from app.api.endpoints.plugin import register_plugin_api
|
||||
from app.core.event import Event, eventmanager
|
||||
from app.log import logger
|
||||
from app.plugins import _PluginBase
|
||||
from app.schemas.types import ChainEventType, EventType
|
||||
|
||||
|
||||
class AgentTokens(_PluginBase):
|
||||
"""
|
||||
Agent Tokens 管理插件。
|
||||
|
||||
通过 Agent LLM 供应商链式事件按优先级选择仍有 token 余量的供应商,
|
||||
并通过 Agent Tokens 用量广播事件回写实际消耗。
|
||||
"""
|
||||
|
||||
plugin_name = "Agent Tokens 管理"
|
||||
plugin_desc = "管理多平台免费 Token 配额,按优先级自动切换 Agent LLM 供应商。"
|
||||
plugin_icon = "agentresourceofficer.png"
|
||||
plugin_version = "1.0.12"
|
||||
plugin_author = "jxxghp"
|
||||
author_url = "https://github.com/jxxghp"
|
||||
plugin_config_prefix = "agenttokens_"
|
||||
plugin_order = 45
|
||||
auth_level = 1
|
||||
|
||||
DATA_KEY_USAGE = "usage"
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
"""
|
||||
初始化插件配置,补齐供应商稳定 ID 以便后续用量能持续关联。
|
||||
"""
|
||||
self._usage_lock = threading.RLock()
|
||||
config = config or {}
|
||||
self._enabled = bool(config.get("enabled"))
|
||||
self._show_sidebar_nav = bool(config.get("show_sidebar_nav", True))
|
||||
self._providers = self._normalize_providers(config.get("providers") or [])
|
||||
self._save_config()
|
||||
|
||||
def get_state(self) -> bool:
|
||||
"""
|
||||
返回插件是否已启用。
|
||||
"""
|
||||
return bool(getattr(self, "_enabled", False))
|
||||
|
||||
@staticmethod
|
||||
def get_command() -> List[Dict[str, Any]]:
|
||||
"""
|
||||
当前插件不注册远程命令。
|
||||
"""
|
||||
return []
|
||||
|
||||
def get_api(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
注册 Vue 界面需要调用的插件 API。
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"path": "/status",
|
||||
"endpoint": self.get_status,
|
||||
"methods": ["GET"],
|
||||
"auth": "bear",
|
||||
"summary": "获取 Agent Tokens 状态",
|
||||
},
|
||||
{
|
||||
"path": "/config",
|
||||
"endpoint": self.save_config_api,
|
||||
"methods": ["POST"],
|
||||
"auth": "bear",
|
||||
"summary": "保存 Agent Tokens 配置",
|
||||
},
|
||||
{
|
||||
"path": "/usage/reset",
|
||||
"endpoint": self.reset_usage_api,
|
||||
"methods": ["POST"],
|
||||
"auth": "bear",
|
||||
"summary": "重置指定供应商用量",
|
||||
},
|
||||
{
|
||||
"path": "/usage/reset_all",
|
||||
"endpoint": self.reset_all_usage_api,
|
||||
"methods": ["POST"],
|
||||
"auth": "bear",
|
||||
"summary": "重置全部供应商用量",
|
||||
},
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def get_render_mode() -> Tuple[str, str]:
|
||||
"""
|
||||
声明插件使用 Vue 联邦组件渲染。
|
||||
"""
|
||||
return "vue", "dist/assets"
|
||||
|
||||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
"""
|
||||
Vue 模式下返回默认配置模型。
|
||||
"""
|
||||
return [], self._current_config()
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
"""
|
||||
Vue 模式下详情页由远程 Page 组件渲染。
|
||||
"""
|
||||
return []
|
||||
|
||||
def get_dashboard_meta(self) -> Optional[List[Dict[str, str]]]:
|
||||
"""
|
||||
声明一个用量概览仪表板组件。
|
||||
"""
|
||||
return [{"key": "usage", "name": "Agent Tokens 管理"}] if self.get_state() else []
|
||||
|
||||
def get_dashboard(self, key: str, **kwargs) -> Optional[Tuple[Dict[str, Any], Dict[str, Any], Optional[List[dict]]]]:
|
||||
"""
|
||||
返回 Vue 仪表板组件的布局与标题配置。
|
||||
"""
|
||||
if not self.get_state():
|
||||
return None
|
||||
return (
|
||||
{"cols": 12, "sm": 6, "md": 4},
|
||||
{
|
||||
"title": "Agent Tokens 管理",
|
||||
"subtitle": "LLM 配额使用情况",
|
||||
"refresh": 30,
|
||||
"border": True,
|
||||
},
|
||||
None,
|
||||
)
|
||||
|
||||
def get_sidebar_nav(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
将 Agent Tokens 管理页注册到主界面侧栏。
|
||||
"""
|
||||
if not self.get_state() or not getattr(self, "_show_sidebar_nav", True):
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"nav_key": "main",
|
||||
"title": "Agent Tokens 管理",
|
||||
"icon": "mdi-key-chain",
|
||||
"section": "system",
|
||||
"permission": "manage",
|
||||
"order": 46,
|
||||
}
|
||||
]
|
||||
|
||||
def stop_service(self):
|
||||
"""
|
||||
插件无后台服务,停用时无需清理额外资源。
|
||||
"""
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _to_int(value: Any, default: int = 0) -> int:
|
||||
"""
|
||||
将配置或事件中的数字字段安全转为整数。
|
||||
"""
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def _clean_text(value: Any) -> str:
|
||||
"""
|
||||
清理配置中的文本字段,避免空白值参与供应商选择。
|
||||
"""
|
||||
return str(value or "").strip()
|
||||
|
||||
@staticmethod
|
||||
def _event_get(event_data: Any, key: str, default: Any = None) -> Any:
|
||||
"""
|
||||
兼容读取 Pydantic 事件模型或字典中的字段。
|
||||
"""
|
||||
if isinstance(event_data, dict):
|
||||
return event_data.get(key, default)
|
||||
return getattr(event_data, key, default)
|
||||
|
||||
@staticmethod
|
||||
def _event_set(event_data: Any, key: str, value: Any) -> None:
|
||||
"""
|
||||
兼容写入 Pydantic 事件模型或字典中的字段。
|
||||
"""
|
||||
if isinstance(event_data, dict):
|
||||
event_data[key] = value
|
||||
else:
|
||||
setattr(event_data, key, value)
|
||||
|
||||
@classmethod
|
||||
def _normalize_provider(cls, provider: dict, index: int) -> dict:
|
||||
"""
|
||||
标准化单个供应商配置,并为旧配置补齐稳定 ID。
|
||||
"""
|
||||
provider = provider or {}
|
||||
provider_id = cls._clean_text(provider.get("id")) or uuid.uuid4().hex
|
||||
token_limit = max(cls._to_int(provider.get("token_limit"), 0), 0)
|
||||
used_tokens = max(cls._to_int(provider.get("used_tokens"), 0), 0)
|
||||
priority = cls._to_int(provider.get("priority"), index + 1)
|
||||
return {
|
||||
"id": provider_id,
|
||||
"enabled": bool(provider.get("enabled", True)),
|
||||
"name": cls._clean_text(provider.get("name")) or f"Provider {index + 1}",
|
||||
"provider": cls._clean_text(
|
||||
provider.get("provider") or provider.get("llm_provider")
|
||||
) or "openai",
|
||||
"base_url": cls._clean_text(provider.get("base_url")),
|
||||
"api_key": cls._clean_text(provider.get("api_key")),
|
||||
"user_agent": cls._clean_text(provider.get("user_agent")),
|
||||
"use_proxy": bool(provider.get("use_proxy", True)),
|
||||
"model": cls._clean_text(provider.get("model")),
|
||||
"token_limit": token_limit,
|
||||
"used_tokens": used_tokens,
|
||||
"priority": priority,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _normalize_providers(cls, providers: list) -> List[dict]:
|
||||
"""
|
||||
标准化供应商列表并按优先级排序。
|
||||
"""
|
||||
normalized = [
|
||||
cls._normalize_provider(provider, index)
|
||||
for index, provider in enumerate(providers or [])
|
||||
if isinstance(provider, dict)
|
||||
]
|
||||
return sorted(normalized, key=lambda item: (item["priority"], item["name"]))
|
||||
|
||||
@staticmethod
|
||||
def _mask_api_key(api_key: str) -> str:
|
||||
"""
|
||||
生成 API Key 的脱敏展示文本。
|
||||
"""
|
||||
if not api_key:
|
||||
return ""
|
||||
if len(api_key) <= 8:
|
||||
return "****"
|
||||
return f"{api_key[:4]}...{api_key[-4:]}"
|
||||
|
||||
def _current_config(self) -> Dict[str, Any]:
|
||||
"""
|
||||
返回当前插件配置快照。
|
||||
"""
|
||||
return {
|
||||
"enabled": bool(getattr(self, "_enabled", False)),
|
||||
"show_sidebar_nav": bool(getattr(self, "_show_sidebar_nav", True)),
|
||||
"providers": list(getattr(self, "_providers", [])),
|
||||
}
|
||||
|
||||
def _save_config(self) -> None:
|
||||
"""
|
||||
保存当前插件配置,确保供应商 ID 的补齐结果能持久化。
|
||||
"""
|
||||
self.update_config(self._current_config())
|
||||
|
||||
def _load_usage(self) -> Dict[str, dict]:
|
||||
"""
|
||||
读取已记录的供应商用量。
|
||||
"""
|
||||
usage = self.get_data(self.DATA_KEY_USAGE) or {}
|
||||
return usage if isinstance(usage, dict) else {}
|
||||
|
||||
def _save_usage(self, usage: Dict[str, dict]) -> None:
|
||||
"""
|
||||
保存供应商用量数据。
|
||||
"""
|
||||
self.save_data(self.DATA_KEY_USAGE, usage or {})
|
||||
|
||||
def _provider_usage(self, provider: dict, usage: Optional[Dict[str, dict]] = None) -> dict:
|
||||
"""
|
||||
汇总供应商的手工初始用量和 Agent 实际记录用量。
|
||||
"""
|
||||
usage = usage if usage is not None else self._load_usage()
|
||||
provider_usage = usage.get(provider["id"], {}) or {}
|
||||
recorded_total = self._to_int(provider_usage.get("total_tokens"), 0)
|
||||
manual_used = self._to_int(provider.get("used_tokens"), 0)
|
||||
total_used = manual_used + recorded_total
|
||||
token_limit = self._to_int(provider.get("token_limit"), 0)
|
||||
remaining = None if token_limit <= 0 else max(token_limit - total_used, 0)
|
||||
percent = 0
|
||||
if token_limit > 0:
|
||||
percent = min(round(total_used * 100 / token_limit, 2), 100)
|
||||
return {
|
||||
"input_tokens": self._to_int(provider_usage.get("input_tokens"), 0),
|
||||
"output_tokens": self._to_int(provider_usage.get("output_tokens"), 0),
|
||||
"recorded_tokens": recorded_total,
|
||||
"manual_used_tokens": manual_used,
|
||||
"total_tokens": total_used,
|
||||
"token_limit": token_limit,
|
||||
"remaining_tokens": remaining,
|
||||
"usage_percent": percent,
|
||||
"model_call_count": self._to_int(provider_usage.get("model_call_count"), 0),
|
||||
"runs": self._to_int(provider_usage.get("runs"), 0),
|
||||
"success_count": self._to_int(provider_usage.get("success_count"), 0),
|
||||
"failure_count": self._to_int(provider_usage.get("failure_count"), 0),
|
||||
"last_used_at": provider_usage.get("last_used_at"),
|
||||
"last_error": provider_usage.get("last_error"),
|
||||
"exhausted": token_limit > 0 and total_used >= token_limit,
|
||||
}
|
||||
|
||||
def _provider_status_rows(self) -> List[dict]:
|
||||
"""
|
||||
构建前端展示用的供应商状态列表。
|
||||
"""
|
||||
usage = self._load_usage()
|
||||
rows = []
|
||||
for provider in getattr(self, "_providers", []):
|
||||
provider_usage = self._provider_usage(provider, usage)
|
||||
rows.append({
|
||||
**provider,
|
||||
"masked_api_key": self._mask_api_key(provider.get("api_key", "")),
|
||||
"usage": provider_usage,
|
||||
})
|
||||
return rows
|
||||
|
||||
def _summary(self) -> Dict[str, Any]:
|
||||
"""
|
||||
汇总当前供应商数量和 token 使用情况。
|
||||
"""
|
||||
rows = self._provider_status_rows()
|
||||
enabled_rows = [row for row in rows if row.get("enabled")]
|
||||
available_rows = [
|
||||
row for row in enabled_rows
|
||||
if not row["usage"].get("exhausted")
|
||||
and row.get("api_key")
|
||||
and row.get("model")
|
||||
and row.get("base_url")
|
||||
]
|
||||
return {
|
||||
"enabled": self.get_state(),
|
||||
"provider_count": len(rows),
|
||||
"enabled_count": len(enabled_rows),
|
||||
"available_count": len(available_rows),
|
||||
"total_limit": sum(row["usage"]["token_limit"] for row in rows),
|
||||
"total_used": sum(row["usage"]["total_tokens"] for row in rows),
|
||||
}
|
||||
|
||||
def _select_provider(self) -> Optional[dict]:
|
||||
"""
|
||||
按优先级选择第一个启用且未耗尽 token 配额的供应商。
|
||||
"""
|
||||
usage = self._load_usage()
|
||||
for provider in getattr(self, "_providers", []):
|
||||
if not provider.get("enabled"):
|
||||
continue
|
||||
if not provider.get("api_key") or not provider.get("model") or not provider.get("base_url"):
|
||||
continue
|
||||
provider_usage = self._provider_usage(provider, usage)
|
||||
if provider_usage["exhausted"]:
|
||||
continue
|
||||
return provider
|
||||
return None
|
||||
|
||||
def get_status(self) -> schemas.Response:
|
||||
"""
|
||||
获取插件配置、供应商用量和概览统计。
|
||||
"""
|
||||
return schemas.Response(
|
||||
success=True,
|
||||
data={
|
||||
"config": self._current_config(),
|
||||
"providers": self._provider_status_rows(),
|
||||
"summary": self._summary(),
|
||||
},
|
||||
)
|
||||
|
||||
def save_config_api(self, config: dict = Body(...)) -> schemas.Response:
|
||||
"""
|
||||
保存前端提交的供应商配置。
|
||||
"""
|
||||
try:
|
||||
self._enabled = bool(config.get("enabled"))
|
||||
self._show_sidebar_nav = bool(config.get("show_sidebar_nav", True))
|
||||
self._providers = self._normalize_providers(config.get("providers") or [])
|
||||
self._save_config()
|
||||
return schemas.Response(success=True, data=self.get_status().data)
|
||||
except Exception as err:
|
||||
logger.error(f"保存 Agent Tokens 配置失败: {err}")
|
||||
return schemas.Response(success=False, message=str(err))
|
||||
|
||||
def reset_usage_api(self, payload: Optional[dict] = Body(default=None)) -> schemas.Response:
|
||||
"""
|
||||
重置指定供应商的已记录用量。
|
||||
"""
|
||||
payload = payload or {}
|
||||
provider_id = self._clean_text(payload.get("provider_id"))
|
||||
if not provider_id:
|
||||
return schemas.Response(success=False, message="缺少 provider_id")
|
||||
with self._usage_lock:
|
||||
usage = self._load_usage()
|
||||
usage.pop(provider_id, None)
|
||||
self._save_usage(usage)
|
||||
return schemas.Response(success=True, data=self.get_status().data)
|
||||
|
||||
def reset_all_usage_api(self) -> schemas.Response:
|
||||
"""
|
||||
重置所有供应商的已记录用量。
|
||||
"""
|
||||
with self._usage_lock:
|
||||
self._save_usage({})
|
||||
return schemas.Response(success=True, data=self.get_status().data)
|
||||
|
||||
@eventmanager.register(ChainEventType.AgentLLMProvider, priority=50)
|
||||
def select_llm_provider(self, event: Event):
|
||||
"""
|
||||
响应 Agent LLM 供应商链式事件,写入当前可用供应商配置。
|
||||
"""
|
||||
if not self.get_state() or not event or not event.event_data:
|
||||
return
|
||||
if self._event_get(event.event_data, "selected_provider_id"):
|
||||
return
|
||||
|
||||
provider = self._select_provider()
|
||||
if not provider:
|
||||
logger.info("Agent Tokens 没有可用供应商,Agent 将使用系统 LLM 配置")
|
||||
return
|
||||
|
||||
provider_name = provider.get("name")
|
||||
model = provider.get("model")
|
||||
logger.info(f"Agent Tokens 分配 LLM 供应商:[{provider_name}] 模型:[{model}]")
|
||||
|
||||
self._event_set(event.event_data, "provider", provider.get("provider") or "openai")
|
||||
self._event_set(event.event_data, "base_url", provider.get("base_url"))
|
||||
self._event_set(event.event_data, "api_key", provider.get("api_key"))
|
||||
self._event_set(event.event_data, "user_agent", provider.get("user_agent"))
|
||||
self._event_set(event.event_data, "use_proxy", bool(provider.get("use_proxy", True)))
|
||||
self._event_set(event.event_data, "model", provider.get("model"))
|
||||
self._event_set(event.event_data, "base_url_preset", None)
|
||||
self._event_set(event.event_data, "selected_provider_id", provider.get("id"))
|
||||
self._event_set(event.event_data, "selected_provider_name", provider.get("name"))
|
||||
self._event_set(event.event_data, "source", self.__class__.__name__)
|
||||
|
||||
@eventmanager.register(EventType.AgentTokensUsage)
|
||||
def record_tokens_usage(self, event: Event):
|
||||
"""
|
||||
响应 Agent Tokens 用量广播事件,累计记录到对应供应商。
|
||||
"""
|
||||
if not self.get_state() or not event or not event.event_data:
|
||||
return
|
||||
|
||||
provider_id = self._clean_text(
|
||||
self._event_get(event.event_data, "selected_provider_id")
|
||||
)
|
||||
if not provider_id:
|
||||
return
|
||||
|
||||
input_tokens = max(self._to_int(self._event_get(event.event_data, "input_tokens"), 0), 0)
|
||||
output_tokens = max(self._to_int(self._event_get(event.event_data, "output_tokens"), 0), 0)
|
||||
total_tokens = max(self._to_int(self._event_get(event.event_data, "total_tokens"), 0), 0)
|
||||
if total_tokens <= 0:
|
||||
total_tokens = input_tokens + output_tokens
|
||||
|
||||
with self._usage_lock:
|
||||
usage = self._load_usage()
|
||||
record = usage.setdefault(provider_id, {})
|
||||
record["input_tokens"] = self._to_int(record.get("input_tokens"), 0) + input_tokens
|
||||
record["output_tokens"] = self._to_int(record.get("output_tokens"), 0) + output_tokens
|
||||
record["total_tokens"] = self._to_int(record.get("total_tokens"), 0) + total_tokens
|
||||
record["model_call_count"] = self._to_int(
|
||||
record.get("model_call_count"), 0
|
||||
) + max(self._to_int(self._event_get(event.event_data, "model_call_count"), 0), 0)
|
||||
record["runs"] = self._to_int(record.get("runs"), 0) + 1
|
||||
if bool(self._event_get(event.event_data, "success", False)):
|
||||
record["success_count"] = self._to_int(record.get("success_count"), 0) + 1
|
||||
record["last_error"] = None
|
||||
else:
|
||||
record["failure_count"] = self._to_int(record.get("failure_count"), 0) + 1
|
||||
record["last_error"] = self._clean_text(self._event_get(event.event_data, "error"))
|
||||
record["last_model"] = self._clean_text(self._event_get(event.event_data, "model"))
|
||||
record["last_used_at"] = (
|
||||
self._clean_text(self._event_get(event.event_data, "finished_at"))
|
||||
or datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
)
|
||||
usage[provider_id] = record
|
||||
|
||||
provider_name = self._clean_text(self._event_get(event.event_data, "selected_provider_name")) or provider_id
|
||||
logger.info(f"Agent Tokens 更新用量记录:供应商 [{provider_name}] 本次消耗了 {total_tokens} Tokens")
|
||||
|
||||
self._save_usage(usage)
|
||||
|
||||
@eventmanager.register(EventType.PluginReload)
|
||||
def reload(self, event: Event):
|
||||
"""
|
||||
插件重载后重新注册动态 API。
|
||||
"""
|
||||
if event.event_data.get("plugin_id") == self.__class__.__name__:
|
||||
register_plugin_api(plugin_id=self.__class__.__name__)
|
||||
148
plugins.v2/agenttokens/dist/assets/AgentTokensManager-9miSzH4d.css
vendored
Normal file
148
plugins.v2/agenttokens/dist/assets/AgentTokensManager-9miSzH4d.css
vendored
Normal file
@@ -0,0 +1,148 @@
|
||||
|
||||
.provider-table-shell[data-v-cd4337d8] {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.provider-table-shell[data-v-cd4337d8] table {
|
||||
min-width: 960px;
|
||||
}
|
||||
.truncate-cell[data-v-cd4337d8] {
|
||||
max-width: 280px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.provider-table-shell[data-v-a305c97e] {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.provider-table-shell[data-v-a305c97e] table {
|
||||
min-width: 760px;
|
||||
}
|
||||
.progress-cell[data-v-a305c97e] {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.usage-overview-card[data-v-f9b76345] {
|
||||
block-size: 100%;
|
||||
padding: 20px;
|
||||
}
|
||||
.usage-overview-card__content[data-v-f9b76345] {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
.usage-overview-card__chart[data-v-f9b76345] {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.usage-overview-card__percent[data-v-f9b76345] {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.usage-overview-card__headline[data-v-f9b76345] {
|
||||
margin-block-start: 4px;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.usage-overview-card__meta[data-v-f9b76345] {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.usage-overview-card[data-v-f9b76345] {
|
||||
padding: 16px;
|
||||
}
|
||||
.usage-overview-card__content[data-v-f9b76345] {
|
||||
grid-template-columns: 1fr;
|
||||
text-align: center;
|
||||
}
|
||||
.usage-overview-card__meta[data-v-f9b76345] {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.agenttokens-page[data-v-a6c1ea54] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
.agenttokens-header[data-v-a6c1ea54] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.agenttokens-control-panel[data-v-a6c1ea54] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.agenttokens-control-panel__switches[data-v-a6c1ea54] {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 20px;
|
||||
}
|
||||
.agenttokens-overview-grid[data-v-a6c1ea54] {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 2fr) repeat(3, minmax(10rem, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.agenttokens-overview-card[data-v-a6c1ea54] {
|
||||
min-block-size: 172px;
|
||||
}
|
||||
.agenttokens-stat-card[data-v-a6c1ea54] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-block-size: 104px;
|
||||
padding: 16px;
|
||||
}
|
||||
.agenttokens-stat-card__value[data-v-a6c1ea54] {
|
||||
margin-block-start: 2px;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.agenttokens-content-panel[data-v-a6c1ea54] {
|
||||
overflow: hidden;
|
||||
}
|
||||
.agenttokens-tabs-row[data-v-a6c1ea54] {
|
||||
padding-inline: 8px;
|
||||
}
|
||||
.agenttokens-window[data-v-a6c1ea54] {
|
||||
padding: 12px;
|
||||
}
|
||||
.agenttokens-table-actions[data-v-a6c1ea54] {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-block-end: 12px;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.agenttokens-overview-grid[data-v-a6c1ea54] {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.agenttokens-overview-card[data-v-a6c1ea54] {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.agenttokens-page[data-v-a6c1ea54] {
|
||||
padding: 12px;
|
||||
}
|
||||
.agenttokens-overview-grid[data-v-a6c1ea54] {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.agenttokens-stat-card[data-v-a6c1ea54] {
|
||||
min-block-size: 88px;
|
||||
}
|
||||
}
|
||||
983
plugins.v2/agenttokens/dist/assets/AgentTokensManager-BTcJgtTd.js
vendored
Normal file
983
plugins.v2/agenttokens/dist/assets/AgentTokensManager-BTcJgtTd.js
vendored
Normal file
@@ -0,0 +1,983 @@
|
||||
import { importShared } from './__federation_fn_import-JrT3xvdd.js';
|
||||
import { _ as _export_sfc, f as formatTokens, P as PROVIDER_TYPE_OPTIONS, d as createProvider, b as buildProviderRows, a as buildProviderSummary, g as getNextProviderPriority, n as normalizeProvider } from './_plugin-vue_export-helper-B_eZRIX_.js';
|
||||
|
||||
const {createElementVNode:_createElementVNode$3,openBlock:_openBlock$4,createElementBlock:_createElementBlock$2,createCommentVNode:_createCommentVNode$2,renderList:_renderList$1,Fragment:_Fragment$1,resolveComponent:_resolveComponent$4,createVNode:_createVNode$4,toDisplayString:_toDisplayString$4,createTextVNode:_createTextVNode$4,withCtx:_withCtx$4,unref:_unref$4,createBlock:_createBlock$4} = await importShared('vue');
|
||||
|
||||
|
||||
const _hoisted_1$3 = { key: 0 };
|
||||
const _hoisted_2$3 = { key: 1 };
|
||||
const _hoisted_3$3 = {
|
||||
key: 0,
|
||||
class: "truncate-cell"
|
||||
};
|
||||
const _hoisted_4$2 = { key: 1 };
|
||||
const _hoisted_5$2 = { class: "text-right" };
|
||||
const _hoisted_6$2 = { key: 0 };
|
||||
const _hoisted_7$2 = ["colspan"];
|
||||
|
||||
|
||||
const _sfc_main$4 = {
|
||||
__name: 'ProviderConfigTable',
|
||||
props: {
|
||||
providers: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
providerRows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
showCredentials: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['edit', 'remove'],
|
||||
setup(__props, { emit: __emit }) {
|
||||
|
||||
const props = __props;
|
||||
|
||||
const emit = __emit;
|
||||
|
||||
// 获取管理页服务端返回的脱敏 Key。
|
||||
function getMaskedApiKey(index) {
|
||||
return props.providerRows[index]?.masked_api_key || '****'
|
||||
}
|
||||
|
||||
return (_ctx, _cache) => {
|
||||
const _component_VSwitch = _resolveComponent$4("VSwitch");
|
||||
const _component_VChip = _resolveComponent$4("VChip");
|
||||
const _component_VBtn = _resolveComponent$4("VBtn");
|
||||
const _component_VTable = _resolveComponent$4("VTable");
|
||||
const _component_VSheet = _resolveComponent$4("VSheet");
|
||||
|
||||
return (_openBlock$4(), _createBlock$4(_component_VSheet, {
|
||||
border: "",
|
||||
rounded: "",
|
||||
class: "provider-table-shell"
|
||||
}, {
|
||||
default: _withCtx$4(() => [
|
||||
_createVNode$4(_component_VTable, { density: "comfortable" }, {
|
||||
default: _withCtx$4(() => [
|
||||
_createElementVNode$3("thead", null, [
|
||||
_createElementVNode$3("tr", null, [
|
||||
_cache[0] || (_cache[0] = _createElementVNode$3("th", null, "启用", -1)),
|
||||
_cache[1] || (_cache[1] = _createElementVNode$3("th", null, "优先级", -1)),
|
||||
_cache[2] || (_cache[2] = _createElementVNode$3("th", null, "名称", -1)),
|
||||
_cache[3] || (_cache[3] = _createElementVNode$3("th", null, "类型", -1)),
|
||||
(__props.showCredentials)
|
||||
? (_openBlock$4(), _createElementBlock$2("th", _hoisted_1$3, "地址"))
|
||||
: _createCommentVNode$2("", true),
|
||||
(__props.showCredentials)
|
||||
? (_openBlock$4(), _createElementBlock$2("th", _hoisted_2$3, "Key"))
|
||||
: _createCommentVNode$2("", true),
|
||||
_cache[4] || (_cache[4] = _createElementVNode$3("th", null, "代理", -1)),
|
||||
_cache[5] || (_cache[5] = _createElementVNode$3("th", null, "模型", -1)),
|
||||
_cache[6] || (_cache[6] = _createElementVNode$3("th", null, "额度", -1)),
|
||||
_cache[7] || (_cache[7] = _createElementVNode$3("th", { class: "text-right" }, "操作", -1))
|
||||
])
|
||||
]),
|
||||
_createElementVNode$3("tbody", null, [
|
||||
(_openBlock$4(true), _createElementBlock$2(_Fragment$1, null, _renderList$1(__props.providers, (row, index) => {
|
||||
return (_openBlock$4(), _createElementBlock$2("tr", {
|
||||
key: row.id || index
|
||||
}, [
|
||||
_createElementVNode$3("td", null, [
|
||||
_createVNode$4(_component_VSwitch, {
|
||||
modelValue: row.enabled,
|
||||
"onUpdate:modelValue": $event => ((row.enabled) = $event),
|
||||
color: "primary",
|
||||
"hide-details": "",
|
||||
density: "compact"
|
||||
}, null, 8, ["modelValue", "onUpdate:modelValue"])
|
||||
]),
|
||||
_createElementVNode$3("td", null, _toDisplayString$4(row.priority), 1),
|
||||
_createElementVNode$3("td", null, _toDisplayString$4(row.name), 1),
|
||||
_createElementVNode$3("td", null, _toDisplayString$4(row.provider), 1),
|
||||
(__props.showCredentials)
|
||||
? (_openBlock$4(), _createElementBlock$2("td", _hoisted_3$3, _toDisplayString$4(row.base_url), 1))
|
||||
: _createCommentVNode$2("", true),
|
||||
(__props.showCredentials)
|
||||
? (_openBlock$4(), _createElementBlock$2("td", _hoisted_4$2, _toDisplayString$4(getMaskedApiKey(index)), 1))
|
||||
: _createCommentVNode$2("", true),
|
||||
_createElementVNode$3("td", null, [
|
||||
_createVNode$4(_component_VChip, {
|
||||
size: "small",
|
||||
color: row.use_proxy === false ? 'default' : 'primary',
|
||||
variant: "tonal"
|
||||
}, {
|
||||
default: _withCtx$4(() => [
|
||||
_createTextVNode$4(_toDisplayString$4(row.use_proxy === false ? '直连' : '代理'), 1)
|
||||
]),
|
||||
_: 2
|
||||
}, 1032, ["color"])
|
||||
]),
|
||||
_createElementVNode$3("td", null, _toDisplayString$4(row.model), 1),
|
||||
_createElementVNode$3("td", null, _toDisplayString$4(row.token_limit > 0 ? _unref$4(formatTokens)(row.token_limit) : '不限'), 1),
|
||||
_createElementVNode$3("td", _hoisted_5$2, [
|
||||
_createVNode$4(_component_VBtn, {
|
||||
icon: "mdi-pencil",
|
||||
size: "small",
|
||||
variant: "text",
|
||||
onClick: $event => (emit('edit', index))
|
||||
}, null, 8, ["onClick"]),
|
||||
_createVNode$4(_component_VBtn, {
|
||||
icon: "mdi-delete",
|
||||
size: "small",
|
||||
variant: "text",
|
||||
color: "error",
|
||||
onClick: $event => (emit('remove', index))
|
||||
}, null, 8, ["onClick"])
|
||||
])
|
||||
]))
|
||||
}), 128)),
|
||||
(!__props.providers.length)
|
||||
? (_openBlock$4(), _createElementBlock$2("tr", _hoisted_6$2, [
|
||||
_createElementVNode$3("td", {
|
||||
colspan: __props.showCredentials ? 10 : 8,
|
||||
class: "text-center text-medium-emphasis py-8"
|
||||
}, "暂无供应商", 8, _hoisted_7$2)
|
||||
]))
|
||||
: _createCommentVNode$2("", true)
|
||||
])
|
||||
]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
const ProviderConfigTable = /*#__PURE__*/_export_sfc(_sfc_main$4, [['__scopeId',"data-v-cd4337d8"]]);
|
||||
|
||||
const {toDisplayString:_toDisplayString$3,createTextVNode:_createTextVNode$3,resolveComponent:_resolveComponent$3,withCtx:_withCtx$3,createVNode:_createVNode$3,unref:_unref$3,openBlock:_openBlock$3,createBlock:_createBlock$3} = await importShared('vue');
|
||||
|
||||
|
||||
const {computed: computed$2} = await importShared('vue');
|
||||
|
||||
|
||||
const _sfc_main$3 = {
|
||||
__name: 'ProviderEditorDialog',
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
provider: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
editorIndex: {
|
||||
type: Number,
|
||||
default: -1,
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue', 'commit'],
|
||||
setup(__props, { emit: __emit }) {
|
||||
|
||||
const props = __props;
|
||||
|
||||
const emit = __emit;
|
||||
|
||||
const dialogVisible = computed$2({
|
||||
get: () => props.modelValue,
|
||||
set: value => emit('update:modelValue', value),
|
||||
});
|
||||
|
||||
// 提交当前弹窗编辑的供应商配置。
|
||||
function commitProvider() {
|
||||
emit('commit');
|
||||
}
|
||||
|
||||
return (_ctx, _cache) => {
|
||||
const _component_VCardTitle = _resolveComponent$3("VCardTitle");
|
||||
const _component_VTextField = _resolveComponent$3("VTextField");
|
||||
const _component_VCol = _resolveComponent$3("VCol");
|
||||
const _component_VSelect = _resolveComponent$3("VSelect");
|
||||
const _component_VSwitch = _resolveComponent$3("VSwitch");
|
||||
const _component_VRow = _resolveComponent$3("VRow");
|
||||
const _component_VCardText = _resolveComponent$3("VCardText");
|
||||
const _component_VSpacer = _resolveComponent$3("VSpacer");
|
||||
const _component_VBtn = _resolveComponent$3("VBtn");
|
||||
const _component_VCardActions = _resolveComponent$3("VCardActions");
|
||||
const _component_VCard = _resolveComponent$3("VCard");
|
||||
const _component_VDialog = _resolveComponent$3("VDialog");
|
||||
|
||||
return (_openBlock$3(), _createBlock$3(_component_VDialog, {
|
||||
modelValue: dialogVisible.value,
|
||||
"onUpdate:modelValue": _cache[11] || (_cache[11] = $event => ((dialogVisible).value = $event)),
|
||||
"max-width": "760",
|
||||
"max-height": "85vh",
|
||||
scrollable: ""
|
||||
}, {
|
||||
default: _withCtx$3(() => [
|
||||
_createVNode$3(_component_VCard, null, {
|
||||
default: _withCtx$3(() => [
|
||||
_createVNode$3(_component_VCardTitle, null, {
|
||||
default: _withCtx$3(() => [
|
||||
_createTextVNode$3(_toDisplayString$3(__props.editorIndex >= 0 ? '编辑供应商' : '新增供应商'), 1)
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode$3(_component_VCardText, null, {
|
||||
default: _withCtx$3(() => [
|
||||
_createVNode$3(_component_VRow, null, {
|
||||
default: _withCtx$3(() => [
|
||||
_createVNode$3(_component_VCol, {
|
||||
cols: "12",
|
||||
md: "8"
|
||||
}, {
|
||||
default: _withCtx$3(() => [
|
||||
_createVNode$3(_component_VTextField, {
|
||||
modelValue: __props.provider.name,
|
||||
"onUpdate:modelValue": _cache[0] || (_cache[0] = $event => ((__props.provider.name) = $event)),
|
||||
label: "名称",
|
||||
variant: "outlined",
|
||||
density: "comfortable"
|
||||
}, null, 8, ["modelValue"])
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode$3(_component_VCol, {
|
||||
cols: "12",
|
||||
md: "4"
|
||||
}, {
|
||||
default: _withCtx$3(() => [
|
||||
_createVNode$3(_component_VTextField, {
|
||||
modelValue: __props.provider.priority,
|
||||
"onUpdate:modelValue": _cache[1] || (_cache[1] = $event => ((__props.provider.priority) = $event)),
|
||||
modelModifiers: { number: true },
|
||||
label: "优先级",
|
||||
type: "number",
|
||||
variant: "outlined"
|
||||
}, null, 8, ["modelValue"])
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode$3(_component_VCol, {
|
||||
cols: "12",
|
||||
md: "6"
|
||||
}, {
|
||||
default: _withCtx$3(() => [
|
||||
_createVNode$3(_component_VSelect, {
|
||||
modelValue: __props.provider.provider,
|
||||
"onUpdate:modelValue": _cache[2] || (_cache[2] = $event => ((__props.provider.provider) = $event)),
|
||||
items: _unref$3(PROVIDER_TYPE_OPTIONS),
|
||||
label: "类型",
|
||||
variant: "outlined"
|
||||
}, null, 8, ["modelValue", "items"])
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode$3(_component_VCol, {
|
||||
cols: "12",
|
||||
md: "6"
|
||||
}, {
|
||||
default: _withCtx$3(() => [
|
||||
_createVNode$3(_component_VTextField, {
|
||||
modelValue: __props.provider.model,
|
||||
"onUpdate:modelValue": _cache[3] || (_cache[3] = $event => ((__props.provider.model) = $event)),
|
||||
label: "模型",
|
||||
variant: "outlined"
|
||||
}, null, 8, ["modelValue"])
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode$3(_component_VCol, { cols: "12" }, {
|
||||
default: _withCtx$3(() => [
|
||||
_createVNode$3(_component_VTextField, {
|
||||
modelValue: __props.provider.base_url,
|
||||
"onUpdate:modelValue": _cache[4] || (_cache[4] = $event => ((__props.provider.base_url) = $event)),
|
||||
label: "API 地址",
|
||||
variant: "outlined"
|
||||
}, null, 8, ["modelValue"])
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode$3(_component_VCol, { cols: "12" }, {
|
||||
default: _withCtx$3(() => [
|
||||
_createVNode$3(_component_VTextField, {
|
||||
modelValue: __props.provider.api_key,
|
||||
"onUpdate:modelValue": _cache[5] || (_cache[5] = $event => ((__props.provider.api_key) = $event)),
|
||||
label: "API Key",
|
||||
type: "password",
|
||||
variant: "outlined"
|
||||
}, null, 8, ["modelValue"])
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode$3(_component_VCol, { cols: "12" }, {
|
||||
default: _withCtx$3(() => [
|
||||
_createVNode$3(_component_VTextField, {
|
||||
modelValue: __props.provider.user_agent,
|
||||
"onUpdate:modelValue": _cache[6] || (_cache[6] = $event => ((__props.provider.user_agent) = $event)),
|
||||
label: "User-Agent",
|
||||
variant: "outlined"
|
||||
}, null, 8, ["modelValue"])
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode$3(_component_VCol, { cols: "12" }, {
|
||||
default: _withCtx$3(() => [
|
||||
_createVNode$3(_component_VSwitch, {
|
||||
modelValue: __props.provider.use_proxy,
|
||||
"onUpdate:modelValue": _cache[7] || (_cache[7] = $event => ((__props.provider.use_proxy) = $event)),
|
||||
color: "primary",
|
||||
label: "使用代理服务器",
|
||||
hint: "启用后,Agent 连接该供应商时会使用系统代理服务器",
|
||||
"persistent-hint": ""
|
||||
}, null, 8, ["modelValue"])
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode$3(_component_VCol, {
|
||||
cols: "12",
|
||||
md: "6"
|
||||
}, {
|
||||
default: _withCtx$3(() => [
|
||||
_createVNode$3(_component_VTextField, {
|
||||
modelValue: __props.provider.token_limit,
|
||||
"onUpdate:modelValue": _cache[8] || (_cache[8] = $event => ((__props.provider.token_limit) = $event)),
|
||||
modelModifiers: { number: true },
|
||||
label: "Token 额度",
|
||||
type: "number",
|
||||
variant: "outlined"
|
||||
}, null, 8, ["modelValue"])
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode$3(_component_VCol, {
|
||||
cols: "12",
|
||||
md: "6"
|
||||
}, {
|
||||
default: _withCtx$3(() => [
|
||||
_createVNode$3(_component_VTextField, {
|
||||
modelValue: __props.provider.used_tokens,
|
||||
"onUpdate:modelValue": _cache[9] || (_cache[9] = $event => ((__props.provider.used_tokens) = $event)),
|
||||
modelModifiers: { number: true },
|
||||
label: "初始已用",
|
||||
type: "number",
|
||||
variant: "outlined"
|
||||
}, null, 8, ["modelValue"])
|
||||
]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode$3(_component_VCardActions, null, {
|
||||
default: _withCtx$3(() => [
|
||||
_createVNode$3(_component_VSpacer),
|
||||
_createVNode$3(_component_VBtn, {
|
||||
variant: "text",
|
||||
onClick: _cache[10] || (_cache[10] = $event => (dialogVisible.value = false))
|
||||
}, {
|
||||
default: _withCtx$3(() => [...(_cache[12] || (_cache[12] = [
|
||||
_createTextVNode$3("取消", -1)
|
||||
]))]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode$3(_component_VBtn, {
|
||||
color: "primary",
|
||||
onClick: commitProvider
|
||||
}, {
|
||||
default: _withCtx$3(() => [...(_cache[13] || (_cache[13] = [
|
||||
_createTextVNode$3("确定", -1)
|
||||
]))]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
}, 8, ["modelValue"]))
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
const {createElementVNode:_createElementVNode$2,renderList:_renderList,Fragment:_Fragment,openBlock:_openBlock$2,createElementBlock:_createElementBlock$1,toDisplayString:_toDisplayString$2,unref:_unref$2,resolveComponent:_resolveComponent$2,createVNode:_createVNode$2,createTextVNode:_createTextVNode$2,withCtx:_withCtx$2,createCommentVNode:_createCommentVNode$1,createBlock:_createBlock$2} = await importShared('vue');
|
||||
|
||||
|
||||
const _hoisted_1$2 = { class: "progress-cell" };
|
||||
const _hoisted_2$2 = { class: "text-right" };
|
||||
const _hoisted_3$2 = { key: 0 };
|
||||
|
||||
|
||||
const _sfc_main$2 = {
|
||||
__name: 'ProviderUsageTable',
|
||||
props: {
|
||||
providerRows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
emits: ['reset'],
|
||||
setup(__props, { emit: __emit }) {
|
||||
|
||||
|
||||
|
||||
const emit = __emit;
|
||||
|
||||
// 根据供应商状态返回 Vuetify 颜色。
|
||||
function rowStatusColor(row) {
|
||||
if (!row.enabled) return 'default'
|
||||
if (row.usage?.exhausted) return 'error'
|
||||
if (!row.api_key || !row.base_url || !row.model) return 'warning'
|
||||
return 'success'
|
||||
}
|
||||
|
||||
// 根据供应商状态返回短标签。
|
||||
function rowStatusText(row) {
|
||||
if (!row.enabled) return '停用'
|
||||
if (row.usage?.exhausted) return '耗尽'
|
||||
if (!row.api_key || !row.base_url || !row.model) return '缺配置'
|
||||
return '可用'
|
||||
}
|
||||
|
||||
return (_ctx, _cache) => {
|
||||
const _component_VProgressLinear = _resolveComponent$2("VProgressLinear");
|
||||
const _component_VChip = _resolveComponent$2("VChip");
|
||||
const _component_VBtn = _resolveComponent$2("VBtn");
|
||||
const _component_VTable = _resolveComponent$2("VTable");
|
||||
const _component_VSheet = _resolveComponent$2("VSheet");
|
||||
|
||||
return (_openBlock$2(), _createBlock$2(_component_VSheet, {
|
||||
border: "",
|
||||
rounded: "",
|
||||
class: "provider-table-shell"
|
||||
}, {
|
||||
default: _withCtx$2(() => [
|
||||
_createVNode$2(_component_VTable, { density: "comfortable" }, {
|
||||
default: _withCtx$2(() => [
|
||||
_cache[1] || (_cache[1] = _createElementVNode$2("thead", null, [
|
||||
_createElementVNode$2("tr", null, [
|
||||
_createElementVNode$2("th", null, "优先级"),
|
||||
_createElementVNode$2("th", null, "名称"),
|
||||
_createElementVNode$2("th", null, "模型"),
|
||||
_createElementVNode$2("th", null, "已用"),
|
||||
_createElementVNode$2("th", null, "余量"),
|
||||
_createElementVNode$2("th", null, "进度"),
|
||||
_createElementVNode$2("th", null, "状态"),
|
||||
_createElementVNode$2("th", { class: "text-right" }, "操作")
|
||||
])
|
||||
], -1)),
|
||||
_createElementVNode$2("tbody", null, [
|
||||
(_openBlock$2(true), _createElementBlock$1(_Fragment, null, _renderList(__props.providerRows, (row, index) => {
|
||||
return (_openBlock$2(), _createElementBlock$1("tr", {
|
||||
key: row.id || index
|
||||
}, [
|
||||
_createElementVNode$2("td", null, _toDisplayString$2(row.priority), 1),
|
||||
_createElementVNode$2("td", null, _toDisplayString$2(row.name), 1),
|
||||
_createElementVNode$2("td", null, _toDisplayString$2(row.model), 1),
|
||||
_createElementVNode$2("td", null, _toDisplayString$2(_unref$2(formatTokens)(row.usage?.total_tokens)), 1),
|
||||
_createElementVNode$2("td", null, _toDisplayString$2(row.usage?.remaining_tokens === null ? '不限' : _unref$2(formatTokens)(row.usage?.remaining_tokens)), 1),
|
||||
_createElementVNode$2("td", _hoisted_1$2, [
|
||||
_createVNode$2(_component_VProgressLinear, {
|
||||
"model-value": row.usage?.usage_percent || 0,
|
||||
color: rowStatusColor(row),
|
||||
height: "8",
|
||||
rounded: ""
|
||||
}, null, 8, ["model-value", "color"])
|
||||
]),
|
||||
_createElementVNode$2("td", null, [
|
||||
_createVNode$2(_component_VChip, {
|
||||
size: "small",
|
||||
color: rowStatusColor(row),
|
||||
variant: "tonal"
|
||||
}, {
|
||||
default: _withCtx$2(() => [
|
||||
_createTextVNode$2(_toDisplayString$2(rowStatusText(row)), 1)
|
||||
]),
|
||||
_: 2
|
||||
}, 1032, ["color"])
|
||||
]),
|
||||
_createElementVNode$2("td", _hoisted_2$2, [
|
||||
_createVNode$2(_component_VBtn, {
|
||||
icon: "mdi-backup-restore",
|
||||
size: "small",
|
||||
variant: "text",
|
||||
onClick: $event => (emit('reset', row.id, index))
|
||||
}, null, 8, ["onClick"])
|
||||
])
|
||||
]))
|
||||
}), 128)),
|
||||
(!__props.providerRows.length)
|
||||
? (_openBlock$2(), _createElementBlock$1("tr", _hoisted_3$2, [...(_cache[0] || (_cache[0] = [
|
||||
_createElementVNode$2("td", {
|
||||
colspan: "8",
|
||||
class: "text-center text-medium-emphasis py-8"
|
||||
}, "暂无供应商", -1)
|
||||
]))]))
|
||||
: _createCommentVNode$1("", true)
|
||||
])
|
||||
]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
const ProviderUsageTable = /*#__PURE__*/_export_sfc(_sfc_main$2, [['__scopeId',"data-v-a305c97e"]]);
|
||||
|
||||
const {toDisplayString:_toDisplayString$1,createElementVNode:_createElementVNode$1,resolveComponent:_resolveComponent$1,withCtx:_withCtx$1,createVNode:_createVNode$1,unref:_unref$1,createTextVNode:_createTextVNode$1,openBlock:_openBlock$1,createBlock:_createBlock$1} = await importShared('vue');
|
||||
|
||||
|
||||
const _hoisted_1$1 = { class: "usage-overview-card__content" };
|
||||
const _hoisted_2$1 = { class: "usage-overview-card__chart" };
|
||||
const _hoisted_3$1 = { class: "usage-overview-card__percent" };
|
||||
const _hoisted_4$1 = { class: "usage-overview-card__body" };
|
||||
const _hoisted_5$1 = { class: "usage-overview-card__headline" };
|
||||
const _hoisted_6$1 = { class: "text-medium-emphasis" };
|
||||
const _hoisted_7$1 = { class: "usage-overview-card__meta" };
|
||||
|
||||
const {computed: computed$1} = await importShared('vue');
|
||||
|
||||
|
||||
const _sfc_main$1 = {
|
||||
__name: 'UsageOverviewCard',
|
||||
props: {
|
||||
summary: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
setup(__props) {
|
||||
|
||||
const props = __props;
|
||||
|
||||
const totalUsed = computed$1(() => Number(props.summary.total_used || 0));
|
||||
const totalLimit = computed$1(() => Number(props.summary.total_limit || 0));
|
||||
const usagePercent = computed$1(() => {
|
||||
if (totalLimit.value <= 0) return 0
|
||||
return Math.min((totalUsed.value * 100) / totalLimit.value, 100)
|
||||
});
|
||||
const usagePercentText = computed$1(() => `${Math.round(usagePercent.value)}%`);
|
||||
const remainingTokens = computed$1(() => {
|
||||
if (totalLimit.value <= 0) return null
|
||||
return Math.max(totalLimit.value - totalUsed.value, 0)
|
||||
});
|
||||
const progressColor = computed$1(() => {
|
||||
if (totalLimit.value <= 0) return 'primary'
|
||||
if (usagePercent.value >= 90) return 'error'
|
||||
if (usagePercent.value >= 70) return 'warning'
|
||||
return 'success'
|
||||
});
|
||||
|
||||
return (_ctx, _cache) => {
|
||||
const _component_VProgressCircular = _resolveComponent$1("VProgressCircular");
|
||||
const _component_VProgressLinear = _resolveComponent$1("VProgressLinear");
|
||||
const _component_VSheet = _resolveComponent$1("VSheet");
|
||||
|
||||
return (_openBlock$1(), _createBlock$1(_component_VSheet, {
|
||||
border: "",
|
||||
rounded: "",
|
||||
class: "usage-overview-card"
|
||||
}, {
|
||||
default: _withCtx$1(() => [
|
||||
_createElementVNode$1("div", _hoisted_1$1, [
|
||||
_createElementVNode$1("div", _hoisted_2$1, [
|
||||
_createVNode$1(_component_VProgressCircular, {
|
||||
"model-value": usagePercent.value,
|
||||
color: progressColor.value,
|
||||
"bg-color": "surface-variant",
|
||||
size: 132,
|
||||
width: 12
|
||||
}, {
|
||||
default: _withCtx$1(() => [
|
||||
_createElementVNode$1("div", _hoisted_3$1, _toDisplayString$1(totalLimit.value > 0 ? usagePercentText.value : '不限'), 1)
|
||||
]),
|
||||
_: 1
|
||||
}, 8, ["model-value", "color"])
|
||||
]),
|
||||
_createElementVNode$1("div", _hoisted_4$1, [
|
||||
_cache[0] || (_cache[0] = _createElementVNode$1("div", { class: "text-caption text-medium-emphasis" }, "总使用进度", -1)),
|
||||
_createElementVNode$1("div", _hoisted_5$1, [
|
||||
_createTextVNode$1(_toDisplayString$1(_unref$1(formatTokens)(totalUsed.value)) + " ", 1),
|
||||
_createElementVNode$1("span", _hoisted_6$1, "/ " + _toDisplayString$1(totalLimit.value > 0 ? _unref$1(formatTokens)(totalLimit.value) : '不限'), 1)
|
||||
]),
|
||||
_createVNode$1(_component_VProgressLinear, {
|
||||
"model-value": usagePercent.value,
|
||||
color: progressColor.value,
|
||||
height: "8",
|
||||
rounded: "",
|
||||
class: "my-4"
|
||||
}, null, 8, ["model-value", "color"]),
|
||||
_createElementVNode$1("div", _hoisted_7$1, [
|
||||
_createElementVNode$1("span", null, "剩余 " + _toDisplayString$1(remainingTokens.value === null ? '不限' : _unref$1(formatTokens)(remainingTokens.value)), 1),
|
||||
_createElementVNode$1("span", null, "可用 " + _toDisplayString$1(__props.summary.available_count || 0) + " / " + _toDisplayString$1(__props.summary.enabled_count || 0), 1)
|
||||
])
|
||||
])
|
||||
])
|
||||
]),
|
||||
_: 1
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
const UsageOverviewCard = /*#__PURE__*/_export_sfc(_sfc_main$1, [['__scopeId',"data-v-f9b76345"]]);
|
||||
|
||||
const {createElementVNode:_createElementVNode,resolveComponent:_resolveComponent,createVNode:_createVNode,openBlock:_openBlock,createElementBlock:_createElementBlock,createCommentVNode:_createCommentVNode,toDisplayString:_toDisplayString,createTextVNode:_createTextVNode,withCtx:_withCtx,createBlock:_createBlock,unref:_unref} = await importShared('vue');
|
||||
|
||||
|
||||
const _hoisted_1 = { class: "agenttokens-page" };
|
||||
const _hoisted_2 = {
|
||||
key: 0,
|
||||
class: "agenttokens-header"
|
||||
};
|
||||
const _hoisted_3 = { class: "agenttokens-control-panel__switches" };
|
||||
const _hoisted_4 = { class: "agenttokens-overview-grid" };
|
||||
const _hoisted_5 = { class: "agenttokens-stat-card__value" };
|
||||
const _hoisted_6 = { class: "agenttokens-stat-card__value" };
|
||||
const _hoisted_7 = { class: "agenttokens-stat-card__value" };
|
||||
const _hoisted_8 = { class: "agenttokens-tabs-row" };
|
||||
const _hoisted_9 = { class: "agenttokens-table-actions" };
|
||||
|
||||
const {computed,ref} = await importShared('vue');
|
||||
|
||||
|
||||
const _sfc_main = {
|
||||
__name: 'AgentTokensManager',
|
||||
props: {
|
||||
config: {
|
||||
type: Object,
|
||||
default: () => ({ enabled: false, show_sidebar_nav: true, providers: [] }),
|
||||
},
|
||||
providerRows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
summary: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
error: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
saving: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hideTitle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['refresh', 'save', 'reset-usage', 'reset-all-usage'],
|
||||
setup(__props, { emit: __emit }) {
|
||||
|
||||
const props = __props;
|
||||
|
||||
const emit = __emit;
|
||||
|
||||
const activeTab = ref('usage');
|
||||
const showEditor = ref(false);
|
||||
const editorIndex = ref(-1);
|
||||
const editedProvider = ref(createProvider());
|
||||
|
||||
const configValue = computed(() => props.config || { enabled: false, show_sidebar_nav: true, providers: [] });
|
||||
const providers = computed(() => (Array.isArray(configValue.value.providers) ? configValue.value.providers : []));
|
||||
const displayProviderRows = computed(() => (
|
||||
props.providerRows.length ? props.providerRows : buildProviderRows(providers.value)
|
||||
));
|
||||
const displaySummary = computed(() => (
|
||||
Object.keys(props.summary || {}).length ? props.summary : buildProviderSummary(displayProviderRows.value)
|
||||
));
|
||||
|
||||
// 打开新增供应商弹窗。
|
||||
function addProvider() {
|
||||
editedProvider.value = { ...createProvider(), priority: getNextProviderPriority(providers.value) };
|
||||
editorIndex.value = -1;
|
||||
showEditor.value = true;
|
||||
}
|
||||
|
||||
// 打开编辑供应商弹窗。
|
||||
function editProvider(index) {
|
||||
editedProvider.value = { ...providers.value[index] };
|
||||
editorIndex.value = index;
|
||||
showEditor.value = true;
|
||||
}
|
||||
|
||||
// 将弹窗中的供应商写回配置列表。
|
||||
function commitProvider() {
|
||||
const nextProviders = [...providers.value];
|
||||
const normalized = normalizeProvider(editedProvider.value, nextProviders.length + 1);
|
||||
if (editorIndex.value >= 0) {
|
||||
nextProviders.splice(editorIndex.value, 1, normalized);
|
||||
} else {
|
||||
nextProviders.push(normalized);
|
||||
}
|
||||
configValue.value.providers = nextProviders;
|
||||
showEditor.value = false;
|
||||
}
|
||||
|
||||
// 从配置列表中移除一个供应商。
|
||||
function removeProvider(index) {
|
||||
const nextProviders = [...providers.value];
|
||||
nextProviders.splice(index, 1);
|
||||
configValue.value.providers = nextProviders;
|
||||
}
|
||||
|
||||
// 请求重置单个供应商用量。
|
||||
function resetUsage(providerId, index) {
|
||||
emit('reset-usage', providerId, index);
|
||||
}
|
||||
|
||||
// 请求重置全部供应商用量。
|
||||
function resetAllUsage() {
|
||||
emit('reset-all-usage');
|
||||
}
|
||||
|
||||
return (_ctx, _cache) => {
|
||||
const _component_VSpacer = _resolveComponent("VSpacer");
|
||||
const _component_VBtn = _resolveComponent("VBtn");
|
||||
const _component_VAlert = _resolveComponent("VAlert");
|
||||
const _component_VSwitch = _resolveComponent("VSwitch");
|
||||
const _component_VSheet = _resolveComponent("VSheet");
|
||||
const _component_VIcon = _resolveComponent("VIcon");
|
||||
const _component_VTab = _resolveComponent("VTab");
|
||||
const _component_VTabs = _resolveComponent("VTabs");
|
||||
const _component_VDivider = _resolveComponent("VDivider");
|
||||
const _component_VWindowItem = _resolveComponent("VWindowItem");
|
||||
const _component_VWindow = _resolveComponent("VWindow");
|
||||
|
||||
return (_openBlock(), _createElementBlock("div", _hoisted_1, [
|
||||
(!__props.hideTitle)
|
||||
? (_openBlock(), _createElementBlock("div", _hoisted_2, [
|
||||
_cache[7] || (_cache[7] = _createElementVNode("h2", { class: "text-2xl font-bold leading-7 text-gray-100 truncate sm:text-3xl sm:leading-9" }, [
|
||||
_createElementVNode("span", { class: "text-moviepilot" }, "Agent Tokens 管理")
|
||||
], -1)),
|
||||
_createVNode(_component_VSpacer),
|
||||
_createVNode(_component_VBtn, {
|
||||
icon: "mdi-refresh",
|
||||
variant: "text",
|
||||
loading: __props.loading,
|
||||
onClick: _cache[0] || (_cache[0] = $event => (emit('refresh')))
|
||||
}, null, 8, ["loading"]),
|
||||
_createVNode(_component_VBtn, {
|
||||
icon: "mdi-content-save",
|
||||
variant: "text",
|
||||
color: "primary",
|
||||
loading: __props.saving,
|
||||
onClick: _cache[1] || (_cache[1] = $event => (emit('save')))
|
||||
}, null, 8, ["loading"])
|
||||
]))
|
||||
: _createCommentVNode("", true),
|
||||
(__props.error)
|
||||
? (_openBlock(), _createBlock(_component_VAlert, {
|
||||
key: 1,
|
||||
type: "error",
|
||||
variant: "tonal",
|
||||
class: "mb-4"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createTextVNode(_toDisplayString(__props.error), 1)
|
||||
]),
|
||||
_: 1
|
||||
}))
|
||||
: _createCommentVNode("", true),
|
||||
_createVNode(_component_VSheet, {
|
||||
border: "",
|
||||
rounded: "",
|
||||
class: "agenttokens-control-panel"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createElementVNode("div", _hoisted_3, [
|
||||
_createVNode(_component_VSwitch, {
|
||||
modelValue: configValue.value.enabled,
|
||||
"onUpdate:modelValue": _cache[2] || (_cache[2] = $event => ((configValue.value.enabled) = $event)),
|
||||
color: "primary",
|
||||
"hide-details": "",
|
||||
inset: "",
|
||||
label: "启用插件"
|
||||
}, null, 8, ["modelValue"]),
|
||||
_createVNode(_component_VSwitch, {
|
||||
modelValue: configValue.value.show_sidebar_nav,
|
||||
"onUpdate:modelValue": _cache[3] || (_cache[3] = $event => ((configValue.value.show_sidebar_nav) = $event)),
|
||||
color: "primary",
|
||||
"hide-details": "",
|
||||
inset: "",
|
||||
label: "侧边栏入口"
|
||||
}, null, 8, ["modelValue"])
|
||||
])
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createElementVNode("div", _hoisted_4, [
|
||||
_createVNode(UsageOverviewCard, {
|
||||
class: "agenttokens-overview-card",
|
||||
summary: displaySummary.value
|
||||
}, null, 8, ["summary"]),
|
||||
_createVNode(_component_VSheet, {
|
||||
border: "",
|
||||
rounded: "",
|
||||
class: "agenttokens-stat-card"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VIcon, {
|
||||
icon: "mdi-check-decagram-outline",
|
||||
color: "success"
|
||||
}),
|
||||
_createElementVNode("div", null, [
|
||||
_cache[8] || (_cache[8] = _createElementVNode("div", { class: "text-caption text-medium-emphasis" }, "可用供应商", -1)),
|
||||
_createElementVNode("div", _hoisted_5, _toDisplayString(displaySummary.value.available_count || 0) + " / " + _toDisplayString(displaySummary.value.enabled_count || 0), 1)
|
||||
])
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VSheet, {
|
||||
border: "",
|
||||
rounded: "",
|
||||
class: "agenttokens-stat-card"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VIcon, {
|
||||
icon: "mdi-chart-timeline-variant",
|
||||
color: "primary"
|
||||
}),
|
||||
_createElementVNode("div", null, [
|
||||
_cache[9] || (_cache[9] = _createElementVNode("div", { class: "text-caption text-medium-emphasis" }, "累计使用", -1)),
|
||||
_createElementVNode("div", _hoisted_6, _toDisplayString(_unref(formatTokens)(displaySummary.value.total_used)), 1)
|
||||
])
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VSheet, {
|
||||
border: "",
|
||||
rounded: "",
|
||||
class: "agenttokens-stat-card"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VIcon, {
|
||||
icon: "mdi-database-outline",
|
||||
color: "info"
|
||||
}),
|
||||
_createElementVNode("div", null, [
|
||||
_cache[10] || (_cache[10] = _createElementVNode("div", { class: "text-caption text-medium-emphasis" }, "总额度", -1)),
|
||||
_createElementVNode("div", _hoisted_7, _toDisplayString(displaySummary.value.total_limit ? _unref(formatTokens)(displaySummary.value.total_limit) : '不限'), 1)
|
||||
])
|
||||
]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_createVNode(_component_VSheet, {
|
||||
border: "",
|
||||
rounded: "",
|
||||
class: "agenttokens-content-panel"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createElementVNode("div", _hoisted_8, [
|
||||
_createVNode(_component_VTabs, {
|
||||
modelValue: activeTab.value,
|
||||
"onUpdate:modelValue": _cache[4] || (_cache[4] = $event => ((activeTab).value = $event)),
|
||||
density: "comfortable"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VTab, { value: "usage" }, {
|
||||
default: _withCtx(() => [...(_cache[11] || (_cache[11] = [
|
||||
_createTextVNode("用量", -1)
|
||||
]))]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VTab, { value: "config" }, {
|
||||
default: _withCtx(() => [...(_cache[12] || (_cache[12] = [
|
||||
_createTextVNode("配置", -1)
|
||||
]))]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
}, 8, ["modelValue"])
|
||||
]),
|
||||
_createVNode(_component_VDivider),
|
||||
_createVNode(_component_VWindow, {
|
||||
modelValue: activeTab.value,
|
||||
"onUpdate:modelValue": _cache[5] || (_cache[5] = $event => ((activeTab).value = $event)),
|
||||
touch: false,
|
||||
class: "agenttokens-window"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VWindowItem, { value: "usage" }, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(ProviderUsageTable, {
|
||||
"provider-rows": displayProviderRows.value,
|
||||
onReset: resetUsage
|
||||
}, null, 8, ["provider-rows"])
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VWindowItem, { value: "config" }, {
|
||||
default: _withCtx(() => [
|
||||
_createElementVNode("div", _hoisted_9, [
|
||||
_createVNode(_component_VBtn, {
|
||||
"prepend-icon": "mdi-plus",
|
||||
color: "primary",
|
||||
variant: "tonal",
|
||||
onClick: addProvider
|
||||
}, {
|
||||
default: _withCtx(() => [...(_cache[13] || (_cache[13] = [
|
||||
_createTextVNode("新增", -1)
|
||||
]))]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VBtn, {
|
||||
"prepend-icon": "mdi-backup-restore",
|
||||
color: "warning",
|
||||
variant: "tonal",
|
||||
onClick: resetAllUsage
|
||||
}, {
|
||||
default: _withCtx(() => [...(_cache[14] || (_cache[14] = [
|
||||
_createTextVNode(" 重置用量 ", -1)
|
||||
]))]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_createVNode(ProviderConfigTable, {
|
||||
providers: providers.value,
|
||||
"provider-rows": displayProviderRows.value,
|
||||
"show-credentials": "",
|
||||
onEdit: editProvider,
|
||||
onRemove: removeProvider
|
||||
}, null, 8, ["providers", "provider-rows"])
|
||||
]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
}, 8, ["modelValue"])
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_sfc_main$3, {
|
||||
modelValue: showEditor.value,
|
||||
"onUpdate:modelValue": _cache[6] || (_cache[6] = $event => ((showEditor).value = $event)),
|
||||
provider: editedProvider.value,
|
||||
"editor-index": editorIndex.value,
|
||||
onCommit: commitProvider
|
||||
}, null, 8, ["modelValue", "provider", "editor-index"])
|
||||
]))
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
const AgentTokensManager = /*#__PURE__*/_export_sfc(_sfc_main, [['__scopeId',"data-v-a6c1ea54"]]);
|
||||
|
||||
export { AgentTokensManager as A };
|
||||
130
plugins.v2/agenttokens/dist/assets/__federation_expose_AppPage-EV4Kchio.js
vendored
Normal file
130
plugins.v2/agenttokens/dist/assets/__federation_expose_AppPage-EV4Kchio.js
vendored
Normal file
@@ -0,0 +1,130 @@
|
||||
import { importShared } from './__federation_fn_import-JrT3xvdd.js';
|
||||
import { A as AgentTokensManager } from './AgentTokensManager-BTcJgtTd.js';
|
||||
import { u as unwrapResponse } from './_plugin-vue_export-helper-B_eZRIX_.js';
|
||||
|
||||
const {openBlock:_openBlock,createBlock:_createBlock} = await importShared('vue');
|
||||
|
||||
|
||||
const {computed,onMounted,ref} = await importShared('vue');
|
||||
|
||||
|
||||
const _sfc_main = {
|
||||
__name: 'AppPage',
|
||||
props: {
|
||||
api: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
pluginId: {
|
||||
type: String,
|
||||
default: 'AgentTokens',
|
||||
},
|
||||
hideTitle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(__props, { expose: __expose }) {
|
||||
|
||||
const props = __props;
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const error = ref('');
|
||||
const status = ref({
|
||||
config: { enabled: false, show_sidebar_nav: true, providers: [] },
|
||||
providers: [],
|
||||
summary: {},
|
||||
});
|
||||
|
||||
// 构造 API 基础路径。
|
||||
const pluginBase = computed(() => `plugin/${props.pluginId || 'AgentTokens'}`);
|
||||
const config = computed(() => status.value.config || { enabled: false, show_sidebar_nav: true, providers: [] });
|
||||
const providerRows = computed(() => status.value.providers || []);
|
||||
const summary = computed(() => status.value.summary || {});
|
||||
|
||||
// 从插件 API 拉取当前配置和用量状态。
|
||||
async function loadStatus() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const response = await props.api.get(`${pluginBase.value}/status`);
|
||||
status.value = unwrapResponse(response) || status.value;
|
||||
} catch (err) {
|
||||
error.value = err?.message || '加载失败';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 保存完整插件配置并刷新服务端标准化后的状态。
|
||||
async function saveConfig() {
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const payload = {
|
||||
enabled: Boolean(config.value.enabled),
|
||||
show_sidebar_nav: Boolean(config.value.show_sidebar_nav),
|
||||
providers: [...(config.value.providers || [])],
|
||||
};
|
||||
const response = await props.api.post(`${pluginBase.value}/config`, payload);
|
||||
status.value = unwrapResponse(response) || status.value;
|
||||
} catch (err) {
|
||||
error.value = err?.message || '保存失败';
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 重置指定供应商的运行记录。
|
||||
async function resetUsage(providerId) {
|
||||
if (!providerId) return
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await props.api.post(`${pluginBase.value}/usage/reset`, { provider_id: providerId });
|
||||
status.value = unwrapResponse(response) || status.value;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 重置全部供应商的运行记录。
|
||||
async function resetAllUsage() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await props.api.post(`${pluginBase.value}/usage/reset_all`, {});
|
||||
status.value = unwrapResponse(response) || status.value;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
__expose({
|
||||
loadStatus,
|
||||
saveConfig,
|
||||
loading,
|
||||
saving,
|
||||
});
|
||||
|
||||
onMounted(loadStatus);
|
||||
|
||||
return (_ctx, _cache) => {
|
||||
return (_openBlock(), _createBlock(AgentTokensManager, {
|
||||
config: config.value,
|
||||
"provider-rows": providerRows.value,
|
||||
summary: summary.value,
|
||||
error: error.value,
|
||||
loading: loading.value,
|
||||
saving: saving.value,
|
||||
"hide-title": __props.hideTitle,
|
||||
onRefresh: loadStatus,
|
||||
onSave: saveConfig,
|
||||
onResetUsage: resetUsage,
|
||||
onResetAllUsage: resetAllUsage
|
||||
}, null, 8, ["config", "provider-rows", "summary", "error", "loading", "saving", "hide-title"]))
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
export { _sfc_main as default };
|
||||
103
plugins.v2/agenttokens/dist/assets/__federation_expose_Config-CpvEDTaR.js
vendored
Normal file
103
plugins.v2/agenttokens/dist/assets/__federation_expose_Config-CpvEDTaR.js
vendored
Normal file
@@ -0,0 +1,103 @@
|
||||
import { importShared } from './__federation_fn_import-JrT3xvdd.js';
|
||||
import { A as AgentTokensManager } from './AgentTokensManager-BTcJgtTd.js';
|
||||
import { c as cloneConfig } from './_plugin-vue_export-helper-B_eZRIX_.js';
|
||||
|
||||
const {createElementVNode:_createElementVNode,resolveComponent:_resolveComponent,createVNode:_createVNode,withCtx:_withCtx,openBlock:_openBlock,createElementBlock:_createElementBlock} = await importShared('vue');
|
||||
|
||||
|
||||
const _hoisted_1 = { class: "agenttokens-config" };
|
||||
|
||||
const {onMounted,ref} = await importShared('vue');
|
||||
|
||||
|
||||
const _sfc_main = {
|
||||
__name: 'Config',
|
||||
props: {
|
||||
initialConfig: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
emits: ['save', 'close'],
|
||||
setup(__props, { emit: __emit }) {
|
||||
|
||||
const props = __props;
|
||||
|
||||
const emit = __emit;
|
||||
|
||||
const localConfig = ref({ enabled: false, show_sidebar_nav: true, providers: [] });
|
||||
|
||||
// 重置本地配置中的单个供应商用量。
|
||||
function resetUsage(providerId, index) {
|
||||
const providers = localConfig.value.providers || [];
|
||||
const providerIndex = providers.findIndex(provider => provider.id && provider.id === providerId);
|
||||
const targetIndex = providerIndex >= 0 ? providerIndex : index;
|
||||
if (!providers[targetIndex]) return
|
||||
providers[targetIndex].used_tokens = 0;
|
||||
}
|
||||
|
||||
// 重置本地配置中的全部供应商用量。
|
||||
function resetAllUsage() {
|
||||
(localConfig.value.providers || []).forEach(provider => {
|
||||
provider.used_tokens = 0;
|
||||
});
|
||||
}
|
||||
|
||||
// 通知宿主保存 Vue 配置。
|
||||
function saveConfig() {
|
||||
emit('save', cloneConfig(localConfig.value));
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
localConfig.value = cloneConfig(props.initialConfig);
|
||||
if (localConfig.value.show_sidebar_nav === undefined) {
|
||||
localConfig.value.show_sidebar_nav = true;
|
||||
}
|
||||
if (!Array.isArray(localConfig.value.providers)) {
|
||||
localConfig.value.providers = [];
|
||||
}
|
||||
});
|
||||
|
||||
return (_ctx, _cache) => {
|
||||
const _component_VSpacer = _resolveComponent("VSpacer");
|
||||
const _component_VBtn = _resolveComponent("VBtn");
|
||||
const _component_VToolbar = _resolveComponent("VToolbar");
|
||||
const _component_VDivider = _resolveComponent("VDivider");
|
||||
|
||||
return (_openBlock(), _createElementBlock("div", _hoisted_1, [
|
||||
_createVNode(_component_VToolbar, {
|
||||
density: "comfortable",
|
||||
color: "transparent"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_cache[1] || (_cache[1] = _createElementVNode("div", { class: "text-h6 ms-3" }, "Agent Tokens 配置", -1)),
|
||||
_createVNode(_component_VSpacer),
|
||||
_createVNode(_component_VBtn, {
|
||||
icon: "mdi-content-save",
|
||||
variant: "text",
|
||||
color: "primary",
|
||||
onClick: saveConfig
|
||||
}),
|
||||
_createVNode(_component_VBtn, {
|
||||
icon: "mdi-close",
|
||||
variant: "text",
|
||||
onClick: _cache[0] || (_cache[0] = $event => (emit('close')))
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VDivider),
|
||||
_createVNode(AgentTokensManager, {
|
||||
config: localConfig.value,
|
||||
"hide-title": "",
|
||||
onSave: saveConfig,
|
||||
onResetUsage: resetUsage,
|
||||
onResetAllUsage: resetAllUsage
|
||||
}, null, 8, ["config"])
|
||||
]))
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
export { _sfc_main as default };
|
||||
198
plugins.v2/agenttokens/dist/assets/__federation_expose_Dashboard-CMoy7CAI.css
vendored
Normal file
198
plugins.v2/agenttokens/dist/assets/__federation_expose_Dashboard-CMoy7CAI.css
vendored
Normal file
@@ -0,0 +1,198 @@
|
||||
|
||||
.agenttokens-dashboard-widget[data-v-cd87a760] {
|
||||
block-size: 100%;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
inline-size: 100%;
|
||||
|
||||
--agenttokens-divider-color: rgba(var(--v-theme-on-surface), 0.08);
|
||||
--agenttokens-muted-color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
--agenttokens-soft-surface: rgba(var(--v-theme-on-surface), 0.035);
|
||||
--agenttokens-soft-surface-hover: rgba(var(--v-theme-on-surface), 0.055);
|
||||
}
|
||||
.agenttokens-dashboard-card[data-v-cd87a760] {
|
||||
block-size: 100%;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-block-size: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.agenttokens-dashboard-card__header[data-v-cd87a760] {
|
||||
flex: 0 0 auto;
|
||||
padding-block-end: 8px;
|
||||
}
|
||||
.agenttokens-dashboard-card__title[data-v-cd87a760] {
|
||||
font-size: 1rem;
|
||||
line-height: 1.35;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.agenttokens-dashboard-card__body[data-v-cd87a760] {
|
||||
flex: 1 1 auto;
|
||||
min-block-size: 0;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding-block-start: 8px;
|
||||
}
|
||||
.agenttokens-dashboard-card__actions[data-v-cd87a760] {
|
||||
flex: 0 0 auto;
|
||||
min-block-size: 40px;
|
||||
padding: 4px 12px;
|
||||
}
|
||||
.agenttokens-dashboard-state[data-v-cd87a760] {
|
||||
block-size: 100%;
|
||||
min-block-size: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.agenttokens-dashboard-content[data-v-cd87a760] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-block-size: 0;
|
||||
}
|
||||
.agenttokens-dashboard-summary[data-v-cd87a760] {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
.agenttokens-dashboard-summary__percent[data-v-cd87a760] {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.agenttokens-dashboard-summary__body[data-v-cd87a760] {
|
||||
min-inline-size: 0;
|
||||
}
|
||||
.agenttokens-dashboard-summary__count[data-v-cd87a760] {
|
||||
margin-block: 2px 8px;
|
||||
font-size: 1.7rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.agenttokens-dashboard-summary__count span[data-v-cd87a760] {
|
||||
color: var(--agenttokens-muted-color);
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.agenttokens-dashboard-metrics[data-v-cd87a760] {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.agenttokens-dashboard-metric[data-v-cd87a760] {
|
||||
min-block-size: 54px;
|
||||
border: 1px solid var(--agenttokens-divider-color);
|
||||
border-radius: 6px;
|
||||
background: var(--agenttokens-soft-surface);
|
||||
padding: 8px 10px;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
.agenttokens-dashboard-metric[data-v-cd87a760]:hover {
|
||||
background: var(--agenttokens-soft-surface-hover);
|
||||
}
|
||||
.agenttokens-dashboard-metric span[data-v-cd87a760] {
|
||||
display: block;
|
||||
color: var(--agenttokens-muted-color);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.agenttokens-dashboard-metric strong[data-v-cd87a760] {
|
||||
display: block;
|
||||
margin-block-start: 4px;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.2;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.agenttokens-dashboard-list[data-v-cd87a760] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-block-size: 0;
|
||||
}
|
||||
.agenttokens-dashboard-provider[data-v-cd87a760] {
|
||||
min-block-size: 34px;
|
||||
border-radius: 6px;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-inline: 2px;
|
||||
}
|
||||
.agenttokens-dashboard-provider[data-v-cd87a760]:hover {
|
||||
background: var(--agenttokens-soft-surface);
|
||||
}
|
||||
.agenttokens-dashboard-provider__main[data-v-cd87a760] {
|
||||
min-inline-size: 0;
|
||||
}
|
||||
.agenttokens-dashboard-provider__name[data-v-cd87a760],
|
||||
.agenttokens-dashboard-provider__model[data-v-cd87a760] {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.agenttokens-dashboard-provider__name[data-v-cd87a760] {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.agenttokens-dashboard-provider__model[data-v-cd87a760] {
|
||||
color: var(--agenttokens-muted-color);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.agenttokens-dashboard-provider__tokens[data-v-cd87a760] {
|
||||
color: var(--agenttokens-muted-color);
|
||||
font-size: 0.8rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.agenttokens-dashboard-empty[data-v-cd87a760] {
|
||||
min-block-size: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
color: var(--agenttokens-muted-color);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.agenttokens-dashboard-widget--compact .agenttokens-dashboard-card__body[data-v-cd87a760] {
|
||||
padding-block: 6px 10px;
|
||||
}
|
||||
.agenttokens-dashboard-widget--compact .agenttokens-dashboard-content[data-v-cd87a760] {
|
||||
gap: 8px;
|
||||
}
|
||||
.agenttokens-dashboard-widget--compact .agenttokens-dashboard-summary[data-v-cd87a760] {
|
||||
gap: 10px;
|
||||
}
|
||||
.agenttokens-dashboard-widget--compact .agenttokens-dashboard-summary__count[data-v-cd87a760] {
|
||||
margin-block-end: 6px;
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
.agenttokens-dashboard-widget--compact .agenttokens-dashboard-metric[data-v-cd87a760] {
|
||||
min-block-size: 46px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
.agenttokens-dashboard-widget--compact .agenttokens-dashboard-provider[data-v-cd87a760] {
|
||||
min-block-size: 30px;
|
||||
}
|
||||
.agenttokens-dashboard-widget--mini .agenttokens-dashboard-card__header[data-v-cd87a760] {
|
||||
padding-block: 10px 4px;
|
||||
}
|
||||
.agenttokens-dashboard-widget--mini .agenttokens-dashboard-summary[data-v-cd87a760] {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
.agenttokens-dashboard-widget--mini .agenttokens-dashboard-summary__count[data-v-cd87a760] {
|
||||
margin-block: 0 4px;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
.agenttokens-dashboard-widget--mini .agenttokens-dashboard-card__actions[data-v-cd87a760] {
|
||||
justify-content: flex-end;
|
||||
min-block-size: 34px;
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.agenttokens-dashboard-metrics[data-v-cd87a760] {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
429
plugins.v2/agenttokens/dist/assets/__federation_expose_Dashboard-DdqUAuX4.js
vendored
Normal file
429
plugins.v2/agenttokens/dist/assets/__federation_expose_Dashboard-DdqUAuX4.js
vendored
Normal file
@@ -0,0 +1,429 @@
|
||||
import { importShared } from './__federation_fn_import-JrT3xvdd.js';
|
||||
import { _ as _export_sfc, f as formatTokens, u as unwrapResponse } from './_plugin-vue_export-helper-B_eZRIX_.js';
|
||||
|
||||
const {resolveComponent:_resolveComponent,createVNode:_createVNode,withCtx:_withCtx,toDisplayString:_toDisplayString,createTextVNode:_createTextVNode,openBlock:_openBlock,createElementBlock:_createElementBlock,createCommentVNode:_createCommentVNode,createBlock:_createBlock,createElementVNode:_createElementVNode,unref:_unref,renderList:_renderList,Fragment:_Fragment,normalizeClass:_normalizeClass} = await importShared('vue');
|
||||
|
||||
|
||||
const _hoisted_1 = {
|
||||
key: 0,
|
||||
class: "agenttokens-dashboard-state"
|
||||
};
|
||||
const _hoisted_2 = {
|
||||
key: 2,
|
||||
class: "agenttokens-dashboard-content"
|
||||
};
|
||||
const _hoisted_3 = { class: "agenttokens-dashboard-summary" };
|
||||
const _hoisted_4 = { class: "agenttokens-dashboard-summary__percent" };
|
||||
const _hoisted_5 = { class: "agenttokens-dashboard-summary__body" };
|
||||
const _hoisted_6 = { class: "agenttokens-dashboard-summary__count" };
|
||||
const _hoisted_7 = {
|
||||
key: 0,
|
||||
class: "agenttokens-dashboard-metrics"
|
||||
};
|
||||
const _hoisted_8 = { class: "agenttokens-dashboard-metric" };
|
||||
const _hoisted_9 = { class: "agenttokens-dashboard-metric" };
|
||||
const _hoisted_10 = { class: "agenttokens-dashboard-metric" };
|
||||
const _hoisted_11 = {
|
||||
key: 1,
|
||||
class: "agenttokens-dashboard-list"
|
||||
};
|
||||
const _hoisted_12 = { class: "agenttokens-dashboard-provider__main" };
|
||||
const _hoisted_13 = { class: "agenttokens-dashboard-provider__name" };
|
||||
const _hoisted_14 = { class: "agenttokens-dashboard-provider__model" };
|
||||
const _hoisted_15 = { class: "agenttokens-dashboard-provider__tokens" };
|
||||
const _hoisted_16 = {
|
||||
key: 2,
|
||||
class: "agenttokens-dashboard-empty"
|
||||
};
|
||||
const _hoisted_17 = {
|
||||
key: 3,
|
||||
class: "agenttokens-dashboard-state text-caption text-disabled"
|
||||
};
|
||||
const _hoisted_18 = {
|
||||
key: 0,
|
||||
class: "text-caption text-disabled"
|
||||
};
|
||||
|
||||
const {computed,onMounted,onUnmounted,ref} = await importShared('vue');
|
||||
|
||||
|
||||
const _sfc_main = {
|
||||
__name: 'Dashboard',
|
||||
props: {
|
||||
api: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
config: {
|
||||
type: Object,
|
||||
default: () => ({ attrs: {} }),
|
||||
},
|
||||
allowRefresh: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
refreshInterval: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
setup(__props) {
|
||||
|
||||
const props = __props;
|
||||
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
const initialDataLoaded = ref(false);
|
||||
const lastRefreshedAt = ref(null);
|
||||
const widgetRef = ref(null);
|
||||
const widgetSize = ref({ inline: 0, block: 0 });
|
||||
const status = ref({ providers: [], summary: {} });
|
||||
let timer = null;
|
||||
let resizeObserver = null;
|
||||
|
||||
const attrs = computed(() => props.config?.attrs || {});
|
||||
const summary = computed(() => status.value.summary || {});
|
||||
const providers = computed(() => status.value.providers || []);
|
||||
const totalUsed = computed(() => Number(summary.value.total_used || 0));
|
||||
const totalLimit = computed(() => Number(summary.value.total_limit || 0));
|
||||
const remainingTokens = computed(() => {
|
||||
if (totalLimit.value <= 0) return null
|
||||
return Math.max(totalLimit.value - totalUsed.value, 0)
|
||||
});
|
||||
const usagePercent = computed(() => {
|
||||
if (totalLimit.value <= 0) return 0
|
||||
return Math.min((totalUsed.value * 100) / totalLimit.value, 100)
|
||||
});
|
||||
const usagePercentText = computed(() => (totalLimit.value > 0 ? `${Math.round(usagePercent.value)}%` : '不限'));
|
||||
const progressColor = computed(() => {
|
||||
if (totalLimit.value <= 0) return 'primary'
|
||||
if (usagePercent.value >= 90) return 'error'
|
||||
if (usagePercent.value >= 70) return 'warning'
|
||||
return 'success'
|
||||
});
|
||||
const isCompact = computed(() => (
|
||||
(widgetSize.value.inline > 0 && widgetSize.value.inline < 340) ||
|
||||
(widgetSize.value.block > 0 && widgetSize.value.block < 300)
|
||||
));
|
||||
const isMini = computed(() => (
|
||||
(widgetSize.value.inline > 0 && widgetSize.value.inline < 260) ||
|
||||
(widgetSize.value.block > 0 && widgetSize.value.block < 230)
|
||||
));
|
||||
const gaugeSize = computed(() => {
|
||||
if (isMini.value) return 52
|
||||
if (isCompact.value) return 68
|
||||
return 84
|
||||
});
|
||||
const gaugeWidth = computed(() => {
|
||||
if (isMini.value) return 5
|
||||
if (isCompact.value) return 6
|
||||
return 8
|
||||
});
|
||||
const showMetrics = computed(() => !isMini.value);
|
||||
const visibleProviderLimit = computed(() => {
|
||||
if (isMini.value) return 0
|
||||
if (
|
||||
(widgetSize.value.inline > 0 && widgetSize.value.inline < 320) ||
|
||||
(widgetSize.value.block > 0 && widgetSize.value.block < 310)
|
||||
) {
|
||||
return 1
|
||||
}
|
||||
if (
|
||||
(widgetSize.value.inline > 0 && widgetSize.value.inline < 380) ||
|
||||
(widgetSize.value.block > 0 && widgetSize.value.block < 360)
|
||||
) {
|
||||
return 2
|
||||
}
|
||||
return 3
|
||||
});
|
||||
const visibleProviders = computed(() => providers.value.slice(0, visibleProviderLimit.value));
|
||||
// 兼容宿主传入的数字或字符串刷新间隔。
|
||||
const refreshSeconds = computed(() => {
|
||||
const seconds = Number(props.refreshInterval || attrs.value.refresh || 0);
|
||||
return Number.isFinite(seconds) ? seconds : 0
|
||||
});
|
||||
const cardTitle = computed(() => attrs.value.title || 'Agent Tokens 管理');
|
||||
const cardSubtitle = computed(() => attrs.value.subtitle || 'LLM 配额使用情况');
|
||||
const cardFlat = computed(() => attrs.value.border === false);
|
||||
const widgetClasses = computed(() => ({
|
||||
'agenttokens-dashboard-widget--compact': isCompact.value,
|
||||
'agenttokens-dashboard-widget--mini': isMini.value,
|
||||
}));
|
||||
const lastRefreshedTime = computed(() => {
|
||||
if (!lastRefreshedAt.value) return ''
|
||||
return new Date(lastRefreshedAt.value).toLocaleTimeString('zh-CN', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
});
|
||||
|
||||
// 读取 Agent Tokens 仪表板状态。
|
||||
async function loadStatus() {
|
||||
if (!props.api?.get) {
|
||||
error.value = 'API 未就绪';
|
||||
return
|
||||
}
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const response = await props.api.get('plugin/AgentTokens/status');
|
||||
status.value = unwrapResponse(response) || status.value;
|
||||
initialDataLoaded.value = true;
|
||||
lastRefreshedAt.value = Date.now();
|
||||
} catch (err) {
|
||||
error.value = err?.message || '获取数据失败';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 启动宿主传入或插件配置中的自动刷新。
|
||||
function startRefreshTimer() {
|
||||
if (refreshSeconds.value <= 0) return
|
||||
timer = window.setInterval(loadStatus, refreshSeconds.value * 1000);
|
||||
}
|
||||
|
||||
// 清理仪表板自动刷新计时器。
|
||||
function stopRefreshTimer() {
|
||||
if (!timer) return
|
||||
window.clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
|
||||
// 记录宿主 GridStack 分配给组件的实际尺寸,用于切换紧凑布局。
|
||||
function observeWidgetSize() {
|
||||
if (!widgetRef.value || typeof ResizeObserver === 'undefined') return
|
||||
resizeObserver = new ResizeObserver(entries => {
|
||||
const entry = entries[0];
|
||||
if (!entry) return
|
||||
widgetSize.value = {
|
||||
inline: entry.contentRect.width,
|
||||
block: entry.contentRect.height,
|
||||
};
|
||||
});
|
||||
resizeObserver.observe(widgetRef.value);
|
||||
}
|
||||
|
||||
// 停止监听组件尺寸,避免仪表板卸载后继续触发布局计算。
|
||||
function stopWidgetSizeObserver() {
|
||||
if (!resizeObserver) return
|
||||
resizeObserver.disconnect();
|
||||
resizeObserver = null;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
observeWidgetSize();
|
||||
loadStatus();
|
||||
startRefreshTimer();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopWidgetSizeObserver();
|
||||
stopRefreshTimer();
|
||||
});
|
||||
|
||||
return (_ctx, _cache) => {
|
||||
const _component_VIcon = _resolveComponent("VIcon");
|
||||
const _component_VAvatar = _resolveComponent("VAvatar");
|
||||
const _component_VCardTitle = _resolveComponent("VCardTitle");
|
||||
const _component_VCardSubtitle = _resolveComponent("VCardSubtitle");
|
||||
const _component_VCardItem = _resolveComponent("VCardItem");
|
||||
const _component_VProgressCircular = _resolveComponent("VProgressCircular");
|
||||
const _component_VAlert = _resolveComponent("VAlert");
|
||||
const _component_VProgressLinear = _resolveComponent("VProgressLinear");
|
||||
const _component_VCardText = _resolveComponent("VCardText");
|
||||
const _component_VDivider = _resolveComponent("VDivider");
|
||||
const _component_VSpacer = _resolveComponent("VSpacer");
|
||||
const _component_VBtn = _resolveComponent("VBtn");
|
||||
const _component_VCardActions = _resolveComponent("VCardActions");
|
||||
const _component_VCard = _resolveComponent("VCard");
|
||||
|
||||
return (_openBlock(), _createElementBlock("div", {
|
||||
ref_key: "widgetRef",
|
||||
ref: widgetRef,
|
||||
class: _normalizeClass(["agenttokens-dashboard-widget", widgetClasses.value])
|
||||
}, [
|
||||
_createVNode(_component_VCard, {
|
||||
flat: cardFlat.value,
|
||||
loading: loading.value,
|
||||
class: "agenttokens-dashboard-card"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VCardItem, { class: "agenttokens-dashboard-card__header" }, {
|
||||
prepend: _withCtx(() => [
|
||||
_createVNode(_component_VAvatar, {
|
||||
color: "primary",
|
||||
variant: "tonal",
|
||||
size: "36"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VIcon, {
|
||||
icon: "mdi-key-chain",
|
||||
size: "20"
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VCardTitle, { class: "agenttokens-dashboard-card__title" }, {
|
||||
default: _withCtx(() => [
|
||||
_createTextVNode(_toDisplayString(cardTitle.value), 1)
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VCardSubtitle, null, {
|
||||
default: _withCtx(() => [
|
||||
_createTextVNode(_toDisplayString(cardSubtitle.value), 1)
|
||||
]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VCardText, { class: "agenttokens-dashboard-card__body" }, {
|
||||
default: _withCtx(() => [
|
||||
(loading.value && !initialDataLoaded.value)
|
||||
? (_openBlock(), _createElementBlock("div", _hoisted_1, [
|
||||
_createVNode(_component_VProgressCircular, {
|
||||
indeterminate: "",
|
||||
color: "primary",
|
||||
size: "28"
|
||||
})
|
||||
]))
|
||||
: (error.value)
|
||||
? (_openBlock(), _createBlock(_component_VAlert, {
|
||||
key: 1,
|
||||
type: "error",
|
||||
variant: "tonal",
|
||||
density: "compact",
|
||||
class: "text-caption"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createTextVNode(_toDisplayString(error.value), 1)
|
||||
]),
|
||||
_: 1
|
||||
}))
|
||||
: (initialDataLoaded.value)
|
||||
? (_openBlock(), _createElementBlock("div", _hoisted_2, [
|
||||
_createElementVNode("div", _hoisted_3, [
|
||||
_createVNode(_component_VProgressCircular, {
|
||||
"model-value": usagePercent.value,
|
||||
color: progressColor.value,
|
||||
"bg-color": "surface",
|
||||
size: gaugeSize.value,
|
||||
width: gaugeWidth.value
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createElementVNode("span", _hoisted_4, _toDisplayString(usagePercentText.value), 1)
|
||||
]),
|
||||
_: 1
|
||||
}, 8, ["model-value", "color", "size", "width"]),
|
||||
_createElementVNode("div", _hoisted_5, [
|
||||
_cache[0] || (_cache[0] = _createElementVNode("div", { class: "text-caption text-medium-emphasis" }, "可用供应商", -1)),
|
||||
_createElementVNode("div", _hoisted_6, [
|
||||
_createTextVNode(_toDisplayString(summary.value.available_count || 0) + " ", 1),
|
||||
_createElementVNode("span", null, "/ " + _toDisplayString(summary.value.enabled_count || 0), 1)
|
||||
]),
|
||||
_createVNode(_component_VProgressLinear, {
|
||||
"model-value": usagePercent.value,
|
||||
color: progressColor.value,
|
||||
height: "6",
|
||||
rounded: ""
|
||||
}, null, 8, ["model-value", "color"])
|
||||
])
|
||||
]),
|
||||
(showMetrics.value)
|
||||
? (_openBlock(), _createElementBlock("div", _hoisted_7, [
|
||||
_createElementVNode("div", _hoisted_8, [
|
||||
_cache[1] || (_cache[1] = _createElementVNode("span", null, "累计", -1)),
|
||||
_createElementVNode("strong", null, _toDisplayString(_unref(formatTokens)(totalUsed.value)), 1)
|
||||
]),
|
||||
_createElementVNode("div", _hoisted_9, [
|
||||
_cache[2] || (_cache[2] = _createElementVNode("span", null, "额度", -1)),
|
||||
_createElementVNode("strong", null, _toDisplayString(totalLimit.value > 0 ? _unref(formatTokens)(totalLimit.value) : '不限'), 1)
|
||||
]),
|
||||
_createElementVNode("div", _hoisted_10, [
|
||||
_cache[3] || (_cache[3] = _createElementVNode("span", null, "剩余", -1)),
|
||||
_createElementVNode("strong", null, _toDisplayString(remainingTokens.value === null ? '不限' : _unref(formatTokens)(remainingTokens.value)), 1)
|
||||
])
|
||||
]))
|
||||
: _createCommentVNode("", true),
|
||||
(visibleProviders.value.length)
|
||||
? (_openBlock(), _createElementBlock("div", _hoisted_11, [
|
||||
(_openBlock(true), _createElementBlock(_Fragment, null, _renderList(visibleProviders.value, (row) => {
|
||||
return (_openBlock(), _createElementBlock("div", {
|
||||
key: row.id,
|
||||
class: "agenttokens-dashboard-provider"
|
||||
}, [
|
||||
_createVNode(_component_VIcon, {
|
||||
icon: row.usage?.exhausted ? 'mdi-alert-circle' : 'mdi-check-circle',
|
||||
color: row.usage?.exhausted ? 'error' : 'success',
|
||||
size: "16"
|
||||
}, null, 8, ["icon", "color"]),
|
||||
_createElementVNode("div", _hoisted_12, [
|
||||
_createElementVNode("div", _hoisted_13, _toDisplayString(row.name || '未命名供应商'), 1),
|
||||
_createElementVNode("div", _hoisted_14, _toDisplayString(row.model || '未配置模型'), 1)
|
||||
]),
|
||||
_createElementVNode("div", _hoisted_15, _toDisplayString(_unref(formatTokens)(row.usage?.total_tokens)), 1)
|
||||
]))
|
||||
}), 128))
|
||||
]))
|
||||
: (!providers.value.length)
|
||||
? (_openBlock(), _createElementBlock("div", _hoisted_16, [
|
||||
_createVNode(_component_VIcon, {
|
||||
icon: "mdi-database-off-outline",
|
||||
size: "18"
|
||||
}),
|
||||
_cache[4] || (_cache[4] = _createElementVNode("span", null, "暂无供应商", -1))
|
||||
]))
|
||||
: _createCommentVNode("", true)
|
||||
]))
|
||||
: (_openBlock(), _createElementBlock("div", _hoisted_17, " 暂无数据 "))
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
(__props.allowRefresh)
|
||||
? (_openBlock(), _createBlock(_component_VDivider, { key: 0 }))
|
||||
: _createCommentVNode("", true),
|
||||
(__props.allowRefresh)
|
||||
? (_openBlock(), _createBlock(_component_VCardActions, {
|
||||
key: 1,
|
||||
class: "agenttokens-dashboard-card__actions"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
(!isMini.value)
|
||||
? (_openBlock(), _createElementBlock("span", _hoisted_18, _toDisplayString(lastRefreshedTime.value ? `更新于 ${lastRefreshedTime.value}` : '等待更新'), 1))
|
||||
: _createCommentVNode("", true),
|
||||
_createVNode(_component_VSpacer),
|
||||
_createVNode(_component_VBtn, {
|
||||
icon: "",
|
||||
variant: "text",
|
||||
size: "small",
|
||||
loading: loading.value,
|
||||
onClick: loadStatus
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VIcon, {
|
||||
icon: "mdi-refresh",
|
||||
size: "18"
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
}, 8, ["loading"])
|
||||
]),
|
||||
_: 1
|
||||
}))
|
||||
: _createCommentVNode("", true)
|
||||
]),
|
||||
_: 1
|
||||
}, 8, ["flat", "loading"])
|
||||
], 2))
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
const Dashboard = /*#__PURE__*/_export_sfc(_sfc_main, [['__scopeId',"data-v-cd87a760"]]);
|
||||
|
||||
export { Dashboard as default };
|
||||
79
plugins.v2/agenttokens/dist/assets/__federation_expose_Page-BikS33tm.js
vendored
Normal file
79
plugins.v2/agenttokens/dist/assets/__federation_expose_Page-BikS33tm.js
vendored
Normal file
@@ -0,0 +1,79 @@
|
||||
import { importShared } from './__federation_fn_import-JrT3xvdd.js';
|
||||
import _sfc_main$1 from './__federation_expose_AppPage-EV4Kchio.js';
|
||||
import { _ as _export_sfc } from './_plugin-vue_export-helper-B_eZRIX_.js';
|
||||
|
||||
const {createElementVNode:_createElementVNode,resolveComponent:_resolveComponent,createVNode:_createVNode,withCtx:_withCtx,openBlock:_openBlock,createElementBlock:_createElementBlock} = await importShared('vue');
|
||||
|
||||
|
||||
const _hoisted_1 = { class: "agenttokens-page-wrapper" };
|
||||
|
||||
const {ref} = await importShared('vue');
|
||||
|
||||
|
||||
const _sfc_main = {
|
||||
__name: 'Page',
|
||||
props: {
|
||||
api: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
emits: ['close'],
|
||||
setup(__props, { emit: __emit }) {
|
||||
|
||||
|
||||
const emit = __emit;
|
||||
|
||||
const pageRef = ref(null);
|
||||
|
||||
return (_ctx, _cache) => {
|
||||
const _component_VSpacer = _resolveComponent("VSpacer");
|
||||
const _component_VBtn = _resolveComponent("VBtn");
|
||||
const _component_VToolbar = _resolveComponent("VToolbar");
|
||||
const _component_VDivider = _resolveComponent("VDivider");
|
||||
|
||||
return (_openBlock(), _createElementBlock("div", _hoisted_1, [
|
||||
_createVNode(_component_VToolbar, {
|
||||
density: "comfortable",
|
||||
class: "sticky-toolbar"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_cache[3] || (_cache[3] = _createElementVNode("div", { class: "text-h6 ms-3" }, "Agent Tokens 管理", -1)),
|
||||
_createVNode(_component_VSpacer),
|
||||
_createVNode(_component_VBtn, {
|
||||
icon: "mdi-refresh",
|
||||
variant: "text",
|
||||
loading: pageRef.value?.loading,
|
||||
onClick: _cache[0] || (_cache[0] = $event => (pageRef.value?.loadStatus()))
|
||||
}, null, 8, ["loading"]),
|
||||
_createVNode(_component_VBtn, {
|
||||
icon: "mdi-content-save",
|
||||
variant: "text",
|
||||
color: "primary",
|
||||
loading: pageRef.value?.saving,
|
||||
onClick: _cache[1] || (_cache[1] = $event => (pageRef.value?.saveConfig()))
|
||||
}, null, 8, ["loading"]),
|
||||
_createVNode(_component_VBtn, {
|
||||
icon: "mdi-close",
|
||||
variant: "text",
|
||||
onClick: _cache[2] || (_cache[2] = $event => (emit('close')))
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VDivider),
|
||||
_createVNode(_sfc_main$1, {
|
||||
ref_key: "pageRef",
|
||||
ref: pageRef,
|
||||
api: __props.api,
|
||||
"plugin-id": "AgentTokens",
|
||||
"hide-title": ""
|
||||
}, null, 8, ["api"])
|
||||
]))
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
const Page = /*#__PURE__*/_export_sfc(_sfc_main, [['__scopeId',"data-v-2f12fb0f"]]);
|
||||
|
||||
export { Page as default };
|
||||
7
plugins.v2/agenttokens/dist/assets/__federation_expose_Page-vwwFlnk-.css
vendored
Normal file
7
plugins.v2/agenttokens/dist/assets/__federation_expose_Page-vwwFlnk-.css
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
|
||||
.sticky-toolbar[data-v-2f12fb0f] {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
}
|
||||
418
plugins.v2/agenttokens/dist/assets/__federation_fn_import-JrT3xvdd.js
vendored
Normal file
418
plugins.v2/agenttokens/dist/assets/__federation_fn_import-JrT3xvdd.js
vendored
Normal file
@@ -0,0 +1,418 @@
|
||||
const buildIdentifier = "[0-9A-Za-z-]+";
|
||||
const build = `(?:\\+(${buildIdentifier}(?:\\.${buildIdentifier})*))`;
|
||||
const numericIdentifier = "0|[1-9]\\d*";
|
||||
const numericIdentifierLoose = "[0-9]+";
|
||||
const nonNumericIdentifier = "\\d*[a-zA-Z-][a-zA-Z0-9-]*";
|
||||
const preReleaseIdentifierLoose = `(?:${numericIdentifierLoose}|${nonNumericIdentifier})`;
|
||||
const preReleaseLoose = `(?:-?(${preReleaseIdentifierLoose}(?:\\.${preReleaseIdentifierLoose})*))`;
|
||||
const preReleaseIdentifier = `(?:${numericIdentifier}|${nonNumericIdentifier})`;
|
||||
const preRelease = `(?:-(${preReleaseIdentifier}(?:\\.${preReleaseIdentifier})*))`;
|
||||
const xRangeIdentifier = `${numericIdentifier}|x|X|\\*`;
|
||||
const xRangePlain = `[v=\\s]*(${xRangeIdentifier})(?:\\.(${xRangeIdentifier})(?:\\.(${xRangeIdentifier})(?:${preRelease})?${build}?)?)?`;
|
||||
const hyphenRange = `^\\s*(${xRangePlain})\\s+-\\s+(${xRangePlain})\\s*$`;
|
||||
const mainVersionLoose = `(${numericIdentifierLoose})\\.(${numericIdentifierLoose})\\.(${numericIdentifierLoose})`;
|
||||
const loosePlain = `[v=\\s]*${mainVersionLoose}${preReleaseLoose}?${build}?`;
|
||||
const gtlt = "((?:<|>)?=?)";
|
||||
const comparatorTrim = `(\\s*)${gtlt}\\s*(${loosePlain}|${xRangePlain})`;
|
||||
const loneTilde = "(?:~>?)";
|
||||
const tildeTrim = `(\\s*)${loneTilde}\\s+`;
|
||||
const loneCaret = "(?:\\^)";
|
||||
const caretTrim = `(\\s*)${loneCaret}\\s+`;
|
||||
const star = "(<|>)?=?\\s*\\*";
|
||||
const caret = `^${loneCaret}${xRangePlain}$`;
|
||||
const mainVersion = `(${numericIdentifier})\\.(${numericIdentifier})\\.(${numericIdentifier})`;
|
||||
const fullPlain = `v?${mainVersion}${preRelease}?${build}?`;
|
||||
const tilde = `^${loneTilde}${xRangePlain}$`;
|
||||
const xRange = `^${gtlt}\\s*${xRangePlain}$`;
|
||||
const comparator = `^${gtlt}\\s*(${fullPlain})$|^$`;
|
||||
const gte0 = "^\\s*>=\\s*0.0.0\\s*$";
|
||||
function parseRegex(source) {
|
||||
return new RegExp(source);
|
||||
}
|
||||
function isXVersion(version) {
|
||||
return !version || version.toLowerCase() === "x" || version === "*";
|
||||
}
|
||||
function pipe(...fns) {
|
||||
return (x) => {
|
||||
return fns.reduce((v, f) => f(v), x);
|
||||
};
|
||||
}
|
||||
function extractComparator(comparatorString) {
|
||||
return comparatorString.match(parseRegex(comparator));
|
||||
}
|
||||
function combineVersion(major, minor, patch, preRelease2) {
|
||||
const mainVersion2 = `${major}.${minor}.${patch}`;
|
||||
if (preRelease2) {
|
||||
return `${mainVersion2}-${preRelease2}`;
|
||||
}
|
||||
return mainVersion2;
|
||||
}
|
||||
function parseHyphen(range) {
|
||||
return range.replace(
|
||||
parseRegex(hyphenRange),
|
||||
(_range, from, fromMajor, fromMinor, fromPatch, _fromPreRelease, _fromBuild, to, toMajor, toMinor, toPatch, toPreRelease) => {
|
||||
if (isXVersion(fromMajor)) {
|
||||
from = "";
|
||||
} else if (isXVersion(fromMinor)) {
|
||||
from = `>=${fromMajor}.0.0`;
|
||||
} else if (isXVersion(fromPatch)) {
|
||||
from = `>=${fromMajor}.${fromMinor}.0`;
|
||||
} else {
|
||||
from = `>=${from}`;
|
||||
}
|
||||
if (isXVersion(toMajor)) {
|
||||
to = "";
|
||||
} else if (isXVersion(toMinor)) {
|
||||
to = `<${+toMajor + 1}.0.0-0`;
|
||||
} else if (isXVersion(toPatch)) {
|
||||
to = `<${toMajor}.${+toMinor + 1}.0-0`;
|
||||
} else if (toPreRelease) {
|
||||
to = `<=${toMajor}.${toMinor}.${toPatch}-${toPreRelease}`;
|
||||
} else {
|
||||
to = `<=${to}`;
|
||||
}
|
||||
return `${from} ${to}`.trim();
|
||||
}
|
||||
);
|
||||
}
|
||||
function parseComparatorTrim(range) {
|
||||
return range.replace(parseRegex(comparatorTrim), "$1$2$3");
|
||||
}
|
||||
function parseTildeTrim(range) {
|
||||
return range.replace(parseRegex(tildeTrim), "$1~");
|
||||
}
|
||||
function parseCaretTrim(range) {
|
||||
return range.replace(parseRegex(caretTrim), "$1^");
|
||||
}
|
||||
function parseCarets(range) {
|
||||
return range.trim().split(/\s+/).map((rangeVersion) => {
|
||||
return rangeVersion.replace(
|
||||
parseRegex(caret),
|
||||
(_, major, minor, patch, preRelease2) => {
|
||||
if (isXVersion(major)) {
|
||||
return "";
|
||||
} else if (isXVersion(minor)) {
|
||||
return `>=${major}.0.0 <${+major + 1}.0.0-0`;
|
||||
} else if (isXVersion(patch)) {
|
||||
if (major === "0") {
|
||||
return `>=${major}.${minor}.0 <${major}.${+minor + 1}.0-0`;
|
||||
} else {
|
||||
return `>=${major}.${minor}.0 <${+major + 1}.0.0-0`;
|
||||
}
|
||||
} else if (preRelease2) {
|
||||
if (major === "0") {
|
||||
if (minor === "0") {
|
||||
return `>=${major}.${minor}.${patch}-${preRelease2} <${major}.${minor}.${+patch + 1}-0`;
|
||||
} else {
|
||||
return `>=${major}.${minor}.${patch}-${preRelease2} <${major}.${+minor + 1}.0-0`;
|
||||
}
|
||||
} else {
|
||||
return `>=${major}.${minor}.${patch}-${preRelease2} <${+major + 1}.0.0-0`;
|
||||
}
|
||||
} else {
|
||||
if (major === "0") {
|
||||
if (minor === "0") {
|
||||
return `>=${major}.${minor}.${patch} <${major}.${minor}.${+patch + 1}-0`;
|
||||
} else {
|
||||
return `>=${major}.${minor}.${patch} <${major}.${+minor + 1}.0-0`;
|
||||
}
|
||||
}
|
||||
return `>=${major}.${minor}.${patch} <${+major + 1}.0.0-0`;
|
||||
}
|
||||
}
|
||||
);
|
||||
}).join(" ");
|
||||
}
|
||||
function parseTildes(range) {
|
||||
return range.trim().split(/\s+/).map((rangeVersion) => {
|
||||
return rangeVersion.replace(
|
||||
parseRegex(tilde),
|
||||
(_, major, minor, patch, preRelease2) => {
|
||||
if (isXVersion(major)) {
|
||||
return "";
|
||||
} else if (isXVersion(minor)) {
|
||||
return `>=${major}.0.0 <${+major + 1}.0.0-0`;
|
||||
} else if (isXVersion(patch)) {
|
||||
return `>=${major}.${minor}.0 <${major}.${+minor + 1}.0-0`;
|
||||
} else if (preRelease2) {
|
||||
return `>=${major}.${minor}.${patch}-${preRelease2} <${major}.${+minor + 1}.0-0`;
|
||||
}
|
||||
return `>=${major}.${minor}.${patch} <${major}.${+minor + 1}.0-0`;
|
||||
}
|
||||
);
|
||||
}).join(" ");
|
||||
}
|
||||
function parseXRanges(range) {
|
||||
return range.split(/\s+/).map((rangeVersion) => {
|
||||
return rangeVersion.trim().replace(
|
||||
parseRegex(xRange),
|
||||
(ret, gtlt2, major, minor, patch, preRelease2) => {
|
||||
const isXMajor = isXVersion(major);
|
||||
const isXMinor = isXMajor || isXVersion(minor);
|
||||
const isXPatch = isXMinor || isXVersion(patch);
|
||||
if (gtlt2 === "=" && isXPatch) {
|
||||
gtlt2 = "";
|
||||
}
|
||||
preRelease2 = "";
|
||||
if (isXMajor) {
|
||||
if (gtlt2 === ">" || gtlt2 === "<") {
|
||||
return "<0.0.0-0";
|
||||
} else {
|
||||
return "*";
|
||||
}
|
||||
} else if (gtlt2 && isXPatch) {
|
||||
if (isXMinor) {
|
||||
minor = 0;
|
||||
}
|
||||
patch = 0;
|
||||
if (gtlt2 === ">") {
|
||||
gtlt2 = ">=";
|
||||
if (isXMinor) {
|
||||
major = +major + 1;
|
||||
minor = 0;
|
||||
patch = 0;
|
||||
} else {
|
||||
minor = +minor + 1;
|
||||
patch = 0;
|
||||
}
|
||||
} else if (gtlt2 === "<=") {
|
||||
gtlt2 = "<";
|
||||
if (isXMinor) {
|
||||
major = +major + 1;
|
||||
} else {
|
||||
minor = +minor + 1;
|
||||
}
|
||||
}
|
||||
if (gtlt2 === "<") {
|
||||
preRelease2 = "-0";
|
||||
}
|
||||
return `${gtlt2 + major}.${minor}.${patch}${preRelease2}`;
|
||||
} else if (isXMinor) {
|
||||
return `>=${major}.0.0${preRelease2} <${+major + 1}.0.0-0`;
|
||||
} else if (isXPatch) {
|
||||
return `>=${major}.${minor}.0${preRelease2} <${major}.${+minor + 1}.0-0`;
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
);
|
||||
}).join(" ");
|
||||
}
|
||||
function parseStar(range) {
|
||||
return range.trim().replace(parseRegex(star), "");
|
||||
}
|
||||
function parseGTE0(comparatorString) {
|
||||
return comparatorString.trim().replace(parseRegex(gte0), "");
|
||||
}
|
||||
function compareAtom(rangeAtom, versionAtom) {
|
||||
rangeAtom = +rangeAtom || rangeAtom;
|
||||
versionAtom = +versionAtom || versionAtom;
|
||||
if (rangeAtom > versionAtom) {
|
||||
return 1;
|
||||
}
|
||||
if (rangeAtom === versionAtom) {
|
||||
return 0;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
function comparePreRelease(rangeAtom, versionAtom) {
|
||||
const { preRelease: rangePreRelease } = rangeAtom;
|
||||
const { preRelease: versionPreRelease } = versionAtom;
|
||||
if (rangePreRelease === void 0 && !!versionPreRelease) {
|
||||
return 1;
|
||||
}
|
||||
if (!!rangePreRelease && versionPreRelease === void 0) {
|
||||
return -1;
|
||||
}
|
||||
if (rangePreRelease === void 0 && versionPreRelease === void 0) {
|
||||
return 0;
|
||||
}
|
||||
for (let i = 0, n = rangePreRelease.length; i <= n; i++) {
|
||||
const rangeElement = rangePreRelease[i];
|
||||
const versionElement = versionPreRelease[i];
|
||||
if (rangeElement === versionElement) {
|
||||
continue;
|
||||
}
|
||||
if (rangeElement === void 0 && versionElement === void 0) {
|
||||
return 0;
|
||||
}
|
||||
if (!rangeElement) {
|
||||
return 1;
|
||||
}
|
||||
if (!versionElement) {
|
||||
return -1;
|
||||
}
|
||||
return compareAtom(rangeElement, versionElement);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
function compareVersion(rangeAtom, versionAtom) {
|
||||
return compareAtom(rangeAtom.major, versionAtom.major) || compareAtom(rangeAtom.minor, versionAtom.minor) || compareAtom(rangeAtom.patch, versionAtom.patch) || comparePreRelease(rangeAtom, versionAtom);
|
||||
}
|
||||
function eq(rangeAtom, versionAtom) {
|
||||
return rangeAtom.version === versionAtom.version;
|
||||
}
|
||||
function compare(rangeAtom, versionAtom) {
|
||||
switch (rangeAtom.operator) {
|
||||
case "":
|
||||
case "=":
|
||||
return eq(rangeAtom, versionAtom);
|
||||
case ">":
|
||||
return compareVersion(rangeAtom, versionAtom) < 0;
|
||||
case ">=":
|
||||
return eq(rangeAtom, versionAtom) || compareVersion(rangeAtom, versionAtom) < 0;
|
||||
case "<":
|
||||
return compareVersion(rangeAtom, versionAtom) > 0;
|
||||
case "<=":
|
||||
return eq(rangeAtom, versionAtom) || compareVersion(rangeAtom, versionAtom) > 0;
|
||||
case void 0: {
|
||||
return true;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
function parseComparatorString(range) {
|
||||
return pipe(
|
||||
parseCarets,
|
||||
parseTildes,
|
||||
parseXRanges,
|
||||
parseStar
|
||||
)(range);
|
||||
}
|
||||
function parseRange(range) {
|
||||
return pipe(
|
||||
parseHyphen,
|
||||
parseComparatorTrim,
|
||||
parseTildeTrim,
|
||||
parseCaretTrim
|
||||
)(range.trim()).split(/\s+/).join(" ");
|
||||
}
|
||||
function satisfy(version, range) {
|
||||
if (!version) {
|
||||
return false;
|
||||
}
|
||||
const parsedRange = parseRange(range);
|
||||
const parsedComparator = parsedRange.split(" ").map((rangeVersion) => parseComparatorString(rangeVersion)).join(" ");
|
||||
const comparators = parsedComparator.split(/\s+/).map((comparator2) => parseGTE0(comparator2));
|
||||
const extractedVersion = extractComparator(version);
|
||||
if (!extractedVersion) {
|
||||
return false;
|
||||
}
|
||||
const [
|
||||
,
|
||||
versionOperator,
|
||||
,
|
||||
versionMajor,
|
||||
versionMinor,
|
||||
versionPatch,
|
||||
versionPreRelease
|
||||
] = extractedVersion;
|
||||
const versionAtom = {
|
||||
version: combineVersion(
|
||||
versionMajor,
|
||||
versionMinor,
|
||||
versionPatch,
|
||||
versionPreRelease
|
||||
),
|
||||
major: versionMajor,
|
||||
minor: versionMinor,
|
||||
patch: versionPatch,
|
||||
preRelease: versionPreRelease == null ? void 0 : versionPreRelease.split(".")
|
||||
};
|
||||
for (const comparator2 of comparators) {
|
||||
const extractedComparator = extractComparator(comparator2);
|
||||
if (!extractedComparator) {
|
||||
return false;
|
||||
}
|
||||
const [
|
||||
,
|
||||
rangeOperator,
|
||||
,
|
||||
rangeMajor,
|
||||
rangeMinor,
|
||||
rangePatch,
|
||||
rangePreRelease
|
||||
] = extractedComparator;
|
||||
const rangeAtom = {
|
||||
operator: rangeOperator,
|
||||
version: combineVersion(
|
||||
rangeMajor,
|
||||
rangeMinor,
|
||||
rangePatch,
|
||||
rangePreRelease
|
||||
),
|
||||
major: rangeMajor,
|
||||
minor: rangeMinor,
|
||||
patch: rangePatch,
|
||||
preRelease: rangePreRelease == null ? void 0 : rangePreRelease.split(".")
|
||||
};
|
||||
if (!compare(rangeAtom, versionAtom)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
const moduleMap = {};
|
||||
const moduleCache = Object.create(null);
|
||||
async function importShared(name, shareScope = 'default') {
|
||||
return moduleCache[name]
|
||||
? new Promise((r) => r(moduleCache[name]))
|
||||
: (await getSharedFromRuntime(name, shareScope)) || getSharedFromLocal(name)
|
||||
}
|
||||
async function getSharedFromRuntime(name, shareScope) {
|
||||
let module = null;
|
||||
if (globalThis?.__federation_shared__?.[shareScope]?.[name]) {
|
||||
const versionObj = globalThis.__federation_shared__[shareScope][name];
|
||||
const requiredVersion = moduleMap[name]?.requiredVersion;
|
||||
const hasRequiredVersion = !!requiredVersion;
|
||||
if (hasRequiredVersion) {
|
||||
const versionKey = Object.keys(versionObj).find((version) =>
|
||||
satisfy(version, requiredVersion)
|
||||
);
|
||||
if (versionKey) {
|
||||
const versionValue = versionObj[versionKey];
|
||||
module = await (await versionValue.get())();
|
||||
} else {
|
||||
console.log(
|
||||
`provider support ${name}(${versionKey}) is not satisfied requiredVersion(\${moduleMap[name].requiredVersion})`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const versionKey = Object.keys(versionObj)[0];
|
||||
const versionValue = versionObj[versionKey];
|
||||
module = await (await versionValue.get())();
|
||||
}
|
||||
}
|
||||
if (module) {
|
||||
return flattenModule(module, name)
|
||||
}
|
||||
}
|
||||
async function getSharedFromLocal(name) {
|
||||
if (moduleMap[name]?.import) {
|
||||
let module = await (await moduleMap[name].get())();
|
||||
return flattenModule(module, name)
|
||||
} else {
|
||||
console.error(
|
||||
`consumer config import=false,so cant use callback shared module`
|
||||
);
|
||||
}
|
||||
}
|
||||
function flattenModule(module, name) {
|
||||
// use a shared module which export default a function will getting error 'TypeError: xxx is not a function'
|
||||
if (typeof module.default === 'function') {
|
||||
Object.keys(module).forEach((key) => {
|
||||
if (key !== 'default') {
|
||||
module.default[key] = module[key];
|
||||
}
|
||||
});
|
||||
moduleCache[name] = module.default;
|
||||
return module.default
|
||||
}
|
||||
if (module.default) module = Object.assign({}, module.default, module);
|
||||
moduleCache[name] = module;
|
||||
return module
|
||||
}
|
||||
|
||||
export { importShared, getSharedFromLocal as importSharedLocal, getSharedFromRuntime as importSharedRuntime };
|
||||
112
plugins.v2/agenttokens/dist/assets/_plugin-vue_export-helper-B_eZRIX_.js
vendored
Normal file
112
plugins.v2/agenttokens/dist/assets/_plugin-vue_export-helper-B_eZRIX_.js
vendored
Normal file
@@ -0,0 +1,112 @@
|
||||
const PROVIDER_TYPE_OPTIONS = [
|
||||
{ title: 'OpenAI Compatible', value: 'openai' },
|
||||
{ title: 'DeepSeek', value: 'deepseek' },
|
||||
{ title: 'Google Gemini', value: 'google' },
|
||||
{ title: 'Anthropic Compatible', value: 'anthropic' },
|
||||
{ title: 'ChatGPT', value: 'chatgpt' },
|
||||
];
|
||||
|
||||
// 构建一个新的供应商默认配置。
|
||||
function createProvider() {
|
||||
return {
|
||||
id: '',
|
||||
enabled: true,
|
||||
name: '',
|
||||
provider: 'openai',
|
||||
base_url: '',
|
||||
api_key: '',
|
||||
user_agent: '',
|
||||
use_proxy: true,
|
||||
model: '',
|
||||
token_limit: 0,
|
||||
used_tokens: 0,
|
||||
priority: 1,
|
||||
}
|
||||
}
|
||||
|
||||
// 生成深拷贝配置,避免直接修改父组件传入对象。
|
||||
function cloneConfig(config) {
|
||||
return JSON.parse(JSON.stringify(config || { enabled: false, show_sidebar_nav: true, providers: [] }))
|
||||
}
|
||||
|
||||
// 格式化 token 数字,保持表格和统计展示可读。
|
||||
function formatTokens(value) {
|
||||
const numberValue = Number(value || 0);
|
||||
return Number.isFinite(numberValue) ? numberValue.toLocaleString() : '0'
|
||||
}
|
||||
|
||||
// 兼容 MoviePilot API 包装器和原始响应两种返回形态。
|
||||
function unwrapResponse(response) {
|
||||
if (response && Object.prototype.hasOwnProperty.call(response, 'data') && response.success !== undefined) {
|
||||
return response.data
|
||||
}
|
||||
return response?.data ?? response
|
||||
}
|
||||
|
||||
// 计算新增供应商的下一个优先级。
|
||||
function getNextProviderPriority(providers) {
|
||||
return Math.max(0, ...(providers || []).map(item => Number(item.priority || 0))) + 1
|
||||
}
|
||||
|
||||
// 标准化弹窗中写回的供应商数值字段。
|
||||
function normalizeProvider(provider, fallbackPriority) {
|
||||
return {
|
||||
...provider,
|
||||
use_proxy: provider.use_proxy !== false,
|
||||
token_limit: Number(provider.token_limit || 0),
|
||||
used_tokens: Number(provider.used_tokens || 0),
|
||||
priority: Number(provider.priority || fallbackPriority),
|
||||
}
|
||||
}
|
||||
|
||||
// 按配置生成本地用量行,供配置弹窗复用管理页展示结构。
|
||||
function buildProviderRow(provider) {
|
||||
const tokenLimit = Number(provider.token_limit || 0);
|
||||
const totalTokens = Number(provider.used_tokens || 0);
|
||||
const remainingTokens = tokenLimit <= 0 ? null : Math.max(tokenLimit - totalTokens, 0);
|
||||
const usagePercent = tokenLimit <= 0 ? 0 : Math.min((totalTokens * 100) / tokenLimit, 100);
|
||||
|
||||
return {
|
||||
...provider,
|
||||
masked_api_key: provider.api_key ? '****' : '',
|
||||
usage: {
|
||||
total_tokens: totalTokens,
|
||||
remaining_tokens: remainingTokens,
|
||||
usage_percent: usagePercent,
|
||||
exhausted: tokenLimit > 0 && remainingTokens === 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 批量生成本地供应商用量行。
|
||||
function buildProviderRows(providers) {
|
||||
return (providers || []).map(provider => buildProviderRow(provider))
|
||||
}
|
||||
|
||||
// 根据供应商行汇总用量统计。
|
||||
function buildProviderSummary(rows) {
|
||||
const providers = rows || [];
|
||||
const enabledRows = providers.filter(row => row.enabled);
|
||||
const totalUsed = providers.reduce((sum, row) => sum + Number(row.usage?.total_tokens || row.used_tokens || 0), 0);
|
||||
const totalLimit = providers.reduce((sum, row) => {
|
||||
const tokenLimit = Number(row.token_limit || 0);
|
||||
return tokenLimit > 0 ? sum + tokenLimit : sum
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
available_count: enabledRows.filter(row => !row.usage?.exhausted && row.api_key && row.base_url && row.model).length,
|
||||
enabled_count: enabledRows.length,
|
||||
total_used: totalUsed,
|
||||
total_limit: totalLimit,
|
||||
}
|
||||
}
|
||||
|
||||
const _export_sfc = (sfc, props) => {
|
||||
const target = sfc.__vccOpts || sfc;
|
||||
for (const [key, val] of props) {
|
||||
target[key] = val;
|
||||
}
|
||||
return target;
|
||||
};
|
||||
|
||||
export { PROVIDER_TYPE_OPTIONS as P, _export_sfc as _, buildProviderSummary as a, buildProviderRows as b, cloneConfig as c, createProvider as d, formatTokens as f, getNextProviderPriority as g, normalizeProvider as n, unwrapResponse as u };
|
||||
44
plugins.v2/agenttokens/dist/assets/index-Cqxebwzg.js
vendored
Normal file
44
plugins.v2/agenttokens/dist/assets/index-Cqxebwzg.js
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
import { importShared } from './__federation_fn_import-JrT3xvdd.js';
|
||||
import _sfc_main from './__federation_expose_AppPage-EV4Kchio.js';
|
||||
|
||||
true&&(function polyfill() {
|
||||
const relList = document.createElement("link").relList;
|
||||
if (relList && relList.supports && relList.supports("modulepreload")) {
|
||||
return;
|
||||
}
|
||||
for (const link of document.querySelectorAll('link[rel="modulepreload"]')) {
|
||||
processPreload(link);
|
||||
}
|
||||
new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
if (mutation.type !== "childList") {
|
||||
continue;
|
||||
}
|
||||
for (const node of mutation.addedNodes) {
|
||||
if (node.tagName === "LINK" && node.rel === "modulepreload")
|
||||
processPreload(node);
|
||||
}
|
||||
}
|
||||
}).observe(document, { childList: true, subtree: true });
|
||||
function getFetchOpts(link) {
|
||||
const fetchOpts = {};
|
||||
if (link.integrity) fetchOpts.integrity = link.integrity;
|
||||
if (link.referrerPolicy) fetchOpts.referrerPolicy = link.referrerPolicy;
|
||||
if (link.crossOrigin === "use-credentials")
|
||||
fetchOpts.credentials = "include";
|
||||
else if (link.crossOrigin === "anonymous") fetchOpts.credentials = "omit";
|
||||
else fetchOpts.credentials = "same-origin";
|
||||
return fetchOpts;
|
||||
}
|
||||
function processPreload(link) {
|
||||
if (link.ep)
|
||||
return;
|
||||
link.ep = true;
|
||||
const fetchOpts = getFetchOpts(link);
|
||||
fetch(link.href, fetchOpts);
|
||||
}
|
||||
}());
|
||||
|
||||
const {createApp} = await importShared('vue');
|
||||
|
||||
createApp(_sfc_main).mount('#app');
|
||||
90
plugins.v2/agenttokens/dist/assets/remoteEntry.js
vendored
Normal file
90
plugins.v2/agenttokens/dist/assets/remoteEntry.js
vendored
Normal file
@@ -0,0 +1,90 @@
|
||||
const currentImports = {};
|
||||
const exportSet = new Set(['Module', '__esModule', 'default', '_export_sfc']);
|
||||
let moduleMap = {
|
||||
"./Page":()=>{
|
||||
dynamicLoadingCss(["__federation_expose_Page-vwwFlnk-.css","AgentTokensManager-9miSzH4d.css"], false, './Page');
|
||||
return __federation_import('./__federation_expose_Page-BikS33tm.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},
|
||||
"./Config":()=>{
|
||||
dynamicLoadingCss(["AgentTokensManager-9miSzH4d.css"], false, './Config');
|
||||
return __federation_import('./__federation_expose_Config-CpvEDTaR.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},
|
||||
"./Dashboard":()=>{
|
||||
dynamicLoadingCss(["__federation_expose_Dashboard-CMoy7CAI.css"], false, './Dashboard');
|
||||
return __federation_import('./__federation_expose_Dashboard-DdqUAuX4.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},
|
||||
"./AppPage":()=>{
|
||||
dynamicLoadingCss(["AgentTokensManager-9miSzH4d.css"], false, './AppPage');
|
||||
return __federation_import('./__federation_expose_AppPage-EV4Kchio.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},};
|
||||
const seen = {};
|
||||
const dynamicLoadingCss = (cssFilePaths, dontAppendStylesToHead, exposeItemName) => {
|
||||
const metaUrl = import.meta.url;
|
||||
if (typeof metaUrl === 'undefined') {
|
||||
console.warn('The remote style takes effect only when the build.target option in the vite.config.ts file is higher than that of "es2020".');
|
||||
return;
|
||||
}
|
||||
|
||||
const curUrl = metaUrl.substring(0, metaUrl.lastIndexOf('remoteEntry.js'));
|
||||
const base = '/';
|
||||
'assets';
|
||||
|
||||
cssFilePaths.forEach(cssPath => {
|
||||
let href = '';
|
||||
const baseUrl = base || curUrl;
|
||||
if (baseUrl) {
|
||||
const trimmer = {
|
||||
trailing: (path) => (path.endsWith('/') ? path.slice(0, -1) : path),
|
||||
leading: (path) => (path.startsWith('/') ? path.slice(1) : path)
|
||||
};
|
||||
const isAbsoluteUrl = (url) => url.startsWith('http') || url.startsWith('//');
|
||||
|
||||
const cleanBaseUrl = trimmer.trailing(baseUrl);
|
||||
const cleanCssPath = trimmer.leading(cssPath);
|
||||
const cleanCurUrl = trimmer.trailing(curUrl);
|
||||
|
||||
if (isAbsoluteUrl(baseUrl)) {
|
||||
href = [cleanBaseUrl, cleanCssPath].filter(Boolean).join('/');
|
||||
} else {
|
||||
if (cleanCurUrl.includes(cleanBaseUrl)) {
|
||||
href = [cleanCurUrl, cleanCssPath].filter(Boolean).join('/');
|
||||
} else {
|
||||
href = [cleanCurUrl + cleanBaseUrl, cleanCssPath].filter(Boolean).join('/');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
href = cssPath;
|
||||
}
|
||||
|
||||
if (dontAppendStylesToHead) {
|
||||
const key = 'css__AgentTokens__' + exposeItemName;
|
||||
window[key] = window[key] || [];
|
||||
window[key].push(href);
|
||||
return;
|
||||
}
|
||||
|
||||
if (href in seen) return;
|
||||
seen[href] = true;
|
||||
|
||||
const element = document.createElement('link');
|
||||
element.rel = 'stylesheet';
|
||||
element.href = href;
|
||||
document.head.appendChild(element);
|
||||
});
|
||||
};
|
||||
async function __federation_import(name) {
|
||||
currentImports[name] ??= import(name);
|
||||
return currentImports[name]
|
||||
} const get =(module) => {
|
||||
if(!moduleMap[module]) throw new Error('Can not find remote module ' + module)
|
||||
return moduleMap[module]();
|
||||
};
|
||||
const init =(shareScope) => {
|
||||
globalThis.__federation_shared__= globalThis.__federation_shared__|| {};
|
||||
Object.entries(shareScope).forEach(([key, value]) => {
|
||||
for (const [versionKey, versionValue] of Object.entries(value)) {
|
||||
const scope = versionValue.scope || 'default';
|
||||
globalThis.__federation_shared__[scope] = globalThis.__federation_shared__[scope] || {};
|
||||
const shared= globalThis.__federation_shared__[scope];
|
||||
(shared[key] = shared[key]||{})[versionKey] = versionValue;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export { dynamicLoadingCss, get, init };
|
||||
7
plugins.v2/agenttokens/dist/index.html
vendored
Normal file
7
plugins.v2/agenttokens/dist/index.html
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
<script type="module" crossorigin src="/assets/index-Cqxebwzg.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/__federation_fn_import-JrT3xvdd.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/_plugin-vue_export-helper-B_eZRIX_.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/AgentTokensManager-BTcJgtTd.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/__federation_expose_AppPage-EV4Kchio.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/AgentTokensManager-9miSzH4d.css">
|
||||
<div id="app"></div>
|
||||
2
plugins.v2/agenttokens/index.html
Normal file
2
plugins.v2/agenttokens/index.html
Normal file
@@ -0,0 +1,2 @@
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
18
plugins.v2/agenttokens/package.json
Normal file
18
plugins.v2/agenttokens/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "moviepilot-agenttokens-plugin",
|
||||
"private": true,
|
||||
"version": "1.0.12",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.13",
|
||||
"vuetify": "3.7.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@originjs/vite-plugin-federation": "^1.4.1",
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"vite": "^5.4.11"
|
||||
}
|
||||
}
|
||||
299
plugins.v2/agenttokens/src/components/AgentTokensManager.vue
Normal file
299
plugins.v2/agenttokens/src/components/AgentTokensManager.vue
Normal file
@@ -0,0 +1,299 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import ProviderConfigTable from './ProviderConfigTable.vue'
|
||||
import ProviderEditorDialog from './ProviderEditorDialog.vue'
|
||||
import ProviderUsageTable from './ProviderUsageTable.vue'
|
||||
import UsageOverviewCard from './UsageOverviewCard.vue'
|
||||
import {
|
||||
buildProviderRows,
|
||||
buildProviderSummary,
|
||||
createProvider,
|
||||
formatTokens,
|
||||
getNextProviderPriority,
|
||||
normalizeProvider,
|
||||
} from '../provider'
|
||||
|
||||
const props = defineProps({
|
||||
config: {
|
||||
type: Object,
|
||||
default: () => ({ enabled: false, show_sidebar_nav: true, providers: [] }),
|
||||
},
|
||||
providerRows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
summary: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
error: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
saving: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hideTitle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['refresh', 'save', 'reset-usage', 'reset-all-usage'])
|
||||
|
||||
const activeTab = ref('usage')
|
||||
const showEditor = ref(false)
|
||||
const editorIndex = ref(-1)
|
||||
const editedProvider = ref(createProvider())
|
||||
|
||||
const configValue = computed(() => props.config || { enabled: false, show_sidebar_nav: true, providers: [] })
|
||||
const providers = computed(() => (Array.isArray(configValue.value.providers) ? configValue.value.providers : []))
|
||||
const displayProviderRows = computed(() => (
|
||||
props.providerRows.length ? props.providerRows : buildProviderRows(providers.value)
|
||||
))
|
||||
const displaySummary = computed(() => (
|
||||
Object.keys(props.summary || {}).length ? props.summary : buildProviderSummary(displayProviderRows.value)
|
||||
))
|
||||
|
||||
// 打开新增供应商弹窗。
|
||||
function addProvider() {
|
||||
editedProvider.value = { ...createProvider(), priority: getNextProviderPriority(providers.value) }
|
||||
editorIndex.value = -1
|
||||
showEditor.value = true
|
||||
}
|
||||
|
||||
// 打开编辑供应商弹窗。
|
||||
function editProvider(index) {
|
||||
editedProvider.value = { ...providers.value[index] }
|
||||
editorIndex.value = index
|
||||
showEditor.value = true
|
||||
}
|
||||
|
||||
// 将弹窗中的供应商写回配置列表。
|
||||
function commitProvider() {
|
||||
const nextProviders = [...providers.value]
|
||||
const normalized = normalizeProvider(editedProvider.value, nextProviders.length + 1)
|
||||
if (editorIndex.value >= 0) {
|
||||
nextProviders.splice(editorIndex.value, 1, normalized)
|
||||
} else {
|
||||
nextProviders.push(normalized)
|
||||
}
|
||||
configValue.value.providers = nextProviders
|
||||
showEditor.value = false
|
||||
}
|
||||
|
||||
// 从配置列表中移除一个供应商。
|
||||
function removeProvider(index) {
|
||||
const nextProviders = [...providers.value]
|
||||
nextProviders.splice(index, 1)
|
||||
configValue.value.providers = nextProviders
|
||||
}
|
||||
|
||||
// 请求重置单个供应商用量。
|
||||
function resetUsage(providerId, index) {
|
||||
emit('reset-usage', providerId, index)
|
||||
}
|
||||
|
||||
// 请求重置全部供应商用量。
|
||||
function resetAllUsage() {
|
||||
emit('reset-all-usage')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="agenttokens-page">
|
||||
<div v-if="!hideTitle" class="agenttokens-header">
|
||||
<h2 class="text-2xl font-bold leading-7 text-gray-100 truncate sm:text-3xl sm:leading-9">
|
||||
<span class="text-moviepilot">Agent Tokens 管理</span>
|
||||
</h2>
|
||||
<VSpacer />
|
||||
<VBtn icon="mdi-refresh" variant="text" :loading="loading" @click="emit('refresh')" />
|
||||
<VBtn icon="mdi-content-save" variant="text" color="primary" :loading="saving" @click="emit('save')" />
|
||||
</div>
|
||||
|
||||
<VAlert v-if="error" type="error" variant="tonal" class="mb-4">{{ error }}</VAlert>
|
||||
|
||||
<VSheet border rounded class="agenttokens-control-panel">
|
||||
<div class="agenttokens-control-panel__switches">
|
||||
<VSwitch v-model="configValue.enabled" color="primary" hide-details inset label="启用插件" />
|
||||
<VSwitch v-model="configValue.show_sidebar_nav" color="primary" hide-details inset label="侧边栏入口" />
|
||||
</div>
|
||||
</VSheet>
|
||||
|
||||
<div class="agenttokens-overview-grid">
|
||||
<UsageOverviewCard class="agenttokens-overview-card" :summary="displaySummary" />
|
||||
<VSheet border rounded class="agenttokens-stat-card">
|
||||
<VIcon icon="mdi-check-decagram-outline" color="success" />
|
||||
<div>
|
||||
<div class="text-caption text-medium-emphasis">可用供应商</div>
|
||||
<div class="agenttokens-stat-card__value">
|
||||
{{ displaySummary.available_count || 0 }} / {{ displaySummary.enabled_count || 0 }}
|
||||
</div>
|
||||
</div>
|
||||
</VSheet>
|
||||
<VSheet border rounded class="agenttokens-stat-card">
|
||||
<VIcon icon="mdi-chart-timeline-variant" color="primary" />
|
||||
<div>
|
||||
<div class="text-caption text-medium-emphasis">累计使用</div>
|
||||
<div class="agenttokens-stat-card__value">{{ formatTokens(displaySummary.total_used) }}</div>
|
||||
</div>
|
||||
</VSheet>
|
||||
<VSheet border rounded class="agenttokens-stat-card">
|
||||
<VIcon icon="mdi-database-outline" color="info" />
|
||||
<div>
|
||||
<div class="text-caption text-medium-emphasis">总额度</div>
|
||||
<div class="agenttokens-stat-card__value">
|
||||
{{ displaySummary.total_limit ? formatTokens(displaySummary.total_limit) : '不限' }}
|
||||
</div>
|
||||
</div>
|
||||
</VSheet>
|
||||
</div>
|
||||
|
||||
<VSheet border rounded class="agenttokens-content-panel">
|
||||
<div class="agenttokens-tabs-row">
|
||||
<VTabs v-model="activeTab" density="comfortable">
|
||||
<VTab value="usage">用量</VTab>
|
||||
<VTab value="config">配置</VTab>
|
||||
</VTabs>
|
||||
</div>
|
||||
|
||||
<VDivider />
|
||||
|
||||
<VWindow v-model="activeTab" :touch="false" class="agenttokens-window">
|
||||
<VWindowItem value="usage">
|
||||
<ProviderUsageTable :provider-rows="displayProviderRows" @reset="resetUsage" />
|
||||
</VWindowItem>
|
||||
|
||||
<VWindowItem value="config">
|
||||
<div class="agenttokens-table-actions">
|
||||
<VBtn prepend-icon="mdi-plus" color="primary" variant="tonal" @click="addProvider">新增</VBtn>
|
||||
<VBtn prepend-icon="mdi-backup-restore" color="warning" variant="tonal" @click="resetAllUsage">
|
||||
重置用量
|
||||
</VBtn>
|
||||
</div>
|
||||
<ProviderConfigTable
|
||||
:providers="providers"
|
||||
:provider-rows="displayProviderRows"
|
||||
show-credentials
|
||||
@edit="editProvider"
|
||||
@remove="removeProvider"
|
||||
/>
|
||||
</VWindowItem>
|
||||
</VWindow>
|
||||
</VSheet>
|
||||
|
||||
<ProviderEditorDialog
|
||||
v-model="showEditor"
|
||||
:provider="editedProvider"
|
||||
:editor-index="editorIndex"
|
||||
@commit="commitProvider"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.agenttokens-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.agenttokens-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.agenttokens-control-panel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.agenttokens-control-panel__switches {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 20px;
|
||||
}
|
||||
|
||||
.agenttokens-overview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 2fr) repeat(3, minmax(10rem, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.agenttokens-overview-card {
|
||||
min-block-size: 172px;
|
||||
}
|
||||
|
||||
.agenttokens-stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-block-size: 104px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.agenttokens-stat-card__value {
|
||||
margin-block-start: 2px;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.agenttokens-content-panel {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.agenttokens-tabs-row {
|
||||
padding-inline: 8px;
|
||||
}
|
||||
|
||||
.agenttokens-window {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.agenttokens-table-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-block-end: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.agenttokens-overview-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.agenttokens-overview-card {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.agenttokens-page {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.agenttokens-table-actions > :deep(.v-btn) {
|
||||
flex: 1 1 10rem;
|
||||
}
|
||||
|
||||
.agenttokens-overview-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.agenttokens-stat-card {
|
||||
min-block-size: 88px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
116
plugins.v2/agenttokens/src/components/AppPage.vue
Normal file
116
plugins.v2/agenttokens/src/components/AppPage.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import AgentTokensManager from './AgentTokensManager.vue'
|
||||
import { unwrapResponse } from '../provider'
|
||||
|
||||
const props = defineProps({
|
||||
api: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
pluginId: {
|
||||
type: String,
|
||||
default: 'AgentTokens',
|
||||
},
|
||||
hideTitle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const status = ref({
|
||||
config: { enabled: false, show_sidebar_nav: true, providers: [] },
|
||||
providers: [],
|
||||
summary: {},
|
||||
})
|
||||
|
||||
// 构造 API 基础路径。
|
||||
const pluginBase = computed(() => `plugin/${props.pluginId || 'AgentTokens'}`)
|
||||
const config = computed(() => status.value.config || { enabled: false, show_sidebar_nav: true, providers: [] })
|
||||
const providerRows = computed(() => status.value.providers || [])
|
||||
const summary = computed(() => status.value.summary || {})
|
||||
|
||||
// 从插件 API 拉取当前配置和用量状态。
|
||||
async function loadStatus() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const response = await props.api.get(`${pluginBase.value}/status`)
|
||||
status.value = unwrapResponse(response) || status.value
|
||||
} catch (err) {
|
||||
error.value = err?.message || '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 保存完整插件配置并刷新服务端标准化后的状态。
|
||||
async function saveConfig() {
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const payload = {
|
||||
enabled: Boolean(config.value.enabled),
|
||||
show_sidebar_nav: Boolean(config.value.show_sidebar_nav),
|
||||
providers: [...(config.value.providers || [])],
|
||||
}
|
||||
const response = await props.api.post(`${pluginBase.value}/config`, payload)
|
||||
status.value = unwrapResponse(response) || status.value
|
||||
} catch (err) {
|
||||
error.value = err?.message || '保存失败'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 重置指定供应商的运行记录。
|
||||
async function resetUsage(providerId) {
|
||||
if (!providerId) return
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await props.api.post(`${pluginBase.value}/usage/reset`, { provider_id: providerId })
|
||||
status.value = unwrapResponse(response) || status.value
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 重置全部供应商的运行记录。
|
||||
async function resetAllUsage() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await props.api.post(`${pluginBase.value}/usage/reset_all`, {})
|
||||
status.value = unwrapResponse(response) || status.value
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
loadStatus,
|
||||
saveConfig,
|
||||
loading,
|
||||
saving,
|
||||
})
|
||||
|
||||
onMounted(loadStatus)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AgentTokensManager
|
||||
:config="config"
|
||||
:provider-rows="providerRows"
|
||||
:summary="summary"
|
||||
:error="error"
|
||||
:loading="loading"
|
||||
:saving="saving"
|
||||
:hide-title="hideTitle"
|
||||
@refresh="loadStatus"
|
||||
@save="saveConfig"
|
||||
@reset-usage="resetUsage"
|
||||
@reset-all-usage="resetAllUsage"
|
||||
/>
|
||||
</template>
|
||||
67
plugins.v2/agenttokens/src/components/Config.vue
Normal file
67
plugins.v2/agenttokens/src/components/Config.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import AgentTokensManager from './AgentTokensManager.vue'
|
||||
import { cloneConfig } from '../provider'
|
||||
|
||||
const props = defineProps({
|
||||
initialConfig: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['save', 'close'])
|
||||
|
||||
const localConfig = ref({ enabled: false, show_sidebar_nav: true, providers: [] })
|
||||
|
||||
// 重置本地配置中的单个供应商用量。
|
||||
function resetUsage(providerId, index) {
|
||||
const providers = localConfig.value.providers || []
|
||||
const providerIndex = providers.findIndex(provider => provider.id && provider.id === providerId)
|
||||
const targetIndex = providerIndex >= 0 ? providerIndex : index
|
||||
if (!providers[targetIndex]) return
|
||||
providers[targetIndex].used_tokens = 0
|
||||
}
|
||||
|
||||
// 重置本地配置中的全部供应商用量。
|
||||
function resetAllUsage() {
|
||||
;(localConfig.value.providers || []).forEach(provider => {
|
||||
provider.used_tokens = 0
|
||||
})
|
||||
}
|
||||
|
||||
// 通知宿主保存 Vue 配置。
|
||||
function saveConfig() {
|
||||
emit('save', cloneConfig(localConfig.value))
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
localConfig.value = cloneConfig(props.initialConfig)
|
||||
if (localConfig.value.show_sidebar_nav === undefined) {
|
||||
localConfig.value.show_sidebar_nav = true
|
||||
}
|
||||
if (!Array.isArray(localConfig.value.providers)) {
|
||||
localConfig.value.providers = []
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="agenttokens-config">
|
||||
<VToolbar density="comfortable" color="transparent">
|
||||
<div class="text-h6 ms-3">Agent Tokens 配置</div>
|
||||
<VSpacer />
|
||||
<VBtn icon="mdi-content-save" variant="text" color="primary" @click="saveConfig" />
|
||||
<VBtn icon="mdi-close" variant="text" @click="emit('close')" />
|
||||
</VToolbar>
|
||||
<VDivider />
|
||||
|
||||
<AgentTokensManager
|
||||
:config="localConfig"
|
||||
hide-title
|
||||
@save="saveConfig"
|
||||
@reset-usage="resetUsage"
|
||||
@reset-all-usage="resetAllUsage"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
530
plugins.v2/agenttokens/src/components/Dashboard.vue
Normal file
530
plugins.v2/agenttokens/src/components/Dashboard.vue
Normal file
@@ -0,0 +1,530 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { formatTokens, unwrapResponse } from '../provider'
|
||||
|
||||
const props = defineProps({
|
||||
api: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
config: {
|
||||
type: Object,
|
||||
default: () => ({ attrs: {} }),
|
||||
},
|
||||
allowRefresh: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
refreshInterval: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const initialDataLoaded = ref(false)
|
||||
const lastRefreshedAt = ref(null)
|
||||
const widgetRef = ref(null)
|
||||
const widgetSize = ref({ inline: 0, block: 0 })
|
||||
const status = ref({ providers: [], summary: {} })
|
||||
let timer = null
|
||||
let resizeObserver = null
|
||||
|
||||
const attrs = computed(() => props.config?.attrs || {})
|
||||
const summary = computed(() => status.value.summary || {})
|
||||
const providers = computed(() => status.value.providers || [])
|
||||
const totalUsed = computed(() => Number(summary.value.total_used || 0))
|
||||
const totalLimit = computed(() => Number(summary.value.total_limit || 0))
|
||||
const remainingTokens = computed(() => {
|
||||
if (totalLimit.value <= 0) return null
|
||||
return Math.max(totalLimit.value - totalUsed.value, 0)
|
||||
})
|
||||
const usagePercent = computed(() => {
|
||||
if (totalLimit.value <= 0) return 0
|
||||
return Math.min((totalUsed.value * 100) / totalLimit.value, 100)
|
||||
})
|
||||
const usagePercentText = computed(() => (totalLimit.value > 0 ? `${Math.round(usagePercent.value)}%` : '不限'))
|
||||
const progressColor = computed(() => {
|
||||
if (totalLimit.value <= 0) return 'primary'
|
||||
if (usagePercent.value >= 90) return 'error'
|
||||
if (usagePercent.value >= 70) return 'warning'
|
||||
return 'success'
|
||||
})
|
||||
const isCompact = computed(() => (
|
||||
(widgetSize.value.inline > 0 && widgetSize.value.inline < 340) ||
|
||||
(widgetSize.value.block > 0 && widgetSize.value.block < 300)
|
||||
))
|
||||
const isMini = computed(() => (
|
||||
(widgetSize.value.inline > 0 && widgetSize.value.inline < 260) ||
|
||||
(widgetSize.value.block > 0 && widgetSize.value.block < 230)
|
||||
))
|
||||
const gaugeSize = computed(() => {
|
||||
if (isMini.value) return 52
|
||||
if (isCompact.value) return 68
|
||||
return 84
|
||||
})
|
||||
const gaugeWidth = computed(() => {
|
||||
if (isMini.value) return 5
|
||||
if (isCompact.value) return 6
|
||||
return 8
|
||||
})
|
||||
const showMetrics = computed(() => !isMini.value)
|
||||
const visibleProviderLimit = computed(() => {
|
||||
if (isMini.value) return 0
|
||||
if (
|
||||
(widgetSize.value.inline > 0 && widgetSize.value.inline < 320) ||
|
||||
(widgetSize.value.block > 0 && widgetSize.value.block < 310)
|
||||
) {
|
||||
return 1
|
||||
}
|
||||
if (
|
||||
(widgetSize.value.inline > 0 && widgetSize.value.inline < 380) ||
|
||||
(widgetSize.value.block > 0 && widgetSize.value.block < 360)
|
||||
) {
|
||||
return 2
|
||||
}
|
||||
return 3
|
||||
})
|
||||
const visibleProviders = computed(() => providers.value.slice(0, visibleProviderLimit.value))
|
||||
// 兼容宿主传入的数字或字符串刷新间隔。
|
||||
const refreshSeconds = computed(() => {
|
||||
const seconds = Number(props.refreshInterval || attrs.value.refresh || 0)
|
||||
return Number.isFinite(seconds) ? seconds : 0
|
||||
})
|
||||
const cardTitle = computed(() => attrs.value.title || 'Agent Tokens 管理')
|
||||
const cardSubtitle = computed(() => attrs.value.subtitle || 'LLM 配额使用情况')
|
||||
const cardFlat = computed(() => attrs.value.border === false)
|
||||
const widgetClasses = computed(() => ({
|
||||
'agenttokens-dashboard-widget--compact': isCompact.value,
|
||||
'agenttokens-dashboard-widget--mini': isMini.value,
|
||||
}))
|
||||
const lastRefreshedTime = computed(() => {
|
||||
if (!lastRefreshedAt.value) return ''
|
||||
return new Date(lastRefreshedAt.value).toLocaleTimeString('zh-CN', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
})
|
||||
|
||||
// 读取 Agent Tokens 仪表板状态。
|
||||
async function loadStatus() {
|
||||
if (!props.api?.get) {
|
||||
error.value = 'API 未就绪'
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const response = await props.api.get('plugin/AgentTokens/status')
|
||||
status.value = unwrapResponse(response) || status.value
|
||||
initialDataLoaded.value = true
|
||||
lastRefreshedAt.value = Date.now()
|
||||
} catch (err) {
|
||||
error.value = err?.message || '获取数据失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 启动宿主传入或插件配置中的自动刷新。
|
||||
function startRefreshTimer() {
|
||||
if (refreshSeconds.value <= 0) return
|
||||
timer = window.setInterval(loadStatus, refreshSeconds.value * 1000)
|
||||
}
|
||||
|
||||
// 清理仪表板自动刷新计时器。
|
||||
function stopRefreshTimer() {
|
||||
if (!timer) return
|
||||
window.clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
|
||||
// 记录宿主 GridStack 分配给组件的实际尺寸,用于切换紧凑布局。
|
||||
function observeWidgetSize() {
|
||||
if (!widgetRef.value || typeof ResizeObserver === 'undefined') return
|
||||
resizeObserver = new ResizeObserver(entries => {
|
||||
const entry = entries[0]
|
||||
if (!entry) return
|
||||
widgetSize.value = {
|
||||
inline: entry.contentRect.width,
|
||||
block: entry.contentRect.height,
|
||||
}
|
||||
})
|
||||
resizeObserver.observe(widgetRef.value)
|
||||
}
|
||||
|
||||
// 停止监听组件尺寸,避免仪表板卸载后继续触发布局计算。
|
||||
function stopWidgetSizeObserver() {
|
||||
if (!resizeObserver) return
|
||||
resizeObserver.disconnect()
|
||||
resizeObserver = null
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
observeWidgetSize()
|
||||
loadStatus()
|
||||
startRefreshTimer()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopWidgetSizeObserver()
|
||||
stopRefreshTimer()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="widgetRef" class="agenttokens-dashboard-widget" :class="widgetClasses">
|
||||
<VCard :flat="cardFlat" :loading="loading" class="agenttokens-dashboard-card">
|
||||
<VCardItem class="agenttokens-dashboard-card__header">
|
||||
<template #prepend>
|
||||
<VAvatar color="primary" variant="tonal" size="36">
|
||||
<VIcon icon="mdi-key-chain" size="20" />
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VCardTitle class="agenttokens-dashboard-card__title">{{ cardTitle }}</VCardTitle>
|
||||
<VCardSubtitle>{{ cardSubtitle }}</VCardSubtitle>
|
||||
</VCardItem>
|
||||
|
||||
<VCardText class="agenttokens-dashboard-card__body">
|
||||
<div v-if="loading && !initialDataLoaded" class="agenttokens-dashboard-state">
|
||||
<VProgressCircular indeterminate color="primary" size="28" />
|
||||
</div>
|
||||
|
||||
<VAlert v-else-if="error" type="error" variant="tonal" density="compact" class="text-caption">
|
||||
{{ error }}
|
||||
</VAlert>
|
||||
|
||||
<div v-else-if="initialDataLoaded" class="agenttokens-dashboard-content">
|
||||
<div class="agenttokens-dashboard-summary">
|
||||
<VProgressCircular
|
||||
:model-value="usagePercent"
|
||||
:color="progressColor"
|
||||
bg-color="surface"
|
||||
:size="gaugeSize"
|
||||
:width="gaugeWidth"
|
||||
>
|
||||
<span class="agenttokens-dashboard-summary__percent">{{ usagePercentText }}</span>
|
||||
</VProgressCircular>
|
||||
|
||||
<div class="agenttokens-dashboard-summary__body">
|
||||
<div class="text-caption text-medium-emphasis">可用供应商</div>
|
||||
<div class="agenttokens-dashboard-summary__count">
|
||||
{{ summary.available_count || 0 }}
|
||||
<span>/ {{ summary.enabled_count || 0 }}</span>
|
||||
</div>
|
||||
<VProgressLinear
|
||||
:model-value="usagePercent"
|
||||
:color="progressColor"
|
||||
height="6"
|
||||
rounded
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showMetrics" class="agenttokens-dashboard-metrics">
|
||||
<div class="agenttokens-dashboard-metric">
|
||||
<span>累计</span>
|
||||
<strong>{{ formatTokens(totalUsed) }}</strong>
|
||||
</div>
|
||||
<div class="agenttokens-dashboard-metric">
|
||||
<span>额度</span>
|
||||
<strong>{{ totalLimit > 0 ? formatTokens(totalLimit) : '不限' }}</strong>
|
||||
</div>
|
||||
<div class="agenttokens-dashboard-metric">
|
||||
<span>剩余</span>
|
||||
<strong>{{ remainingTokens === null ? '不限' : formatTokens(remainingTokens) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="visibleProviders.length" class="agenttokens-dashboard-list">
|
||||
<div v-for="row in visibleProviders" :key="row.id" class="agenttokens-dashboard-provider">
|
||||
<VIcon
|
||||
:icon="row.usage?.exhausted ? 'mdi-alert-circle' : 'mdi-check-circle'"
|
||||
:color="row.usage?.exhausted ? 'error' : 'success'"
|
||||
size="16"
|
||||
/>
|
||||
<div class="agenttokens-dashboard-provider__main">
|
||||
<div class="agenttokens-dashboard-provider__name">{{ row.name || '未命名供应商' }}</div>
|
||||
<div class="agenttokens-dashboard-provider__model">{{ row.model || '未配置模型' }}</div>
|
||||
</div>
|
||||
<div class="agenttokens-dashboard-provider__tokens">
|
||||
{{ formatTokens(row.usage?.total_tokens) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!providers.length" class="agenttokens-dashboard-empty">
|
||||
<VIcon icon="mdi-database-off-outline" size="18" />
|
||||
<span>暂无供应商</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="agenttokens-dashboard-state text-caption text-disabled">
|
||||
暂无数据
|
||||
</div>
|
||||
</VCardText>
|
||||
|
||||
<VDivider v-if="allowRefresh" />
|
||||
<VCardActions v-if="allowRefresh" class="agenttokens-dashboard-card__actions">
|
||||
<span v-if="!isMini" class="text-caption text-disabled">
|
||||
{{ lastRefreshedTime ? `更新于 ${lastRefreshedTime}` : '等待更新' }}
|
||||
</span>
|
||||
<VSpacer />
|
||||
<VBtn icon variant="text" size="small" :loading="loading" @click="loadStatus">
|
||||
<VIcon icon="mdi-refresh" size="18" />
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.agenttokens-dashboard-widget {
|
||||
block-size: 100%;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
inline-size: 100%;
|
||||
|
||||
--agenttokens-divider-color: rgba(var(--v-theme-on-surface), 0.08);
|
||||
--agenttokens-muted-color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
--agenttokens-soft-surface: rgba(var(--v-theme-on-surface), 0.035);
|
||||
--agenttokens-soft-surface-hover: rgba(var(--v-theme-on-surface), 0.055);
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-card {
|
||||
block-size: 100%;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-block-size: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-card__header {
|
||||
flex: 0 0 auto;
|
||||
padding-block-end: 8px;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-card__header :deep(.v-card-item__content) {
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-card__title {
|
||||
font-size: 1rem;
|
||||
line-height: 1.35;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-card__body {
|
||||
flex: 1 1 auto;
|
||||
min-block-size: 0;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding-block-start: 8px;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-card__actions {
|
||||
flex: 0 0 auto;
|
||||
min-block-size: 40px;
|
||||
padding: 4px 12px;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-state {
|
||||
block-size: 100%;
|
||||
min-block-size: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-summary {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-summary__percent {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-summary__body {
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-summary__count {
|
||||
margin-block: 2px 8px;
|
||||
font-size: 1.7rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-summary__count span {
|
||||
color: var(--agenttokens-muted-color);
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-metric {
|
||||
min-block-size: 54px;
|
||||
border: 1px solid var(--agenttokens-divider-color);
|
||||
border-radius: 6px;
|
||||
background: var(--agenttokens-soft-surface);
|
||||
padding: 8px 10px;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-metric:hover {
|
||||
background: var(--agenttokens-soft-surface-hover);
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-metric span {
|
||||
display: block;
|
||||
color: var(--agenttokens-muted-color);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-metric strong {
|
||||
display: block;
|
||||
margin-block-start: 4px;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.2;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-provider {
|
||||
min-block-size: 34px;
|
||||
border-radius: 6px;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-inline: 2px;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-provider:hover {
|
||||
background: var(--agenttokens-soft-surface);
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-provider__main {
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-provider__name,
|
||||
.agenttokens-dashboard-provider__model {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-provider__name {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-provider__model {
|
||||
color: var(--agenttokens-muted-color);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-provider__tokens {
|
||||
color: var(--agenttokens-muted-color);
|
||||
font-size: 0.8rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-empty {
|
||||
min-block-size: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
color: var(--agenttokens-muted-color);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-widget :deep(.v-progress-circular__underlay) {
|
||||
stroke: rgba(var(--v-theme-on-surface), 0.12);
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-widget--compact .agenttokens-dashboard-card__body {
|
||||
padding-block: 6px 10px;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-widget--compact .agenttokens-dashboard-content {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-widget--compact .agenttokens-dashboard-summary {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-widget--compact .agenttokens-dashboard-summary__count {
|
||||
margin-block-end: 6px;
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-widget--compact .agenttokens-dashboard-metric {
|
||||
min-block-size: 46px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-widget--compact .agenttokens-dashboard-provider {
|
||||
min-block-size: 30px;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-widget--mini .agenttokens-dashboard-card__header {
|
||||
padding-block: 10px 4px;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-widget--mini .agenttokens-dashboard-card__header :deep(.v-card-subtitle) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-widget--mini .agenttokens-dashboard-summary {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-widget--mini .agenttokens-dashboard-summary__count {
|
||||
margin-block: 0 4px;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-widget--mini .agenttokens-dashboard-card__actions {
|
||||
justify-content: flex-end;
|
||||
min-block-size: 34px;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.agenttokens-dashboard-metrics {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
38
plugins.v2/agenttokens/src/components/Page.vue
Normal file
38
plugins.v2/agenttokens/src/components/Page.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import AppPage from './AppPage.vue'
|
||||
|
||||
defineProps({
|
||||
api: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
})
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const pageRef = ref(null)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="agenttokens-page-wrapper">
|
||||
<VToolbar density="comfortable" class="sticky-toolbar">
|
||||
<div class="text-h6 ms-3">Agent Tokens 管理</div>
|
||||
<VSpacer />
|
||||
<VBtn icon="mdi-refresh" variant="text" :loading="pageRef?.loading" @click="pageRef?.loadStatus()" />
|
||||
<VBtn icon="mdi-content-save" variant="text" color="primary" :loading="pageRef?.saving" @click="pageRef?.saveConfig()" />
|
||||
<VBtn icon="mdi-close" variant="text" @click="emit('close')" />
|
||||
</VToolbar>
|
||||
<VDivider />
|
||||
|
||||
<AppPage ref="pageRef" :api="api" plugin-id="AgentTokens" hide-title />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sticky-toolbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,89 @@
|
||||
<script setup>
|
||||
import { formatTokens } from '../provider'
|
||||
|
||||
const props = defineProps({
|
||||
providers: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
providerRows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
showCredentials: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['edit', 'remove'])
|
||||
|
||||
// 获取管理页服务端返回的脱敏 Key。
|
||||
function getMaskedApiKey(index) {
|
||||
return props.providerRows[index]?.masked_api_key || '****'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VSheet border rounded class="provider-table-shell">
|
||||
<VTable density="comfortable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>启用</th>
|
||||
<th>优先级</th>
|
||||
<th>名称</th>
|
||||
<th>类型</th>
|
||||
<th v-if="showCredentials">地址</th>
|
||||
<th v-if="showCredentials">Key</th>
|
||||
<th>代理</th>
|
||||
<th>模型</th>
|
||||
<th>额度</th>
|
||||
<th class="text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, index) in providers" :key="row.id || index">
|
||||
<td>
|
||||
<VSwitch v-model="row.enabled" color="primary" hide-details density="compact" />
|
||||
</td>
|
||||
<td>{{ row.priority }}</td>
|
||||
<td>{{ row.name }}</td>
|
||||
<td>{{ row.provider }}</td>
|
||||
<td v-if="showCredentials" class="truncate-cell">{{ row.base_url }}</td>
|
||||
<td v-if="showCredentials">{{ getMaskedApiKey(index) }}</td>
|
||||
<td>
|
||||
<VChip size="small" :color="row.use_proxy === false ? 'default' : 'primary'" variant="tonal">
|
||||
{{ row.use_proxy === false ? '直连' : '代理' }}
|
||||
</VChip>
|
||||
</td>
|
||||
<td>{{ row.model }}</td>
|
||||
<td>{{ row.token_limit > 0 ? formatTokens(row.token_limit) : '不限' }}</td>
|
||||
<td class="text-right">
|
||||
<VBtn icon="mdi-pencil" size="small" variant="text" @click="emit('edit', index)" />
|
||||
<VBtn icon="mdi-delete" size="small" variant="text" color="error" @click="emit('remove', index)" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!providers.length">
|
||||
<td :colspan="showCredentials ? 10 : 8" class="text-center text-medium-emphasis py-8">暂无供应商</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</VTable>
|
||||
</VSheet>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.provider-table-shell {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.provider-table-shell :deep(table) {
|
||||
min-width: 960px;
|
||||
}
|
||||
|
||||
.truncate-cell {
|
||||
max-width: 280px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,84 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { PROVIDER_TYPE_OPTIONS } from '../provider'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
provider: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
editorIndex: {
|
||||
type: Number,
|
||||
default: -1,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'commit'])
|
||||
|
||||
const dialogVisible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
// 提交当前弹窗编辑的供应商配置。
|
||||
function commitProvider() {
|
||||
emit('commit')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-model="dialogVisible" max-width="760" max-height="85vh" scrollable>
|
||||
<VCard>
|
||||
<VCardTitle>{{ editorIndex >= 0 ? '编辑供应商' : '新增供应商' }}</VCardTitle>
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="12" md="8">
|
||||
<VTextField v-model="provider.name" label="名称" variant="outlined" density="comfortable" />
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField v-model.number="provider.priority" label="优先级" type="number" variant="outlined" />
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSelect v-model="provider.provider" :items="PROVIDER_TYPE_OPTIONS" label="类型" variant="outlined" />
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField v-model="provider.model" label="模型" variant="outlined" />
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField v-model="provider.base_url" label="API 地址" variant="outlined" />
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField v-model="provider.api_key" label="API Key" type="password" variant="outlined" />
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField v-model="provider.user_agent" label="User-Agent" variant="outlined" />
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VSwitch
|
||||
v-model="provider.use_proxy"
|
||||
color="primary"
|
||||
label="使用代理服务器"
|
||||
hint="启用后,Agent 连接该供应商时会使用系统代理服务器"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField v-model.number="provider.token_limit" label="Token 额度" type="number" variant="outlined" />
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField v-model.number="provider.used_tokens" label="初始已用" type="number" variant="outlined" />
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn variant="text" @click="dialogVisible = false">取消</VBtn>
|
||||
<VBtn color="primary" @click="commitProvider">确定</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
89
plugins.v2/agenttokens/src/components/ProviderUsageTable.vue
Normal file
89
plugins.v2/agenttokens/src/components/ProviderUsageTable.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<script setup>
|
||||
import { formatTokens } from '../provider'
|
||||
|
||||
defineProps({
|
||||
providerRows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['reset'])
|
||||
|
||||
// 根据供应商状态返回 Vuetify 颜色。
|
||||
function rowStatusColor(row) {
|
||||
if (!row.enabled) return 'default'
|
||||
if (row.usage?.exhausted) return 'error'
|
||||
if (!row.api_key || !row.base_url || !row.model) return 'warning'
|
||||
return 'success'
|
||||
}
|
||||
|
||||
// 根据供应商状态返回短标签。
|
||||
function rowStatusText(row) {
|
||||
if (!row.enabled) return '停用'
|
||||
if (row.usage?.exhausted) return '耗尽'
|
||||
if (!row.api_key || !row.base_url || !row.model) return '缺配置'
|
||||
return '可用'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VSheet border rounded class="provider-table-shell">
|
||||
<VTable density="comfortable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>优先级</th>
|
||||
<th>名称</th>
|
||||
<th>模型</th>
|
||||
<th>已用</th>
|
||||
<th>余量</th>
|
||||
<th>进度</th>
|
||||
<th>状态</th>
|
||||
<th class="text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, index) in providerRows" :key="row.id || index">
|
||||
<td>{{ row.priority }}</td>
|
||||
<td>{{ row.name }}</td>
|
||||
<td>{{ row.model }}</td>
|
||||
<td>{{ formatTokens(row.usage?.total_tokens) }}</td>
|
||||
<td>
|
||||
{{ row.usage?.remaining_tokens === null ? '不限' : formatTokens(row.usage?.remaining_tokens) }}
|
||||
</td>
|
||||
<td class="progress-cell">
|
||||
<VProgressLinear
|
||||
:model-value="row.usage?.usage_percent || 0"
|
||||
:color="rowStatusColor(row)"
|
||||
height="8"
|
||||
rounded
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<VChip size="small" :color="rowStatusColor(row)" variant="tonal">{{ rowStatusText(row) }}</VChip>
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<VBtn icon="mdi-backup-restore" size="small" variant="text" @click="emit('reset', row.id, index)" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!providerRows.length">
|
||||
<td colspan="8" class="text-center text-medium-emphasis py-8">暂无供应商</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</VTable>
|
||||
</VSheet>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.provider-table-shell {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.provider-table-shell :deep(table) {
|
||||
min-width: 760px;
|
||||
}
|
||||
|
||||
.progress-cell {
|
||||
min-width: 140px;
|
||||
}
|
||||
</style>
|
||||
121
plugins.v2/agenttokens/src/components/UsageOverviewCard.vue
Normal file
121
plugins.v2/agenttokens/src/components/UsageOverviewCard.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { formatTokens } from '../provider'
|
||||
|
||||
const props = defineProps({
|
||||
summary: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
})
|
||||
|
||||
const totalUsed = computed(() => Number(props.summary.total_used || 0))
|
||||
const totalLimit = computed(() => Number(props.summary.total_limit || 0))
|
||||
const usagePercent = computed(() => {
|
||||
if (totalLimit.value <= 0) return 0
|
||||
return Math.min((totalUsed.value * 100) / totalLimit.value, 100)
|
||||
})
|
||||
const usagePercentText = computed(() => `${Math.round(usagePercent.value)}%`)
|
||||
const remainingTokens = computed(() => {
|
||||
if (totalLimit.value <= 0) return null
|
||||
return Math.max(totalLimit.value - totalUsed.value, 0)
|
||||
})
|
||||
const progressColor = computed(() => {
|
||||
if (totalLimit.value <= 0) return 'primary'
|
||||
if (usagePercent.value >= 90) return 'error'
|
||||
if (usagePercent.value >= 70) return 'warning'
|
||||
return 'success'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VSheet border rounded class="usage-overview-card">
|
||||
<div class="usage-overview-card__content">
|
||||
<div class="usage-overview-card__chart">
|
||||
<VProgressCircular
|
||||
:model-value="usagePercent"
|
||||
:color="progressColor"
|
||||
bg-color="surface-variant"
|
||||
:size="132"
|
||||
:width="12"
|
||||
>
|
||||
<div class="usage-overview-card__percent">{{ totalLimit > 0 ? usagePercentText : '不限' }}</div>
|
||||
</VProgressCircular>
|
||||
</div>
|
||||
|
||||
<div class="usage-overview-card__body">
|
||||
<div class="text-caption text-medium-emphasis">总使用进度</div>
|
||||
<div class="usage-overview-card__headline">
|
||||
{{ formatTokens(totalUsed) }}
|
||||
<span class="text-medium-emphasis">/ {{ totalLimit > 0 ? formatTokens(totalLimit) : '不限' }}</span>
|
||||
</div>
|
||||
<VProgressLinear
|
||||
:model-value="usagePercent"
|
||||
:color="progressColor"
|
||||
height="8"
|
||||
rounded
|
||||
class="my-4"
|
||||
/>
|
||||
<div class="usage-overview-card__meta">
|
||||
<span>剩余 {{ remainingTokens === null ? '不限' : formatTokens(remainingTokens) }}</span>
|
||||
<span>可用 {{ summary.available_count || 0 }} / {{ summary.enabled_count || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</VSheet>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.usage-overview-card {
|
||||
block-size: 100%;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.usage-overview-card__content {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.usage-overview-card__chart {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.usage-overview-card__percent {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.usage-overview-card__headline {
|
||||
margin-block-start: 4px;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.usage-overview-card__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.usage-overview-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.usage-overview-card__content {
|
||||
grid-template-columns: 1fr;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.usage-overview-card__meta {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
4
plugins.v2/agenttokens/src/main.js
Normal file
4
plugins.v2/agenttokens/src/main.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import { createApp } from 'vue'
|
||||
import AppPage from './components/AppPage.vue'
|
||||
|
||||
createApp(AppPage).mount('#app')
|
||||
102
plugins.v2/agenttokens/src/provider.js
Normal file
102
plugins.v2/agenttokens/src/provider.js
Normal file
@@ -0,0 +1,102 @@
|
||||
export const PROVIDER_TYPE_OPTIONS = [
|
||||
{ title: 'OpenAI Compatible', value: 'openai' },
|
||||
{ title: 'DeepSeek', value: 'deepseek' },
|
||||
{ title: 'Google Gemini', value: 'google' },
|
||||
{ title: 'Anthropic Compatible', value: 'anthropic' },
|
||||
{ title: 'ChatGPT', value: 'chatgpt' },
|
||||
]
|
||||
|
||||
// 构建一个新的供应商默认配置。
|
||||
export function createProvider() {
|
||||
return {
|
||||
id: '',
|
||||
enabled: true,
|
||||
name: '',
|
||||
provider: 'openai',
|
||||
base_url: '',
|
||||
api_key: '',
|
||||
user_agent: '',
|
||||
use_proxy: true,
|
||||
model: '',
|
||||
token_limit: 0,
|
||||
used_tokens: 0,
|
||||
priority: 1,
|
||||
}
|
||||
}
|
||||
|
||||
// 生成深拷贝配置,避免直接修改父组件传入对象。
|
||||
export function cloneConfig(config) {
|
||||
return JSON.parse(JSON.stringify(config || { enabled: false, show_sidebar_nav: true, providers: [] }))
|
||||
}
|
||||
|
||||
// 格式化 token 数字,保持表格和统计展示可读。
|
||||
export function formatTokens(value) {
|
||||
const numberValue = Number(value || 0)
|
||||
return Number.isFinite(numberValue) ? numberValue.toLocaleString() : '0'
|
||||
}
|
||||
|
||||
// 兼容 MoviePilot API 包装器和原始响应两种返回形态。
|
||||
export function unwrapResponse(response) {
|
||||
if (response && Object.prototype.hasOwnProperty.call(response, 'data') && response.success !== undefined) {
|
||||
return response.data
|
||||
}
|
||||
return response?.data ?? response
|
||||
}
|
||||
|
||||
// 计算新增供应商的下一个优先级。
|
||||
export function getNextProviderPriority(providers) {
|
||||
return Math.max(0, ...(providers || []).map(item => Number(item.priority || 0))) + 1
|
||||
}
|
||||
|
||||
// 标准化弹窗中写回的供应商数值字段。
|
||||
export function normalizeProvider(provider, fallbackPriority) {
|
||||
return {
|
||||
...provider,
|
||||
use_proxy: provider.use_proxy !== false,
|
||||
token_limit: Number(provider.token_limit || 0),
|
||||
used_tokens: Number(provider.used_tokens || 0),
|
||||
priority: Number(provider.priority || fallbackPriority),
|
||||
}
|
||||
}
|
||||
|
||||
// 按配置生成本地用量行,供配置弹窗复用管理页展示结构。
|
||||
export function buildProviderRow(provider) {
|
||||
const tokenLimit = Number(provider.token_limit || 0)
|
||||
const totalTokens = Number(provider.used_tokens || 0)
|
||||
const remainingTokens = tokenLimit <= 0 ? null : Math.max(tokenLimit - totalTokens, 0)
|
||||
const usagePercent = tokenLimit <= 0 ? 0 : Math.min((totalTokens * 100) / tokenLimit, 100)
|
||||
|
||||
return {
|
||||
...provider,
|
||||
masked_api_key: provider.api_key ? '****' : '',
|
||||
usage: {
|
||||
total_tokens: totalTokens,
|
||||
remaining_tokens: remainingTokens,
|
||||
usage_percent: usagePercent,
|
||||
exhausted: tokenLimit > 0 && remainingTokens === 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 批量生成本地供应商用量行。
|
||||
export function buildProviderRows(providers) {
|
||||
return (providers || []).map(provider => buildProviderRow(provider))
|
||||
}
|
||||
|
||||
// 根据供应商行汇总用量统计。
|
||||
export function buildProviderSummary(rows) {
|
||||
const providers = rows || []
|
||||
const enabledRows = providers.filter(row => row.enabled)
|
||||
const totalUsed = providers.reduce((sum, row) => sum + Number(row.usage?.total_tokens || row.used_tokens || 0), 0)
|
||||
const totalLimit = providers.reduce((sum, row) => {
|
||||
const tokenLimit = Number(row.token_limit || 0)
|
||||
return tokenLimit > 0 ? sum + tokenLimit : sum
|
||||
}, 0)
|
||||
|
||||
return {
|
||||
available_count: enabledRows.filter(row => !row.usage?.exhausted && row.api_key && row.base_url && row.model).length,
|
||||
enabled_count: enabledRows.length,
|
||||
total_used: totalUsed,
|
||||
total_limit: totalLimit,
|
||||
}
|
||||
}
|
||||
57
plugins.v2/agenttokens/vite.config.js
Normal file
57
plugins.v2/agenttokens/vite.config.js
Normal file
@@ -0,0 +1,57 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import federation from '@originjs/vite-plugin-federation'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
federation({
|
||||
name: 'AgentTokens',
|
||||
filename: 'remoteEntry.js',
|
||||
exposes: {
|
||||
'./Page': './src/components/Page.vue',
|
||||
'./Config': './src/components/Config.vue',
|
||||
'./Dashboard': './src/components/Dashboard.vue',
|
||||
'./AppPage': './src/components/AppPage.vue',
|
||||
},
|
||||
shared: {
|
||||
vue: {
|
||||
requiredVersion: false,
|
||||
generate: false,
|
||||
},
|
||||
},
|
||||
format: 'esm',
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
target: 'esnext',
|
||||
minify: false,
|
||||
cssCodeSplit: true,
|
||||
},
|
||||
css: {
|
||||
postcss: {
|
||||
plugins: [
|
||||
{
|
||||
postcssPlugin: 'internal:charset-removal',
|
||||
AtRule: {
|
||||
charset: atRule => {
|
||||
if (atRule.name === 'charset') {
|
||||
atRule.remove()
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
postcssPlugin: 'vuetify-filter',
|
||||
Root(root) {
|
||||
root.walkRules(rule => {
|
||||
if (rule.selector && (rule.selector.includes('.v-') || rule.selector.includes('.mdi-'))) {
|
||||
rule.remove()
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -50,6 +50,36 @@
|
||||
- `request_timeout`
|
||||
- `max_retries`
|
||||
- `save_failed_samples`
|
||||
- `save_title_only_samples`
|
||||
- `max_failed_samples`
|
||||
- `auto_remove_applied_sample`
|
||||
- `clear_failed_samples_once`
|
||||
|
||||
## 新增数据面
|
||||
|
||||
### 可处理失败样本
|
||||
|
||||
用于承载低置信度、可继续分析和出队的样本数据,支持:
|
||||
|
||||
- 摘要列表
|
||||
- 洞察汇总
|
||||
- 重放复查
|
||||
- 批量复查
|
||||
- 批量建议
|
||||
- 批量写入
|
||||
- 清空与按索引出队
|
||||
|
||||
### LLM 错误诊断记录
|
||||
|
||||
用于承载超时、网络错误、模型不可用等 LLM 调用失败信息,和可处理失败样本分开存储,避免噪音污染主样本池。
|
||||
|
||||
### 样本来源标注
|
||||
|
||||
失败样本与诊断记录都会保留轻量 provenance 标记,便于区分:
|
||||
|
||||
- 路径样本
|
||||
- 仅标题样本
|
||||
- 来自哪个 source plugin
|
||||
|
||||
## 二期规划
|
||||
|
||||
@@ -78,6 +108,8 @@
|
||||
- 已支持失败样本复查:按当前识别词和当前识别器重跑,并可自动把已修复样本出队
|
||||
- 已支持失败样本批量复查:可批量重跑并按结果批量出队
|
||||
- 已支持失败样本批量建议与批量写入:可批量生成建议并批量落库
|
||||
- 已支持 LLM 错误诊断记录独立存储,避免污染可处理样本池
|
||||
- 已支持样本来源标注,便于区分路径样本与仅标题样本
|
||||
- 已支持低 token 精简摘要输出,适合作为智能体批处理入口
|
||||
- 已支持识别词建议模型退化时自动切换到精确规则兜底,优先保证稳定落地
|
||||
- 下一步重点会放在提示词打磨、失败样本回放和识别词建议质量提升
|
||||
|
||||
@@ -45,18 +45,22 @@ MoviePilot 原版智能体已经提供“整理失败后自动接管再试一次
|
||||
- 用当前 LLM 结构化判断标题、年份、类型、季集
|
||||
- 回写 `name / year / season / episode`
|
||||
- 交回 MoviePilot 原生链路继续二次识别
|
||||
- 保存低置信度失败样本
|
||||
- 保存低置信度失败样本(可处理)
|
||||
- 保存 LLM 调用错误诊断记录(独立存储,不污染可处理样本池)
|
||||
- 失败样本和 LLM 诊断记录附带来源标注(`sample_source_kind` / `sample_source_plugin`)
|
||||
- 可配置是否保存仅标题样本(无真实文件路径),默认关闭以减少噪音
|
||||
- 提供失败样本工作清单、洞察、重放、删除和清空能力
|
||||
- 生成并应用 `CustomIdentifiers` 建议
|
||||
- 设置页提供“保存时清空失败样本(一次性)”开关,可在保存配置时顺手重置失败样本池
|
||||
|
||||
## 主要接口
|
||||
|
||||
- `GET /api/v1/plugin/AIRecognizerEnhancer/health`
|
||||
- 查看插件状态、LLM 提供方、模型、阈值和超时配置
|
||||
- `POST /api/v1/plugin/AIRecognizerEnhancer/recognize`
|
||||
- 对单个标题做一次本地结构化识别测试
|
||||
### 可处理失败样本接口
|
||||
|
||||
这些接口只返回因置信度不足或名称为空而落盘的识别失败记录,可用于生成识别词建议、复查和出队。
|
||||
|
||||
- `GET /api/v1/plugin/AIRecognizerEnhancer/failed_samples`
|
||||
- 查看最近保存的失败样本
|
||||
- 查看最近保存的可处理失败样本
|
||||
- `GET /api/v1/plugin/AIRecognizerEnhancer/sample_worklist`
|
||||
- 返回适合继续处理的失败样本摘要列表
|
||||
- `GET /api/v1/plugin/AIRecognizerEnhancer/sample_insights`
|
||||
@@ -68,12 +72,23 @@ MoviePilot 原版智能体已经提供“整理失败后自动接管再试一次
|
||||
- `POST /api/v1/plugin/AIRecognizerEnhancer/apply_suggested_identifier`
|
||||
- 把建议规则写入系统 `CustomIdentifiers`
|
||||
|
||||
### LLM 诊断错误接口
|
||||
|
||||
这些接口返回因 LLM 调用异常(如超时、网络错误、模型不可用)而产生的诊断记录。它们不参与识别词生成流程,仅供排查 LLM 问题使用。
|
||||
|
||||
- `GET /api/v1/plugin/AIRecognizerEnhancer/llm_errors`
|
||||
- 查看 LLM 调用失败的诊断记录
|
||||
- `POST /api/v1/plugin/AIRecognizerEnhancer/clear_llm_errors`
|
||||
- 清空 LLM 错误诊断记录
|
||||
|
||||
其余批量接口和清理接口可以按需要继续使用,详细路径以插件 `get_api()` 暴露结果为准。
|
||||
|
||||
## 配置建议
|
||||
|
||||
- 先确认 MoviePilot 本身已经配置好可用的 LLM
|
||||
- 建议保持“保存失败样本”开启
|
||||
- 建议保持”保存失败样本”开启
|
||||
- 默认情况下”保存仅标题样本”是关闭的,这可以减少没有真实文件路径的低价值噪音;如果你的使用场景以纯标题匹配为主,可以在设置中手动开启
|
||||
- 如果失败样本池已经积累了大量历史噪音,可在设置页勾选“一次性清空”后保存
|
||||
- 如果你经常处理历史资源或网盘资源,建议定期查看:
|
||||
- `failed_samples`
|
||||
- `sample_worklist`
|
||||
@@ -81,9 +96,9 @@ MoviePilot 原版智能体已经提供“整理失败后自动接管再试一次
|
||||
|
||||
## 已验证情况
|
||||
|
||||
当前版本:`0.1.12`
|
||||
当前版本:`0.1.13`
|
||||
|
||||
当前 Release:https://github.com/liuyuexi1987/MoviePilot-Plugins/releases/tag/v0.2.68
|
||||
当前 Release:https://github.com/liuyuexi1987/MoviePilot-Plugins/releases/tag/v0.2.73
|
||||
|
||||
这版已经验证过:
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ class AIRecognizerEnhancer(_PluginBase):
|
||||
plugin_name = "AI识别增强"
|
||||
plugin_desc = "直接复用 MoviePilot 当前 LLM 配置,在原生识别失败后做本地结构化识别兜底,并交回原生链路继续二次识别。"
|
||||
plugin_icon = "https://raw.githubusercontent.com/liuyuexi1987/MoviePilot-Plugins/main/icons/airecognizerenhancer.png"
|
||||
plugin_version = "0.1.12"
|
||||
plugin_version = "0.1.13"
|
||||
plugin_author = "liuyuexi1987"
|
||||
plugin_level = 1
|
||||
author_url = "https://github.com/liuyuexi1987"
|
||||
@@ -67,8 +67,10 @@ class AIRecognizerEnhancer(_PluginBase):
|
||||
_request_timeout = 25
|
||||
_max_retries = 2
|
||||
_save_failed_samples = True
|
||||
_save_title_only_samples = False
|
||||
_max_failed_samples = 200
|
||||
_auto_remove_applied_sample = True
|
||||
_clear_failed_samples_once = False
|
||||
_systemconfig: Optional[SystemConfigOper] = None
|
||||
|
||||
def init_plugin(self, config: Optional[Dict[str, Any]] = None):
|
||||
@@ -79,10 +81,17 @@ class AIRecognizerEnhancer(_PluginBase):
|
||||
self._request_timeout = self._safe_int(config.get("request_timeout"), 25)
|
||||
self._max_retries = max(1, min(5, self._safe_int(config.get("max_retries"), 2)))
|
||||
self._save_failed_samples = bool(config.get("save_failed_samples", True))
|
||||
self._save_title_only_samples = bool(config.get("save_title_only_samples", False))
|
||||
self._max_failed_samples = max(20, min(1000, self._safe_int(config.get("max_failed_samples"), 200)))
|
||||
self._auto_remove_applied_sample = bool(config.get("auto_remove_applied_sample", True))
|
||||
self._clear_failed_samples_once = bool(config.get("clear_failed_samples_once", False))
|
||||
self._systemconfig = SystemConfigOper()
|
||||
self._register_events()
|
||||
if self._clear_failed_samples_once:
|
||||
cleared = self._clear_failed_samples()
|
||||
self._clear_failed_samples_once = False
|
||||
self.update_config(self._build_config({"clear_failed_samples_once": False}))
|
||||
logger.info(f"[AI识别增强] 已按配置清空失败样本 {cleared} 条")
|
||||
|
||||
def get_state(self) -> bool:
|
||||
return self._enabled
|
||||
@@ -117,11 +126,28 @@ class AIRecognizerEnhancer(_PluginBase):
|
||||
if header.lower().startswith("bearer "):
|
||||
return header.split(" ", 1)[1].strip()
|
||||
if body:
|
||||
for key in ("apikey", "api_key"):
|
||||
for key in ("apikey", "api_key", "token"):
|
||||
token = str(body.get(key) or "").strip()
|
||||
if token:
|
||||
return token
|
||||
return str(request.query_params.get("apikey") or "").strip()
|
||||
return str(request.query_params.get("apikey") or request.query_params.get("token") or "").strip()
|
||||
|
||||
def _build_config(self, overrides: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
config = {
|
||||
"enabled": self._enabled,
|
||||
"debug": self._debug,
|
||||
"confidence_threshold": self._confidence_threshold,
|
||||
"request_timeout": self._request_timeout,
|
||||
"max_retries": self._max_retries,
|
||||
"save_failed_samples": self._save_failed_samples,
|
||||
"save_title_only_samples": self._save_title_only_samples,
|
||||
"max_failed_samples": self._max_failed_samples,
|
||||
"auto_remove_applied_sample": self._auto_remove_applied_sample,
|
||||
"clear_failed_samples_once": self._clear_failed_samples_once,
|
||||
}
|
||||
if overrides:
|
||||
config.update(overrides)
|
||||
return config
|
||||
|
||||
def _check_api_access(self, request: Request, body: Optional[Dict[str, Any]] = None) -> Tuple[bool, str]:
|
||||
expected = str(getattr(settings, "API_TOKEN", "") or "").strip()
|
||||
@@ -174,6 +200,30 @@ class AIRecognizerEnhancer(_PluginBase):
|
||||
)
|
||||
return str(title or "").strip(), str(path or "").strip()
|
||||
|
||||
@staticmethod
|
||||
def _extract_provenance(event_data: Any) -> Dict[str, str]:
|
||||
"""Extract lightweight provenance metadata from event data for sample recording."""
|
||||
source_plugin = ""
|
||||
if isinstance(event_data, dict):
|
||||
source_plugin = str(event_data.get("source_plugin") or "").strip()
|
||||
else:
|
||||
source_plugin = str(getattr(event_data, "source_plugin", "") or "").strip()
|
||||
|
||||
title = ""
|
||||
path = ""
|
||||
if isinstance(event_data, dict):
|
||||
title = str(event_data.get("title") or event_data.get("name") or event_data.get("org_string") or "").strip()
|
||||
path = str(event_data.get("path") or event_data.get("file_path") or event_data.get("org_string") or "").strip()
|
||||
else:
|
||||
title = str(getattr(event_data, "title", "") or getattr(event_data, "name", "") or getattr(event_data, "org_string", "") or "").strip()
|
||||
path = str(getattr(event_data, "path", "") or getattr(event_data, "file_path", "") or getattr(event_data, "org_string", "") or "").strip()
|
||||
|
||||
is_path_backed = bool(path) and path != title and ("/" in path or "\\" in path)
|
||||
return {
|
||||
"sample_source_kind": "path_backed" if is_path_backed else "title_only",
|
||||
"sample_source_plugin": source_plugin,
|
||||
}
|
||||
|
||||
def _build_meta_hint(self, raw_text: str) -> Dict[str, Any]:
|
||||
try:
|
||||
meta = MetaInfo(raw_text)
|
||||
@@ -221,6 +271,12 @@ class AIRecognizerEnhancer(_PluginBase):
|
||||
def _sample_path(self) -> Path:
|
||||
return self.get_data_path() / "failed_samples.jsonl"
|
||||
|
||||
def _llm_errors_path(self) -> Path:
|
||||
return self.get_data_path() / "llm_errors.jsonl"
|
||||
|
||||
def _failed_sample_cap(self) -> int:
|
||||
return max(20, min(1000, self._safe_int(self._max_failed_samples, 200)))
|
||||
|
||||
@staticmethod
|
||||
def _sample_identity(payload: Dict[str, Any]) -> str:
|
||||
return json.dumps(
|
||||
@@ -236,7 +292,8 @@ class AIRecognizerEnhancer(_PluginBase):
|
||||
def _write_failed_samples(self, rows: List[Dict[str, Any]]) -> None:
|
||||
sample_path = self._sample_path()
|
||||
sample_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
trimmed = rows[-self._max_failed_samples:]
|
||||
filtered = [row for row in rows if not str(row.get("reason") or "").startswith("llm_error:")]
|
||||
trimmed = filtered[-self._failed_sample_cap():]
|
||||
with sample_path.open("w", encoding="utf-8") as f:
|
||||
for row in trimmed:
|
||||
f.write(json.dumps(row, ensure_ascii=False) + "\n")
|
||||
@@ -254,6 +311,69 @@ class AIRecognizerEnhancer(_PluginBase):
|
||||
except Exception as exc:
|
||||
logger.warning(f"[AI识别增强] 写入失败样本失败: {exc}")
|
||||
|
||||
def _record_llm_error(self, title: str, path: str, meta_hint: Dict[str, Any], error: Any, provenance: Optional[Dict[str, str]] = None) -> None:
|
||||
try:
|
||||
error_path = self._llm_errors_path()
|
||||
error_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
provenance = provenance or {}
|
||||
entry = {
|
||||
"title": title,
|
||||
"path": path,
|
||||
"meta_hint": meta_hint,
|
||||
"reason": f"llm_error:{error}",
|
||||
"timestamp": __import__("datetime").datetime.now().isoformat(),
|
||||
"sample_source_kind": provenance.get("sample_source_kind", "unknown"),
|
||||
"sample_source_plugin": provenance.get("sample_source_plugin", ""),
|
||||
}
|
||||
existing = self._read_llm_errors(limit=1000)
|
||||
existing.reverse()
|
||||
new_identity = {"title": title, "path": path, "reason": entry["reason"]}
|
||||
existing = [
|
||||
row for row in existing
|
||||
if {
|
||||
"title": row.get("title"),
|
||||
"path": row.get("path"),
|
||||
"reason": row.get("reason"),
|
||||
} != new_identity
|
||||
]
|
||||
existing.append(entry)
|
||||
trimmed = existing[-self._failed_sample_cap():]
|
||||
with error_path.open("w", encoding="utf-8") as f:
|
||||
for row in trimmed:
|
||||
f.write(json.dumps(row, ensure_ascii=False) + "\n")
|
||||
except Exception as exc:
|
||||
logger.warning(f"[AI识别增强] 写入 LLM 错误诊断记录失败: {exc}")
|
||||
|
||||
def _read_llm_errors(self, limit: int = 20) -> List[Dict[str, Any]]:
|
||||
error_path = self._llm_errors_path()
|
||||
if not error_path.exists():
|
||||
return []
|
||||
rows: List[Dict[str, Any]] = []
|
||||
try:
|
||||
with error_path.open("r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
rows.append(json.loads(line))
|
||||
except Exception:
|
||||
continue
|
||||
except Exception as exc:
|
||||
logger.warning(f"[AI识别增强] 读取 LLM 错误诊断记录失败: {exc}")
|
||||
return []
|
||||
if limit > 0:
|
||||
rows = rows[-limit:]
|
||||
rows.reverse()
|
||||
return rows
|
||||
|
||||
def _clear_llm_errors(self) -> int:
|
||||
rows = self._read_llm_errors(limit=10000)
|
||||
error_path = self._llm_errors_path()
|
||||
if error_path.exists():
|
||||
error_path.unlink()
|
||||
return len(rows)
|
||||
|
||||
def _read_failed_samples(self, limit: int = 20) -> List[Dict[str, Any]]:
|
||||
sample_path = self._sample_path()
|
||||
if not sample_path.exists():
|
||||
@@ -353,7 +473,7 @@ class AIRecognizerEnhancer(_PluginBase):
|
||||
sample_index: Optional[Any] = None,
|
||||
limit: int = 100,
|
||||
) -> Tuple[Optional[int], Optional[Dict[str, Any]], str]:
|
||||
samples = self._read_failed_samples(limit=max(1, min(limit, 200)))
|
||||
samples = self._read_failed_samples(limit=max(1, min(limit, self._failed_sample_cap())))
|
||||
if not samples:
|
||||
return None, None, "暂无失败样本"
|
||||
index = self._safe_int(sample_index, 0)
|
||||
@@ -369,9 +489,13 @@ class AIRecognizerEnhancer(_PluginBase):
|
||||
self,
|
||||
sample_indexes: Optional[List[Any]] = None,
|
||||
limit: int = 10,
|
||||
pool_limit: int = 200,
|
||||
pool_limit: int = 0,
|
||||
) -> Tuple[List[int], List[Dict[str, Any]], str]:
|
||||
current_samples = self._inject_sample_indices(self._read_failed_samples(limit=max(1, min(pool_limit, 1000))))
|
||||
if pool_limit <= 0:
|
||||
pool_limit = self._failed_sample_cap()
|
||||
current_samples = self._inject_sample_indices(
|
||||
self._read_failed_samples(limit=max(1, min(pool_limit, self._failed_sample_cap())))
|
||||
)
|
||||
if not current_samples:
|
||||
return [], [], "暂无失败样本"
|
||||
if isinstance(sample_indexes, list) and sample_indexes:
|
||||
@@ -414,6 +538,8 @@ class AIRecognizerEnhancer(_PluginBase):
|
||||
"title": sample.get("title"),
|
||||
"path": sample.get("path"),
|
||||
"reason": sample.get("reason"),
|
||||
"sample_source_kind": sample.get("sample_source_kind", ""),
|
||||
"sample_source_plugin": sample.get("sample_source_plugin", ""),
|
||||
"guess_name": guess.get("name"),
|
||||
"guess_confidence": self._safe_float(guess.get("confidence"), 0.0),
|
||||
"verified_title": verified.get("title"),
|
||||
@@ -551,7 +677,10 @@ class AIRecognizerEnhancer(_PluginBase):
|
||||
label = self._sample_display_name(summary)
|
||||
confidence = round(self._safe_float(summary.get("guess_confidence"), 0.0), 2)
|
||||
can_suggest = "可建议" if summary.get("can_auto_suggest") else "需人工"
|
||||
lines.append(f"{summary.get('sample_index')}. {label} | 置信度 {confidence} | {can_suggest}")
|
||||
source_tag = "有路径" if summary.get("sample_source_kind") == "path_backed" else "仅标题"
|
||||
source_plugin = summary.get("sample_source_plugin") or ""
|
||||
source_info = f" | {source_tag}" + (f" ({source_plugin})" if source_plugin else "")
|
||||
lines.append(f"{summary.get('sample_index')}. {label} | 置信度 {confidence} | {can_suggest}{source_info}")
|
||||
lines.append("下一步:可直接调用批量建议或批量复查接口。")
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -937,7 +1066,7 @@ AI 识别增强结果:
|
||||
selected_indexes, _, message = self._select_failed_sample_indexes(
|
||||
sample_indexes=body.get("sample_indexes"),
|
||||
limit=limit,
|
||||
pool_limit=200,
|
||||
pool_limit=self._failed_sample_cap(),
|
||||
)
|
||||
if not selected_indexes:
|
||||
return {"success": False, "message": message}
|
||||
@@ -1006,7 +1135,7 @@ AI 识别增强结果:
|
||||
selected_indexes, _, message = self._select_failed_sample_indexes(
|
||||
sample_indexes=body.get("sample_indexes"),
|
||||
limit=limit,
|
||||
pool_limit=200,
|
||||
pool_limit=self._failed_sample_cap(),
|
||||
)
|
||||
if not selected_indexes:
|
||||
return {"success": False, "message": message}
|
||||
@@ -1102,7 +1231,7 @@ AI 识别增强结果:
|
||||
selected_indexes, _, message = self._select_failed_sample_indexes(
|
||||
sample_indexes=body.get("sample_indexes"),
|
||||
limit=limit,
|
||||
pool_limit=200,
|
||||
pool_limit=self._failed_sample_cap(),
|
||||
)
|
||||
if not selected_indexes:
|
||||
return {"success": False, "message": message}
|
||||
@@ -1356,40 +1485,49 @@ AI 识别增强结果:
|
||||
logger.warning(f"[AI识别增强] 二次校验失败: {exc}")
|
||||
return None
|
||||
|
||||
def _recognize(self, title: str, path: str = "", record_failed_sample: bool = True) -> Dict[str, Any]:
|
||||
def _recognize(
|
||||
self, title: str, path: str = "", record_failed_sample: bool = True,
|
||||
provenance: Optional[Dict[str, str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
title = str(title or "").strip()
|
||||
path = str(path or "").strip()
|
||||
if not title and path:
|
||||
title = Path(path).name
|
||||
if not title:
|
||||
return {"success": False, "message": "标题为空"}
|
||||
provenance = provenance or {}
|
||||
sample_source_kind = provenance.get("sample_source_kind")
|
||||
is_title_only = sample_source_kind == "title_only" if sample_source_kind else not path
|
||||
try:
|
||||
guess = self._invoke_llm(title, path)
|
||||
except Exception as exc:
|
||||
if record_failed_sample:
|
||||
self._record_failed_sample(
|
||||
{
|
||||
"title": title,
|
||||
"path": path,
|
||||
"meta_hint": self._build_meta_hint(path or title),
|
||||
"reason": f"llm_error:{exc}",
|
||||
}
|
||||
)
|
||||
if is_title_only and not self._save_title_only_samples:
|
||||
if self._debug:
|
||||
logger.info(f"[AI识别增强] 跳过保存仅标题 LLM 错误: {title} (save_title_only_samples=False)")
|
||||
else:
|
||||
self._record_llm_error(title, path, self._build_meta_hint(path or title), exc, provenance=provenance)
|
||||
return {"success": False, "message": f"LLM 调用失败: {exc}"}
|
||||
|
||||
verified = self._verify_guess(title, path, guess)
|
||||
passed = bool(guess.name and guess.confidence >= self._confidence_threshold)
|
||||
if not passed and record_failed_sample:
|
||||
self._record_failed_sample(
|
||||
{
|
||||
"title": title,
|
||||
"path": path,
|
||||
"meta_hint": self._build_meta_hint(path or title),
|
||||
"guess": guess.model_dump(),
|
||||
"verified_media_info": self._compact_verified_summary(verified),
|
||||
"reason": "low_confidence_or_empty_name",
|
||||
}
|
||||
)
|
||||
if is_title_only and not self._save_title_only_samples:
|
||||
if self._debug:
|
||||
logger.info(f"[AI识别增强] 跳过保存仅标题样本: {title} (save_title_only_samples=False)")
|
||||
else:
|
||||
self._record_failed_sample(
|
||||
{
|
||||
"title": title,
|
||||
"path": path,
|
||||
"meta_hint": self._build_meta_hint(path or title),
|
||||
"guess": guess.model_dump(),
|
||||
"verified_media_info": self._compact_verified_summary(verified),
|
||||
"reason": "low_confidence_or_empty_name",
|
||||
"sample_source_kind": provenance.get("sample_source_kind", "unknown"),
|
||||
"sample_source_plugin": provenance.get("sample_source_plugin", ""),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"success": passed,
|
||||
"message": "success" if passed else "识别结果置信度不足,已放弃注入",
|
||||
@@ -1404,7 +1542,8 @@ AI 识别增强结果:
|
||||
title, path = self._extract_title_path(event_data)
|
||||
if not title and not path:
|
||||
return
|
||||
result = self._recognize(title=title, path=path)
|
||||
provenance = self._extract_provenance(event_data)
|
||||
result = self._recognize(title=title, path=path, provenance=provenance)
|
||||
if not result.get("success"):
|
||||
if self._debug:
|
||||
logger.info(f"[AI识别增强] 跳过注入: {title or path} - {result.get('message')}")
|
||||
@@ -1496,7 +1635,7 @@ AI 识别增强结果:
|
||||
if not ok:
|
||||
return {"success": False, "message": message}
|
||||
limit = self._safe_int(request.query_params.get("limit"), 50)
|
||||
limit = max(1, min(limit, 200))
|
||||
limit = max(1, min(limit, self._failed_sample_cap()))
|
||||
top = self._safe_int(request.query_params.get("top"), 10)
|
||||
top = max(1, min(top, 20))
|
||||
samples = self._inject_sample_indices(self._read_failed_samples(limit=limit))
|
||||
@@ -1512,7 +1651,7 @@ AI 识别增强结果:
|
||||
return {"success": False, "message": message}
|
||||
limit = self._safe_int(request.query_params.get("limit"), 5)
|
||||
limit = max(1, min(limit, 20))
|
||||
samples = self._inject_sample_indices(self._read_failed_samples(limit=100))
|
||||
samples = self._inject_sample_indices(self._read_failed_samples(limit=self._failed_sample_cap()))
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
@@ -1558,6 +1697,34 @@ AI 识别增强结果:
|
||||
},
|
||||
}
|
||||
|
||||
async def api_llm_errors(self, request: Request):
|
||||
ok, message = self._check_api_access(request)
|
||||
if not ok:
|
||||
return {"success": False, "message": message}
|
||||
limit = self._safe_int(request.query_params.get("limit"), 20)
|
||||
limit = max(1, min(limit, 100))
|
||||
errors = self._read_llm_errors(limit=limit)
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"count": len(errors),
|
||||
"errors": errors,
|
||||
},
|
||||
}
|
||||
|
||||
async def api_clear_llm_errors(self, request: Request):
|
||||
ok, message = self._check_api_access(request)
|
||||
if not ok:
|
||||
return {"success": False, "message": message}
|
||||
cleared = self._clear_llm_errors()
|
||||
return {
|
||||
"success": True,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"cleared_count": cleared,
|
||||
},
|
||||
}
|
||||
|
||||
async def api_remove_failed_sample(self, request: Request):
|
||||
body = await request.json()
|
||||
ok, message = self._check_api_access(request, body)
|
||||
@@ -1697,6 +1864,18 @@ AI 识别增强结果:
|
||||
"methods": ["POST"],
|
||||
"summary": "清空失败样本文件",
|
||||
},
|
||||
{
|
||||
"path": "/llm_errors",
|
||||
"endpoint": self.api_llm_errors,
|
||||
"methods": ["GET"],
|
||||
"summary": "查看 LLM 调用失败的诊断记录",
|
||||
},
|
||||
{
|
||||
"path": "/clear_llm_errors",
|
||||
"endpoint": self.api_clear_llm_errors,
|
||||
"methods": ["POST"],
|
||||
"summary": "清空 LLM 错误诊断记录",
|
||||
},
|
||||
{
|
||||
"path": "/remove_failed_sample",
|
||||
"endpoint": self.api_remove_failed_sample,
|
||||
@@ -1731,7 +1910,8 @@ AI 识别增强结果:
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
llm_ready = bool(getattr(settings, "LLM_API_KEY", None))
|
||||
failed_samples_count = len(self._read_failed_samples(limit=200))
|
||||
failed_samples_count = len(self._read_failed_samples(limit=self._failed_sample_cap()))
|
||||
llm_errors_count = len(self._read_llm_errors(limit=self._max_failed_samples))
|
||||
custom_identifiers_count = len(self._get_custom_identifiers())
|
||||
llm_provider = getattr(settings, "LLM_PROVIDER", "—")
|
||||
llm_model = getattr(settings, "LLM_MODEL", "—")
|
||||
@@ -1784,22 +1964,27 @@ AI 识别增强结果:
|
||||
"content": [
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 3},
|
||||
"props": {"cols": 12, "sm": 6, "md": 2},
|
||||
"content": [stat_card("当前状态", "已启用" if self._enabled else "未启用")],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 3},
|
||||
"props": {"cols": 12, "sm": 6, "md": 2},
|
||||
"content": [stat_card("LLM 可用", "是" if llm_ready else "否", f"{llm_provider} / {llm_model}")],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 3},
|
||||
"content": [stat_card("失败样本", f"{failed_samples_count} 条", f"上限 {self._max_failed_samples} 条")],
|
||||
"props": {"cols": 12, "sm": 6, "md": 3},
|
||||
"content": [stat_card("可处理失败样本", f"{failed_samples_count} 条", f"上限 {self._max_failed_samples} 条")],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 3},
|
||||
"props": {"cols": 12, "sm": 6, "md": 2},
|
||||
"content": [stat_card("LLM 错误", f"{llm_errors_count} 条", "诊断记录")],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "sm": 6, "md": 3},
|
||||
"content": [stat_card("自定义识别词", f"{custom_identifiers_count} 条", "系统 CustomIdentifiers")],
|
||||
},
|
||||
],
|
||||
@@ -1810,34 +1995,7 @@ AI 识别增强结果:
|
||||
"content": [
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 6},
|
||||
"content": [
|
||||
{
|
||||
"component": "VCard",
|
||||
"props": {"variant": "outlined", "class": "pa-4 h-100"},
|
||||
"content": [
|
||||
{
|
||||
"component": "div",
|
||||
"props": {"class": "text-subtitle-1 font-weight-bold mb-2"},
|
||||
"text": "识别兜底",
|
||||
},
|
||||
{
|
||||
"component": "div",
|
||||
"props": {"class": "text-body-2 text-medium-emphasis"},
|
||||
"text": "在 Chain NameRecognize 阶段回写 name / year / season / episode,供 MoviePilot 继续原生二次识别。",
|
||||
},
|
||||
{
|
||||
"component": "div",
|
||||
"props": {"class": "text-caption text-medium-emphasis mt-3"},
|
||||
"text": f"置信度阈值:{self._confidence_threshold};请求超时:{self._request_timeout} 秒",
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 6},
|
||||
"props": {"cols": 12, "md": 12},
|
||||
"content": [
|
||||
{
|
||||
"component": "VCard",
|
||||
@@ -1873,6 +2031,7 @@ AI 识别增强结果:
|
||||
return "vuetify", None
|
||||
|
||||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
failed_samples_count = len(self._read_failed_samples(limit=self._failed_sample_cap()))
|
||||
form = [
|
||||
{
|
||||
"component": "VForm",
|
||||
@@ -1896,6 +2055,25 @@ AI 识别增强结果:
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12},
|
||||
"content": [
|
||||
{
|
||||
"component": "VAlert",
|
||||
"props": {
|
||||
"type": "warning",
|
||||
"variant": "tonal",
|
||||
"text": f"当前累计 {failed_samples_count} 条失败样本。如需重置噪音数据,请勾选下方“一次性清空”开关后点击保存。该操作只清空失败样本,不会删除已写入的 CustomIdentifiers。",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [
|
||||
@@ -1929,6 +2107,19 @@ AI 识别增强结果:
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 4},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSwitch",
|
||||
"props": {
|
||||
"model": "save_title_only_samples",
|
||||
"label": "保存仅标题样本",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -2010,6 +2201,24 @@ AI 识别增强结果:
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSwitch",
|
||||
"props": {
|
||||
"model": "clear_failed_samples_once",
|
||||
"label": "保存时清空失败样本(一次性)",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [
|
||||
@@ -2038,6 +2247,8 @@ AI 识别增强结果:
|
||||
"request_timeout": 25,
|
||||
"max_retries": 2,
|
||||
"save_failed_samples": True,
|
||||
"save_title_only_samples": False,
|
||||
"max_failed_samples": 200,
|
||||
"auto_remove_applied_sample": True,
|
||||
"clear_failed_samples_once": False,
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -114,11 +114,44 @@ class SentrySanitizer:
|
||||
|
||||
return False
|
||||
|
||||
TRACEBACK_PATTERN = re.compile(
|
||||
r"Traceback \(most recent call last\):|"
|
||||
r"File \"[^\"]+\", line \d+|"
|
||||
r"^\s+raise \w+|"
|
||||
r"^\w+Error:|^\w+Exception:",
|
||||
re.MULTILINE
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def has_stacktrace(cls, event) -> bool:
|
||||
"""
|
||||
判断事件是否包含明确的异常堆栈信息(结构化异常或日志文本中的堆栈)
|
||||
"""
|
||||
if "exception" in event:
|
||||
for exc in event["exception"].get("values", []):
|
||||
stacktrace = exc.get("stacktrace")
|
||||
if stacktrace and stacktrace.get("frames"):
|
||||
return True
|
||||
|
||||
if "message" in event and cls.TRACEBACK_PATTERN.search(event["message"]):
|
||||
return True
|
||||
|
||||
if "logentry" in event:
|
||||
msg = event["logentry"].get("message", "") or event["logentry"].get("formatted", "")
|
||||
if cls.TRACEBACK_PATTERN.search(msg):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def before_send(cls, event, hint):
|
||||
"""
|
||||
在发送到 Sentry 之前脱敏和过滤
|
||||
"""
|
||||
# 只上报包含明确异常堆栈的事件,普通 error 日志不上报
|
||||
if not cls.has_stacktrace(event):
|
||||
return None
|
||||
|
||||
# 如果是网络连接错误,直接返回 None 不上报
|
||||
if cls.is_network_error(event):
|
||||
return None
|
||||
@@ -171,7 +204,7 @@ class BugReporter(_PluginBase):
|
||||
# 插件图标
|
||||
plugin_icon = "Alist_encrypt_A.png"
|
||||
# 插件版本
|
||||
plugin_version = "1.3"
|
||||
plugin_version = "1.5.1"
|
||||
# 插件作者
|
||||
plugin_author = "jxxghp"
|
||||
# 作者主页
|
||||
@@ -188,7 +221,7 @@ class BugReporter(_PluginBase):
|
||||
def init_plugin(self, config: dict = None):
|
||||
self._enable = config.get("enable")
|
||||
if self._enable:
|
||||
sentry_sdk.init("https://88da01ad33b4423cb0380620de53efa8@glitchtip.movie-pilot.org/1",
|
||||
sentry_sdk.init("https://3999f6a035db46a588b03e5a92b9f592@glitchtip.movie-pilot.org/2",
|
||||
before_send=SentrySanitizer.before_send,
|
||||
release=APP_VERSION,
|
||||
send_default_pii=False)
|
||||
@@ -240,7 +273,7 @@ class BugReporter(_PluginBase):
|
||||
'props': {
|
||||
'type': 'warning',
|
||||
'variant': 'tonal',
|
||||
'text': '注意:开启插件即代表你同意将部分异常信息自动发送给开发者,以帮助改进软件;如果你不希望自动发送任何数据,请关闭或卸载此插件;仅上报系统异常信息,不会包含任何个人隐私信息或敏感数据;网络连接错误类异常不会上报;异常信息采集为使用开源项目解决方案:GlitchTip。',
|
||||
'text': '注意:开启插件即代表你同意将部分异常信息自动发送给开发者,以帮助改进软件;如果你不希望自动发送任何数据,请关闭或卸载此插件;仅上报包含异常堆栈的系统错误,普通日志和网络连接错误不会上报;不会包含任何个人隐私信息或敏感数据;异常信息采集为使用开源项目解决方案:GlitchTip。',
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1 +1 @@
|
||||
sentry_sdk~=2.44.0
|
||||
sentry_sdk
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,235 +1,216 @@
|
||||
import asyncio
|
||||
import inspect
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from typing import List, Union
|
||||
import threading
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import openai
|
||||
from cacheout import Cache
|
||||
|
||||
OpenAISessionCache = Cache(maxsize=100, ttl=3600, timer=time.time, default=None)
|
||||
from app.agent.llm import LLMHelper
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
|
||||
|
||||
class OpenAi:
|
||||
_api_key: str = None
|
||||
_api_url: str = None
|
||||
_model: str = "gpt-3.5-turbo"
|
||||
_prompt: str = '接下来我会给你一个电影或电视剧的文件名,你需要识别文件名中的名称、版本、分段、年份、分瓣率、季集等信息,并按以下JSON格式返回:{"name":string,"version":string,"part":string,"year":string,"resolution":string,"season":number|null,"episode":number|null},特别注意返回结果需要严格附合JSON格式,不需要有任何其它的字符。如果中文电影或电视剧的文件名中存在谐音字或字母替代的情况,请还原最有可能的结果。'
|
||||
_client: openai.OpenAI = None
|
||||
"""
|
||||
Lightweight LLM recognition client kept under the original module name for plugin compatibility.
|
||||
"""
|
||||
|
||||
def __init__(self, api_key: str = None, api_url: str = None,
|
||||
proxy: dict = None, model: str = None,
|
||||
compatible: bool = False, customize_prompt: str = None):
|
||||
_JSON_FENCE_PATTERN = re.compile(r"^```(?:json)?\s*([\s\S]*?)\s*```$", re.IGNORECASE)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str = None,
|
||||
api_url: str = None,
|
||||
provider: str = None,
|
||||
model: str = None,
|
||||
base_url_preset: str = None,
|
||||
user_agent: str = None,
|
||||
use_proxy: bool = None,
|
||||
thinking_level: str = None,
|
||||
customize_prompt: str = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
初始化用于媒体识别的 LLM 客户端运行参数。
|
||||
"""
|
||||
self._api_key = api_key
|
||||
self._api_url = api_url
|
||||
if model:
|
||||
self._model = model
|
||||
if customize_prompt:
|
||||
self._prompt = customize_prompt
|
||||
|
||||
# 初始化 OpenAI 客户端
|
||||
if self._api_key and self._api_url:
|
||||
base_url = self._api_url if compatible else self._api_url + "/v1"
|
||||
http_client = None
|
||||
if proxy and proxy.get("https"):
|
||||
import httpx
|
||||
proxy_url = proxy.get("https")
|
||||
# httpx 支持字符串格式的代理 URL
|
||||
http_client = httpx.Client(proxies=proxy_url, timeout=60.0)
|
||||
self._client = openai.OpenAI(
|
||||
api_key=self._api_key,
|
||||
base_url=base_url,
|
||||
http_client=http_client
|
||||
)
|
||||
self._provider = provider or "openai"
|
||||
self._model = model
|
||||
self._base_url_preset = base_url_preset
|
||||
self._user_agent = user_agent
|
||||
self._use_proxy = use_proxy
|
||||
self._thinking_level = thinking_level
|
||||
self._prompt = customize_prompt or ""
|
||||
self._last_usage: Dict[str, int] = {}
|
||||
|
||||
def get_state(self) -> bool:
|
||||
return True if self._api_key else False
|
||||
"""
|
||||
返回当前客户端是否具备发起识别调用的必要模型配置。
|
||||
"""
|
||||
return bool(self._api_key and self._model)
|
||||
|
||||
def get_last_usage(self) -> Dict[str, int]:
|
||||
"""
|
||||
返回最近一次模型调用提取到的 token 用量。
|
||||
"""
|
||||
return dict(self._last_usage or {})
|
||||
|
||||
@staticmethod
|
||||
def __save_session(session_id: str, message: str):
|
||||
def _run_async_compatible(value: Any) -> Any:
|
||||
"""
|
||||
保存会话
|
||||
:param session_id: 会话ID
|
||||
:param message: 消息
|
||||
:return:
|
||||
在同步插件回调中兼容执行新版 MoviePilot 的异步 LLM 初始化。
|
||||
"""
|
||||
seasion = OpenAISessionCache.get(session_id)
|
||||
if seasion:
|
||||
seasion.append({
|
||||
"role": "assistant",
|
||||
"content": message
|
||||
})
|
||||
OpenAISessionCache.set(session_id, seasion)
|
||||
if not inspect.isawaitable(value):
|
||||
return value
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return asyncio.run(value)
|
||||
|
||||
result: Dict[str, Any] = {}
|
||||
error: Dict[str, BaseException] = {}
|
||||
|
||||
def _worker() -> None:
|
||||
"""
|
||||
在独立线程中运行协程,避免嵌套事件循环。
|
||||
"""
|
||||
try:
|
||||
result["value"] = asyncio.run(value)
|
||||
except BaseException as exc: # noqa: BLE001
|
||||
error["exc"] = exc
|
||||
|
||||
thread = threading.Thread(target=_worker, daemon=True)
|
||||
thread.start()
|
||||
thread.join()
|
||||
if "exc" in error:
|
||||
raise error["exc"]
|
||||
return result.get("value")
|
||||
|
||||
@staticmethod
|
||||
def __get_session(session_id: str, message: str) -> List[dict]:
|
||||
def _lookup_int(data: Any, key: str) -> Optional[int]:
|
||||
"""
|
||||
获取会话
|
||||
:param session_id: 会话ID
|
||||
:return: 会话上下文
|
||||
从字典或对象字段中安全读取整数 token 统计。
|
||||
"""
|
||||
seasion = OpenAISessionCache.get(session_id)
|
||||
if seasion:
|
||||
seasion.append({
|
||||
"role": "user",
|
||||
"content": message
|
||||
})
|
||||
else:
|
||||
seasion = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "请在接下来的对话中请使用中文回复,并且内容尽可能详细。"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": message
|
||||
}]
|
||||
OpenAISessionCache.set(session_id, seasion)
|
||||
return seasion
|
||||
if not data:
|
||||
return None
|
||||
value = data.get(key) if isinstance(data, dict) else getattr(data, key, None)
|
||||
try:
|
||||
return int(value) if value is not None else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
def __get_model(self, message: Union[str, List[dict]],
|
||||
prompt: str = None,
|
||||
user: str = "MoviePilot",
|
||||
**kwargs):
|
||||
@classmethod
|
||||
def _extract_usage(cls, response: Any) -> Dict[str, int]:
|
||||
"""
|
||||
获取模型
|
||||
从 LangChain AIMessage 中提取 token 用量。
|
||||
"""
|
||||
if not self._client:
|
||||
raise ValueError("OpenAI client not initialized. Please check API key and API URL.")
|
||||
if not isinstance(message, list):
|
||||
if prompt:
|
||||
message = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": prompt
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": message
|
||||
}
|
||||
]
|
||||
else:
|
||||
message = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": message
|
||||
}
|
||||
]
|
||||
# 新版本 API 不支持 user 参数,需要从 kwargs 中移除
|
||||
kwargs.pop('user', None)
|
||||
return self._client.chat.completions.create(
|
||||
model=self._model,
|
||||
messages=message,
|
||||
**kwargs
|
||||
usage_metadata = getattr(response, "usage_metadata", None)
|
||||
response_metadata = getattr(response, "response_metadata", None) or {}
|
||||
token_usage = (
|
||||
response_metadata.get("token_usage")
|
||||
or response_metadata.get("usage")
|
||||
or response_metadata.get("usage_metadata")
|
||||
or {}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def __clear_session(session_id: str):
|
||||
"""
|
||||
清除会话
|
||||
:param session_id: 会话ID
|
||||
:return:
|
||||
"""
|
||||
if OpenAISessionCache.get(session_id):
|
||||
OpenAISessionCache.delete(session_id)
|
||||
input_tokens = (
|
||||
cls._lookup_int(usage_metadata, "input_tokens")
|
||||
or cls._lookup_int(token_usage, "input_tokens")
|
||||
or cls._lookup_int(token_usage, "prompt_tokens")
|
||||
or 0
|
||||
)
|
||||
output_tokens = (
|
||||
cls._lookup_int(usage_metadata, "output_tokens")
|
||||
or cls._lookup_int(token_usage, "output_tokens")
|
||||
or cls._lookup_int(token_usage, "completion_tokens")
|
||||
or 0
|
||||
)
|
||||
total_tokens = (
|
||||
cls._lookup_int(usage_metadata, "total_tokens")
|
||||
or cls._lookup_int(token_usage, "total_tokens")
|
||||
or input_tokens + output_tokens
|
||||
)
|
||||
return {
|
||||
"input_tokens": max(input_tokens, 0),
|
||||
"output_tokens": max(output_tokens, 0),
|
||||
"total_tokens": max(total_tokens, 0),
|
||||
}
|
||||
|
||||
def get_media_name(self, filename: str):
|
||||
def _get_llm(self) -> Any:
|
||||
"""
|
||||
从文件名中提取媒体名称等要素
|
||||
:param filename: 文件名
|
||||
:return: Json
|
||||
按当前运行参数创建 MoviePilot LLM 实例。
|
||||
"""
|
||||
llm = LLMHelper.get_llm(
|
||||
streaming=False,
|
||||
provider=self._provider,
|
||||
model=self._model,
|
||||
thinking_level=self._thinking_level,
|
||||
api_key=self._api_key,
|
||||
base_url=self._api_url,
|
||||
base_url_preset=self._base_url_preset,
|
||||
user_agent=self._user_agent,
|
||||
use_proxy=self._use_proxy,
|
||||
)
|
||||
return self._run_async_compatible(llm)
|
||||
|
||||
@staticmethod
|
||||
def _extract_response_text(response: Any) -> str:
|
||||
"""
|
||||
从模型响应对象中提取文本内容。
|
||||
"""
|
||||
content = getattr(response, "content", response)
|
||||
return LLMHelper._extract_text_content(content).strip()
|
||||
|
||||
@classmethod
|
||||
def _strip_json_fence(cls, text: str) -> str:
|
||||
"""
|
||||
移除模型可能附加的 Markdown JSON 代码块包裹。
|
||||
"""
|
||||
text = str(text or "").strip()
|
||||
match = cls._JSON_FENCE_PATTERN.match(text)
|
||||
return match.group(1).strip() if match else text
|
||||
|
||||
@classmethod
|
||||
def _extract_json_text(cls, text: str) -> str:
|
||||
"""
|
||||
从模型回复中提取第一个 JSON 对象文本。
|
||||
"""
|
||||
text = cls._strip_json_fence(text)
|
||||
if text.startswith("{") and text.endswith("}"):
|
||||
return text
|
||||
|
||||
start = text.find("{")
|
||||
end = text.rfind("}")
|
||||
if start >= 0 and end > start:
|
||||
return text[start:end + 1]
|
||||
return text
|
||||
|
||||
def get_media_name(self, filename: str) -> Dict[str, Any]:
|
||||
"""
|
||||
从媒体文件名中提取结构化识别信息。
|
||||
"""
|
||||
self._last_usage = {}
|
||||
if not self.get_state():
|
||||
return None
|
||||
return {"errorMsg": "LLM API Key or model is not configured"}
|
||||
|
||||
result = ""
|
||||
try:
|
||||
_filename_prompt = self._prompt
|
||||
completion = self.__get_model(prompt=_filename_prompt, message=filename)
|
||||
result = completion.choices[0].message.content
|
||||
# 有些模型返回json数据时会使用 ```json ``` 包裹json对象 所以需要进行提取
|
||||
# 定义正则表达式模式,匹配```json开头和```结尾的内容
|
||||
pattern = r'^```json\s*([\s\S]*?)\s*```$'
|
||||
# 使用正则表达式进行匹配
|
||||
match = re.match(pattern, result.strip())
|
||||
if match:
|
||||
# 提取中间的JSON部分
|
||||
result = match.group(1)
|
||||
return json.loads(result)
|
||||
except Exception as e:
|
||||
llm = self._get_llm()
|
||||
completion = llm.invoke(
|
||||
[
|
||||
SystemMessage(content=self._prompt),
|
||||
HumanMessage(content=str(filename or "")),
|
||||
]
|
||||
)
|
||||
self._last_usage = self._extract_usage(completion)
|
||||
result = self._extract_response_text(completion)
|
||||
json_text = self._extract_json_text(result)
|
||||
data = json.loads(json_text)
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("LLM response is not a JSON object")
|
||||
return data
|
||||
except Exception as exc:
|
||||
return {
|
||||
"content": result,
|
||||
"errorMsg": str(e)
|
||||
"errorMsg": str(exc),
|
||||
}
|
||||
|
||||
def get_response(self, text: str, userid: str):
|
||||
"""
|
||||
聊天对话,获取答案
|
||||
:param text: 输入文本
|
||||
:param userid: 用户ID
|
||||
:return:
|
||||
"""
|
||||
if not self.get_state():
|
||||
return ""
|
||||
try:
|
||||
if not userid:
|
||||
return "用户信息错误"
|
||||
else:
|
||||
userid = str(userid)
|
||||
if text == "#清除":
|
||||
self.__clear_session(userid)
|
||||
return "会话已清除"
|
||||
# 获取历史上下文
|
||||
messages = self.__get_session(userid, text)
|
||||
completion = self.__get_model(message=messages, user=userid)
|
||||
result = completion.choices[0].message.content
|
||||
if result:
|
||||
self.__save_session(userid, text)
|
||||
return result
|
||||
except openai.RateLimitError as e:
|
||||
return f"请求被ChatGPT拒绝了,{str(e)}"
|
||||
except openai.APIConnectionError as e:
|
||||
return f"ChatGPT网络连接失败:{str(e)}"
|
||||
except openai.APITimeoutError as e:
|
||||
return f"没有接收到ChatGPT的返回消息:{str(e)}"
|
||||
except Exception as e:
|
||||
return f"请求ChatGPT出现错误:{str(e)}"
|
||||
|
||||
def translate_to_zh(self, text: str):
|
||||
"""
|
||||
翻译为中文
|
||||
:param text: 输入文本
|
||||
"""
|
||||
if not self.get_state():
|
||||
return False, None
|
||||
system_prompt = "You are a translation engine that can only translate text and cannot interpret it."
|
||||
user_prompt = f"translate to zh-CN:\n\n{text}"
|
||||
result = ""
|
||||
try:
|
||||
completion = self.__get_model(prompt=system_prompt,
|
||||
message=user_prompt,
|
||||
temperature=0,
|
||||
top_p=1,
|
||||
frequency_penalty=0,
|
||||
presence_penalty=0)
|
||||
result = completion.choices[0].message.content.strip()
|
||||
return True, result
|
||||
except Exception as e:
|
||||
print(f"{str(e)}:{result}")
|
||||
return False, str(e)
|
||||
|
||||
def get_question_answer(self, question: str):
|
||||
"""
|
||||
从给定问题和选项中获取正确答案
|
||||
:param question: 问题及选项
|
||||
:return: Json
|
||||
"""
|
||||
if not self.get_state():
|
||||
return None
|
||||
result = ""
|
||||
try:
|
||||
_question_prompt = "下面我们来玩一个游戏,你是老师,我是学生,你需要回答我的问题,我会给你一个题目和几个选项,你的回复必须是给定选项中正确答案对应的序号,请直接回复数字"
|
||||
completion = self.__get_model(prompt=_question_prompt, message=question)
|
||||
result = completion.choices[0].message.content
|
||||
return result
|
||||
except Exception as e:
|
||||
print(f"{str(e)}:{result}")
|
||||
return {}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
cacheout~=0.16.0
|
||||
@@ -36,7 +36,7 @@ class ClashRuleProvider(_PluginBase):
|
||||
# 插件图标
|
||||
plugin_icon = "Mihomo_Meta_A.png"
|
||||
# 插件版本
|
||||
plugin_version = "2.1.6"
|
||||
plugin_version = "2.1.7"
|
||||
# 插件作者
|
||||
plugin_author = "wumode"
|
||||
# 作者主页
|
||||
|
||||
@@ -7,7 +7,6 @@ from app.log import logger
|
||||
from .proxy import Proxy
|
||||
from .proxygroups import ProxyGroup
|
||||
from .proxyproviders import ProxyProvider
|
||||
from .proxy.tlsmixin import ClientFingerprint
|
||||
from .ruleproviders import RuleProvider
|
||||
from .rule import RuleType, Action, RoutingRuleType
|
||||
from ..helper.clashruleparser import ClashRuleParser
|
||||
@@ -70,8 +69,7 @@ class ClashConfig(BaseModel):
|
||||
interface_name: str | None = Field(default=None, alias="interface-name")
|
||||
routing_mark: int | None = Field(default=None, alias="routing-mark")
|
||||
tls: dict[str, Any] | None = Field(default=None, alias="tls")
|
||||
global_client_fingerprint: ClientFingerprint | None = Field(default=ClientFingerprint.chrome,
|
||||
alias="global-client-fingerprint")
|
||||
|
||||
geodata_mode: bool | None = Field(default=None, alias="geodata-mode")
|
||||
geodata_loader: Literal["memconservative", "standard"] = Field(default="memconservative", alias="geodata-loader")
|
||||
geo_auto_update: bool = Field(default=False, alias="geo-auto-update")
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
websockets
|
||||
sse_starlette>=3.0.0
|
||||
sse_starlette>=3.0.0,<3.0.4
|
||||
PyYAML~=6.0.2
|
||||
jsonpatch~=1.33
|
||||
simpleeval~=1.0.3
|
||||
simpleeval~=1.0.3
|
||||
|
||||
@@ -179,7 +179,7 @@ class CrossSeed(_PluginBase):
|
||||
# 插件图标
|
||||
plugin_icon = "qingwa.png"
|
||||
# 插件版本
|
||||
plugin_version = "3.0.2"
|
||||
plugin_version = "3.0.3"
|
||||
# 插件作者
|
||||
plugin_author = "233@qingwa"
|
||||
# 作者主页
|
||||
@@ -218,6 +218,8 @@ class CrossSeed(_PluginBase):
|
||||
_success_caches = []
|
||||
# 辅种缓存,出错的种子不再重复辅种,且无法清除。种子被删除404等情况
|
||||
_permanent_error_caches = []
|
||||
# 辅种缓存最大保存条数,避免长期运行时配置缓存无限增长
|
||||
_seed_cache_max_items = 10000
|
||||
_torrentpaths = []
|
||||
_site_cs_infos = []
|
||||
# 辅种计数
|
||||
@@ -229,6 +231,11 @@ class CrossSeed(_PluginBase):
|
||||
cached = 0
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
self._error_caches = []
|
||||
self._success_caches = []
|
||||
self._permanent_error_caches = []
|
||||
self._torrentpaths = []
|
||||
self._site_cs_infos = []
|
||||
|
||||
# 读取配置
|
||||
if config:
|
||||
@@ -245,9 +252,14 @@ class CrossSeed(_PluginBase):
|
||||
self._nolabels = config.get("nolabels")
|
||||
self._nopaths = config.get("nopaths")
|
||||
self._clearcache = config.get("clearcache")
|
||||
self._permanent_error_caches = [] if self._clearcache else config.get("permanent_error_caches") or []
|
||||
self._error_caches = [] if self._clearcache else config.get("error_caches") or []
|
||||
self._success_caches = [] if self._clearcache else config.get("success_caches") or []
|
||||
self._permanent_error_caches = (
|
||||
[] if self._clearcache else list(config.get("permanent_error_caches") or [])
|
||||
)
|
||||
self._error_caches = [] if self._clearcache else list(config.get("error_caches") or [])
|
||||
self._success_caches = [] if self._clearcache else list(config.get("success_caches") or [])
|
||||
self.__trim_seed_cache(self._permanent_error_caches)
|
||||
self.__trim_seed_cache(self._error_caches)
|
||||
self.__trim_seed_cache(self._success_caches)
|
||||
|
||||
# 过滤掉已删除的站点
|
||||
inner_site_list = SiteOper().list_order_by_pri()
|
||||
@@ -317,6 +329,8 @@ class CrossSeed(_PluginBase):
|
||||
|
||||
# 停止现有任务
|
||||
self.stop_service()
|
||||
# 重新初始化运行期校验队列,避免类级字典跨插件重载残留。
|
||||
self._recheck_torrents = {}
|
||||
|
||||
# 启动定时任务 & 立即运行一次
|
||||
if self.get_state() or self._onlyonce:
|
||||
@@ -756,6 +770,32 @@ class CrossSeed(_PluginBase):
|
||||
"permanent_error_caches": self._permanent_error_caches
|
||||
})
|
||||
|
||||
def __trim_seed_cache(self, cache: list):
|
||||
"""
|
||||
去重并限制辅种缓存大小,避免长期任务把配置缓存无限撑大。
|
||||
"""
|
||||
if not cache:
|
||||
return
|
||||
unique_cache = []
|
||||
seen = set()
|
||||
for item in reversed(cache):
|
||||
if not item or item in seen:
|
||||
continue
|
||||
seen.add(item)
|
||||
unique_cache.append(item)
|
||||
unique_cache.reverse()
|
||||
cache[:] = unique_cache[-self._seed_cache_max_items:]
|
||||
|
||||
def __append_seed_cache(self, cache: list, value: str):
|
||||
"""
|
||||
写入辅种缓存并保持上限,重复值只保留一份。
|
||||
"""
|
||||
if not value:
|
||||
return
|
||||
if value not in cache:
|
||||
cache.append(value)
|
||||
self.__trim_seed_cache(cache)
|
||||
|
||||
def auto_seed(self):
|
||||
"""
|
||||
开始辅种
|
||||
@@ -1106,10 +1146,10 @@ class CrossSeed(_PluginBase):
|
||||
self.cached += 1
|
||||
# 加入失败缓存
|
||||
if error_msg and ('无法打开链接' in error_msg or '触发站点流控' in error_msg):
|
||||
self._error_caches.append(tor.get_name_id_tag())
|
||||
self.__append_seed_cache(self._error_caches, tor.get_name_id_tag())
|
||||
else:
|
||||
# 种子不存在的情况
|
||||
self._permanent_error_caches.append(tor.get_name_id_tag())
|
||||
self.__append_seed_cache(self._permanent_error_caches, tor.get_name_id_tag())
|
||||
logger.error(f"下载种子文件失败:{tor.get_name_id_tag()}")
|
||||
return False
|
||||
|
||||
@@ -1120,7 +1160,7 @@ class CrossSeed(_PluginBase):
|
||||
tors, msg = downloader_obj.get_torrents(ids=[tmp_tor_info.info_hash])
|
||||
if tors:
|
||||
self.exist += 1
|
||||
self._success_caches.append(tor.get_name_id_tag())
|
||||
self.__append_seed_cache(self._success_caches, tor.get_name_id_tag())
|
||||
logger.info(f"下载的种子{tor.get_name_id_tag()}已存在, 跳过")
|
||||
return True
|
||||
else:
|
||||
@@ -1136,7 +1176,7 @@ class CrossSeed(_PluginBase):
|
||||
self.fail += 1
|
||||
self.cached += 1
|
||||
# 加入失败缓存
|
||||
self._error_caches.append(tor.get_name_id_tag())
|
||||
self.__append_seed_cache(self._error_caches, tor.get_name_id_tag())
|
||||
return False
|
||||
else:
|
||||
self.success += 1
|
||||
@@ -1149,7 +1189,7 @@ class CrossSeed(_PluginBase):
|
||||
# 下载成功
|
||||
logger.info(f"成功添加辅种下载,站点种子:{tor.get_name_id_tag()}")
|
||||
# 成功也加入缓存,有一些改了路径校验不通过的,手动删除后,下一次又会辅上
|
||||
self._success_caches.append(tor.get_name_id_tag())
|
||||
self.__append_seed_cache(self._success_caches, tor.get_name_id_tag())
|
||||
return True
|
||||
|
||||
def __add_recheck_torrents(self, service: ServiceInfo, download_id: str):
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
fast-bencode~=1.1.7
|
||||
@@ -20,7 +20,7 @@ from app.log import logger
|
||||
from app.plugins import _PluginBase
|
||||
from app.schemas.types import EventType
|
||||
|
||||
from .helper import PyCookieCloud, MySender, IpLocationParser
|
||||
from .helper import PyCookieCloud, MySender, IpLocationParser, JsonFieldManager
|
||||
|
||||
|
||||
class DynamicWeChat(_PluginBase):
|
||||
@@ -31,7 +31,7 @@ class DynamicWeChat(_PluginBase):
|
||||
# 插件图标
|
||||
plugin_icon = "Wecom_A.png"
|
||||
# 插件版本
|
||||
plugin_version = "2.0.0"
|
||||
plugin_version = "2.1.2"
|
||||
# 插件作者
|
||||
plugin_author = "RamenRa"
|
||||
# 作者主页
|
||||
@@ -73,13 +73,13 @@ class DynamicWeChat(_PluginBase):
|
||||
_notification_token = ''
|
||||
# 标记企业微信通知可用
|
||||
_wechat_available = True
|
||||
# 标记IP变动后 是否发送通知
|
||||
# 仅标记IP变动后 通知发送过了没有
|
||||
_send_notification = False
|
||||
|
||||
# 匹配ip地址的正则
|
||||
_ip_pattern = r'\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b'
|
||||
# 获取ip地址的网址列表
|
||||
_ip_urls = ["https://myip.ipip.net", "https://ddns.oray.com/checkip", "https://ip.3322.net", "https://4.ipw.cn"]
|
||||
_ip_urls = ["https://myip.ipip.net", "https://ddns.oray.com/checkip", "https://ip.3322.net", "https://r.inews.qq.com/api/ip2city", "https://uapis.cn/api/v1/network/myip"]
|
||||
# 当前ip地址
|
||||
_current_ip_address = '0.0.0.0'
|
||||
# 企业微信登录
|
||||
@@ -116,9 +116,13 @@ class DynamicWeChat(_PluginBase):
|
||||
@staticmethod
|
||||
def _launch_browser_context(headless: bool = True):
|
||||
"""
|
||||
使用 CloakBrowser 启动企业微信页面上下文,统一注入中文语言参数。
|
||||
使用 CloakBrowser 启动企业微信页面上下文,统一注入中文语言环境。
|
||||
"""
|
||||
return launch_context(headless=headless, args=['--lang=zh-CN'])
|
||||
context = launch_context(headless=headless, args=['--lang=zh-CN'])
|
||||
context.set_extra_http_headers({
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.1'
|
||||
})
|
||||
return context
|
||||
|
||||
if hasattr(settings, 'VERSION_FLAG'):
|
||||
version = settings.VERSION_FLAG # V2
|
||||
@@ -127,6 +131,7 @@ class DynamicWeChat(_PluginBase):
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
# 清空配置
|
||||
self._last_code = ""
|
||||
self._notification_token = ''
|
||||
self._cron = '*/10 * * * *'
|
||||
self._ip_changed = True
|
||||
@@ -136,13 +141,14 @@ class DynamicWeChat(_PluginBase):
|
||||
self._input_id_list = ''
|
||||
self._cookie_header = ""
|
||||
self._settings_file_path = self.get_data_path() / "settings.json"
|
||||
self.cfg = JsonFieldManager(self._settings_file_path)
|
||||
self._qr_running = False
|
||||
if config:
|
||||
self._enabled = config.get("enabled")
|
||||
self._notification_token = config.get("notification_token")
|
||||
self._cron = config.get("cron")
|
||||
self._onlyonce = config.get("onlyonce")
|
||||
self._input_id_list = config.get("input_id_list")
|
||||
# self._current_ip_address = config.get("current_ip_address")
|
||||
self._forced_update = config.get("forced_update")
|
||||
self._local_scan = config.get("local_scan")
|
||||
self._use_cookiecloud = config.get("use_cookiecloud")
|
||||
@@ -166,7 +172,9 @@ class DynamicWeChat(_PluginBase):
|
||||
self._current_ip_address = self.wan2.read_ips("ips") # 从文件中读取
|
||||
else:
|
||||
self.wan2 = None
|
||||
_, self._current_ip_address = self.get_ip_from_url() # 直接从网页获取
|
||||
# _, self._current_ip_address = self.get_ip_from_url() # 直接从网页获取 返回URL和IP
|
||||
self._current_ip_address = self.cfg.get("WECHAT_NOW_IP") # 应对MP/NAS长时间关闭后公网IP和可信IP不一致
|
||||
|
||||
# 停止现有任务
|
||||
self.stop_service()
|
||||
if (self._enabled or self._onlyonce) and self._input_id_list:
|
||||
@@ -236,14 +244,33 @@ class DynamicWeChat(_PluginBase):
|
||||
|
||||
def _send_cookie_false(self):
|
||||
self._cookie_valid = False
|
||||
if self._my_send and not self._await_ip: # 不启用“IP变动后通知”
|
||||
if self._my_send and not self._await_ip and self._wechat_available: # 配置了通知 且 不启用“IP变动后通知 且 微信通知有效
|
||||
error = self._my_send.send(
|
||||
title="cookie已失效,请及时更新",
|
||||
content="请在企业微信应用发送/push_qr, 如有验证码以'?'结束发送到企业微信应用。 如果使用’微信通知‘请确保公网IP还没有变动",
|
||||
content="请在企业微信应用发送/push_qr, 验证码以'?'结束发送到企业微信应用。 如果使用’微信通知‘请确保公网IP还没有变动",
|
||||
image=None, force_send=False
|
||||
)
|
||||
if error:
|
||||
logger.info(f"cookie失效通知发送失败,原因:{error}")
|
||||
return None
|
||||
elif self._my_send and not self._wechat_available and self._my_send.other_channel: # self._my_send 防止空对象
|
||||
'''
|
||||
# 微信通知无效(IP已不一致) 且 配置了第三方通知
|
||||
'''
|
||||
for channel, token in self._my_send.other_channel:
|
||||
# logger.info(f"正常尝试:{channel} {token}")
|
||||
error = self._my_send.send(
|
||||
title="cookie已失效,且微信通知失效",
|
||||
content="请在企业微信应用发送/push_qr, 验证码以'?'结束发送到企业微信应用。",
|
||||
image=None, force_send=False, diy_channel=channel, diy_token=token
|
||||
)
|
||||
if error:
|
||||
logger.error(f"通道 {channel} 发送失败,原因:{error}")
|
||||
else:
|
||||
return None
|
||||
else:
|
||||
# logger.error(f"通道 {self._my_send} 发送失败,原因:{error}")
|
||||
return None
|
||||
|
||||
@eventmanager.register(EventType.PluginAction)
|
||||
def forced_change(self, event: Event = None):
|
||||
@@ -293,12 +320,14 @@ class DynamicWeChat(_PluginBase):
|
||||
if not event_data or event_data.get("action") != "dynamicwechat":
|
||||
return
|
||||
context = None
|
||||
self._qr_running = True
|
||||
try:
|
||||
context = self._launch_browser_context(headless=True)
|
||||
page = context.new_page()
|
||||
page.goto(self._wechatUrl)
|
||||
time.sleep(3) # 页面加载等待时间
|
||||
if self.find_qrc(page):
|
||||
img, _ = self.find_qrc(page)
|
||||
if img:
|
||||
current_time = datetime.now()
|
||||
future_time = current_time + timedelta(seconds=110)
|
||||
self._future_timestamp = int(future_time.timestamp())
|
||||
@@ -321,6 +350,7 @@ class DynamicWeChat(_PluginBase):
|
||||
except Exception as e:
|
||||
logger.error(f"本地扫码任务: 本地扫码失败: {e}")
|
||||
finally:
|
||||
self._qr_running = False
|
||||
if context:
|
||||
context.close()
|
||||
|
||||
@@ -366,36 +396,26 @@ class DynamicWeChat(_PluginBase):
|
||||
if not event_data or event_data.get("action") != "dynamicwechat":
|
||||
return
|
||||
|
||||
# 情况1:cookie有效
|
||||
if self._cookie_valid:
|
||||
logger.info("开始检测公网IP")
|
||||
if self.CheckIP():
|
||||
self.ChangeIP()
|
||||
self.__update_config()
|
||||
logger.info("----------------------本次任务结束----------------------")
|
||||
elif self._await_ip and not self._send_notification:
|
||||
# logger.info("cookie已失效。但配置了第三方通知,继续检测公网IP。当IP变动企业微信通知彻底无法使用时通知用户")
|
||||
return
|
||||
|
||||
# 情况2:cookie失效 + 启用IP变动后通知(有第三方通知)
|
||||
if self._await_ip:
|
||||
logger.info("开始检测公网IP,等待IP变动后发送通知")
|
||||
if self.CheckIP(func="public"):
|
||||
# logger.info(f"配置的第三方通知{self._my_send.other_channel}")
|
||||
for channel, token in self._my_send.other_channel:
|
||||
# logger.info(f"正常尝试:{channel} {token}")
|
||||
error = self._my_send.send(
|
||||
title="公网IP与企业微信IP不一致",
|
||||
content="请在企业微信应用发送/push_qr, 如有验证码以'?'结束发送到企业微信应用。",
|
||||
image=None, force_send=False, diy_channel=channel, diy_token=token
|
||||
)
|
||||
if error:
|
||||
logger.error(f"通道 {channel} 发送失败,原因:{error}")
|
||||
else:
|
||||
self._send_notification = True
|
||||
break # 发送成功后退出循环
|
||||
self._wechat_available = False # 标记不可用
|
||||
self._send_cookie_false()
|
||||
logger.info("----------------------本次任务结束----------------------")
|
||||
else:
|
||||
if self._send_notification:
|
||||
logger.info("企业微信可信IP和公网IP不一致,微信通知可能已经无法使用。第三方通知已经发送。")
|
||||
else:
|
||||
logger.info("cookie已失效请及时更新,本次不检查公网IP")
|
||||
return
|
||||
|
||||
# 情况3:cookie失效 + 不等待IP变化
|
||||
logger.info("Cookie已失效,本次不检查IP")
|
||||
self._send_cookie_false()
|
||||
|
||||
def CheckIP(self, func=None):
|
||||
if self.wan2:
|
||||
@@ -404,7 +424,7 @@ class DynamicWeChat(_PluginBase):
|
||||
else:
|
||||
url, ip_address = self.get_ip_from_url()
|
||||
|
||||
if ip_address == "获取IP失败" or not url:
|
||||
if not ip_address or ip_address == "获取IP失败" or not url:
|
||||
logger.error("获取IP失败 不操作可信IP")
|
||||
return False
|
||||
|
||||
@@ -434,6 +454,7 @@ class DynamicWeChat(_PluginBase):
|
||||
# 检查 IP 是否变化
|
||||
if ip_address != self._current_ip_address:
|
||||
logger.info("检测到IP变化")
|
||||
self._wechat_available = False
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -466,7 +487,6 @@ class DynamicWeChat(_PluginBase):
|
||||
urls = self._input_id_list
|
||||
else:
|
||||
urls = self._ip_urls
|
||||
|
||||
# 随机化 URL 列表
|
||||
random.shuffle(urls)
|
||||
if not self.wan2:
|
||||
@@ -542,7 +562,7 @@ class DynamicWeChat(_PluginBase):
|
||||
time.sleep(3)
|
||||
img_src, refuse_time = self.find_qrc(page)
|
||||
if img_src:
|
||||
if self._my_send: # 统一逻辑,只有用户发送'/push_qr'才会发生二维码
|
||||
if self._my_send: # 统一逻辑,只有用户发送'/push_qr'才会发送二维码
|
||||
self._ip_changed = False
|
||||
self._send_cookie_false()
|
||||
logger.info("已尝试发送cookie失效通知")
|
||||
@@ -595,7 +615,7 @@ class DynamicWeChat(_PluginBase):
|
||||
formatted_cookies[domain] = []
|
||||
formatted_cookies[domain].append(cookie)
|
||||
if self._cc_server.update_cookie(formatted_cookies):
|
||||
logger.info("更新 CookieCloud 成功")
|
||||
logger.info("更新 CookieCloud 成功,如没有CC服务器同步cookie请不要在其他地方登录企业微信")
|
||||
self._cookie_valid = True
|
||||
self._is_special_upload = True
|
||||
else:
|
||||
@@ -617,7 +637,7 @@ class DynamicWeChat(_PluginBase):
|
||||
self._is_special_upload = False
|
||||
return
|
||||
else:
|
||||
logger.info("更新本地 Cookie成功")
|
||||
logger.info("更新本地 Cookie成功,请不要在其他地方登录企业微信")
|
||||
self._is_special_upload = True
|
||||
self._saved_cookie = current_cookies # 保存
|
||||
self._cookie_valid = True
|
||||
@@ -626,16 +646,24 @@ class DynamicWeChat(_PluginBase):
|
||||
logger.error(f"更新本地 cookie 发生错误: {e}")
|
||||
|
||||
def get_cookie(self):
|
||||
"""
|
||||
获取企业微信 Cookie。
|
||||
获取优先级:
|
||||
1. 本地内存缓存(_saved_cookie 且标记有效)
|
||||
2. CookieCloud 中 .work.weixin.qq.com 域名的 cookie
|
||||
Returns:
|
||||
Playwright 格式的 Cookie 字典列表;获取失败或未启用时返回 None。
|
||||
"""
|
||||
if self._saved_cookie and self._cookie_valid:
|
||||
return self._saved_cookie
|
||||
try:
|
||||
cookie_header = ''
|
||||
if not self._use_cookiecloud:
|
||||
return
|
||||
return None
|
||||
cookies, msg = self._cookiecloud.download()
|
||||
if not cookies: # CookieCloud获取cookie失败
|
||||
logger.error(f"CookieCloud获取cookie失败,失败原因:{msg}")
|
||||
return
|
||||
return None
|
||||
for domain, cookie in cookies.items():
|
||||
if domain == ".work.weixin.qq.com":
|
||||
cookie_header = cookie
|
||||
@@ -646,7 +674,7 @@ class DynamicWeChat(_PluginBase):
|
||||
return cookie
|
||||
except Exception as e:
|
||||
logger.error(f"从CookieCloud获取cookie错误,错误原因:{e}")
|
||||
return
|
||||
return None
|
||||
|
||||
# @staticmethod
|
||||
def parse_cookie_header(self, cookie_header):
|
||||
@@ -671,17 +699,17 @@ class DynamicWeChat(_PluginBase):
|
||||
context = self._launch_browser_context(headless=True)
|
||||
cookie_used = False
|
||||
if self._saved_cookie:
|
||||
# logger.info("尝试使用本地保存的 cookie")
|
||||
# logger.info("尝试使用内存保存的 cookie")
|
||||
context.add_cookies(self._saved_cookie)
|
||||
page = context.new_page()
|
||||
page.goto(self._wechatUrl)
|
||||
time.sleep(3)
|
||||
if self.check_login_status(page, task='refresh_cookie'):
|
||||
# logger.info("本地保存的 cookie 有效")
|
||||
# logger.info("本地内存保存的 cookie 有效")
|
||||
self._cookie_valid = True
|
||||
cookie_used = True
|
||||
else:
|
||||
# logger.warning("本地保存的 cookie 无效")
|
||||
# logger.warning("本地内存保存的 cookie 无效")
|
||||
self._cookie_valid = False
|
||||
self._saved_cookie = None # 清空无效的 cookie
|
||||
|
||||
@@ -776,7 +804,7 @@ class DynamicWeChat(_PluginBase):
|
||||
except:
|
||||
continue
|
||||
else:
|
||||
logger.error("未收到短信验证码")
|
||||
logger.error("未收到短信验证码,请以问号结尾发送到企业微信应用。如:510010? 使用全局AI助手需使用/wxcode 510010的格式发送验证码")
|
||||
return False
|
||||
except Exception as e:
|
||||
# logger.debug(str(e)) # 基于bug运行,请不要将错误输出到日志
|
||||
@@ -841,10 +869,16 @@ class DynamicWeChat(_PluginBase):
|
||||
if self._ip_changed:
|
||||
self._wechat_available = True # 标记微信通知重新有效
|
||||
self._send_notification = False # 重置第三方通知已发送标记
|
||||
self.cfg.update("WECHAT_NOW_IP", self._current_ip_address)
|
||||
'''
|
||||
将填入企业微信的IP写入settings.json
|
||||
应对MP/NAS长时间关闭后公网IP和可信IP不一致
|
||||
'''
|
||||
# self.wan2 = IpLocationParser(self._settings_file_path, max_ips=1)
|
||||
masked_ips = [self.mask_ip(ip) for ip in self._current_ip_address.split(';')]
|
||||
masked_ip_string = ";".join(masked_ips)
|
||||
logger.info(f"应用: {app_id} 输入IP:" + self._current_ip_address)
|
||||
if self._my_send:
|
||||
if self._my_send and not self._my_send.quiet_flag: # 没有开启安静模式才发通知
|
||||
self._my_send.send(title="更新可信IP成功",
|
||||
content='应用: ' + app_id + ' 输入IP:' + masked_ip_string,
|
||||
force_send=True, diy_channel="WeChat")
|
||||
@@ -1251,6 +1285,11 @@ class DynamicWeChat(_PluginBase):
|
||||
event_data = event.event_data
|
||||
if not event_data or event_data.get("action") != "push_qrcode":
|
||||
return
|
||||
if self._qr_running:
|
||||
# logger.warning("二维码任务正在执行,忽略重复触发")
|
||||
return
|
||||
self._qr_running = True
|
||||
|
||||
context = None
|
||||
try:
|
||||
context = self._launch_browser_context(headless=True)
|
||||
@@ -1260,7 +1299,7 @@ class DynamicWeChat(_PluginBase):
|
||||
image_src, refuse_time = self.find_qrc(page)
|
||||
if image_src:
|
||||
if self._my_send:
|
||||
if not self._wechat_available and self._my_send.other_channel: # 微信通知已经无法使用
|
||||
if not self._wechat_available and self._my_send.other_channel: # 微信通知已经无法使用,但是配置了第三方通知
|
||||
for channel, token in self._my_send.other_channel:
|
||||
# logger.info(f"正常尝试:{channel} {token}")
|
||||
error = self._my_send.send(
|
||||
@@ -1271,19 +1310,27 @@ class DynamicWeChat(_PluginBase):
|
||||
logger.warning(f"通道 {channel} 推送二维码失败,原因:{error}")
|
||||
else:
|
||||
break # 发送成功后退出循环
|
||||
else: # 硬发
|
||||
else: # 只配置了微信通知 硬发
|
||||
error = self._my_send.send("企业微信登录二维码", image=image_src)
|
||||
if error:
|
||||
logger.info(f"远程推送任务: 二维码发送失败,原因:{error}")
|
||||
logger.info("----------------------本次任务结束----------------------")
|
||||
return
|
||||
logger.info("远程推送任务: 二维码发送成功,等待用户 90 秒内扫码登录。V2'微信通知'的用户,此消息并不准确")
|
||||
logger.info("远程推送任务: 二维码发送成功,等待用户 80 秒内扫码登录。V2'微信通知'的用户,此消息并不准确")
|
||||
# logger.info("远程推送任务: 如收到短信验证码请以?结束,发送到<企业微信应用> 如: 110301?")
|
||||
time.sleep(90)
|
||||
if self.check_login_status(page, 'push_qr_code'):
|
||||
self._update_cookie(page, context) # 刷新cookie
|
||||
# logger.info("远程推送任务: 没有可用的CookieCloud服务器,只修改可信IP")
|
||||
self.click_app_management_buttons(page)
|
||||
# time.sleep(90)
|
||||
max_attempts = 4
|
||||
attempt = 0
|
||||
while attempt < max_attempts:
|
||||
time.sleep(20)
|
||||
attempt += 1
|
||||
if self.check_login_status(page, 'push_qr_code'):
|
||||
self._update_cookie(page, context) # 刷新cookie
|
||||
# logger.info("远程推送任务: 没有可用的CookieCloud服务器,只修改可信IP")
|
||||
self.click_app_management_buttons(page)
|
||||
break
|
||||
else:
|
||||
logger.info("用户可能没有扫码或登录失败")
|
||||
else:
|
||||
logger.warning("远程推送任务: 没有找到可用的通知方式")
|
||||
else:
|
||||
@@ -1294,6 +1341,43 @@ class DynamicWeChat(_PluginBase):
|
||||
finally:
|
||||
if context:
|
||||
context.close()
|
||||
self._qr_running = False
|
||||
|
||||
@eventmanager.register(EventType.PluginAction)
|
||||
def receive_code(self, event: Event = None):
|
||||
"""
|
||||
接收企业微信验证码
|
||||
"""
|
||||
if not self._enabled or not event:
|
||||
return
|
||||
|
||||
event_data = event.event_data or {}
|
||||
|
||||
if event_data.get("action") != "wxcode":
|
||||
return
|
||||
if not self._qr_running:
|
||||
return
|
||||
raw = event_data.get("arg_str") or ""
|
||||
|
||||
# 去掉无效日志噪音(只在调试时保留)
|
||||
# logger.info(f"完整event_data: {event_data}")
|
||||
# logger.info(f"原始内容: {raw}")
|
||||
|
||||
match = re.search(r"\d{6}", raw)
|
||||
if not match:
|
||||
logger.warning(f"收到无效验证码: {raw}")
|
||||
return
|
||||
|
||||
code = match.group(0)
|
||||
|
||||
# 防重复接收(关键优化)
|
||||
if getattr(self, "_last_code", None) == code:
|
||||
return
|
||||
|
||||
self._last_code = code
|
||||
self._verification_code = code
|
||||
|
||||
logger.info(f"收到验证码:{code}")
|
||||
|
||||
@staticmethod
|
||||
def get_command() -> List[Dict[str, Any]]:
|
||||
@@ -1306,6 +1390,15 @@ class DynamicWeChat(_PluginBase):
|
||||
"data": {
|
||||
"action": "push_qrcode"
|
||||
}
|
||||
},
|
||||
{
|
||||
"cmd": "/wxcode",
|
||||
"event": EventType.PluginAction,
|
||||
"desc": "提交企业微信验证码",
|
||||
"category": "",
|
||||
"data": {
|
||||
"action": "wxcode"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1319,12 +1412,18 @@ class DynamicWeChat(_PluginBase):
|
||||
"""
|
||||
if not self._enabled:
|
||||
return
|
||||
if not self._qr_running:
|
||||
return
|
||||
self.text = event.event_data.get("text")
|
||||
if len(self.text) == 7 and re.fullmatch(r".*\d{6}.*", self.text):
|
||||
match = re.search(r"\d{6}", self.text)
|
||||
if match:
|
||||
self._verification_code = match.group(0)
|
||||
logger.info(f"收到验证码:{self._verification_code}")
|
||||
code = match.group(0)
|
||||
# self._verification_code = match.group(0)
|
||||
if code != self._last_code:
|
||||
self._verification_code = code
|
||||
self._last_code = code
|
||||
logger.info(f"收到验证码:{code}")
|
||||
|
||||
def get_service(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
|
||||
@@ -130,6 +130,15 @@ class PyCookieCloud:
|
||||
|
||||
class MySender:
|
||||
def __init__(self, token=None, func=None):
|
||||
self.raw_token = token or ""
|
||||
|
||||
self.quiet_flag = False # 安静模式标志
|
||||
if self.raw_token.endswith("||Q") or self.raw_token.endswith("||q"):
|
||||
self.quiet_flag = True
|
||||
token = self.raw_token.rsplit("||", 1)[0] # 去掉控制段
|
||||
else:
|
||||
token = self.raw_token
|
||||
|
||||
self.tokens = token.split('||') if token and '||' in token else [token] if token else []
|
||||
self.channels = [MySender._detect_channel(t) for t in self.tokens]
|
||||
self.current_index = 0 # 当前使用的 token 和 channel 的索引
|
||||
@@ -137,6 +146,7 @@ class MySender:
|
||||
self.init_success = bool(self.tokens) # 标识初始化是否成功
|
||||
self.post_message_func = func # V2 微信模式的 post_message 方法
|
||||
|
||||
|
||||
@property
|
||||
def other_channel(self):
|
||||
"""
|
||||
@@ -147,15 +157,15 @@ class MySender:
|
||||
|
||||
@staticmethod
|
||||
def _detect_channel(token):
|
||||
"""根据 token 确定通知渠道"""
|
||||
if "WeChat" in token or "wechat" in token:
|
||||
return "WeChat"
|
||||
"""根据 token 判断通知渠道"""
|
||||
token = token.lower()
|
||||
|
||||
letters_only = ''.join(re.findall(r'[A-Za-z]', token))
|
||||
if token.lower().startswith("sct"):
|
||||
if "wechat" in token:
|
||||
return "WeChat"
|
||||
if token.startswith("sct"):
|
||||
return "ServerChan"
|
||||
elif letters_only.isupper():
|
||||
return "AnPush"
|
||||
elif "iyuu" in token:
|
||||
return "IYUU"
|
||||
else:
|
||||
return "PushPlus"
|
||||
|
||||
@@ -195,8 +205,8 @@ class MySender:
|
||||
return self._send_wechat(title, content, image, token)
|
||||
elif channel == "ServerChan":
|
||||
return self._send_serverchan(title, content, image, diy_token)
|
||||
elif channel == "AnPush":
|
||||
return self._send_anpush(title, content, image, diy_token)
|
||||
elif channel == "IYUU":
|
||||
return self._send_iyuu(title, content, image, diy_token)
|
||||
elif channel == "PushPlus":
|
||||
return self._send_pushplus(title, content, image, diy_token)
|
||||
else:
|
||||
@@ -250,30 +260,38 @@ class MySender:
|
||||
return f"Server酱通知错误: {result.get('message')}"
|
||||
return None
|
||||
|
||||
def _send_anpush(self, title, content, image, diy_token=None):
|
||||
def _send_iyuu(self, title, content, image, diy_token=None):
|
||||
"""
|
||||
发送爱语飞飞通知
|
||||
:param title: 通知标题
|
||||
:param content: 通知内容(无图片时使用)
|
||||
:param image: 图片URL(有图片时使用,会覆盖content)
|
||||
:param diy_token: 自定义token,若为None则从self.tokens中取
|
||||
:return: 成功返回None,失败返回错误字符串
|
||||
"""
|
||||
# 确定使用的token
|
||||
if diy_token:
|
||||
token = diy_token
|
||||
else:
|
||||
token = self.tokens[self.current_index] # 获取当前通道对应的 token
|
||||
if ',' in token:
|
||||
channel, token = token.split(',', 1)
|
||||
token = self.tokens[self.current_index] # 假设self.tokens存在
|
||||
|
||||
# 构造URL和请求体
|
||||
url = f"https://iyuu.cn/{token}.send"
|
||||
headers = {"Content-Type": "application/json; charset=UTF-8"}
|
||||
if image:
|
||||
desp = f'<img src="{image}" style="max-width: 100%; height: auto;" />'
|
||||
else:
|
||||
return "AnPush可能没有配置消息通道ID"
|
||||
url = f"https://api.anpush.com/push/{token}"
|
||||
payload = {
|
||||
"title": title,
|
||||
"content": f"<img src=\"{image}\" width=\"100%\">" if image else content,
|
||||
"channel": channel
|
||||
}
|
||||
headers = {"Content-Type": "application/x-www-form-urlencoded"}
|
||||
response = requests.post(url, headers=headers, data=payload)
|
||||
result = response.json()
|
||||
# 判断返回的code和msgIds
|
||||
if result.get('code') != 200:
|
||||
return f"AnPush: {result.get('msg')}"
|
||||
elif not result.get('data') or not result['data'].get('msgIds'):
|
||||
return "AnPush 消息通道未找到"
|
||||
return None
|
||||
desp = content # 发送文字内容
|
||||
payload = {"text": title, "desp": desp}
|
||||
|
||||
try:
|
||||
response = requests.post(url, json=payload, headers=headers, timeout=10)
|
||||
result = response.json()
|
||||
if result.get("errcode") != 0:
|
||||
return f"爱语飞飞通知错误: {result.get('errmsg')}"
|
||||
return None
|
||||
except Exception as e:
|
||||
return f"爱语飞飞请求异常: {str(e)}"
|
||||
|
||||
def _send_pushplus(self, title, content, image, diy_token=None):
|
||||
if diy_token:
|
||||
@@ -530,3 +548,69 @@ class IpLocationParser:
|
||||
# 写入更新后的 IP 地址
|
||||
self.overwrite_ips(field, updated_ips)
|
||||
|
||||
|
||||
|
||||
class JsonFieldManager:
|
||||
"""
|
||||
通用 JSON 配置文件字段管理器。
|
||||
所有操作均遵循「读-改-写」模式,确保不修改无关字段。
|
||||
"""
|
||||
|
||||
def __init__(self, settings_file_path: str):
|
||||
self._settings_file_path = settings_file_path
|
||||
|
||||
def _load(self) -> dict:
|
||||
"""读取完整 JSON 内容;若文件损坏或不存在则返回空字典。"""
|
||||
try:
|
||||
with open(self._settings_file_path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, IOError):
|
||||
return {}
|
||||
|
||||
def _save(self, data: dict) -> None:
|
||||
"""直接写入原文件。"""
|
||||
with open(self._settings_file_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=4, ensure_ascii=False)
|
||||
|
||||
# -------------------------- 公开接口 --------------------------
|
||||
|
||||
def get(self, field: str, default: Any = None) -> Any:
|
||||
"""
|
||||
读取指定字段的值。
|
||||
:param field: 字段名
|
||||
:param default: 字段不存在时返回的默认值
|
||||
:return: 字段值或 default
|
||||
"""
|
||||
return self._load().get(field, default)
|
||||
|
||||
def add(self, field: str, value: Any) -> bool:
|
||||
"""
|
||||
添加新字段。若字段已存在则返回 False,不会修改任何已有数据。
|
||||
:param field: 要添加的字段名
|
||||
:param value: 字段值
|
||||
:return: 是否添加成功
|
||||
"""
|
||||
data = self._load()
|
||||
if field in data:
|
||||
return False
|
||||
data[field] = value
|
||||
self._save(data)
|
||||
return True
|
||||
|
||||
def update(self, field: str, value: Any) -> None:
|
||||
"""
|
||||
修改指定字段的值;若字段不存在则自动创建。
|
||||
不修改其他字段。
|
||||
:param field: 要修改或创建的字段名
|
||||
:param value: 新值
|
||||
"""
|
||||
data = self._load()
|
||||
data[field] = value
|
||||
self._save(data)
|
||||
|
||||
def set(self, field: str, value: Any) -> None:
|
||||
"""
|
||||
新增或修改字段(字段不存在则创建,存在则覆盖)。
|
||||
不修改其他字段。与 update 行为一致。
|
||||
"""
|
||||
self.update(field, value)
|
||||
@@ -105,5 +105,5 @@ POST /api/v1/plugin/FeishuCommandBridgeLong/assistant/pick
|
||||
## 依赖
|
||||
|
||||
```txt
|
||||
lark-oapi==1.5.3
|
||||
lark-oapi>=1.4.0
|
||||
```
|
||||
|
||||
@@ -1 +1 @@
|
||||
lark-oapi==1.5.3
|
||||
lark-oapi>=1.4.0
|
||||
|
||||
@@ -5,7 +5,7 @@ from datetime import datetime
|
||||
from typing import Any, Callable, Coroutine, Dict, Optional, List, Tuple
|
||||
from urllib.parse import quote
|
||||
|
||||
import zhconv
|
||||
from zhconv_rs import zhconv as zhconv_convert
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from fastapi import Query
|
||||
|
||||
@@ -35,7 +35,7 @@ class ImdbSource(_PluginBase):
|
||||
# 插件图标
|
||||
plugin_icon = "IMDb_IOS-OSX_App.png"
|
||||
# 插件版本
|
||||
plugin_version = "1.6.8"
|
||||
plugin_version = "1.6.9"
|
||||
# 插件作者
|
||||
plugin_author = "wumode"
|
||||
# 作者主页
|
||||
@@ -1931,7 +1931,7 @@ class ImdbSource(_PluginBase):
|
||||
meta.type = mtype
|
||||
info: Optional[ImdbMediaInfo] = None
|
||||
# 简体名称
|
||||
zh_name = zhconv.convert(meta.cn_name, 'zh-hans') if meta.cn_name else None
|
||||
zh_name = zhconv_convert(meta.cn_name, 'zh-hans') if meta.cn_name else None
|
||||
media_names = list(dict.fromkeys([k for k in [meta.cn_name, zh_name, meta.en_name] if k]))
|
||||
names: list[str] = [name for name in media_names if isinstance(name, str)]
|
||||
for name in names:
|
||||
@@ -2003,7 +2003,7 @@ class ImdbSource(_PluginBase):
|
||||
meta.type = mtype
|
||||
info: Optional[ImdbMediaInfo] = None
|
||||
# 简体名称
|
||||
zh_name = zhconv.convert(meta.cn_name, 'zh-hans') if meta.cn_name else None
|
||||
zh_name = zhconv_convert(meta.cn_name, 'zh-hans') if meta.cn_name else None
|
||||
media_names = list(dict.fromkeys([k for k in [meta.cn_name, zh_name, meta.en_name] if k]))
|
||||
names: list[str] = [name for name in media_names if isinstance(name, str)]
|
||||
for name in names:
|
||||
|
||||
@@ -24,7 +24,7 @@ class InvitesSignin(_PluginBase):
|
||||
# 插件图标
|
||||
plugin_icon = "invites.png"
|
||||
# 插件版本
|
||||
plugin_version = "3.0.0"
|
||||
plugin_version = "3.0.1"
|
||||
# 插件作者
|
||||
plugin_author = "thsrite"
|
||||
# 作者主页
|
||||
@@ -375,7 +375,7 @@ class InvitesSignin(_PluginBase):
|
||||
|
||||
def __signin(self):
|
||||
"""药丸签到"""
|
||||
# 1. 检查今日是否已签到
|
||||
# 1. 本地历史只作为提示,最终以站点实时状态为准,避免旧版本误记成功后跳过真实签到
|
||||
try:
|
||||
history = self.get_data('history') or []
|
||||
if history:
|
||||
@@ -387,8 +387,7 @@ class InvitesSignin(_PluginBase):
|
||||
# 获取今日日期字符串 YYYY-MM-DD
|
||||
today_str = datetime.now().strftime('%Y-%m-%d')
|
||||
if last_date.startswith(today_str):
|
||||
logger.info(f"今日已签到 ({last_date}),跳过本次任务")
|
||||
return
|
||||
logger.info(f"本地已有今日签到记录 ({last_date}),继续校验站点实时签到状态")
|
||||
except Exception as e:
|
||||
logger.warning(f"检查签到历史失败: {e}")
|
||||
|
||||
@@ -532,31 +531,160 @@ class InvitesSignin(_PluginBase):
|
||||
logger.error(f"登录签到过程中发生异常: {e}")
|
||||
return False
|
||||
|
||||
def __build_api_headers(self, csrf_token: str, referer: str = "https://invites.fun/") -> dict:
|
||||
"""
|
||||
构建药丸 API 请求头,贴近前端真实签到请求。
|
||||
"""
|
||||
return {
|
||||
'accept': '*/*',
|
||||
'accept-language': 'zh-CN,zh-Hans;q=0.9',
|
||||
'origin': 'https://invites.fun',
|
||||
'referer': referer,
|
||||
'sec-fetch-dest': 'empty',
|
||||
'sec-fetch-mode': 'cors',
|
||||
'sec-fetch-site': 'same-origin',
|
||||
'x-csrf-token': csrf_token,
|
||||
'user-agent': self._user_agent
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def __extract_checkin_state(payload: dict) -> dict:
|
||||
"""
|
||||
从药丸 JSON:API 用户响应中提取签到状态字段。
|
||||
"""
|
||||
if not isinstance(payload, dict):
|
||||
return {}
|
||||
|
||||
data = payload.get('data')
|
||||
if not isinstance(data, dict):
|
||||
return {}
|
||||
|
||||
attrs = data.get('attributes')
|
||||
if not isinstance(attrs, dict):
|
||||
return {}
|
||||
|
||||
return {
|
||||
"user_id": str(data.get('id') or ""),
|
||||
"canCheckin": attrs.get('canCheckin'),
|
||||
"lastCheckinTime": attrs.get('lastCheckinTime') or "",
|
||||
"totalContinuousCheckIn": attrs.get('totalContinuousCheckIn'),
|
||||
"money": attrs.get('money')
|
||||
}
|
||||
|
||||
def __fetch_checkin_state(self, user_id: str, cookies: dict, csrf_token: str) -> dict:
|
||||
"""
|
||||
查询用户当前签到状态,用于签到前判断和签到后复核。
|
||||
"""
|
||||
try:
|
||||
response = RequestUtils(
|
||||
cookies=cookies,
|
||||
headers=self.__build_api_headers(csrf_token),
|
||||
proxies=self.__get_proxies()
|
||||
).get_res(url=f'https://invites.fun/api/users/{user_id}')
|
||||
if response is None:
|
||||
logger.error("查询药丸签到状态失败:无响应")
|
||||
return {}
|
||||
if response.status_code != 200:
|
||||
logger.error(f"查询药丸签到状态失败,状态码: {response.status_code}")
|
||||
return {}
|
||||
return self.__extract_checkin_state(response.json())
|
||||
except Exception as e:
|
||||
logger.error(f"查询药丸签到状态异常: {e}")
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def __is_today_checkin(state: dict) -> bool:
|
||||
"""
|
||||
判断签到状态是否已经落到当天。
|
||||
"""
|
||||
last_checkin_time = str((state or {}).get("lastCheckinTime") or "")
|
||||
return bool(last_checkin_time and last_checkin_time.startswith(datetime.now().strftime('%Y-%m-%d')))
|
||||
|
||||
@staticmethod
|
||||
def __get_response_error_message(response) -> str:
|
||||
"""
|
||||
从药丸接口错误响应中提取可读提示。
|
||||
"""
|
||||
if response is None:
|
||||
return "无响应"
|
||||
|
||||
try:
|
||||
payload = response.json()
|
||||
errors = payload.get("errors") if isinstance(payload, dict) else None
|
||||
if errors:
|
||||
messages = []
|
||||
for error in errors:
|
||||
if not isinstance(error, dict):
|
||||
continue
|
||||
message = error.get("detail") or error.get("title") or error.get("code")
|
||||
if message:
|
||||
messages.append(str(message))
|
||||
if messages:
|
||||
return ";".join(messages)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
text = getattr(response, "text", "") or ""
|
||||
return text[:200] if text else f"HTTP {response.status_code}"
|
||||
|
||||
def __save_checkin_history(self, state: dict):
|
||||
"""
|
||||
保存签到历史,并按配置保留最近记录。
|
||||
"""
|
||||
checkin_time = str((state or {}).get("lastCheckinTime") or "") or datetime.today().strftime('%Y-%m-%d %H:%M:%S')
|
||||
total_continuous_checkin = (state or {}).get("totalContinuousCheckIn")
|
||||
money = (state or {}).get("money")
|
||||
|
||||
history = self.get_data('history') or []
|
||||
checkin_day = checkin_time[:10]
|
||||
if checkin_day:
|
||||
history = [record for record in history if not str(record.get("date", "")).startswith(checkin_day)]
|
||||
|
||||
history.append({
|
||||
"date": checkin_time,
|
||||
"totalContinuousCheckIn": total_continuous_checkin,
|
||||
"money": money
|
||||
})
|
||||
|
||||
retain_seconds = int(self._history_days or 30) * 24 * 60 * 60
|
||||
expired_timestamp = time.time() - retain_seconds
|
||||
cleaned_history = []
|
||||
for record in history:
|
||||
try:
|
||||
if datetime.strptime(record["date"], '%Y-%m-%d %H:%M:%S').timestamp() >= expired_timestamp:
|
||||
cleaned_history.append(record)
|
||||
except Exception:
|
||||
logger.debug(f"忽略格式异常的签到历史记录: {record}")
|
||||
|
||||
self.save_data(key="history", value=cleaned_history)
|
||||
|
||||
def __notify_checkin_success(self, state: dict, already_signed: bool = False):
|
||||
"""
|
||||
发送签到成功或今日已签到通知。
|
||||
"""
|
||||
if not self._notify:
|
||||
return
|
||||
|
||||
status_text = "✅今日已签到" if already_signed else "✅已签到"
|
||||
money = (state or {}).get("money")
|
||||
total_continuous_checkin = (state or {}).get("totalContinuousCheckIn")
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title="【💊药丸签到】任务完成",
|
||||
text="━━━━━━━━━━━━━━\n"
|
||||
f"✨ 状态:{status_text}\n"
|
||||
"━━━━━━━━━━━━━━\n"
|
||||
"📊 数据统计\n"
|
||||
f"💊 剩余药丸:{money}\n"
|
||||
f"📆 累计签到:{total_continuous_checkin}天\n"
|
||||
"━━━━━━━━━━━━━━\n"
|
||||
f"🕐 签到时间:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
|
||||
def __perform_checkin(self, user_id: str, cookie_str: str, csrf_token: str) -> bool:
|
||||
"""执行实际的签到操作"""
|
||||
try:
|
||||
# 构建签到请求的headers
|
||||
headers = {
|
||||
'accept': '*/*',
|
||||
'content-type': 'application/json; charset=UTF-8',
|
||||
'origin': 'https://invites.fun',
|
||||
'referer': 'https://invites.fun/',
|
||||
'x-csrf-token': csrf_token,
|
||||
'x-http-method-override': 'PATCH',
|
||||
'user-agent': self._user_agent
|
||||
}
|
||||
|
||||
# 构建签到请求的JSON数据
|
||||
json_data = {
|
||||
'data': {
|
||||
'type': 'users',
|
||||
'attributes': {
|
||||
'canCheckin': False,
|
||||
'totalContinuousCheckIn': 2, #连续签到天数
|
||||
},
|
||||
'id': str(user_id),
|
||||
},
|
||||
}
|
||||
headers = self.__build_api_headers(csrf_token)
|
||||
|
||||
# 构建cookies - 使用安全的解析方法
|
||||
cookies = self.__parse_cookie_string(cookie_str)
|
||||
@@ -568,58 +696,49 @@ class InvitesSignin(_PluginBase):
|
||||
|
||||
# 获取代理
|
||||
proxies = self.__get_proxies()
|
||||
|
||||
# 先查询站点实时状态,避免本地历史或旧接口响应造成误判
|
||||
before_state = self.__fetch_checkin_state(user_id, cookies, csrf_token)
|
||||
if before_state and before_state.get("canCheckin") is False and self.__is_today_checkin(before_state):
|
||||
logger.info("药丸今日已签到,跳过重复签到")
|
||||
self.__save_checkin_history(before_state)
|
||||
self.__notify_checkin_success(before_state, already_signed=True)
|
||||
return True
|
||||
|
||||
# 执行签到请求
|
||||
checkin_url = f'https://invites.fun/api/users/{user_id}'
|
||||
checkin_url = 'https://invites.fun/api/checkin'
|
||||
response = RequestUtils(cookies=cookies, headers=headers, proxies=proxies).post_res(
|
||||
checkin_url,
|
||||
json=json_data
|
||||
checkin_url
|
||||
)
|
||||
|
||||
if not response or response.status_code != 200:
|
||||
logger.error(f"签到请求失败,状态码: {response.status_code if response else 'None'}")
|
||||
if response is None:
|
||||
logger.error("签到请求失败:无响应")
|
||||
return False
|
||||
if response.status_code != 200:
|
||||
error_message = self.__get_response_error_message(response)
|
||||
logger.error(f"签到请求失败,状态码: {response.status_code},原因: {error_message}")
|
||||
after_state = self.__fetch_checkin_state(user_id, cookies, csrf_token)
|
||||
if after_state and after_state.get("canCheckin") is False and self.__is_today_checkin(after_state):
|
||||
logger.info("药丸站点状态显示今日已签到")
|
||||
self.__save_checkin_history(after_state)
|
||||
self.__notify_checkin_success(after_state, already_signed=True)
|
||||
return True
|
||||
return False
|
||||
|
||||
# 解析签到响应
|
||||
try:
|
||||
checkin_data = response.json()
|
||||
|
||||
# 提取关键信息
|
||||
total_continuous_checkin = checkin_data['data']['attributes']['totalContinuousCheckIn']
|
||||
money = checkin_data['data']['attributes']['money']
|
||||
|
||||
checkin_state = self.__extract_checkin_state(checkin_data)
|
||||
if not checkin_state:
|
||||
logger.error("签到响应缺少用户状态数据")
|
||||
return False
|
||||
if checkin_state.get("canCheckin") is not False or not self.__is_today_checkin(checkin_state):
|
||||
logger.error(f"签到响应未确认今日已签到: {checkin_state}")
|
||||
return False
|
||||
|
||||
logger.info("药丸签到成功")
|
||||
|
||||
# 发送通知
|
||||
if self._notify:
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title="【💊药丸签到】任务完成",
|
||||
text="━━━━━━━━━━━━━━\n"
|
||||
"✨ 状态:✅已签到\n"
|
||||
"━━━━━━━━━━━━━━\n"
|
||||
"📊 数据统计\n"
|
||||
f"💊 剩余药丸:{money}\n"
|
||||
f"📆 累计签到:{total_continuous_checkin}天\n"
|
||||
"━━━━━━━━━━━━━━\n"
|
||||
f"🕐 签到时间:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
|
||||
# 保存签到历史
|
||||
history = self.get_data('history') or []
|
||||
history.append({
|
||||
"date": datetime.today().strftime('%Y-%m-%d %H:%M:%S'),
|
||||
"totalContinuousCheckIn": total_continuous_checkin,
|
||||
"money": money
|
||||
})
|
||||
|
||||
# 清理超过保留天数的历史记录
|
||||
thirty_days_ago = time.time() - int(self._history_days) * 24 * 60 * 60
|
||||
history = [record for record in history if
|
||||
datetime.strptime(record["date"], '%Y-%m-%d %H:%M:%S').timestamp() >= thirty_days_ago]
|
||||
|
||||
# 保存签到历史
|
||||
self.save_data(key="history", value=history)
|
||||
|
||||
self.__notify_checkin_success(checkin_state)
|
||||
self.__save_checkin_history(checkin_state)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -34,7 +34,7 @@ class IYUUAutoSeed(_PluginBase):
|
||||
# 插件图标
|
||||
plugin_icon = "IYUU.png"
|
||||
# 插件版本
|
||||
plugin_version = "2.15"
|
||||
plugin_version = "2.17"
|
||||
# 插件作者
|
||||
plugin_author = "jxxghp,CKun"
|
||||
# 作者主页
|
||||
@@ -88,6 +88,8 @@ class IYUUAutoSeed(_PluginBase):
|
||||
_success_caches = []
|
||||
# 辅种缓存,出错的种子不再重复辅种,且无法清除。种子被删除404等情况
|
||||
_permanent_error_caches = []
|
||||
# 辅种缓存最大保存条数,避免长期运行时配置缓存无限增长
|
||||
_seed_cache_max_items = 10000
|
||||
# 辅种计数
|
||||
total = 0
|
||||
realtotal = 0
|
||||
@@ -97,6 +99,9 @@ class IYUUAutoSeed(_PluginBase):
|
||||
cached = 0
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
self._error_caches = []
|
||||
self._success_caches = []
|
||||
self._permanent_error_caches = []
|
||||
|
||||
# 读取配置
|
||||
if config:
|
||||
@@ -118,9 +123,14 @@ class IYUUAutoSeed(_PluginBase):
|
||||
self._addhosttotag = config.get("addhosttotag")
|
||||
self._size = float(config.get("size")) if config.get("size") else 0
|
||||
self._clearcache = config.get("clearcache")
|
||||
self._permanent_error_caches = [] if self._clearcache else config.get("permanent_error_caches") or []
|
||||
self._error_caches = [] if self._clearcache else config.get("error_caches") or []
|
||||
self._success_caches = [] if self._clearcache else config.get("success_caches") or []
|
||||
self._permanent_error_caches = (
|
||||
[] if self._clearcache else list(config.get("permanent_error_caches") or [])
|
||||
)
|
||||
self._error_caches = [] if self._clearcache else list(config.get("error_caches") or [])
|
||||
self._success_caches = [] if self._clearcache else list(config.get("success_caches") or [])
|
||||
self.__trim_seed_cache(self._permanent_error_caches)
|
||||
self.__trim_seed_cache(self._error_caches)
|
||||
self.__trim_seed_cache(self._success_caches)
|
||||
|
||||
# 过滤掉已删除的站点
|
||||
all_sites = [site.id for site in SiteOper().list_order_by_pri()] + [site.get("id") for site in
|
||||
@@ -130,6 +140,8 @@ class IYUUAutoSeed(_PluginBase):
|
||||
|
||||
# 停止现有任务
|
||||
self.stop_service()
|
||||
# 重新初始化运行期校验队列,避免类级字典跨插件重载残留。
|
||||
self._recheck_torrents = {}
|
||||
|
||||
# 启动定时任务 & 立即运行一次
|
||||
if self.get_state() or self._onlyonce:
|
||||
@@ -680,6 +692,32 @@ class IYUUAutoSeed(_PluginBase):
|
||||
"permanent_error_caches": self._permanent_error_caches
|
||||
})
|
||||
|
||||
def __trim_seed_cache(self, cache: list):
|
||||
"""
|
||||
去重并限制辅种缓存大小,避免长期任务把配置缓存无限撑大。
|
||||
"""
|
||||
if not cache:
|
||||
return
|
||||
unique_cache = []
|
||||
seen = set()
|
||||
for item in reversed(cache):
|
||||
if not item or item in seen:
|
||||
continue
|
||||
seen.add(item)
|
||||
unique_cache.append(item)
|
||||
unique_cache.reverse()
|
||||
cache[:] = unique_cache[-self._seed_cache_max_items:]
|
||||
|
||||
def __append_seed_cache(self, cache: list, value: str):
|
||||
"""
|
||||
写入辅种缓存并保持上限,重复值只保留一份。
|
||||
"""
|
||||
if not value:
|
||||
return
|
||||
if value not in cache:
|
||||
cache.append(value)
|
||||
self.__trim_seed_cache(cache)
|
||||
|
||||
def auto_seed(self):
|
||||
"""
|
||||
开始辅种
|
||||
@@ -1036,7 +1074,7 @@ class IYUUAutoSeed(_PluginBase):
|
||||
site_url, download_page = self.iyuu_helper.get_torrent_url(seed.get("sid"))
|
||||
if not site_url or not download_page:
|
||||
# 加入缓存
|
||||
self._error_caches.append(seed.get("info_hash"))
|
||||
self.__append_seed_cache(self._error_caches, seed.get("info_hash"))
|
||||
self.fail += 1
|
||||
self.cached += 1
|
||||
return False
|
||||
@@ -1071,7 +1109,7 @@ class IYUUAutoSeed(_PluginBase):
|
||||
base_url=download_page)
|
||||
if not torrent_url:
|
||||
# 加入失败缓存
|
||||
self._error_caches.append(seed.get("info_hash"))
|
||||
self.__append_seed_cache(self._error_caches, seed.get("info_hash"))
|
||||
self.fail += 1
|
||||
self.cached += 1
|
||||
return False
|
||||
@@ -1092,10 +1130,10 @@ class IYUUAutoSeed(_PluginBase):
|
||||
self.fail += 1
|
||||
# 加入失败缓存
|
||||
if error_msg and ('无法打开链接' in error_msg or '触发站点流控' in error_msg):
|
||||
self._error_caches.append(seed.get("info_hash"))
|
||||
self.__append_seed_cache(self._error_caches, seed.get("info_hash"))
|
||||
else:
|
||||
# 种子不存在的情况
|
||||
self._permanent_error_caches.append(seed.get("info_hash"))
|
||||
self.__append_seed_cache(self._permanent_error_caches, seed.get("info_hash"))
|
||||
logger.error(f"下载种子文件失败:{torrent_url}")
|
||||
return False
|
||||
# 添加下载,辅种任务默认暂停
|
||||
@@ -1109,7 +1147,7 @@ class IYUUAutoSeed(_PluginBase):
|
||||
# 下载失败
|
||||
self.fail += 1
|
||||
# 加入失败缓存
|
||||
self._error_caches.append(seed.get("info_hash"))
|
||||
self.__append_seed_cache(self._error_caches, seed.get("info_hash"))
|
||||
return False
|
||||
else:
|
||||
self.success += 1
|
||||
@@ -1130,7 +1168,7 @@ class IYUUAutoSeed(_PluginBase):
|
||||
# 下载成功
|
||||
logger.info(f"成功添加辅种下载,站点:{site_info.get('name')},种子链接:{torrent_url}")
|
||||
# 成功也加入缓存,有一些改了路径校验不通过的,手动删除后,下一次又会辅上
|
||||
self._success_caches.append(seed.get("info_hash"))
|
||||
self.__append_seed_cache(self._success_caches, seed.get("info_hash"))
|
||||
return True
|
||||
|
||||
def __add_recheck_torrents(self, service: ServiceInfo, download_id: str):
|
||||
|
||||
@@ -90,6 +90,15 @@ class IyuuHelper(object):
|
||||
return result.get('sid_sha1')
|
||||
return None
|
||||
|
||||
def __reseed_index(self, json_data: str, sha1: str) -> Tuple[Optional[dict], str]:
|
||||
return self.__request_iyuu(url='/reseed/index/index', method='post', params={
|
||||
'hash': json_data,
|
||||
'sha1': sha1,
|
||||
'sid_sha1': self._sid_sha1,
|
||||
'timestamp': int(time.time()),
|
||||
'version': self._version
|
||||
})
|
||||
|
||||
def get_seed_info(self, info_hashs: list) -> Tuple[Optional[dict], str]:
|
||||
"""
|
||||
返回info_hash对应的站点id、种子id
|
||||
@@ -101,13 +110,10 @@ class IyuuHelper(object):
|
||||
info_hashs.sort()
|
||||
json_data = json.dumps(info_hashs, separators=(',', ':'), ensure_ascii=False)
|
||||
sha1 = self.get_sha1(json_data)
|
||||
result, msg = self.__request_iyuu(url='/reseed/index/index', method='post', params={
|
||||
'hash': json_data,
|
||||
'sha1': sha1,
|
||||
'sid_sha1': self._sid_sha1,
|
||||
'timestamp': int(time.time()),
|
||||
'version': self._version
|
||||
})
|
||||
result, msg = self.__reseed_index(json_data, sha1)
|
||||
if msg and "站点哈希值 require" in msg:
|
||||
self._sid_sha1 = self.__report_existing()
|
||||
result, msg = self.__reseed_index(json_data, sha1)
|
||||
return result, msg
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -1,55 +1,39 @@
|
||||
import asyncio
|
||||
import copy
|
||||
import os
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from collections import Counter
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Tuple, Optional, Literal
|
||||
from typing import Any, Dict, List, Literal, Optional, Tuple
|
||||
|
||||
import pymediainfo
|
||||
from langdetect import detect
|
||||
from langchain_community.callbacks import get_openai_callback
|
||||
from pysubs2 import SSAFile, SSAEvent, SSAStyle, Color, Alignment
|
||||
from pysubs2 import Alignment, Color, SSAEvent, SSAStyle, SSAFile
|
||||
|
||||
from app.core.config import settings
|
||||
from app.agent.llm.helper import LLMHelper
|
||||
from app.chain.media import MediaChain
|
||||
from app.core.cache import cached
|
||||
from app.core.config import global_vars, settings
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.event import Event, eventmanager
|
||||
from app.helper.directory import DirectoryHelper
|
||||
from app.log import logger
|
||||
from app.plugins import _PluginBase
|
||||
from app.core.cache import cached
|
||||
from app.core.event import eventmanager, Event
|
||||
from app.schemas import Response
|
||||
from app.schemas.types import NotificationType, MediaType
|
||||
from app.schemas import Context, Response, TransferInfo
|
||||
from app.schemas.types import EventType, MediaType, NotificationType
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.string import StringUtils
|
||||
from app.schemas import TransferInfo, Context
|
||||
from app.schemas.types import EventType
|
||||
from app.core.context import MediaInfo
|
||||
from app.chain.media import MediaChain
|
||||
|
||||
from .agenttool import QueryAnnotationTasksTool, VocabularyAnnotatingTool
|
||||
from .lexicon import Lexicon
|
||||
from .schemas import (
|
||||
IDGenerator,
|
||||
TaskStatus,
|
||||
Task,
|
||||
TasksApiParams,
|
||||
ProcessResult,
|
||||
SegmentList,
|
||||
TaskParams, SegmentStatistics,
|
||||
)
|
||||
from .pipeline import UNIVERSAL_POS_MAP, extract_advanced_words, llm_process_chain
|
||||
from .schemas import IDGenerator, ProcessResult, SegmentList, SegmentStatistics, Task, TaskParams, TasksApiParams, \
|
||||
TaskStatus, LLMConfig
|
||||
from .spacyworker import SpacyWorker
|
||||
from .subtitle import SubtitleProcessor, style_text
|
||||
from .pipeline import (
|
||||
extract_advanced_words,
|
||||
llm_process_chain,
|
||||
initialize_llm,
|
||||
UNIVERSAL_POS_MAP,
|
||||
)
|
||||
from .subtitle import SubtitleHelper, SubtitleProcessor, style_text
|
||||
|
||||
|
||||
class LexiAnnot(_PluginBase):
|
||||
@@ -60,7 +44,7 @@ class LexiAnnot(_PluginBase):
|
||||
# 插件图标
|
||||
plugin_icon = "LexiAnnot.png"
|
||||
# 插件版本
|
||||
plugin_version = "1.2.5"
|
||||
plugin_version = "1.2.6"
|
||||
# 插件作者
|
||||
plugin_author = "wumode"
|
||||
# 作者主页
|
||||
@@ -91,7 +75,6 @@ class LexiAnnot(_PluginBase):
|
||||
_ffmpeg_path: str = "ffmpeg"
|
||||
_english_only = False
|
||||
_when_file_trans = False
|
||||
_model_temperature = ""
|
||||
_custom_files = ""
|
||||
_accent_color = ""
|
||||
_font_scaling = ""
|
||||
@@ -102,6 +85,8 @@ class LexiAnnot(_PluginBase):
|
||||
_libraries: List[str] = []
|
||||
_use_mp_agent: bool = False
|
||||
_use_proxy: bool = False
|
||||
_test_llm: bool = False
|
||||
_thinking_level: str = None
|
||||
|
||||
# protected variables
|
||||
_lexicon_repo = "https://raw.githubusercontent.com/wumode/LexiAnnot/"
|
||||
@@ -137,7 +122,6 @@ class LexiAnnot(_PluginBase):
|
||||
self._ffmpeg_path = config.get("ffmpeg_path") or "ffmpeg"
|
||||
self._english_only = config.get("english_only")
|
||||
self._when_file_trans = config.get("when_file_trans")
|
||||
self._model_temperature = config.get("model_temperature") or "0.3"
|
||||
self._show_phonetics = config.get("show_phonetics")
|
||||
self._custom_files = config.get("custom_files") or ""
|
||||
self._accent_color = config.get("accent_color")
|
||||
@@ -151,6 +135,8 @@ class LexiAnnot(_PluginBase):
|
||||
self._llm_provider = config.get("llm_provider") or "google"
|
||||
self._use_mp_agent = config.get("use_mp_agent") or False
|
||||
self._use_proxy = config.get("use_proxy") or False
|
||||
self._test_llm = config.get("test_llm") or False
|
||||
self._thinking_level = config.get("thinking_level") or "off"
|
||||
|
||||
libraries = [
|
||||
library.name for library in DirectoryHelper().get_library_dirs()
|
||||
@@ -158,7 +144,7 @@ class LexiAnnot(_PluginBase):
|
||||
self._libraries = [
|
||||
library for library in self._libraries if library in libraries
|
||||
]
|
||||
self._accent_color_rgb = LexiAnnot.hex_to_rgb(self._accent_color) or (255, 255, 0,)
|
||||
self._accent_color_rgb = SubtitleHelper.hex_to_rgb(self._accent_color) or (255, 255, 0,)
|
||||
self._color_alpha = int(self._opacity) if self._opacity and len(self._opacity) else 0
|
||||
if self._delete_data:
|
||||
# 删除不再保存在数据库的数据
|
||||
@@ -193,6 +179,9 @@ class LexiAnnot(_PluginBase):
|
||||
continue
|
||||
self.add_media_file(file_path)
|
||||
self._onlyonce = False
|
||||
if self._test_llm:
|
||||
asyncio.run_coroutine_threadsafe(self.test_llm(), global_vars.loop)
|
||||
self._test_llm = False
|
||||
self.__update_config()
|
||||
|
||||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
@@ -679,14 +668,17 @@ class LexiAnnot(_PluginBase):
|
||||
"model": "gemini_model",
|
||||
"disabled": "use_mp_agent",
|
||||
"label": "模型名称",
|
||||
"hint": "支持手动输入",
|
||||
"persistent-hint": True,
|
||||
"items": [
|
||||
"gemini-2.5-flash",
|
||||
"gemini-2.5-flash-lite",
|
||||
"gemini-3.5-flash",
|
||||
"gemini-3.1-flash-lite",
|
||||
"gemini-2.5-pro",
|
||||
"gemini-2.0-flash",
|
||||
"gemini-2.0-flash-lite",
|
||||
"deepseek-ai/DeepSeek-V3.2",
|
||||
"deepseek-ai/DeepSeek-R1"
|
||||
"gemini-2.5-flash-lite",
|
||||
"deepseek-ai/DeepSeek-V4-Pro",
|
||||
"deepseek-ai/DeepSeek-V4-Flash",
|
||||
"deepseek-v4-flash",
|
||||
"deepseek-v4-pro"
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -735,28 +727,6 @@ class LexiAnnot(_PluginBase):
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 4},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSelect",
|
||||
"props": {
|
||||
"model": "model_temperature",
|
||||
"label": "模型温度",
|
||||
"items": [
|
||||
{"title": "0", "value": "0"},
|
||||
{"title": "0.1", "value": "0.1"},
|
||||
{"title": "0.2", "value": "0.2"},
|
||||
{"title": "0.3", "value": "0.3"},
|
||||
{"title": "0.4", "value": "0.4"},
|
||||
{"title": "0.5", "value": "0.5"},
|
||||
{"title": "1.0", "value": "1.0"},
|
||||
],
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {
|
||||
@@ -777,8 +747,55 @@ class LexiAnnot(_PluginBase):
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 4},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSelect",
|
||||
"props": {
|
||||
"model": "thinking_level",
|
||||
"label": "思考模式",
|
||||
"disabled": "use_mp_agent",
|
||||
"items": [
|
||||
{"title": "关闭 (off)", "value": "off"},
|
||||
{"title": "自动 (auto)", "value": "auto"},
|
||||
{"title": "最小 (minimal)", "value": "minimal"},
|
||||
{"title": "低 (low)", "value": "low"},
|
||||
{"title": "中 (medium)", "value": "medium"},
|
||||
{"title": "高 (high)", "value": "high"},
|
||||
{"title": "极高 (max)", "value": "max"},
|
||||
{"title": "超高 (xhigh)", "value": "xhigh"},
|
||||
],
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {
|
||||
"cols": 12,
|
||||
"md": 12,
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSwitch",
|
||||
"props": {
|
||||
"model": "test_llm",
|
||||
"label": "测试调用",
|
||||
"hint": "启用后,请在插件日志查看测试结果",
|
||||
"persistent-hint": True
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -883,7 +900,6 @@ class LexiAnnot(_PluginBase):
|
||||
"ffmpeg_path": "",
|
||||
"english_only": True,
|
||||
"when_file_trans": True,
|
||||
"model_temperature": "0.3",
|
||||
"custom_files": "",
|
||||
"accent_color": "",
|
||||
"font_scaling": "1",
|
||||
@@ -896,6 +912,8 @@ class LexiAnnot(_PluginBase):
|
||||
"llm_base_url": "",
|
||||
"use_mp_agent": False,
|
||||
"use_proxy": False,
|
||||
"test_llm": False,
|
||||
"thinking_level": "off"
|
||||
}
|
||||
|
||||
def get_api(self) -> List[Dict[str, Any]]:
|
||||
@@ -1046,6 +1064,25 @@ class LexiAnnot(_PluginBase):
|
||||
else:
|
||||
logger.debug("ℹ️ No running worker thread to stop.")
|
||||
|
||||
async def test_llm(self):
|
||||
model_config = self.get_model_config()
|
||||
try:
|
||||
logger.info("测试 LLM 调用...")
|
||||
result = await LLMHelper.test_current_settings(
|
||||
provider=model_config.provider,
|
||||
model=model_config.model_name,
|
||||
thinking_level=model_config.thinking_level,
|
||||
use_proxy=model_config.use_proxy,
|
||||
base_url=model_config.base_url,
|
||||
api_key=model_config.apikey
|
||||
)
|
||||
if not result.get("reply_preview"):
|
||||
logger.warning("LLM 响应为空")
|
||||
else:
|
||||
logger.info(f"LLM 返回: {result['reply_preview']}")
|
||||
except Exception as err:
|
||||
logger.error(f"LLM 调用出错: {str(err)}")
|
||||
|
||||
def delete_data(self):
|
||||
# 删除词典
|
||||
data_path = self.get_data_path()
|
||||
@@ -1156,7 +1193,6 @@ class LexiAnnot(_PluginBase):
|
||||
"ffmpeg_path": self._ffmpeg_path,
|
||||
"english_only": self._english_only,
|
||||
"when_file_trans": self._when_file_trans,
|
||||
"model_temperature": self._model_temperature,
|
||||
"show_phonetics": self._show_phonetics,
|
||||
"custom_files": self._custom_files,
|
||||
"accent_color": self._accent_color,
|
||||
@@ -1170,6 +1206,8 @@ class LexiAnnot(_PluginBase):
|
||||
"llm_base_url": self._llm_base_url,
|
||||
"use_mp_agent": self._use_mp_agent,
|
||||
"use_proxy": self._use_proxy,
|
||||
"test_llm": self._test_llm,
|
||||
"thinking_level": self._thinking_level
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1310,7 +1348,7 @@ class LexiAnnot(_PluginBase):
|
||||
|
||||
ffmpeg_path = self._ffmpeg_path if self._ffmpeg_path else "ffmpeg"
|
||||
eng_mark = ["en", "en-US", "eng", "en-GB", "english", "en-AU"]
|
||||
embedded_subtitles = LexiAnnot._extract_subtitles_by_lang(path, eng_mark, ffmpeg_path)
|
||||
embedded_subtitles = SubtitleHelper.extract_subtitles_by_lang(path, eng_mark, ffmpeg_path)
|
||||
if not embedded_subtitles:
|
||||
return ProcessResult(
|
||||
status=TaskStatus.CANCELED, message="未找到嵌入式英文文本字幕"
|
||||
@@ -1332,7 +1370,7 @@ class LexiAnnot(_PluginBase):
|
||||
return ProcessResult(status=TaskStatus.CANCELED, message="任务已取消")
|
||||
ass_subtitle = SSAFile.from_string(embedded_subtitle["subtitle"], format_="ass")
|
||||
if embedded_subtitle.get("codec_id") == "S_TEXT/UTF8":
|
||||
ass_subtitle = LexiAnnot.set_srt_style(ass_subtitle)
|
||||
ass_subtitle = SubtitleHelper.set_srt_style(ass_subtitle)
|
||||
ass_subtitle = self.__set_style(ass_subtitle)
|
||||
ass_subtitle, stat = self.process_subtitles(ass_subtitle, lexi, spacy_worker, mediainfo)
|
||||
if self._shutdown_event.is_set():
|
||||
@@ -1498,170 +1536,6 @@ class LexiAnnot(_PluginBase):
|
||||
for new_path in transfer_info.file_list_new or []:
|
||||
self.add_media_file(new_path)
|
||||
|
||||
@staticmethod
|
||||
def format_duration(ms):
|
||||
total_seconds, milliseconds = divmod(ms, 1000)
|
||||
hours, remainder = divmod(total_seconds, 3600)
|
||||
minutes, seconds = divmod(remainder, 60)
|
||||
hundredths = milliseconds // 10
|
||||
return f"{hours}:{minutes:02}:{seconds:02}.{hundredths:02}"
|
||||
|
||||
@staticmethod
|
||||
def _remove_substring(replacements: list[dict]):
|
||||
new_list = []
|
||||
replacements.sort(key=lambda x: x["end"] - x["start"], reverse=True)
|
||||
for r in replacements:
|
||||
if any((r["start"] >= new["start"] and r["end"] <= new["end"]) for new in new_list):
|
||||
continue
|
||||
new_list.append(r)
|
||||
return new_list
|
||||
|
||||
@staticmethod
|
||||
def replace_by_plaintext_positions(line: SSAEvent, replacements: List[dict]):
|
||||
"""
|
||||
使用 replacements 中的 plaintext 位置信息, 替换 line.text 中的内容。
|
||||
:param line: SSAEvent line
|
||||
:param replacements: [{'start': int, 'end': int, 'old_text': str, 'new_text': str}, ...]
|
||||
"""
|
||||
text = line.text
|
||||
tag_pattern = re.compile(r"{.*?}") # 匹配 {xxx} 格式控制符
|
||||
special_pattern = re.compile(r"\\[Nh]")
|
||||
# 构建 plaintext 位置到 text 索引的映射
|
||||
mapping = {} # plaintext_index -> text_index
|
||||
p_index = 0 # 当前 plaintext 索引
|
||||
t_index = 0 # 当前 text 索引
|
||||
|
||||
while t_index < len(text):
|
||||
if text[t_index] == "{":
|
||||
# 跳过格式标签
|
||||
match = tag_pattern.match(text, t_index)
|
||||
if match:
|
||||
t_index = match.end()
|
||||
continue
|
||||
elif text[t_index] == "\\":
|
||||
match = special_pattern.match(text, t_index)
|
||||
if match:
|
||||
t_index = match.end() - 1
|
||||
continue
|
||||
# 非格式字符
|
||||
mapping[p_index] = t_index
|
||||
p_index += 1
|
||||
t_index += 1
|
||||
replacements = LexiAnnot._remove_substring(replacements)
|
||||
# 按照 mapping 执行替换(倒序替换防止位置错位)
|
||||
new_text = text
|
||||
for r in sorted(replacements, key=lambda x: x["start"], reverse=True):
|
||||
start = mapping.get(r["start"])
|
||||
end = mapping.get(r["end"] - 1)
|
||||
if start is None or end is None:
|
||||
continue
|
||||
end += 1
|
||||
new_text = new_text[:start] + r["new_text"] + new_text[end:]
|
||||
|
||||
line.text = new_text
|
||||
|
||||
@staticmethod
|
||||
def analyze_ass_language(ass_file: SSAFile):
|
||||
|
||||
def _replace_with_spaces(_text):
|
||||
"""
|
||||
使用等长的空格替换文本中的 (xxx) 模式。
|
||||
例如:"(Hi)" 会被替换成 " " (4个空格)
|
||||
"""
|
||||
pattern = r"(\([^()]*\)|\[[^\[\]]*\])"
|
||||
return re.sub(pattern, lambda match: " " * len(match.group(1)), _text)
|
||||
|
||||
styles = {}
|
||||
for style in ass_file.styles:
|
||||
styles[style] = {"text": [], "duration": 0, "text_size": 0, "times": 0}
|
||||
for dialogue in ass_file:
|
||||
style = dialogue.style
|
||||
text = _replace_with_spaces(dialogue.plaintext)
|
||||
sub_text = text.split("\n")
|
||||
if style not in styles or not text:
|
||||
continue
|
||||
styles[style]["text"].extend(sub_text)
|
||||
styles[style]["duration"] += dialogue.duration
|
||||
styles[style]["text_size"] += len(text)
|
||||
styles[style]["times"] += 1
|
||||
style_language_analysis = {}
|
||||
for style_name, data in styles.items():
|
||||
all_text = " ".join(data["text"])
|
||||
if not all_text.strip():
|
||||
style_language_analysis[style_name] = None
|
||||
continue
|
||||
|
||||
languages = []
|
||||
# 对每个文本片段进行语言检测
|
||||
for text_fragment in data["text"]:
|
||||
try:
|
||||
lang = detect(text_fragment)
|
||||
languages.append(lang)
|
||||
except Exception as e:
|
||||
# 无法检测的文本
|
||||
logger.debug(e)
|
||||
pass
|
||||
|
||||
if languages:
|
||||
language_counts = Counter(languages)
|
||||
most_common_language = language_counts.most_common(1)[0]
|
||||
style_language_analysis[style_name] = {
|
||||
"main_language": most_common_language[0],
|
||||
"proportion": most_common_language[1] / len(languages),
|
||||
"duration": data["duration"],
|
||||
"text_size": data["text_size"],
|
||||
"times": data["times"],
|
||||
}
|
||||
else:
|
||||
style_language_analysis[style_name] = None
|
||||
|
||||
return style_language_analysis
|
||||
|
||||
@staticmethod
|
||||
def select_main_style_weighted(analysis: Dict[str, Any], known_language: str, weights = None):
|
||||
"""
|
||||
根据语言分析结果和已知的字幕语言,使用加权评分选择主要样式
|
||||
|
||||
:params analysis: `analyze_ass_language` 函数的输出结果
|
||||
:params known_language: 已知的字幕语言代码
|
||||
:params weights: 各个维度的权重,权重之和应为 1
|
||||
:returns: 主要字幕的样式名称,如果没有匹配的样式则返回 None
|
||||
"""
|
||||
if weights is None:
|
||||
weights = {"times": 0.5, "text_size": 0.4, "duration": 0.1}
|
||||
matching_styles = []
|
||||
max_times = max([analysis.get("times", 0) for _, analysis in analysis.items() if analysis] or [0]) or 1
|
||||
max_text_size = max([analysis.get("text_size", 0) for _, analysis in analysis.items() if analysis] or [0]) or 1
|
||||
max_duration = max([analysis.get("duration", 0) for _, analysis in analysis.items() if analysis] or [0]) or 1
|
||||
for style, analysis in analysis.items():
|
||||
if not analysis:
|
||||
continue
|
||||
if analysis.get("main_language") == known_language:
|
||||
# 跳过多语言
|
||||
if analysis.get("proportion", 0) < 0.5:
|
||||
continue
|
||||
score = 0
|
||||
score += analysis.get("times", 0) * weights.get("times", 0) / max_times
|
||||
score += analysis.get("text_size", 0) * weights.get("text_size", 0) / max_text_size
|
||||
score += analysis.get("duration", 0) * weights.get("duration", 0) / max_duration
|
||||
matching_styles.append((style, score))
|
||||
|
||||
if not matching_styles:
|
||||
return None
|
||||
|
||||
sorted_styles = sorted(matching_styles, key=lambda item: item[1], reverse=True)
|
||||
return sorted_styles[0][0]
|
||||
|
||||
@staticmethod
|
||||
def set_srt_style(ass: SSAFile) -> SSAFile:
|
||||
ass.info["ScaledBorderAndShadow"] = "no"
|
||||
play_res_y = int(ass.info["PlayResY"])
|
||||
if "Default" in ass.styles:
|
||||
ass.styles["Default"].marginv = play_res_y // 16
|
||||
ass.styles["Default"].fontname = "Microsoft YaHei"
|
||||
ass.styles["Default"].fontsize = play_res_y // 16
|
||||
return ass
|
||||
|
||||
def __set_style(self, ass: SSAFile) -> SSAFile:
|
||||
font_scaling = (
|
||||
float(self._font_scaling)
|
||||
@@ -1747,107 +1621,25 @@ class LexiAnnot(_PluginBase):
|
||||
ass.styles["Annotation EXAM"] = cefr_style
|
||||
return ass
|
||||
|
||||
@staticmethod
|
||||
def hex_to_rgb(hex_color: str | None) -> tuple[int, ...] | None:
|
||||
if not hex_color:
|
||||
return None
|
||||
pattern = r"^#[0-9a-fA-F]{6}$"
|
||||
if re.match(pattern, hex_color) is None:
|
||||
return None
|
||||
hex_color = hex_color.lstrip("#") # 去掉前面的 #
|
||||
return tuple(int(hex_color[i: i + 2], 16) for i in (0, 2, 4))
|
||||
|
||||
@staticmethod
|
||||
def __extract_subtitle(
|
||||
video_path: str,
|
||||
subtitle_stream_index: str,
|
||||
ffmpeg_path: str = "ffmpeg",
|
||||
sub_format="ass",
|
||||
) -> Optional[str]:
|
||||
if sub_format not in ["srt", "ass"]:
|
||||
raise ValueError("Invalid subtitle format")
|
||||
try:
|
||||
map_parameter = f"0:s:{subtitle_stream_index}"
|
||||
command = [ffmpeg_path, "-i", video_path, "-map", map_parameter, "-f", sub_format, "-"]
|
||||
result = subprocess.run(
|
||||
command, capture_output=True, text=True, encoding="utf-8", check=True
|
||||
def get_model_config(self) -> LLMConfig:
|
||||
if self._use_mp_agent:
|
||||
return LLMConfig(
|
||||
apikey=settings.LLM_API_KEY,
|
||||
base_url=settings.LLM_BASE_URL,
|
||||
model_name=settings.LLM_MODEL,
|
||||
thinking_level=settings.LLM_THINKING_LEVEL,
|
||||
provider=settings.LLM_PROVIDER.lower(),
|
||||
use_proxy=settings.LLM_USE_PROXY
|
||||
)
|
||||
return result.stdout
|
||||
except FileNotFoundError:
|
||||
logger.warn(f"错误:找不到视频文件 '{video_path}'")
|
||||
return None
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.warn(f"错误:提取字幕失败。\n错误信息:{e}")
|
||||
logger.warn(
|
||||
f"FFmpeg 输出 (stderr):\n{e.stderr.decode('utf-8', errors='ignore')}"
|
||||
else:
|
||||
return LLMConfig(
|
||||
apikey=self._gemini_apikey,
|
||||
base_url=self._llm_base_url,
|
||||
model_name=self._gemini_model,
|
||||
thinking_level=self._thinking_level,
|
||||
provider=self._llm_provider.lower(),
|
||||
use_proxy=self._use_proxy
|
||||
)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _extract_subtitles_by_lang(
|
||||
video_path: str, lang: str | list = "en", ffmpeg: str = "ffmpeg"
|
||||
) -> list[dict]:
|
||||
"""
|
||||
提取视频文件中的内嵌英文字幕,使用 MediaInfo 查找字幕流。
|
||||
"""
|
||||
|
||||
def check_lang(track_lang: str) -> bool:
|
||||
if isinstance(lang, list):
|
||||
return track_lang in lang
|
||||
return track_lang == lang
|
||||
|
||||
supported_codec = ["S_TEXT/UTF8", "S_TEXT/ASS", "tx3g"]
|
||||
subtitles = []
|
||||
try:
|
||||
media_info: pymediainfo.MediaInfo = pymediainfo.MediaInfo.parse(video_path)
|
||||
for track in media_info.tracks:
|
||||
if (
|
||||
track.track_type == "Text"
|
||||
and check_lang(track_lang=track.language)
|
||||
and track.codec_id in supported_codec
|
||||
):
|
||||
subtitle_stream_index = (
|
||||
track.stream_identifier
|
||||
) # MediaInfo 的 stream_id 从 1 开始,ffmpeg 从 0 开始
|
||||
extracted_subtitle = LexiAnnot.__extract_subtitle(
|
||||
video_path, subtitle_stream_index, ffmpeg
|
||||
)
|
||||
duration = 0
|
||||
if hasattr(track, "duration"):
|
||||
try:
|
||||
duration = int(float(track.duration))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if extracted_subtitle:
|
||||
subtitles.append(
|
||||
{
|
||||
"title": track.title or "",
|
||||
"subtitle": extracted_subtitle,
|
||||
"codec_id": track.codec_id,
|
||||
"stream_id": subtitle_stream_index,
|
||||
"duration": duration,
|
||||
}
|
||||
)
|
||||
if subtitles:
|
||||
# remove outliers with abnormally short duration
|
||||
if len(subtitles) > 1:
|
||||
durations = [sub["duration"] for sub in subtitles if sub["duration"] > 0]
|
||||
if durations:
|
||||
avg_duration = sum(durations) / len(durations)
|
||||
subtitles = [
|
||||
sub for sub in subtitles if sub["duration"] >= avg_duration * 0.2
|
||||
]
|
||||
if not subtitles:
|
||||
logger.warn("未找到标记为英语的文本字幕流")
|
||||
|
||||
except FileNotFoundError:
|
||||
logger.error(f"找不到视频文件 '{video_path}'")
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error(f"错误:提取字幕失败。\n错误信息:{e}")
|
||||
logger.error(f"FFmpeg 输出 (stderr):\n{e.stderr}")
|
||||
except Exception as e:
|
||||
logger.error(f"使用 MediaInfo 提取字幕时发生错误:{e}")
|
||||
return subtitles
|
||||
|
||||
def _process_chain(
|
||||
self,
|
||||
@@ -1867,7 +1659,6 @@ class LexiAnnot(_PluginBase):
|
||||
CEFR_LEVELS = ["A1", "A2", "B1", "B2", "C1", "C2"]
|
||||
simple_vocabulary = set(filter(lambda x: x < self._annot_level, CEFR_LEVELS))
|
||||
learner_level = max(simple_vocabulary)
|
||||
model_temperature = float(self._model_temperature) if self._model_temperature else 0.3
|
||||
logger.info("通过 spaCy 分词...")
|
||||
for seg in segments:
|
||||
if self._shutdown_event.is_set():
|
||||
@@ -1879,25 +1670,19 @@ class LexiAnnot(_PluginBase):
|
||||
simple_level=simple_vocabulary
|
||||
)
|
||||
if self._gemini_available:
|
||||
if self._use_mp_agent:
|
||||
llm_apikey = settings.LLM_API_KEY
|
||||
llm_base_url = settings.LLM_BASE_URL
|
||||
llm_model_name = settings.LLM_MODEL
|
||||
llm_provider = settings.LLM_PROVIDER.lower()
|
||||
else:
|
||||
llm_apikey = self._gemini_apikey
|
||||
llm_base_url = self._llm_base_url
|
||||
llm_model_name = self._gemini_model
|
||||
llm_provider = self._llm_provider.lower()
|
||||
llm = initialize_llm(
|
||||
provider=llm_provider,
|
||||
model_name=llm_model_name,
|
||||
base_url=llm_base_url,
|
||||
api_key=llm_apikey or '',
|
||||
temperature=model_temperature,
|
||||
max_retries=self._max_retries,
|
||||
proxy=self._use_proxy,
|
||||
)
|
||||
llm_config = self.get_model_config()
|
||||
llm = asyncio.run_coroutine_threadsafe(
|
||||
LLMHelper.get_llm(
|
||||
provider=llm_config.provider,
|
||||
model=llm_config.model_name,
|
||||
thinking_level=llm_config.thinking_level,
|
||||
api_key=llm_config.apikey,
|
||||
base_url=llm_config.base_url,
|
||||
use_proxy=llm_config.use_proxy
|
||||
),
|
||||
global_vars.loop
|
||||
).result()
|
||||
|
||||
segments = llm_process_chain(
|
||||
lexi=lexi,
|
||||
llm=llm,
|
||||
@@ -1926,8 +1711,8 @@ class LexiAnnot(_PluginBase):
|
||||
f"{self._accent_color_rgb[1]:02x}{self._accent_color_rgb[0]:02x}&"
|
||||
) # &H00FFFFFF&
|
||||
|
||||
statistical_res = LexiAnnot.analyze_ass_language(ass_file)
|
||||
main_style: str | None = LexiAnnot.select_main_style_weighted(statistical_res, lang)
|
||||
statistical_res = SubtitleHelper.analyze_ass_language(ass_file)
|
||||
main_style: str | None = SubtitleHelper.select_main_style_weighted(statistical_res, lang)
|
||||
if not main_style:
|
||||
logger.error("无法确定主要字幕样式")
|
||||
return None, None
|
||||
@@ -2004,7 +1789,7 @@ class LexiAnnot(_PluginBase):
|
||||
"new_text": new_text,
|
||||
}
|
||||
replacements.append(replacement)
|
||||
LexiAnnot.replace_by_plaintext_positions(
|
||||
SubtitleHelper.replace_by_plaintext_positions(
|
||||
main_processor[seg.index], replacements
|
||||
)
|
||||
if self._sentence_translation:
|
||||
|
||||
@@ -4,9 +4,7 @@ import threading
|
||||
from langchain_core.language_models.chat_models import BaseChatModel
|
||||
from langchain_core.prompts import ChatPromptTemplate
|
||||
from langchain_core.output_parsers import PydanticOutputParser
|
||||
from pydantic import SecretStr
|
||||
|
||||
from app.core.config import settings
|
||||
from app.schemas import Context
|
||||
from app.schemas.types import MediaType
|
||||
from app.log import logger
|
||||
@@ -60,59 +58,6 @@ UNIVERSAL_POS_MAP: dict[UniversalPos, str | None] = {
|
||||
}
|
||||
|
||||
|
||||
def initialize_llm(
|
||||
provider: str,
|
||||
api_key: str,
|
||||
model_name: str,
|
||||
base_url: str | None,
|
||||
temperature: float = 0.1,
|
||||
max_retries: int = 3,
|
||||
proxy: bool = False,
|
||||
) -> BaseChatModel:
|
||||
"""初始化 LLM"""
|
||||
|
||||
if provider == "google":
|
||||
if proxy:
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
return ChatOpenAI(
|
||||
model=settings.LLM_MODEL,
|
||||
api_key=SecretStr(api_key),
|
||||
max_retries=3,
|
||||
base_url="https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
temperature=settings.LLM_TEMPERATURE,
|
||||
openai_proxy=settings.PROXY_HOST,
|
||||
)
|
||||
from langchain_google_genai import ChatGoogleGenerativeAI
|
||||
|
||||
return ChatGoogleGenerativeAI(
|
||||
model=model_name,
|
||||
google_api_key=api_key, # noqa
|
||||
max_retries=max_retries,
|
||||
temperature=temperature,
|
||||
)
|
||||
elif provider == "deepseek":
|
||||
from langchain_deepseek import ChatDeepSeek
|
||||
|
||||
return ChatDeepSeek(
|
||||
model=model_name,
|
||||
api_key=SecretStr(api_key),
|
||||
max_retries=max_retries,
|
||||
temperature=temperature,
|
||||
)
|
||||
else:
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
return ChatOpenAI(
|
||||
model=model_name,
|
||||
api_key=SecretStr(api_key),
|
||||
max_retries=max_retries,
|
||||
base_url=base_url,
|
||||
temperature=temperature,
|
||||
openai_proxy=settings.PROXY_HOST if proxy else None,
|
||||
)
|
||||
|
||||
|
||||
def convert_pos_to_spacy(pos: str):
|
||||
"""
|
||||
将给定的词性列表转换为 spaCy 库中使用的词性标签
|
||||
@@ -727,5 +672,4 @@ def llm_process_chain(
|
||||
lexi, llm, context, start, end, learner_level, media_name, translate_sentences
|
||||
)
|
||||
)
|
||||
|
||||
return SegmentList(root=segments_list)
|
||||
|
||||
@@ -365,3 +365,12 @@ class VocabularyAnnotatingToolInput(BaseModel):
|
||||
class QueryAnnotationTasksToolInput(BaseModel):
|
||||
count: int = Field(default=5, description="The maximum number of returned annotation tasks")
|
||||
explanation: str = Field(..., description="This is a tool for querying the latest annotation tasks in AnnotLexi")
|
||||
|
||||
|
||||
class LLMConfig(BaseModel):
|
||||
apikey: str
|
||||
provider: str
|
||||
model_name: str
|
||||
thinking_level: str | None = Field(default=None)
|
||||
base_url: str | None = Field(default=None)
|
||||
use_proxy: bool = Field(default=False)
|
||||
|
||||
@@ -1,10 +1,277 @@
|
||||
import re
|
||||
import subprocess
|
||||
from collections import Counter
|
||||
from typing import Generator, Any, overload
|
||||
|
||||
from pysubs2 import SSAEvent
|
||||
import pymediainfo
|
||||
from langdetect import detect
|
||||
from pysubs2 import SSAEvent, SSAFile
|
||||
|
||||
from app.log import logger
|
||||
|
||||
from .schemas import SubtitleSegment
|
||||
|
||||
|
||||
class SubtitleHelper:
|
||||
|
||||
@staticmethod
|
||||
def remove_substring(replacements: list[dict]):
|
||||
new_list = []
|
||||
replacements.sort(key=lambda x: x["end"] - x["start"], reverse=True)
|
||||
for r in replacements:
|
||||
if any((r["start"] >= new["start"] and r["end"] <= new["end"]) for new in new_list):
|
||||
continue
|
||||
new_list.append(r)
|
||||
return new_list
|
||||
|
||||
@staticmethod
|
||||
def analyze_ass_language(ass_file: SSAFile):
|
||||
|
||||
def _replace_with_spaces(_text):
|
||||
"""
|
||||
使用等长的空格替换文本中的 (xxx) 模式。
|
||||
例如:"(Hi)" 会被替换成 " " (4个空格)
|
||||
"""
|
||||
pattern = r"(\([^()]*\)|\[[^\[\]]*\])"
|
||||
return re.sub(pattern, lambda match: " " * len(match.group(1)), _text)
|
||||
|
||||
styles = {}
|
||||
for style in ass_file.styles:
|
||||
styles[style] = {"text": [], "duration": 0, "text_size": 0, "times": 0}
|
||||
for dialogue in ass_file:
|
||||
style = dialogue.style
|
||||
text = _replace_with_spaces(dialogue.plaintext)
|
||||
sub_text = text.split("\n")
|
||||
if style not in styles or not text:
|
||||
continue
|
||||
styles[style]["text"].extend(sub_text)
|
||||
styles[style]["duration"] += dialogue.duration
|
||||
styles[style]["text_size"] += len(text)
|
||||
styles[style]["times"] += 1
|
||||
style_language_analysis = {}
|
||||
for style_name, data in styles.items():
|
||||
all_text = " ".join(data["text"])
|
||||
if not all_text.strip():
|
||||
style_language_analysis[style_name] = None
|
||||
continue
|
||||
|
||||
languages = []
|
||||
# 对每个文本片段进行语言检测
|
||||
for text_fragment in data["text"]:
|
||||
try:
|
||||
lang = detect(text_fragment)
|
||||
languages.append(lang)
|
||||
except Exception as e:
|
||||
# 无法检测的文本
|
||||
logger.debug(e)
|
||||
|
||||
if languages:
|
||||
language_counts = Counter(languages)
|
||||
most_common_language = language_counts.most_common(1)[0]
|
||||
style_language_analysis[style_name] = {
|
||||
"main_language": most_common_language[0],
|
||||
"proportion": most_common_language[1] / len(languages),
|
||||
"duration": data["duration"],
|
||||
"text_size": data["text_size"],
|
||||
"times": data["times"],
|
||||
}
|
||||
else:
|
||||
style_language_analysis[style_name] = None
|
||||
|
||||
return style_language_analysis
|
||||
|
||||
@staticmethod
|
||||
def select_main_style_weighted(analysis: dict[str, Any], known_language: str, weights = None):
|
||||
"""
|
||||
根据语言分析结果和已知的字幕语言,使用加权评分选择主要样式
|
||||
|
||||
:params analysis: `analyze_ass_language` 函数的输出结果
|
||||
:params known_language: 已知的字幕语言代码
|
||||
:params weights: 各个维度的权重,权重之和应为 1
|
||||
:returns: 主要字幕的样式名称,如果没有匹配的样式则返回 None
|
||||
"""
|
||||
if weights is None:
|
||||
weights = {"times": 0.5, "text_size": 0.4, "duration": 0.1}
|
||||
matching_styles = []
|
||||
max_times = max([analysis.get("times", 0) for _, analysis in analysis.items() if analysis] or [0]) or 1
|
||||
max_text_size = max([analysis.get("text_size", 0) for _, analysis in analysis.items() if analysis] or [0]) or 1
|
||||
max_duration = max([analysis.get("duration", 0) for _, analysis in analysis.items() if analysis] or [0]) or 1
|
||||
for style, info in analysis.items():
|
||||
if not info:
|
||||
continue
|
||||
if info.get("main_language") == known_language:
|
||||
# 跳过多语言
|
||||
if info.get("proportion", 0) < 0.5:
|
||||
continue
|
||||
score = 0
|
||||
score += info.get("times", 0) * weights.get("times", 0) / max_times
|
||||
score += info.get("text_size", 0) * weights.get("text_size", 0) / max_text_size
|
||||
score += info.get("duration", 0) * weights.get("duration", 0) / max_duration
|
||||
matching_styles.append((style, score))
|
||||
|
||||
if not matching_styles:
|
||||
return None
|
||||
|
||||
sorted_styles = sorted(matching_styles, key=lambda item: item[1], reverse=True)
|
||||
return sorted_styles[0][0]
|
||||
|
||||
@staticmethod
|
||||
def set_srt_style(ass: SSAFile) -> SSAFile:
|
||||
ass.info["ScaledBorderAndShadow"] = "no"
|
||||
play_res_y = int(ass.info["PlayResY"])
|
||||
if "Default" in ass.styles:
|
||||
ass.styles["Default"].marginv = play_res_y // 16
|
||||
ass.styles["Default"].fontname = "Microsoft YaHei"
|
||||
ass.styles["Default"].fontsize = play_res_y // 16
|
||||
return ass
|
||||
|
||||
@staticmethod
|
||||
def __extract_subtitle(
|
||||
video_path: str,
|
||||
subtitle_stream_index: str,
|
||||
ffmpeg_path: str = "ffmpeg",
|
||||
sub_format="ass",
|
||||
) -> str | None:
|
||||
if sub_format not in ["srt", "ass"]:
|
||||
raise ValueError("Invalid subtitle format")
|
||||
try:
|
||||
map_parameter = f"0:s:{subtitle_stream_index}"
|
||||
command = [ffmpeg_path, "-i", video_path, "-map", map_parameter, "-f", sub_format, "-"]
|
||||
result = subprocess.run(
|
||||
command, capture_output=True, text=True, encoding="utf-8", check=True
|
||||
)
|
||||
return result.stdout
|
||||
except FileNotFoundError:
|
||||
logger.warn(f"错误:找不到视频文件 '{video_path}'")
|
||||
return None
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.warn(f"错误:提取字幕失败。\n错误信息:{e}")
|
||||
logger.warn(
|
||||
f"FFmpeg 输出 (stderr):\n{e.stderr.decode('utf-8', errors='ignore')}"
|
||||
)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def extract_subtitles_by_lang(
|
||||
video_path: str, lang: str | list = "en", ffmpeg: str = "ffmpeg"
|
||||
) -> list[dict]:
|
||||
"""
|
||||
提取视频文件中的内嵌英文字幕,使用 MediaInfo 查找字幕流。
|
||||
"""
|
||||
|
||||
def check_lang(track_lang: str) -> bool:
|
||||
if isinstance(lang, list):
|
||||
return track_lang in lang
|
||||
return track_lang == lang
|
||||
|
||||
supported_codec = ["S_TEXT/UTF8", "S_TEXT/ASS", "tx3g"]
|
||||
subtitles = []
|
||||
try:
|
||||
media_info: pymediainfo.MediaInfo = pymediainfo.MediaInfo.parse(video_path)
|
||||
for track in media_info.tracks:
|
||||
if (
|
||||
track.track_type == "Text"
|
||||
and check_lang(track_lang=track.language)
|
||||
and track.codec_id in supported_codec
|
||||
):
|
||||
subtitle_stream_index = (
|
||||
track.stream_identifier
|
||||
) # MediaInfo 的 stream_id 从 1 开始,ffmpeg 从 0 开始
|
||||
extracted_subtitle = SubtitleHelper.__extract_subtitle(
|
||||
video_path, subtitle_stream_index, ffmpeg
|
||||
)
|
||||
duration = 0
|
||||
if hasattr(track, "duration"):
|
||||
try:
|
||||
duration = int(float(track.duration))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if extracted_subtitle:
|
||||
subtitles.append(
|
||||
{
|
||||
"title": track.title or "",
|
||||
"subtitle": extracted_subtitle,
|
||||
"codec_id": track.codec_id,
|
||||
"stream_id": subtitle_stream_index,
|
||||
"duration": duration,
|
||||
}
|
||||
)
|
||||
if subtitles:
|
||||
# remove outliers with abnormally short duration
|
||||
if len(subtitles) > 1:
|
||||
durations = [sub["duration"] for sub in subtitles if sub["duration"] > 0]
|
||||
if durations:
|
||||
avg_duration = sum(durations) / len(durations)
|
||||
subtitles = [
|
||||
sub for sub in subtitles if sub["duration"] >= avg_duration * 0.2
|
||||
]
|
||||
if not subtitles:
|
||||
logger.warn("未找到标记为英语的文本字幕流")
|
||||
|
||||
except FileNotFoundError:
|
||||
logger.error(f"找不到视频文件 '{video_path}'")
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error(f"错误:提取字幕失败。\n错误信息:{e}")
|
||||
logger.error(f"FFmpeg 输出 (stderr):\n{e.stderr}")
|
||||
except Exception as e:
|
||||
logger.error(f"使用 MediaInfo 提取字幕时发生错误:{e}")
|
||||
return subtitles
|
||||
|
||||
@staticmethod
|
||||
def replace_by_plaintext_positions(line: SSAEvent, replacements: list[dict]):
|
||||
"""
|
||||
使用 replacements 中的 plaintext 位置信息, 替换 line.text 中的内容。
|
||||
:param line: SSAEvent line
|
||||
:param replacements: [{'start': int, 'end': int, 'old_text': str, 'new_text': str}, ...]
|
||||
"""
|
||||
text = line.text
|
||||
tag_pattern = re.compile(r"{.*?}") # 匹配 {xxx} 格式控制符
|
||||
special_pattern = re.compile(r"\\[Nh]")
|
||||
# 构建 plaintext 位置到 text 索引的映射
|
||||
mapping = {} # plaintext_index -> text_index
|
||||
p_index = 0 # 当前 plaintext 索引
|
||||
t_index = 0 # 当前 text 索引
|
||||
|
||||
while t_index < len(text):
|
||||
if text[t_index] == "{":
|
||||
# 跳过格式标签
|
||||
match = tag_pattern.match(text, t_index)
|
||||
if match:
|
||||
t_index = match.end()
|
||||
continue
|
||||
elif text[t_index] == "\\":
|
||||
match = special_pattern.match(text, t_index)
|
||||
if match:
|
||||
t_index = match.end() - 1
|
||||
continue
|
||||
# 非格式字符
|
||||
mapping[p_index] = t_index
|
||||
p_index += 1
|
||||
t_index += 1
|
||||
replacements = SubtitleHelper.remove_substring(replacements)
|
||||
# 按照 mapping 执行替换(倒序替换防止位置错位)
|
||||
new_text = text
|
||||
for r in sorted(replacements, key=lambda x: x["start"], reverse=True):
|
||||
start = mapping.get(r["start"])
|
||||
end = mapping.get(r["end"] - 1)
|
||||
if start is None or end is None:
|
||||
continue
|
||||
end += 1
|
||||
new_text = new_text[:start] + r["new_text"] + new_text[end:]
|
||||
|
||||
line.text = new_text
|
||||
|
||||
@staticmethod
|
||||
def hex_to_rgb(hex_color: str | None) -> tuple[int, ...] | None:
|
||||
if not hex_color:
|
||||
return None
|
||||
pattern = r"^#[0-9a-fA-F]{6}$"
|
||||
if re.match(pattern, hex_color) is None:
|
||||
return None
|
||||
hex_color = hex_color.lstrip("#") # 去掉前面的 #
|
||||
return tuple(int(hex_color[i: i + 2], 16) for i in (0, 2, 4))
|
||||
|
||||
|
||||
class SubtitleProcessor:
|
||||
def __init__(self):
|
||||
self._events: list[SSAEvent] = []
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from threading import Event
|
||||
from typing import List, Tuple, Dict, Any
|
||||
from typing import List, Tuple, Dict, Any, Optional
|
||||
|
||||
import pytz
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
@@ -27,7 +27,7 @@ class LibraryScraper(_PluginBase):
|
||||
# 插件图标
|
||||
plugin_icon = "scraper.png"
|
||||
# 插件版本
|
||||
plugin_version = "2.1.1"
|
||||
plugin_version = "2.1.3"
|
||||
# 插件作者
|
||||
plugin_author = "jxxghp"
|
||||
# 作者主页
|
||||
@@ -51,6 +51,9 @@ class LibraryScraper(_PluginBase):
|
||||
_exclude_paths = ""
|
||||
# 退出事件
|
||||
_event = Event()
|
||||
# 刮削目标类型
|
||||
_target_dir = "dir"
|
||||
_target_file = "file"
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
|
||||
@@ -302,7 +305,7 @@ class LibraryScraper(_PluginBase):
|
||||
exclude_paths = self._exclude_paths.split("\n")
|
||||
# 已选择的目录
|
||||
paths = self._scraper_paths.split("\n")
|
||||
# 需要适削的媒体文件夹
|
||||
# 需要刮削的媒体目录或文件
|
||||
scraper_paths = []
|
||||
for path in paths:
|
||||
if not path:
|
||||
@@ -339,38 +342,116 @@ class LibraryScraper(_PluginBase):
|
||||
if exclude_flag:
|
||||
logger.debug(f"{file_path} 在排除目录中,跳过 ...")
|
||||
continue
|
||||
# 识别是电影还是电视剧
|
||||
if not mtype:
|
||||
file_meta = MetaInfoPath(file_path)
|
||||
mtype = file_meta.type
|
||||
# 重命名格式
|
||||
rename_format = settings.TV_RENAME_FORMAT \
|
||||
if mtype == MediaType.TV else settings.MOVIE_RENAME_FORMAT
|
||||
# 计算重命名中的文件夹层数
|
||||
rename_format_level = len(rename_format.split("/")) - 1
|
||||
if rename_format_level < 1:
|
||||
if mtype and not self.__match_forced_type_path(
|
||||
file_path=file_path,
|
||||
scraper_path=scraper_path,
|
||||
mtype=mtype
|
||||
):
|
||||
logger.debug(f"{file_path} 不属于强制指定的{mtype.value}目录,跳过 ...")
|
||||
continue
|
||||
# 取相对路径的第1层目录
|
||||
media_path = file_path.parents[rename_format_level - 1]
|
||||
dir_item = (media_path, mtype)
|
||||
if dir_item not in scraper_paths:
|
||||
logger.info(f"发现目录:{dir_item}")
|
||||
scraper_paths.append(dir_item)
|
||||
# 识别是电影还是电视剧,强制类型只作为默认值,不污染后续文件识别结果
|
||||
file_meta = MetaInfoPath(file_path)
|
||||
file_mtype = mtype
|
||||
if not file_mtype:
|
||||
file_mtype = file_meta.type
|
||||
if file_mtype == MediaType.UNKNOWN:
|
||||
file_mtype = self.__infer_type_from_path(file_path=file_path, scraper_path=scraper_path)
|
||||
scraper_item = self.__get_scrape_item(
|
||||
file_path=file_path,
|
||||
scraper_path=scraper_path,
|
||||
mtype=file_mtype,
|
||||
tmdbid=file_meta.tmdbid
|
||||
)
|
||||
if scraper_item and not self.__contains_scrape_item(scraper_paths, scraper_item):
|
||||
logger.info(f"发现刮削目标:{scraper_item}")
|
||||
scraper_paths.append(scraper_item)
|
||||
# 开始刮削
|
||||
if scraper_paths:
|
||||
for item in scraper_paths:
|
||||
logger.info(f"开始刮削目录:{item[0]} ...")
|
||||
self.__scrape_dir(path=item[0], mtype=item[1])
|
||||
logger.info(f"开始刮削目标:{item[0]} ...")
|
||||
self.__scrape_path(path=item[0], mtype=item[1], target_type=item[2], tmdbid=item[3])
|
||||
else:
|
||||
logger.info(f"未发现需要刮削的目录")
|
||||
|
||||
def __scrape_dir(self, path: Path, mtype: MediaType):
|
||||
@staticmethod
|
||||
def __get_scrape_item(
|
||||
file_path: Path,
|
||||
scraper_path: Path,
|
||||
mtype: MediaType,
|
||||
tmdbid: Optional[int] = None
|
||||
) -> Optional[Tuple[Path, MediaType, str, Optional[int]]]:
|
||||
"""
|
||||
削刮一个目录,该目录必须是媒体文件目录
|
||||
根据扫描根目录和重命名格式,计算真正需要刮削的媒体目录。
|
||||
分类目录通常位于扫描根目录下方,必须用相对路径计算,否则会被误当成媒体目录。
|
||||
"""
|
||||
# 优先读取本地nfo文件
|
||||
tmdbid = None
|
||||
if mtype == MediaType.MOVIE:
|
||||
if not file_path or not scraper_path or not mtype:
|
||||
return None
|
||||
|
||||
rename_format = settings.TV_RENAME_FORMAT if mtype == MediaType.TV else settings.MOVIE_RENAME_FORMAT
|
||||
rename_format_level = len(rename_format.strip("/").split("/")) - 1
|
||||
try:
|
||||
relative_path = file_path.relative_to(scraper_path)
|
||||
except ValueError:
|
||||
relative_path = Path(file_path.name)
|
||||
|
||||
if rename_format_level >= 1:
|
||||
relative_parts = Path(relative_path).parts
|
||||
# 重命名格式中包含几层目录,就从文件往上取几层目录;前缀分类目录不会参与计算。
|
||||
if len(relative_parts) > rename_format_level:
|
||||
media_path = scraper_path.joinpath(*relative_parts[:-rename_format_level])
|
||||
return media_path, mtype, LibraryScraper._target_dir, tmdbid
|
||||
|
||||
# 扁平目录或自定义重命名格式无目录层级时,退回到单文件刮削,避免分类目录识别失败。
|
||||
return file_path, mtype, LibraryScraper._target_file, tmdbid
|
||||
|
||||
@staticmethod
|
||||
def __contains_scrape_item(scraper_paths: List[Tuple[Path, MediaType, str, Optional[int]]],
|
||||
scraper_item: Tuple[Path, MediaType, str, Optional[int]]) -> bool:
|
||||
"""
|
||||
判断刮削目标是否已存在;同一目标只刮削一次,tmdbid 仅作为识别辅助信息。
|
||||
"""
|
||||
return any(item[:3] == scraper_item[:3] for item in scraper_paths)
|
||||
|
||||
@staticmethod
|
||||
def __match_forced_type_path(file_path: Path, scraper_path: Path, mtype: MediaType) -> bool:
|
||||
"""
|
||||
强制指定媒体类型时,如果扫描根目录下同时存在“电影/电视剧”分类,则只处理匹配类型的目录。
|
||||
"""
|
||||
if mtype not in (MediaType.MOVIE, MediaType.TV):
|
||||
return True
|
||||
try:
|
||||
relative_parts = file_path.relative_to(scraper_path).parts
|
||||
except ValueError:
|
||||
return True
|
||||
media_type_parts = {MediaType.MOVIE.value, MediaType.TV.value}.intersection(relative_parts)
|
||||
return not media_type_parts or mtype.value in media_type_parts
|
||||
|
||||
@staticmethod
|
||||
def __infer_type_from_path(file_path: Path, scraper_path: Path) -> MediaType:
|
||||
"""
|
||||
文件名无法识别类型时,从扫描根目录下的“电影/电视剧”分类层推断媒体类型。
|
||||
"""
|
||||
try:
|
||||
relative_parts = file_path.relative_to(scraper_path).parts
|
||||
except ValueError:
|
||||
relative_parts = file_path.parts
|
||||
if MediaType.TV.value in relative_parts:
|
||||
return MediaType.TV
|
||||
if MediaType.MOVIE.value in relative_parts:
|
||||
return MediaType.MOVIE
|
||||
return MediaType.UNKNOWN
|
||||
|
||||
def __scrape_path(self, path: Path, mtype: MediaType, target_type: str = _target_dir,
|
||||
tmdbid: Optional[int] = None):
|
||||
"""
|
||||
刮削一个媒体目录或媒体文件
|
||||
"""
|
||||
# 优先读取本地nfo文件;文件路径中解析出的 tmdbid 作为兜底识别信息保留。
|
||||
if target_type == self._target_file:
|
||||
nfo_path = path.with_suffix(".nfo")
|
||||
if nfo_path.exists():
|
||||
tmdbid = self.__get_tmdbid_from_nfo(nfo_path)
|
||||
elif mtype == MediaType.MOVIE:
|
||||
# 电影
|
||||
movie_nfo = path / "movie.nfo"
|
||||
if movie_nfo.exists():
|
||||
@@ -393,6 +474,10 @@ class LibraryScraper(_PluginBase):
|
||||
meta.type = mtype
|
||||
mediainfo = self.chain.recognize_media(meta=meta)
|
||||
if not mediainfo:
|
||||
if target_type == self._target_dir:
|
||||
# 目录名无法识别时,通常是分类目录,继续尝试其中的具体媒体文件。
|
||||
self.__scrape_child_files(path=path, mtype=mtype)
|
||||
return
|
||||
logger.warn(f"未识别到媒体信息:{path}")
|
||||
return
|
||||
|
||||
@@ -405,13 +490,17 @@ class LibraryScraper(_PluginBase):
|
||||
# 获取图片
|
||||
self.chain.obtain_images(mediainfo)
|
||||
# 刮削
|
||||
item_path = str(path).replace("\\", "/")
|
||||
if target_type == self._target_dir:
|
||||
item_path = f"{item_path}/"
|
||||
MediaChain().scrape_metadata(
|
||||
fileitem=schemas.FileItem(
|
||||
storage="local",
|
||||
type="dir",
|
||||
path=str(path).replace("\\", "/") + "/",
|
||||
type=target_type,
|
||||
path=item_path,
|
||||
name=path.name,
|
||||
basename=path.stem,
|
||||
extension=path.suffix[1:] if target_type == self._target_file else None,
|
||||
modify_time=path.stat().st_mtime,
|
||||
),
|
||||
mediainfo=mediainfo,
|
||||
@@ -419,6 +508,26 @@ class LibraryScraper(_PluginBase):
|
||||
)
|
||||
logger.info(f"{path} 刮削完成")
|
||||
|
||||
def __scrape_child_files(self, path: Path, mtype: MediaType):
|
||||
"""
|
||||
分类目录无法作为单个媒体识别时,继续按目录内的媒体文件逐个刮削。
|
||||
"""
|
||||
child_files = SystemUtils.list_files(path, settings.RMT_MEDIAEXT)
|
||||
if not child_files:
|
||||
logger.warn(f"未识别到媒体信息:{path}")
|
||||
return
|
||||
logger.info(f"{path} 可能是分类目录,开始刮削目录内媒体文件 ...")
|
||||
for child_file in child_files:
|
||||
if self._event.is_set():
|
||||
logger.info(f"媒体库刮削服务停止")
|
||||
return
|
||||
child_mtype = mtype
|
||||
child_meta = MetaInfoPath(child_file)
|
||||
if not child_mtype:
|
||||
child_mtype = child_meta.type
|
||||
self.__scrape_path(path=child_file, mtype=child_mtype, target_type=self._target_file,
|
||||
tmdbid=child_meta.tmdbid)
|
||||
|
||||
@staticmethod
|
||||
def __get_tmdbid_from_nfo(file_path: Path):
|
||||
"""
|
||||
|
||||
@@ -38,7 +38,7 @@ class MediaServerMsg(_PluginBase):
|
||||
# 插件图标
|
||||
plugin_icon = "mediaplay.png"
|
||||
# 插件版本
|
||||
plugin_version = "1.8.2.2"
|
||||
plugin_version = "1.8.2.3"
|
||||
# 插件作者
|
||||
plugin_author = "jxxghp"
|
||||
# 作者主页
|
||||
@@ -67,6 +67,7 @@ class MediaServerMsg(_PluginBase):
|
||||
# Webhook事件映射配置
|
||||
_webhook_actions = {
|
||||
"library.new": "新入库",
|
||||
"ItemAdded": "新入库",
|
||||
"system.notificationtest": "测试",
|
||||
"playback.start": "开始播放",
|
||||
"playback.stop": "停止播放",
|
||||
@@ -79,6 +80,11 @@ class MediaServerMsg(_PluginBase):
|
||||
"item.rate": "标记了"
|
||||
}
|
||||
|
||||
# Jellyfin Webhook 新增媒体事件使用 ItemAdded,与通用入库事件按同一类型处理。
|
||||
_webhook_event_aliases = {
|
||||
"ItemAdded": "library.new"
|
||||
}
|
||||
|
||||
# 媒体服务器默认图标
|
||||
_webhook_images = {
|
||||
"emby": "https://emby.media/notificationicon.png",
|
||||
@@ -188,7 +194,7 @@ class MediaServerMsg(_PluginBase):
|
||||
拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
|
||||
"""
|
||||
types_options = [
|
||||
{"title": "新入库", "value": "library.new"},
|
||||
{"title": "新入库", "value": "library.new|ItemAdded"},
|
||||
{"title": "开始播放", "value": "playback.start|media.play|PlaybackStart"},
|
||||
{"title": "停止播放", "value": "playback.stop|media.stop|PlaybackStop"},
|
||||
{"title": "用户标记", "value": "item.rate"},
|
||||
@@ -427,7 +433,9 @@ class MediaServerMsg(_PluginBase):
|
||||
|
||||
# 检查事件类型是否在支持范围内
|
||||
event_type = getattr(event_info, 'event', None)
|
||||
if not event_type or not self._webhook_actions.get(event_type):
|
||||
event_action_type = self._get_event_action_type(event_type)
|
||||
event_match_types = self._get_event_match_types(event_type)
|
||||
if not event_type or not self._webhook_actions.get(event_action_type):
|
||||
logger.debug(f"事件类型 {event_type} 不在支持范围内")
|
||||
return
|
||||
|
||||
@@ -437,7 +445,7 @@ class MediaServerMsg(_PluginBase):
|
||||
for _type in self._types:
|
||||
allowed_types.update(_type.split("|"))
|
||||
|
||||
if event_type not in allowed_types:
|
||||
if not event_match_types.intersection(allowed_types):
|
||||
logger.debug(f"事件类型 {event_type} 不在用户配置的允许范围内{allowed_types}")
|
||||
logger.info(f"未开启 {event_type} 类型的消息通知")
|
||||
return
|
||||
@@ -460,8 +468,8 @@ class MediaServerMsg(_PluginBase):
|
||||
# 通用去重:构造去重键
|
||||
item_id = getattr(event_info, 'item_id', '')
|
||||
if item_id:
|
||||
# 使用 server_name + event_type + item_id 作为唯一标识
|
||||
dedupe_key = f"{server_name}-{event_type}-{item_id}" if server_name else f"{event_type}-{item_id}"
|
||||
# 使用标准化后的事件类型去重,避免同类事件别名造成重复通知。
|
||||
dedupe_key = f"{server_name}-{event_action_type}-{item_id}" if server_name else f"{event_action_type}-{item_id}"
|
||||
# 检查是否已处理过该事件
|
||||
if dedupe_key in self.__get_elements():
|
||||
logger.debug(f"检测到重复Webhook事件,已处理过: {dedupe_key}")
|
||||
@@ -477,7 +485,7 @@ class MediaServerMsg(_PluginBase):
|
||||
if not self._aggregate_enabled:
|
||||
return False
|
||||
|
||||
if event_type != "library.new":
|
||||
if event_action_type != "library.new":
|
||||
return False
|
||||
|
||||
item_type = getattr(event_info, 'item_type', None)
|
||||
@@ -520,7 +528,7 @@ class MediaServerMsg(_PluginBase):
|
||||
item_name = getattr(event_info, 'item_name', '')
|
||||
|
||||
message_title = ""
|
||||
event_action = self._webhook_actions.get(event_type, event_type)
|
||||
event_action = self._webhook_actions.get(event_action_type, event_type)
|
||||
if item_type in ["TV", "SHOW"]:
|
||||
message_title = f"{event_action}剧集 {item_name}"
|
||||
elif item_type == "MOV":
|
||||
@@ -841,7 +849,7 @@ class MediaServerMsg(_PluginBase):
|
||||
if not first_event.tmdb_id:
|
||||
logger.debug("tmdb_id为空,使用原有逻辑发送消息")
|
||||
# 使用原有逻辑构造消息
|
||||
message_title = f"📺 {self._webhook_actions.get(first_event.event)}剧集:{first_event.item_name}"
|
||||
message_title = f"📺 {self._get_event_action(first_event.event)}剧集:{first_event.item_name}"
|
||||
message_texts = []
|
||||
message_texts.append(
|
||||
f"⏰ 时间:{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time()))}")
|
||||
@@ -908,7 +916,7 @@ class MediaServerMsg(_PluginBase):
|
||||
except Exception as e:
|
||||
logger.error(f"从json_object提取SeriesName时出错: {str(e)}")
|
||||
|
||||
message_title = f"📺 {self._webhook_actions.get(first_event.event, '新入库')}剧集:{show_name}"
|
||||
message_title = f"📺 {self._get_event_action(first_event.event) or '新入库'}剧集:{show_name}"
|
||||
|
||||
if is_multiple_episodes:
|
||||
message_title += f" {events_count}个文件"
|
||||
@@ -1215,6 +1223,29 @@ class MediaServerMsg(_PluginBase):
|
||||
logger.error(f"获取有效元素时出错: {str(e)}")
|
||||
return []
|
||||
|
||||
def _get_event_action_type(self, event_type: Optional[str]) -> Optional[str]:
|
||||
"""
|
||||
获取用于消息文案和去重的标准事件类型。
|
||||
"""
|
||||
if event_type is None:
|
||||
return None
|
||||
return self._webhook_event_aliases.get(str(event_type), str(event_type))
|
||||
|
||||
def _get_event_match_types(self, event_type: Optional[str]) -> set:
|
||||
"""
|
||||
获取配置匹配时允许命中的事件类型,兼容历史配置和媒体服务器原始事件。
|
||||
"""
|
||||
if event_type is None:
|
||||
return set()
|
||||
normalized_type = self._get_event_action_type(event_type)
|
||||
return {str(event_type), normalized_type}
|
||||
|
||||
def _get_event_action(self, event_type: Optional[str]) -> Optional[str]:
|
||||
"""
|
||||
获取事件对应的消息动作文案。
|
||||
"""
|
||||
return self._webhook_actions.get(self._get_event_action_type(event_type))
|
||||
|
||||
def _get_play_link(self, event_info: WebhookEventInfo) -> Optional[str]:
|
||||
"""
|
||||
获取媒体项目的播放链接
|
||||
|
||||
8
plugins.v2/oidcauth/.gitignore
vendored
Normal file
8
plugins.v2/oidcauth/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
# oidcauth plugin - source only
|
||||
src/
|
||||
package.json
|
||||
package-lock.json
|
||||
vite.config.js
|
||||
node_modules/
|
||||
index.dev.html
|
||||
dist/
|
||||
62
plugins.v2/oidcauth/CHANGELOG.md
Normal file
62
plugins.v2/oidcauth/CHANGELOG.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# OidcAuth 更新日志
|
||||
|
||||
## v0.3.1
|
||||
|
||||
### 后端 `__init__.py`(Code Review 修复)
|
||||
|
||||
- 修复回调事件类型不匹配:绑定流程错误时 `event_type` 动态设为 `oidcauth_bind_callback`,避免前端仅显示通用提示
|
||||
- 移除解绑方法中多余的 `_ensure_login_ready()` 检查,允许 OIDC 关闭状态下正常解绑
|
||||
|
||||
---
|
||||
|
||||
## v0.3.0
|
||||
|
||||
### 前端 `AppPage.vue`(完全重构)
|
||||
|
||||
- **双栏布局重构**:废弃旧版单页卡片,改为左侧特性介绍 + 右侧绑定状态的现代化双栏布局
|
||||
- **动态背景装饰**:渐变光斑、浮动光点动画组成的沉浸式背景层
|
||||
- **三步绑定可视化**:绑定流程拆分为 3 步(跳转 IdP → 完成认证 → 自动绑定),每步支持 pending / loading / done / error 四种状态 + 动态图标
|
||||
- **深色/浅色主题自适应**:`MutationObserver` 监听 Vuetify 主题变化,自动切换完整深/浅配色方案
|
||||
- **postMessage 通信增强**:弹窗绑定改用 `postMessage` 事件驱动 + 1s 轮询兜底,替换旧版纯轮询方式
|
||||
- **已绑定详情卡片**:展示绑定用户、OIDC Subject(脱敏截断)、认证状态(绿色"有效"标识)+ 用户名备注
|
||||
- **解绑确认流程**:新增两步确认(点击解绑 → 确认/取消),防止误操作
|
||||
- **功能开关感知**:OIDC 关闭时显式展示黄色警告条 + 绑定/解绑按钮自动 disabled
|
||||
- **四个特性介绍卡片**:左侧新增单点登录、免密认证、统一账号、安全可靠四张彩色特性卡片
|
||||
- **底部信息栏**:版权提示 + 插件版本号展示
|
||||
|
||||
### 后端 `__init__.py`
|
||||
|
||||
- 图标由 `Authelia_A.png` 改为 `Oidcauth_A.png`
|
||||
- 版本号 `0.2.0` → `0.3.0`
|
||||
|
||||
---
|
||||
|
||||
## v0.2.0
|
||||
|
||||
### 后端 `__init__.py`
|
||||
|
||||
- **修复 PROXY_HOST 为空时崩溃**:所有 `proxy=settings.PROXY_HOST` 改为 `proxy=settings.PROXY_HOST or None`(3 处)
|
||||
- **回调 HTML 美化**:从极简白屏升级为带关闭按钮 ✕、居中排版、200ms 延迟自动关闭的友好页面
|
||||
- **补充配置表单使用指南**:`status()` 接口新增 `redirect_uri`、`masked_sub` 等字段
|
||||
|
||||
### 前端 `AuthPage.vue`
|
||||
|
||||
- **挂载后自动跳转 OIDC 授权**:`onMounted` 自动调用 `checkAndStart()`,免去手动点击按钮
|
||||
- **加载动画**:新增 `checking` 状态 + `VProgressCircular` 旋转指示器
|
||||
- **错误重试机制**:错误时展示"重试"按钮,点击可重新发起认证
|
||||
- **增强错误信息**:新增"管理员未启用 OIDC 认证"、"无法连接到认证服务"等精确提示
|
||||
- **`messageReceived` 防误判**:弹窗关闭时检查是否已收到 postMessage,避免误报"认证窗口已关闭"
|
||||
|
||||
---
|
||||
|
||||
## v0.1.0
|
||||
|
||||
### 首次发布
|
||||
|
||||
- **OIDC 授权码流程登录**:支持标准 OIDC Authorization Code Flow
|
||||
- **账号绑定/解绑**:已登录用户可绑定 OIDC 身份,支持解绑
|
||||
- **Provider 配置**:支持任意兼容标准 OIDC 协议的服务(Authelia、Keycloak、Casdoor 等)
|
||||
- **联邦认证界面**:基于 Vue 3 + Vite Federation 的前端组件
|
||||
- **登录票据认证桥接**:`create_plugin_auth_ticket` 与 MoviePilot 认证系统集成
|
||||
- **图标**:`plugin_icon` 为 `Authelia_A.png`
|
||||
- **作者信息**:`plugin_author` 为 `ui-beam-9,jxxghp`
|
||||
837
plugins.v2/oidcauth/__init__.py
Normal file
837
plugins.v2/oidcauth/__init__.py
Normal file
@@ -0,0 +1,837 @@
|
||||
import hashlib
|
||||
import json
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from app import schemas
|
||||
from app.core.auth_bridge import create_plugin_auth_ticket
|
||||
from app.core.config import settings
|
||||
from app.db.models.user import User
|
||||
from app.db.user_oper import get_current_active_user
|
||||
from app.log import logger
|
||||
from app.plugins import _PluginBase
|
||||
|
||||
|
||||
class OidcAuth(_PluginBase):
|
||||
"""
|
||||
OIDC 认证插件。
|
||||
"""
|
||||
|
||||
plugin_name = "OIDC 认证"
|
||||
plugin_desc = (
|
||||
"通过 OpenID Connect Provider 为 MoviePilot 提供插件化登录与账号绑定。"
|
||||
)
|
||||
plugin_icon = "Oidcauth_A.png"
|
||||
plugin_version = "0.3.1"
|
||||
plugin_author = "ui-beam-9,jxxghp"
|
||||
author_url = "https://github.com/ui-beam-9"
|
||||
plugin_label = "认证,OIDC,SSO"
|
||||
plugin_order = 36
|
||||
|
||||
_STATE_TTL_SECONDS = 300
|
||||
_PLUGIN_ID = "OidcAuth"
|
||||
|
||||
def __init__(self):
|
||||
"""
|
||||
初始化插件运行状态。
|
||||
"""
|
||||
super().__init__()
|
||||
self._enabled = False
|
||||
self._config: Dict[str, Any] = {}
|
||||
self._states: Dict[str, Dict[str, Any]] = {}
|
||||
self._state_lock = threading.RLock()
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
"""
|
||||
初始化插件配置。
|
||||
|
||||
:param config: 插件配置
|
||||
"""
|
||||
self._config = self._normalize_config(config or {})
|
||||
self._enabled = bool(self._config.get("enabled"))
|
||||
|
||||
def get_state(self) -> bool:
|
||||
"""
|
||||
获取插件启用状态。
|
||||
|
||||
:return: 是否启用
|
||||
"""
|
||||
return bool(self._enabled)
|
||||
|
||||
@staticmethod
|
||||
def get_command() -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取插件命令列表。
|
||||
|
||||
:return: 命令列表
|
||||
"""
|
||||
return []
|
||||
|
||||
def get_api(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
注册插件 API。
|
||||
|
||||
:return: API 路由声明列表
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"path": "/public/status",
|
||||
"endpoint": self.public_status,
|
||||
"methods": ["GET"],
|
||||
"summary": "查询 OIDC 登录公开状态",
|
||||
"allow_anonymous": True,
|
||||
},
|
||||
{
|
||||
"path": "/authorize",
|
||||
"endpoint": self.authorize,
|
||||
"methods": ["GET"],
|
||||
"summary": "发起 OIDC 登录",
|
||||
"allow_anonymous": True,
|
||||
},
|
||||
{
|
||||
"path": "/callback",
|
||||
"endpoint": self.callback,
|
||||
"methods": ["GET"],
|
||||
"summary": "OIDC 回调",
|
||||
"allow_anonymous": True,
|
||||
},
|
||||
{
|
||||
"path": "/status",
|
||||
"endpoint": self.status,
|
||||
"methods": ["GET"],
|
||||
"auth": "bear",
|
||||
"summary": "查询 OIDC 插件状态",
|
||||
},
|
||||
{
|
||||
"path": "/config",
|
||||
"endpoint": self.save_config_api,
|
||||
"methods": ["POST"],
|
||||
"auth": "bear",
|
||||
"summary": "保存 OIDC 插件配置",
|
||||
},
|
||||
{
|
||||
"path": "/test",
|
||||
"endpoint": self.test_api,
|
||||
"methods": ["POST"],
|
||||
"auth": "bear",
|
||||
"summary": "测试 OIDC Provider",
|
||||
},
|
||||
{
|
||||
"path": "/bind/start",
|
||||
"endpoint": self.bind_start,
|
||||
"methods": ["POST"],
|
||||
"auth": "bear",
|
||||
"summary": "发起 OIDC 账号绑定",
|
||||
},
|
||||
{
|
||||
"path": "/unbind",
|
||||
"endpoint": self.unbind,
|
||||
"methods": ["POST"],
|
||||
"auth": "bear",
|
||||
"summary": "解绑 OIDC 账号",
|
||||
},
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def get_render_mode() -> Tuple[str, str]:
|
||||
"""
|
||||
声明插件使用 Vue 联邦组件。
|
||||
|
||||
:return: 渲染模式与构建产物路径
|
||||
"""
|
||||
return "vue", "assets"
|
||||
|
||||
def get_auth_providers(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
声明未登录页面可展示的认证入口。
|
||||
|
||||
:return: 认证入口列表
|
||||
"""
|
||||
if not self._is_login_ready():
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"id": "oidc",
|
||||
"name": self._config.get("provider_name") or "OIDC 登录",
|
||||
"icon": "mdi-openid",
|
||||
"component": "AuthPage",
|
||||
"enabled": True,
|
||||
}
|
||||
]
|
||||
|
||||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
"""
|
||||
返回 Vue 模式配置表单占位。
|
||||
|
||||
:return: 表单配置与默认模型
|
||||
"""
|
||||
return [], self._config
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
"""
|
||||
返回 Vue 模式详情页占位。
|
||||
|
||||
:return: 页面配置
|
||||
"""
|
||||
return []
|
||||
|
||||
def get_sidebar_nav(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
声明插件侧栏管理入口。
|
||||
不设置 permission 限制,所有登录用户均可访问。
|
||||
|
||||
:return: 侧栏导航项
|
||||
"""
|
||||
if not self.get_state():
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"nav_key": "main",
|
||||
"title": "OIDC 认证",
|
||||
"icon": "mdi-openid",
|
||||
"section": "system",
|
||||
"order": 47,
|
||||
}
|
||||
]
|
||||
|
||||
def stop_service(self):
|
||||
"""
|
||||
停止插件服务。
|
||||
"""
|
||||
with self._state_lock:
|
||||
self._states.clear()
|
||||
|
||||
def public_status(self) -> schemas.Response:
|
||||
"""
|
||||
查询匿名可见的 OIDC 登录状态。
|
||||
|
||||
:return: 公开状态响应
|
||||
"""
|
||||
return schemas.Response(
|
||||
success=True,
|
||||
data={
|
||||
"enabled": self._is_login_ready(),
|
||||
"name": self._config.get("provider_name") or "OIDC 登录",
|
||||
"icon": "mdi-openid",
|
||||
},
|
||||
)
|
||||
|
||||
async def authorize(self, request: Request) -> RedirectResponse:
|
||||
"""
|
||||
发起 OIDC 登录授权。
|
||||
|
||||
:param request: 当前请求
|
||||
:return: IdP 授权跳转响应
|
||||
"""
|
||||
self._ensure_login_ready()
|
||||
state = self._create_state(action="login")
|
||||
redirect_uri = self._callback_url(request)
|
||||
authorize_url = await self._build_authorize_url(
|
||||
redirect_uri=redirect_uri, state=state
|
||||
)
|
||||
return RedirectResponse(authorize_url)
|
||||
|
||||
async def callback(
|
||||
self,
|
||||
request: Request,
|
||||
code: Optional[str] = None,
|
||||
state: Optional[str] = None,
|
||||
error: Optional[str] = None,
|
||||
error_description: Optional[str] = None,
|
||||
) -> HTMLResponse:
|
||||
"""
|
||||
处理 OIDC 登录或绑定回调。
|
||||
|
||||
:param request: 当前请求
|
||||
:param code: 授权码
|
||||
:param state: CSRF state
|
||||
:param error: IdP 返回的错误码
|
||||
:param error_description: IdP 返回的错误描述
|
||||
:return: 回调 HTML
|
||||
"""
|
||||
if error:
|
||||
return self._callback_html(False, "oidc_error", error_description or error)
|
||||
if not code or not state:
|
||||
return self._callback_html(
|
||||
False, "oidc_invalid_callback", "OIDC 回调参数不完整"
|
||||
)
|
||||
state_data = self._pop_state(state)
|
||||
if not state_data:
|
||||
return self._callback_html(
|
||||
False, "oidc_invalid_state", "OIDC state 无效或已过期"
|
||||
)
|
||||
action = state_data.get("action")
|
||||
event_type = "oidcauth_bind_callback" if action == "bind" else "oidcauth_callback"
|
||||
try:
|
||||
redirect_uri = self._callback_url(request)
|
||||
token_data = await self._exchange_code(code=code, redirect_uri=redirect_uri)
|
||||
userinfo = await self._fetch_userinfo(token_data)
|
||||
sub = str(userinfo.get("sub") or "")
|
||||
if not sub:
|
||||
return self._callback_html(
|
||||
False, "oidc_no_sub", "OIDC 用户信息缺少 sub", event_type=event_type
|
||||
)
|
||||
if action == "bind":
|
||||
return self._handle_bind_callback(
|
||||
state_data=state_data, userinfo=userinfo, sub=sub
|
||||
)
|
||||
return self._handle_login_callback(userinfo=userinfo, sub=sub)
|
||||
except Exception as err:
|
||||
logger.error(f"OIDC 回调处理失败: {err}", exc_info=True)
|
||||
return self._callback_html(False, "oidc_error", str(err), event_type=event_type)
|
||||
|
||||
def status(
|
||||
self, current_user: User = Depends(get_current_active_user)
|
||||
) -> schemas.Response:
|
||||
"""
|
||||
查询当前用户绑定状态和管理员配置。
|
||||
|
||||
:param current_user: 当前登录用户
|
||||
:return: 插件状态响应
|
||||
"""
|
||||
binding = self._get_user_binding(current_user.id)
|
||||
data: Dict[str, Any] = {
|
||||
"public": {
|
||||
"enabled": self._is_login_ready(),
|
||||
"name": self._config.get("provider_name") or "OIDC 登录",
|
||||
"redirect_uri": self._configured_or_display_redirect_uri(),
|
||||
},
|
||||
"binding": {
|
||||
"bound": bool(binding),
|
||||
"sub": (binding or {}).get("sub"),
|
||||
"masked_sub": self._mask_sub((binding or {}).get("sub")),
|
||||
"username": (binding or {}).get("username"),
|
||||
"email": (binding or {}).get("email"),
|
||||
"local_username": current_user.name,
|
||||
},
|
||||
"plugin_version": self.plugin_version,
|
||||
"is_superuser": bool(current_user.is_superuser),
|
||||
}
|
||||
if current_user.is_superuser:
|
||||
data["config"] = self._config.copy()
|
||||
return schemas.Response(success=True, data=data)
|
||||
|
||||
def save_config_api(
|
||||
self, config: dict, current_user: User = Depends(get_current_active_user)
|
||||
) -> schemas.Response:
|
||||
"""
|
||||
保存 OIDC 插件配置。
|
||||
|
||||
:param config: 前端提交的配置
|
||||
:param current_user: 当前登录用户
|
||||
:return: 保存结果
|
||||
"""
|
||||
if not current_user.is_superuser:
|
||||
raise HTTPException(status_code=403, detail="用户权限不足")
|
||||
normalized = self._normalize_config(config or {})
|
||||
self._config = normalized
|
||||
self._enabled = bool(normalized.get("enabled"))
|
||||
self.update_config(normalized)
|
||||
|
||||
return schemas.Response(success=True, data={"config": normalized})
|
||||
|
||||
async def test_api(
|
||||
self, body: dict, current_user: User = Depends(get_current_active_user)
|
||||
) -> schemas.Response:
|
||||
"""
|
||||
测试 OIDC Provider 发现文档。
|
||||
|
||||
:param body: 待测试配置
|
||||
:param current_user: 当前登录用户
|
||||
:return: 测试结果
|
||||
"""
|
||||
if not current_user.is_superuser:
|
||||
raise HTTPException(status_code=403, detail="用户权限不足")
|
||||
test_config = self._normalize_config({**self._config, **(body or {})})
|
||||
try:
|
||||
discovery = await self._get_discovery(test_config)
|
||||
missing = [
|
||||
key
|
||||
for key in (
|
||||
"authorization_endpoint",
|
||||
"token_endpoint",
|
||||
"userinfo_endpoint",
|
||||
)
|
||||
if not discovery.get(key)
|
||||
]
|
||||
if missing:
|
||||
return schemas.Response(
|
||||
success=False, message=f"发现文档缺少端点: {', '.join(missing)}"
|
||||
)
|
||||
return schemas.Response(success=True, message="OIDC Provider 连接正常")
|
||||
except Exception as err:
|
||||
return schemas.Response(success=False, message=str(err))
|
||||
|
||||
async def bind_start(
|
||||
self,
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
) -> schemas.Response:
|
||||
"""
|
||||
发起当前用户的 OIDC 绑定流程。
|
||||
|
||||
:param request: 当前请求
|
||||
:param current_user: 当前登录用户
|
||||
:return: 授权地址
|
||||
"""
|
||||
self._ensure_login_ready()
|
||||
if self._get_user_binding(current_user.id):
|
||||
return schemas.Response(success=False, message="当前用户已绑定 OIDC 账号")
|
||||
state = self._create_state(action="bind", user_id=current_user.id)
|
||||
redirect_uri = self._callback_url(request)
|
||||
authorize_url = await self._build_authorize_url(
|
||||
redirect_uri=redirect_uri, state=state
|
||||
)
|
||||
return schemas.Response(success=True, data={"authorize_url": authorize_url})
|
||||
|
||||
def unbind(
|
||||
self, current_user: User = Depends(get_current_active_user)
|
||||
) -> schemas.Response:
|
||||
"""
|
||||
解绑当前用户的 OIDC 账号。
|
||||
|
||||
:param current_user: 当前登录用户
|
||||
:return: 解绑结果
|
||||
"""
|
||||
binding = self._get_user_binding(current_user.id)
|
||||
if not binding:
|
||||
return schemas.Response(success=False, message="当前用户未绑定 OIDC 账号")
|
||||
self.del_data(
|
||||
self._sub_key(binding.get("issuer") or "", binding.get("sub") or "")
|
||||
)
|
||||
self.del_data(self._user_key(current_user.id))
|
||||
return schemas.Response(success=True)
|
||||
|
||||
def _normalize_config(self, config: dict) -> Dict[str, Any]:
|
||||
"""
|
||||
规范化插件配置。
|
||||
|
||||
:param config: 原始配置
|
||||
:return: 规范化后的配置
|
||||
"""
|
||||
return {
|
||||
"enabled": bool(config.get("enabled")),
|
||||
"provider_name": str(config.get("provider_name") or "OIDC 登录"),
|
||||
"issuer": str(config.get("issuer") or "").strip().rstrip("/"),
|
||||
"client_id": str(config.get("client_id") or "").strip(),
|
||||
"client_secret": str(config.get("client_secret") or ""),
|
||||
"scopes": str(config.get("scopes") or "openid profile email").strip(),
|
||||
"redirect_uri": str(config.get("redirect_uri") or "").strip(),
|
||||
"username_claim": str(
|
||||
config.get("username_claim") or "preferred_username"
|
||||
).strip(),
|
||||
"email_claim": str(config.get("email_claim") or "email").strip(),
|
||||
"allow_auto_bind_by_username": bool(
|
||||
config.get("allow_auto_bind_by_username")
|
||||
),
|
||||
}
|
||||
|
||||
def _is_login_ready(self) -> bool:
|
||||
"""
|
||||
判断 OIDC 登录是否具备最小可用配置。
|
||||
|
||||
:return: 是否可用
|
||||
"""
|
||||
return bool(
|
||||
self._enabled
|
||||
and self._config.get("issuer")
|
||||
and self._config.get("client_id")
|
||||
and self._config.get("client_secret")
|
||||
)
|
||||
|
||||
def _ensure_login_ready(self) -> None:
|
||||
"""
|
||||
确认 OIDC 登录已可用。
|
||||
"""
|
||||
if not self._is_login_ready():
|
||||
raise HTTPException(status_code=400, detail="OIDC 登录未启用或配置不完整")
|
||||
|
||||
async def _get_discovery(self, config: Optional[dict] = None) -> dict:
|
||||
"""
|
||||
获取 OIDC Provider 发现文档。
|
||||
|
||||
:param config: 指定配置,未传入时使用当前配置
|
||||
:return: 发现文档
|
||||
"""
|
||||
oidc_config = config or self._config
|
||||
issuer = str(oidc_config.get("issuer") or "").rstrip("/")
|
||||
if not issuer:
|
||||
raise ValueError("OIDC issuer 未配置")
|
||||
discovery_url = (
|
||||
issuer
|
||||
if issuer.endswith("/.well-known/openid-configuration")
|
||||
else f"{issuer}/.well-known/openid-configuration"
|
||||
)
|
||||
async with httpx.AsyncClient(
|
||||
timeout=10.0, proxy=settings.PROXY_HOST or None
|
||||
) as client:
|
||||
response = await client.get(discovery_url)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def _build_authorize_url(self, redirect_uri: str, state: str) -> str:
|
||||
"""
|
||||
构造 OIDC 授权地址。
|
||||
|
||||
:param redirect_uri: 回调地址
|
||||
:param state: CSRF state
|
||||
:return: 授权地址
|
||||
"""
|
||||
discovery = await self._get_discovery()
|
||||
authorization_endpoint = discovery.get("authorization_endpoint")
|
||||
if not authorization_endpoint:
|
||||
raise ValueError("OIDC 发现文档缺少 authorization_endpoint")
|
||||
params = {
|
||||
"client_id": self._config.get("client_id"),
|
||||
"response_type": "code",
|
||||
"scope": self._config.get("scopes") or "openid profile email",
|
||||
"redirect_uri": redirect_uri,
|
||||
"state": state,
|
||||
}
|
||||
return f"{authorization_endpoint}?{urlencode(params)}"
|
||||
|
||||
async def _exchange_code(self, code: str, redirect_uri: str) -> dict:
|
||||
"""
|
||||
使用授权码换取 Token。
|
||||
|
||||
:param code: 授权码
|
||||
:param redirect_uri: 回调地址
|
||||
:return: Token 响应
|
||||
"""
|
||||
discovery = await self._get_discovery()
|
||||
token_endpoint = discovery.get("token_endpoint")
|
||||
if not token_endpoint:
|
||||
raise ValueError("OIDC 发现文档缺少 token_endpoint")
|
||||
async with httpx.AsyncClient(
|
||||
timeout=10.0, proxy=settings.PROXY_HOST or None
|
||||
) as client:
|
||||
response = await client.post(
|
||||
token_endpoint,
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": redirect_uri,
|
||||
"client_id": self._config.get("client_id"),
|
||||
"client_secret": self._config.get("client_secret"),
|
||||
},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def _fetch_userinfo(self, token_data: dict) -> dict:
|
||||
"""
|
||||
使用 Access Token 获取用户信息。
|
||||
|
||||
:param token_data: Token 响应
|
||||
:return: 用户信息
|
||||
"""
|
||||
access_token = token_data.get("access_token")
|
||||
if not access_token:
|
||||
raise ValueError("OIDC Token 响应缺少 access_token")
|
||||
discovery = await self._get_discovery()
|
||||
userinfo_endpoint = discovery.get("userinfo_endpoint")
|
||||
if not userinfo_endpoint:
|
||||
raise ValueError("OIDC 发现文档缺少 userinfo_endpoint")
|
||||
async with httpx.AsyncClient(
|
||||
timeout=10.0, proxy=settings.PROXY_HOST or None
|
||||
) as client:
|
||||
response = await client.get(
|
||||
userinfo_endpoint, headers={"Authorization": f"Bearer {access_token}"}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def _handle_login_callback(self, userinfo: dict, sub: str) -> HTMLResponse:
|
||||
"""
|
||||
处理 OIDC 登录回调。
|
||||
|
||||
:param userinfo: OIDC 用户信息
|
||||
:param sub: OIDC subject
|
||||
:return: 回调 HTML
|
||||
"""
|
||||
issuer = self._config.get("issuer") or ""
|
||||
binding = self.get_data(self._sub_key(issuer, sub))
|
||||
user = (
|
||||
User.get(db=None, rid=(binding or {}).get("user_id")) if binding else None
|
||||
)
|
||||
if not user and self._config.get("allow_auto_bind_by_username"):
|
||||
user = self._auto_bind_by_username(userinfo=userinfo, sub=sub)
|
||||
if not user:
|
||||
return self._callback_html(
|
||||
False, "oidc_unbound", "该 OIDC 账号尚未绑定 MoviePilot 用户"
|
||||
)
|
||||
if not user.is_active:
|
||||
return self._callback_html(False, "user_inactive", "用户已被禁用")
|
||||
ticket = create_plugin_auth_ticket(
|
||||
user_id=user.id,
|
||||
provider_id=f"{self._PLUGIN_ID}:oidc",
|
||||
metadata={"sub": sub, "issuer": issuer},
|
||||
)
|
||||
return self._callback_html(True, data={"ticket": ticket})
|
||||
|
||||
def _handle_bind_callback(
|
||||
self, state_data: dict, userinfo: dict, sub: str
|
||||
) -> HTMLResponse:
|
||||
"""
|
||||
处理 OIDC 绑定回调。
|
||||
|
||||
:param state_data: state 中保存的绑定上下文
|
||||
:param userinfo: OIDC 用户信息
|
||||
:param sub: OIDC subject
|
||||
:return: 回调 HTML
|
||||
"""
|
||||
user_id = state_data.get("user_id")
|
||||
user = User.get(db=None, rid=user_id) if user_id else None
|
||||
if not user or not user.is_active:
|
||||
return self._callback_html(
|
||||
False,
|
||||
"bind_user_invalid",
|
||||
"绑定用户不存在或已禁用",
|
||||
event_type="oidcauth_bind_callback",
|
||||
)
|
||||
issuer = self._config.get("issuer") or ""
|
||||
existing = self.get_data(self._sub_key(issuer, sub))
|
||||
if existing and existing.get("user_id") != user.id:
|
||||
return self._callback_html(
|
||||
False,
|
||||
"bind_conflict",
|
||||
"该 OIDC 账号已绑定其他用户",
|
||||
event_type="oidcauth_bind_callback",
|
||||
)
|
||||
if self._get_user_binding(user.id):
|
||||
return self._callback_html(
|
||||
False,
|
||||
"already_bound",
|
||||
"当前用户已绑定 OIDC 账号",
|
||||
event_type="oidcauth_bind_callback",
|
||||
)
|
||||
binding = self._binding_payload(user_id=user.id, userinfo=userinfo, sub=sub)
|
||||
self.save_data(self._user_key(user.id), binding)
|
||||
self.save_data(self._sub_key(issuer, sub), binding)
|
||||
return self._callback_html(
|
||||
True, data={"bound": True}, event_type="oidcauth_bind_callback"
|
||||
)
|
||||
|
||||
def _auto_bind_by_username(self, userinfo: dict, sub: str) -> Optional[User]:
|
||||
"""
|
||||
按用户名 claim 自动绑定已有用户。
|
||||
|
||||
:param userinfo: OIDC 用户信息
|
||||
:param sub: OIDC subject
|
||||
:return: 绑定成功的用户
|
||||
"""
|
||||
username = str(
|
||||
userinfo.get(self._config.get("username_claim") or "preferred_username")
|
||||
or ""
|
||||
).strip()
|
||||
if not username:
|
||||
return None
|
||||
user = User.get_by_name(db=None, name=username)
|
||||
if not user or not user.is_active or self._get_user_binding(user.id):
|
||||
return None
|
||||
binding = self._binding_payload(user_id=user.id, userinfo=userinfo, sub=sub)
|
||||
issuer = self._config.get("issuer") or ""
|
||||
self.save_data(self._user_key(user.id), binding)
|
||||
self.save_data(self._sub_key(issuer, sub), binding)
|
||||
return user
|
||||
|
||||
def _binding_payload(self, user_id: int, userinfo: dict, sub: str) -> dict:
|
||||
"""
|
||||
构造绑定数据。
|
||||
|
||||
:param user_id: 本地用户 ID
|
||||
:param userinfo: OIDC 用户信息
|
||||
:param sub: OIDC subject
|
||||
:return: 绑定数据
|
||||
"""
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"issuer": self._config.get("issuer") or "",
|
||||
"sub": sub,
|
||||
"username": userinfo.get(
|
||||
self._config.get("username_claim") or "preferred_username"
|
||||
),
|
||||
"email": userinfo.get(self._config.get("email_claim") or "email"),
|
||||
"updated_at": int(time.time()),
|
||||
}
|
||||
|
||||
def _create_state(self, action: str, user_id: Optional[int] = None) -> str:
|
||||
"""
|
||||
创建并缓存 OIDC state。
|
||||
|
||||
:param action: login 或 bind
|
||||
:param user_id: 绑定用户 ID
|
||||
:return: state 字符串
|
||||
"""
|
||||
state = secrets.token_urlsafe(32)
|
||||
with self._state_lock:
|
||||
self._cleanup_states()
|
||||
self._states[state] = {
|
||||
"action": action,
|
||||
"user_id": user_id,
|
||||
"created_at": time.time(),
|
||||
}
|
||||
return state
|
||||
|
||||
def _pop_state(self, state: str) -> Optional[dict]:
|
||||
"""
|
||||
取出并删除 OIDC state。
|
||||
|
||||
:param state: state 字符串
|
||||
:return: state 数据
|
||||
"""
|
||||
with self._state_lock:
|
||||
data = self._states.pop(state, None)
|
||||
self._cleanup_states()
|
||||
if not data:
|
||||
return None
|
||||
if time.time() - float(data.get("created_at") or 0) > self._STATE_TTL_SECONDS:
|
||||
return None
|
||||
return data
|
||||
|
||||
def _cleanup_states(self) -> None:
|
||||
"""
|
||||
清理过期 state。
|
||||
"""
|
||||
now = time.time()
|
||||
expired = [
|
||||
key
|
||||
for key, value in self._states.items()
|
||||
if now - float(value.get("created_at") or 0) > self._STATE_TTL_SECONDS
|
||||
]
|
||||
for key in expired:
|
||||
self._states.pop(key, None)
|
||||
|
||||
def _callback_url(self, request: Request) -> str:
|
||||
"""
|
||||
生成 OIDC 回调地址。
|
||||
|
||||
:param request: 当前请求
|
||||
:return: 回调地址
|
||||
"""
|
||||
if self._config.get("redirect_uri"):
|
||||
return self._config["redirect_uri"]
|
||||
path = f"{settings.API_V1_STR}/plugin/{self._PLUGIN_ID}/callback"
|
||||
if settings.MP_DOMAIN(path):
|
||||
return settings.MP_DOMAIN(path)
|
||||
return f"{str(request.base_url).rstrip('/')}{path}"
|
||||
|
||||
def _configured_or_display_redirect_uri(self) -> str:
|
||||
"""
|
||||
获取展示用回调地址。
|
||||
|
||||
:return: 回调地址或默认路径
|
||||
"""
|
||||
return (
|
||||
self._config.get("redirect_uri")
|
||||
or f"{settings.API_V1_STR}/plugin/{self._PLUGIN_ID}/callback"
|
||||
)
|
||||
|
||||
def _get_user_binding(self, user_id: int) -> Optional[dict]:
|
||||
"""
|
||||
获取用户绑定信息。
|
||||
|
||||
:param user_id: 本地用户 ID
|
||||
:return: 绑定信息
|
||||
"""
|
||||
return self.get_data(self._user_key(user_id))
|
||||
|
||||
@staticmethod
|
||||
def _user_key(user_id: int) -> str:
|
||||
"""
|
||||
构造用户绑定数据键。
|
||||
|
||||
:param user_id: 本地用户 ID
|
||||
:return: 数据键
|
||||
"""
|
||||
return f"binding:user:{user_id}"
|
||||
|
||||
@staticmethod
|
||||
def _sub_key(issuer: str, sub: str) -> str:
|
||||
"""
|
||||
构造 OIDC subject 反查数据键。
|
||||
|
||||
:param issuer: OIDC issuer
|
||||
:param sub: OIDC subject
|
||||
:return: 数据键
|
||||
"""
|
||||
digest = hashlib.sha256(f"{issuer}|{sub}".encode("utf-8")).hexdigest()
|
||||
return f"binding:sub:{digest}"
|
||||
|
||||
@staticmethod
|
||||
def _mask_sub(sub: Optional[str]) -> str:
|
||||
"""
|
||||
脱敏 OIDC subject。
|
||||
|
||||
:param sub: OIDC subject
|
||||
:return: 脱敏字符串
|
||||
"""
|
||||
if not sub:
|
||||
return ""
|
||||
value = str(sub)
|
||||
return f"{value[:6]}***" if len(value) > 6 else f"{value}***"
|
||||
|
||||
def _callback_html(
|
||||
self,
|
||||
success: bool,
|
||||
error: Optional[str] = None,
|
||||
message: Optional[str] = None,
|
||||
data: Optional[dict] = None,
|
||||
event_type: str = "oidcauth_callback",
|
||||
) -> HTMLResponse:
|
||||
"""
|
||||
构造回调 HTML,通过 postMessage 通知插件联邦页面。
|
||||
|
||||
:param success: 是否成功
|
||||
:param error: 错误码
|
||||
:param message: 错误信息
|
||||
:param data: 成功数据
|
||||
:param event_type: postMessage 事件类型
|
||||
:return: HTML 响应
|
||||
"""
|
||||
payload = {
|
||||
"type": event_type,
|
||||
"success": success,
|
||||
"error": error,
|
||||
"message": message,
|
||||
"data": data or {},
|
||||
}
|
||||
payload_json = json.dumps(payload, ensure_ascii=False)
|
||||
html = f"""<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>OIDC Callback</title>
|
||||
<style>
|
||||
* {{ margin:0; padding:0; box-sizing:border-box; }}
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f5f5; min-height: 100vh; display: flex; align-items: center; justify-content: center; }}
|
||||
.close-btn {{ position: fixed; top: 12px; right: 16px; width: 36px; height: 36px; border: none; background: rgba(0,0,0,0.06); border-radius: 50%; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 20px; color: #666; transition: background 0.2s, color 0.2s; }}
|
||||
.close-btn:hover {{ background: rgba(0,0,0,0.12); color: #333; }}
|
||||
.msg {{ padding: 24px; text-align: center; color: #333; font-size: 16px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<button class="close-btn" onclick="window.close()" title="关闭">✕</button>
|
||||
<div class="msg" id="msg"></div>
|
||||
<script>
|
||||
(function() {{
|
||||
var payload = {payload_json};
|
||||
if (window.opener && !window.opener.closed) {{
|
||||
window.opener.postMessage(payload, window.location.origin);
|
||||
setTimeout(function() {{ window.close(); }}, 200);
|
||||
}} else {{
|
||||
document.getElementById('msg').innerText = payload.success ? '认证成功,请关闭此窗口' : (payload.message || '认证失败');
|
||||
}}
|
||||
}})();
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
return HTMLResponse(content=html)
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,842 @@
|
||||
|
||||
.oidc-page[data-v-8a889949] {
|
||||
position: relative;
|
||||
height: 100vh;
|
||||
max-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
padding: 24px 32px 16px;
|
||||
color: #e4e4e7;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: #0c0c10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 背景装饰层 */
|
||||
.oidc-bg-decor[data-v-8a889949] {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* 大光斑 */
|
||||
.oidc-bg-blob[data-v-8a889949] {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.oidc-bg-blob-1[data-v-8a889949] {
|
||||
top: -160px;
|
||||
left: -160px;
|
||||
width: 600px;
|
||||
height: 600px;
|
||||
background: radial-gradient(circle, rgba(109, 40, 217, 0.18) 0%, transparent 70%);
|
||||
filter: blur(120px);
|
||||
}
|
||||
.oidc-bg-blob-2[data-v-8a889949] {
|
||||
bottom: -160px;
|
||||
right: -80px;
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
background: radial-gradient(circle, rgba(79, 70, 229, 0.14) 0%, transparent 70%);
|
||||
filter: blur(100px);
|
||||
}
|
||||
|
||||
/* 网格 */
|
||||
.oidc-bg-grid[data-v-8a889949] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(255, 255, 255, 0.14) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.14) 1px, transparent 1px);
|
||||
background-size: 48px 48px;
|
||||
}
|
||||
|
||||
/* 浮动光点 */
|
||||
.oidc-bg-orb[data-v-8a889949] {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
animation: orb-float-8a889949 var(--orb-dur, 6s) ease-in-out infinite;
|
||||
animation-delay: var(--orb-delay, 0s);
|
||||
}
|
||||
.oidc-bg-orb-1[data-v-8a889949] {
|
||||
--orb-dur: 6s;
|
||||
--orb-delay: 0s;
|
||||
top: 25%;
|
||||
left: 10%;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: rgba(167, 139, 250, 0.4);
|
||||
}
|
||||
.oidc-bg-orb-2[data-v-8a889949] {
|
||||
--orb-dur: 8s;
|
||||
--orb-delay: 1s;
|
||||
top: 33%;
|
||||
right: 12%;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: rgba(129, 140, 248, 0.4);
|
||||
}
|
||||
.oidc-bg-orb-3[data-v-8a889949] {
|
||||
--orb-dur: 7s;
|
||||
--orb-delay: 2.5s;
|
||||
bottom: 33%;
|
||||
left: 20%;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
background: rgba(196, 181, 253, 0.5);
|
||||
}
|
||||
@keyframes orb-float-8a889949 {
|
||||
0%, 100% { transform: translateY(0); opacity: 0.4;
|
||||
}
|
||||
50% { transform: translateY(calc(var(--orb-range, -20px))); opacity: 0.7;
|
||||
}
|
||||
}
|
||||
.oidc-bg-orb-1[data-v-8a889949] { --orb-range: -20px;
|
||||
}
|
||||
.oidc-bg-orb-2[data-v-8a889949] { --orb-range: 16px;
|
||||
}
|
||||
.oidc-bg-orb-3[data-v-8a889949] { --orb-range: -12px;
|
||||
}
|
||||
|
||||
/* 主内容区 */
|
||||
.oidc-main[data-v-8a889949] {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 28px;
|
||||
width: 100%;
|
||||
max-width: 1024px;
|
||||
align-items: stretch;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.oidc-main[data-v-8a889949] {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* 卡片通用 */
|
||||
.oidc-card[data-v-8a889949] {
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 18px;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 左侧 */
|
||||
.oidc-left-header[data-v-8a889949] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.oidc-left-icon[data-v-8a889949] {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 16px;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 0 28px rgba(124, 58, 237, 0.3);
|
||||
}
|
||||
.oidc-left-titles[data-v-8a889949] {
|
||||
min-width: 0;
|
||||
}
|
||||
.oidc-left-title[data-v-8a889949] {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.oidc-left-sub[data-v-8a889949] {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
margin: 4px 0 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.oidc-left-desc[data-v-8a889949] {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
line-height: 1.7;
|
||||
margin: 0 0 16px;
|
||||
max-width: 420px;
|
||||
}
|
||||
.oidc-left-tags[data-v-8a889949] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.oidc-left-tag[data-v-8a889949] {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
border-radius: 6px;
|
||||
padding: 5px 12px;
|
||||
line-height: 1;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
}
|
||||
.oidc-left-tag-sep[data-v-8a889949] {
|
||||
width: 20px;
|
||||
height: 1px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
/* 特性卡片 */
|
||||
.oidc-features[data-v-8a889949] {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.feature-card[data-v-8a889949] {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
border-radius: 14px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
background: rgba(255, 255, 255, 0.015);
|
||||
}
|
||||
.feature-card.feature-violet[data-v-8a889949] {
|
||||
background: rgba(124, 58, 237, 0.06);
|
||||
border-color: rgba(124, 58, 237, 0.15);
|
||||
}
|
||||
.feature-card.feature-blue[data-v-8a889949] {
|
||||
background: rgba(59, 130, 246, 0.06);
|
||||
border-color: rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
.feature-card.feature-green[data-v-8a889949] {
|
||||
background: rgba(16, 185, 129, 0.06);
|
||||
border-color: rgba(16, 185, 129, 0.15);
|
||||
}
|
||||
.feature-card.feature-amber[data-v-8a889949] {
|
||||
background: rgba(234, 179, 8, 0.06);
|
||||
border-color: rgba(234, 179, 8, 0.15);
|
||||
}
|
||||
.feature-icon[data-v-8a889949] {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.feature-icon svg[data-v-8a889949] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
.feature-purple[data-v-8a889949] {
|
||||
background: rgba(124, 58, 237, 0.12);
|
||||
color: #a78bfa;
|
||||
}
|
||||
.feature-blue-bg[data-v-8a889949] {
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
color: #60a5fa;
|
||||
}
|
||||
.feature-green-bg[data-v-8a889949] {
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
color: #34d399;
|
||||
}
|
||||
.feature-yellow-bg[data-v-8a889949] {
|
||||
background: rgba(234, 179, 8, 0.12);
|
||||
color: #facc15;
|
||||
}
|
||||
.feature-text[data-v-8a889949] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.feature-title[data-v-8a889949] {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.feature-desc[data-v-8a889949] {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 右侧 */
|
||||
.oidc-right[data-v-8a889949] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.oidc-right-top[data-v-8a889949] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.oidc-right-bigicon[data-v-8a889949] {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, #7c3aed 0%, #5b21b6 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 14px;
|
||||
box-shadow: 0 4px 20px rgba(124, 58, 237, 0.3);
|
||||
}
|
||||
.oidc-right-bigicon[data-v-8a889949] svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
.oidc-right-title[data-v-8a889949] {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #f1f1f5;
|
||||
margin: 0 0 8px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.oidc-right-sub[data-v-8a889949] {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
margin: 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* 中间内容区 */
|
||||
.oidc-right-body[data-v-8a889949] {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 未启用警告 */
|
||||
.oidc-disabled-banner[data-v-8a889949] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 12px;
|
||||
border-radius: 10px;
|
||||
background: rgba(234, 179, 8, 0.08);
|
||||
border: 1px solid rgba(234, 179, 8, 0.15);
|
||||
color: #eab308;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.oidc-disabled-icon[data-v-8a889949] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 步骤流程卡片 */
|
||||
.oidc-steps[data-v-8a889949] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.oidc-step[data-v-8a889949] {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
padding: 12px 16px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 12px;
|
||||
transition: background 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
.oidc-step[data-v-8a889949]:hover {
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.oidc-step-active[data-v-8a889949] {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.oidc-step-done-step[data-v-8a889949] {
|
||||
border-color: rgba(16, 185, 129, 0.15);
|
||||
background: rgba(16, 185, 129, 0.04);
|
||||
}
|
||||
.oidc-step-error-step[data-v-8a889949] {
|
||||
border-color: rgba(239, 68, 68, 0.15);
|
||||
background: rgba(239, 68, 68, 0.04);
|
||||
}
|
||||
.oidc-step-left[data-v-8a889949] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-right: 14px;
|
||||
flex-shrink: 0;
|
||||
padding-top: 2px;
|
||||
}
|
||||
.oidc-step-num[data-v-8a889949] {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 2px 8px rgba(124, 58, 237, 0.25);
|
||||
transition: background 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
.oidc-num-done[data-v-8a889949] {
|
||||
background: #10b981 !important;
|
||||
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.25) !important;
|
||||
}
|
||||
.oidc-num-loading[data-v-8a889949] {
|
||||
background: rgba(124, 58, 237, 0.4) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.oidc-num-error[data-v-8a889949] {
|
||||
background: #ef4444 !important;
|
||||
box-shadow: 0 2px 8px rgba(239, 68, 68, 0.25) !important;
|
||||
}
|
||||
|
||||
/* 转圈 spinner */
|
||||
.oidc-spinner[data-v-8a889949] {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: oidc-spin-8a889949 0.7s linear infinite;
|
||||
}
|
||||
@keyframes oidc-spin-8a889949 {
|
||||
to { transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 打勾 / 打叉 图标 */
|
||||
.oidc-step-check-icon[data-v-8a889949] {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: #fff;
|
||||
}
|
||||
.oidc-step-x-icon[data-v-8a889949] {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
color: #fff;
|
||||
}
|
||||
.oidc-step-right[data-v-8a889949] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
.oidc-step-title[data-v-8a889949] {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
line-height: 1.4;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
.oidc-step-desc[data-v-8a889949] {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
line-height: 1.5;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
/* 已绑定 */
|
||||
.oidc-bound-badge[data-v-8a889949] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: #10b981;
|
||||
background: rgba(16, 185, 129, 0.08);
|
||||
border: 1px solid rgba(16, 185, 129, 0.12);
|
||||
border-radius: 999px;
|
||||
padding: 4px 12px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.oidc-dot[data-v-8a889949] {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #10b981;
|
||||
box-shadow: 0 0 5px rgba(16, 185, 129, 0.4);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.oidc-info-rows[data-v-8a889949] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.oidc-info-row[data-v-8a889949] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
padding: 12px 14px;
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.oidc-info-row-label[data-v-8a889949] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.oidc-row-icon[data-v-8a889949] {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.oidc-info-row-value[data-v-8a889949] {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: right;
|
||||
margin-left: 12px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.oidc-info-row-status[data-v-8a889949] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: #10b981;
|
||||
font-weight: 500;
|
||||
margin-left: 12px;
|
||||
}
|
||||
.oidc-status-dot[data-v-8a889949] {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #10b981;
|
||||
box-shadow: 0 0 4px rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
.oidc-bound-desc[data-v-8a889949] {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 底部按钮区 */
|
||||
.oidc-right-footer[data-v-8a889949] {
|
||||
margin-top: auto;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
/* 按钮 */
|
||||
.oidc-btn[data-v-8a889949] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
outline: none;
|
||||
line-height: 1;
|
||||
}
|
||||
.oidc-btn[data-v-8a889949]:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.oidc-btn-primary[data-v-8a889949] {
|
||||
background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 14px rgba(124, 58, 237, 0.25);
|
||||
}
|
||||
.oidc-btn-primary[data-v-8a889949]:hover:not(:disabled) {
|
||||
box-shadow: 0 6px 20px rgba(124, 58, 237, 0.4);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.oidc-btn-primary[data-v-8a889949]:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
.oidc-btn-icon[data-v-8a889949] {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 解绑 */
|
||||
.oidc-btn-unbind[data-v-8a889949] {
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
color: #f87171;
|
||||
border: 1px solid rgba(239, 68, 68, 0.15);
|
||||
}
|
||||
.oidc-btn-unbind[data-v-8a889949]:hover:not(:disabled) {
|
||||
background: rgba(239, 68, 68, 0.14);
|
||||
border-color: rgba(239, 68, 68, 0.25);
|
||||
}
|
||||
.oidc-unbind-confirm-text[data-v-8a889949] {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
margin: 0 0 12px;
|
||||
text-align: center;
|
||||
}
|
||||
.oidc-unbind-actions[data-v-8a889949] {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.oidc-unbind-actions .oidc-btn[data-v-8a889949] {
|
||||
width: auto;
|
||||
flex: 1;
|
||||
}
|
||||
.oidc-btn-outline[data-v-8a889949] {
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.oidc-btn-outline[data-v-8a889949]:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
.oidc-btn-danger[data-v-8a889949] {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
color: #f87171;
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
.oidc-btn-danger[data-v-8a889949]:hover:not(:disabled) {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
border-color: rgba(239, 68, 68, 0.35);
|
||||
}
|
||||
|
||||
/* 提示 */
|
||||
.oidc-alert[data-v-8a889949] {
|
||||
margin-top: 12px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.oidc-alert-error[data-v-8a889949] {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #f87171;
|
||||
border: 1px solid rgba(239, 68, 68, 0.15);
|
||||
}
|
||||
.oidc-alert-success[data-v-8a889949] {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: #34d399;
|
||||
border: 1px solid rgba(16, 185, 129, 0.15);
|
||||
}
|
||||
|
||||
/* 底部 */
|
||||
.oidc-bottom[data-v-8a889949] {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
max-width: 1024px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.oidc-bottom-line[data-v-8a889949] {
|
||||
height: 1px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.oidc-bottom-content[data-v-8a889949] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
.oidc-bottom-left[data-v-8a889949] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.oidc-warn-icon[data-v-8a889949] {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
color: rgba(234, 179, 8, 0.6);
|
||||
}
|
||||
.oidc-bottom-right[data-v-8a889949] {
|
||||
color: rgba(255, 255, 255, 0.2);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* 过渡 */
|
||||
.oidc-fade-enter-active[data-v-8a889949],
|
||||
.oidc-fade-leave-active[data-v-8a889949] {
|
||||
transition: opacity 0.25s ease, transform 0.25s ease;
|
||||
}
|
||||
.oidc-fade-enter-from[data-v-8a889949],
|
||||
.oidc-fade-leave-to[data-v-8a889949] {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
/* ===== 浅色主题 ===== */
|
||||
.oidc-page.oidc-light[data-v-8a889949] {
|
||||
color: #1f2937;
|
||||
background: #f1f5f9;
|
||||
}
|
||||
.oidc-page.oidc-light .oidc-bg-blob-1[data-v-8a889949] {
|
||||
background: radial-gradient(circle, rgba(124, 58, 237, 0.08) 0%, transparent 70%);
|
||||
}
|
||||
.oidc-page.oidc-light .oidc-bg-blob-2[data-v-8a889949] {
|
||||
background: radial-gradient(circle, rgba(79, 70, 229, 0.06) 0%, transparent 70%);
|
||||
}
|
||||
.oidc-page.oidc-light .oidc-bg-grid[data-v-8a889949] {
|
||||
background-image:
|
||||
linear-gradient(rgba(0, 0, 0, 0.05) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0, 0, 0, 0.05) 1px, transparent 1px);
|
||||
}
|
||||
.oidc-page.oidc-light .oidc-bg-orb-1[data-v-8a889949] { background: rgba(124, 58, 237, 0.12);
|
||||
}
|
||||
.oidc-page.oidc-light .oidc-bg-orb-2[data-v-8a889949] { background: rgba(79, 70, 229, 0.1);
|
||||
}
|
||||
.oidc-page.oidc-light .oidc-bg-orb-3[data-v-8a889949] { background: rgba(139, 92, 246, 0.14);
|
||||
}
|
||||
|
||||
/* 卡片 */
|
||||
.oidc-page.oidc-light .oidc-card[data-v-8a889949] {
|
||||
background: #ffffff;
|
||||
border-color: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
/* 左侧 */
|
||||
.oidc-page.oidc-light .oidc-left-title[data-v-8a889949] { color: #111827;
|
||||
}
|
||||
.oidc-page.oidc-light .oidc-left-sub[data-v-8a889949] { color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.oidc-page.oidc-light .oidc-left-desc[data-v-8a889949] { color: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
.oidc-page.oidc-light .oidc-left-tag[data-v-8a889949] {
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
border-color: rgba(0, 0, 0, 0.07);
|
||||
}
|
||||
.oidc-page.oidc-light .oidc-left-tag-sep[data-v-8a889949] { background: rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
/* 特性卡片 */
|
||||
.oidc-page.oidc-light .feature-card[data-v-8a889949] {
|
||||
background: rgba(0, 0, 0, 0.015);
|
||||
border-color: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.oidc-page.oidc-light .feature-card.feature-violet[data-v-8a889949] {
|
||||
background: rgba(124, 58, 237, 0.04);
|
||||
border-color: rgba(124, 58, 237, 0.1);
|
||||
}
|
||||
.oidc-page.oidc-light .feature-card.feature-blue[data-v-8a889949] {
|
||||
background: rgba(59, 130, 246, 0.04);
|
||||
border-color: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
.oidc-page.oidc-light .feature-card.feature-green[data-v-8a889949] {
|
||||
background: rgba(16, 185, 129, 0.04);
|
||||
border-color: rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
.oidc-page.oidc-light .feature-card.feature-amber[data-v-8a889949] {
|
||||
background: rgba(234, 179, 8, 0.04);
|
||||
border-color: rgba(234, 179, 8, 0.1);
|
||||
}
|
||||
.oidc-page.oidc-light .feature-title[data-v-8a889949] { color: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
.oidc-page.oidc-light .feature-desc[data-v-8a889949] { color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
/* 右侧 */
|
||||
.oidc-page.oidc-light .oidc-right-title[data-v-8a889949] { color: #111827;
|
||||
}
|
||||
.oidc-page.oidc-light .oidc-right-sub[data-v-8a889949] { color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
/* 步骤卡片 */
|
||||
.oidc-page.oidc-light .oidc-step[data-v-8a889949] {
|
||||
background: #f8fafc;
|
||||
border-color: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.oidc-page.oidc-light .oidc-step[data-v-8a889949]:hover {
|
||||
background: #f1f5f9;
|
||||
border-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.oidc-page.oidc-light .oidc-step-active[data-v-8a889949] {
|
||||
background: #f1f5f9;
|
||||
border-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.oidc-page.oidc-light .oidc-step-done-step[data-v-8a889949] {
|
||||
border-color: rgba(16, 185, 129, 0.15);
|
||||
background: rgba(16, 185, 129, 0.04);
|
||||
}
|
||||
.oidc-page.oidc-light .oidc-step-error-step[data-v-8a889949] {
|
||||
border-color: rgba(239, 68, 68, 0.15);
|
||||
background: rgba(239, 68, 68, 0.04);
|
||||
}
|
||||
.oidc-page.oidc-light .oidc-step-title[data-v-8a889949] { color: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
.oidc-page.oidc-light .oidc-step-desc[data-v-8a889949] { color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.oidc-page.oidc-light .oidc-disabled-banner[data-v-8a889949] {
|
||||
background: rgba(234, 179, 8, 0.06);
|
||||
border-color: rgba(234, 179, 8, 0.2);
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
/* 信息行 */
|
||||
.oidc-page.oidc-light .oidc-info-row[data-v-8a889949] {
|
||||
background: #f8fafc;
|
||||
border-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.oidc-page.oidc-light .oidc-info-row-label[data-v-8a889949] { color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.oidc-page.oidc-light .oidc-info-row-value[data-v-8a889949] { color: rgba(0, 0, 0, 0.75);
|
||||
}
|
||||
.oidc-page.oidc-light .oidc-bound-desc[data-v-8a889949] { color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
/* 按钮 */
|
||||
.oidc-page.oidc-light .oidc-btn-outline[data-v-8a889949] {
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
border-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.oidc-page.oidc-light .oidc-btn-outline[data-v-8a889949]:hover:not(:disabled) {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
border-color: rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
.oidc-page.oidc-light .oidc-unbind-confirm-text[data-v-8a889949] { color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
/* 底部 */
|
||||
.oidc-page.oidc-light .oidc-bottom-line[data-v-8a889949] { background: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.oidc-page.oidc-light .oidc-bottom-content[data-v-8a889949] { color: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.oidc-page.oidc-light .oidc-bottom-right[data-v-8a889949] { color: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
/* spinner - 浅色下保持可读 */
|
||||
.oidc-page.oidc-light .oidc-spinner[data-v-8a889949] {
|
||||
border-color: rgba(0, 0, 0, 0.15);
|
||||
border-top-color: #7c3aed;
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { importShared } from './__federation_fn_import-JrT3xvdd.js';
|
||||
|
||||
const {resolveComponent:_resolveComponent,openBlock:_openBlock,createBlock:_createBlock,createCommentVNode:_createCommentVNode,createElementBlock:_createElementBlock,toDisplayString:_toDisplayString,createTextVNode:_createTextVNode,withCtx:_withCtx} = await importShared('vue');
|
||||
|
||||
|
||||
const _hoisted_1 = { class: "oidc-auth-page text-center" };
|
||||
const _hoisted_2 = {
|
||||
key: 1,
|
||||
class: "text-body-2 text-medium-emphasis mb-2"
|
||||
};
|
||||
const _hoisted_3 = {
|
||||
key: 3,
|
||||
class: "text-body-2 text-medium-emphasis mb-2"
|
||||
};
|
||||
|
||||
const {computed,onMounted,onUnmounted,ref} = await importShared('vue');
|
||||
|
||||
|
||||
|
||||
const _sfc_main = {
|
||||
__name: 'AuthPage',
|
||||
props: {
|
||||
api: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
provider: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
pluginId: {
|
||||
type: String,
|
||||
default: 'OidcAuth',
|
||||
},
|
||||
},
|
||||
emits: ['authenticated', 'error', 'close'],
|
||||
setup(__props, { emit: __emit }) {
|
||||
|
||||
const props = __props;
|
||||
|
||||
const emit = __emit;
|
||||
|
||||
const checking = ref(true);
|
||||
const loading = ref(false);
|
||||
const errorMessage = ref('');
|
||||
let popupTimer = null;
|
||||
let messageReceived = false;
|
||||
|
||||
const pluginBase = computed(() => `plugin/${props.pluginId || 'OidcAuth'}`);
|
||||
const providerName = computed(() => props.provider?.name || 'OIDC 登录');
|
||||
|
||||
/** 拼接 API 路径为可用于 window.open 的 URL。 */
|
||||
function buildApiUrl(path) {
|
||||
const base = props.api?.defaults?.baseURL || '/api/v1/';
|
||||
const normalizedBase = base.endsWith('/') ? base : `${base}/`;
|
||||
const normalizedPath = String(path || '').replace(/^\/+/, '');
|
||||
return `${normalizedBase}${normalizedPath}`
|
||||
}
|
||||
|
||||
/** 关闭弹窗轮询并清理状态。 */
|
||||
function clearPopupTimer() {
|
||||
if (popupTimer) {
|
||||
clearInterval(popupTimer);
|
||||
popupTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 处理 OIDC 回调窗口发回的认证消息。 */
|
||||
function handleOidcMessage(event) {
|
||||
if (event.origin !== window.location.origin) return
|
||||
if (event.data?.type !== 'oidcauth_callback') return
|
||||
messageReceived = true;
|
||||
window.removeEventListener('message', handleOidcMessage);
|
||||
clearPopupTimer();
|
||||
loading.value = false;
|
||||
if (event.data.success && event.data.data?.ticket) {
|
||||
emit('authenticated', { ticket: event.data.data.ticket });
|
||||
return
|
||||
}
|
||||
const message = event.data?.message || 'OIDC 认证失败';
|
||||
errorMessage.value = message;
|
||||
emit('error', { message });
|
||||
}
|
||||
|
||||
/** 先自检 OIDC 是否已启用,再决定是否发起授权弹窗。 */
|
||||
async function checkAndStart() {
|
||||
checking.value = true;
|
||||
errorMessage.value = '';
|
||||
try {
|
||||
const response = await props.api.get(`${pluginBase.value}/public/status`);
|
||||
const data = response?.data !== undefined ? response.data : response;
|
||||
if (!data?.enabled) {
|
||||
errorMessage.value = '管理员未启用OIDC认证,请联系管理员开启';
|
||||
emit('error', { message: errorMessage.value });
|
||||
return
|
||||
}
|
||||
startLogin();
|
||||
} catch {
|
||||
errorMessage.value = '无法连接到认证服务';
|
||||
emit('error', { message: errorMessage.value });
|
||||
} finally {
|
||||
checking.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 发起 OIDC 登录授权弹窗。 */
|
||||
function startLogin() {
|
||||
errorMessage.value = '';
|
||||
loading.value = true;
|
||||
messageReceived = false;
|
||||
window.addEventListener('message', handleOidcMessage);
|
||||
const popup = window.open(
|
||||
buildApiUrl(`${pluginBase.value}/authorize`),
|
||||
'moviepilot_oidc_login',
|
||||
'width=600,height=720,left=200,top=80',
|
||||
);
|
||||
if (!popup) {
|
||||
loading.value = false;
|
||||
window.removeEventListener('message', handleOidcMessage);
|
||||
errorMessage.value = '浏览器阻止了认证弹窗';
|
||||
emit('error', { message: errorMessage.value });
|
||||
return
|
||||
}
|
||||
popupTimer = setInterval(() => {
|
||||
if (!popup.closed) return
|
||||
clearPopupTimer();
|
||||
window.removeEventListener('message', handleOidcMessage);
|
||||
if (loading.value && !messageReceived) {
|
||||
loading.value = false;
|
||||
errorMessage.value = '认证窗口已关闭';
|
||||
emit('error', { message: errorMessage.value });
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
/** 组件挂载后自检,通过后自动发起登录。 */
|
||||
onMounted(() => {
|
||||
checkAndStart();
|
||||
});
|
||||
|
||||
/** 组件卸载时清理监听器和定时器。 */
|
||||
onUnmounted(() => {
|
||||
clearPopupTimer();
|
||||
window.removeEventListener('message', handleOidcMessage);
|
||||
});
|
||||
|
||||
return (_ctx, _cache) => {
|
||||
const _component_VProgressCircular = _resolveComponent("VProgressCircular");
|
||||
const _component_VAlert = _resolveComponent("VAlert");
|
||||
const _component_VBtn = _resolveComponent("VBtn");
|
||||
|
||||
return (_openBlock(), _createElementBlock("div", _hoisted_1, [
|
||||
(checking.value)
|
||||
? (_openBlock(), _createBlock(_component_VProgressCircular, {
|
||||
key: 0,
|
||||
indeterminate: "",
|
||||
color: "primary",
|
||||
class: "mb-4"
|
||||
}))
|
||||
: _createCommentVNode("", true),
|
||||
(checking.value)
|
||||
? (_openBlock(), _createElementBlock("div", _hoisted_2, "正在检查认证服务状态..."))
|
||||
: (loading.value)
|
||||
? (_openBlock(), _createBlock(_component_VProgressCircular, {
|
||||
key: 2,
|
||||
indeterminate: "",
|
||||
color: "primary",
|
||||
class: "mb-4"
|
||||
}))
|
||||
: (loading.value)
|
||||
? (_openBlock(), _createElementBlock("div", _hoisted_3, "正在打开 " + _toDisplayString(providerName.value) + " 授权页面...", 1))
|
||||
: _createCommentVNode("", true),
|
||||
(!loading.value && !checking.value && errorMessage.value)
|
||||
? (_openBlock(), _createBlock(_component_VAlert, {
|
||||
key: 4,
|
||||
type: "error",
|
||||
variant: "tonal",
|
||||
class: "mb-2"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createTextVNode(_toDisplayString(errorMessage.value), 1)
|
||||
]),
|
||||
_: 1
|
||||
}))
|
||||
: _createCommentVNode("", true),
|
||||
(!loading.value && !checking.value)
|
||||
? (_openBlock(), _createBlock(_component_VBtn, {
|
||||
key: 5,
|
||||
block: "",
|
||||
color: "primary",
|
||||
onClick: checkAndStart
|
||||
}, {
|
||||
default: _withCtx(() => [...(_cache[1] || (_cache[1] = [
|
||||
_createTextVNode("重试", -1)
|
||||
]))]),
|
||||
_: 1
|
||||
}))
|
||||
: _createCommentVNode("", true),
|
||||
(!loading.value && !checking.value)
|
||||
? (_openBlock(), _createBlock(_component_VBtn, {
|
||||
key: 6,
|
||||
block: "",
|
||||
variant: "text",
|
||||
class: "mt-2",
|
||||
onClick: _cache[0] || (_cache[0] = $event => (emit('close')))
|
||||
}, {
|
||||
default: _withCtx(() => [...(_cache[2] || (_cache[2] = [
|
||||
_createTextVNode("取消", -1)
|
||||
]))]),
|
||||
_: 1
|
||||
}))
|
||||
: _createCommentVNode("", true)
|
||||
]))
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
export { _sfc_main as default };
|
||||
@@ -0,0 +1,482 @@
|
||||
import { importShared } from './__federation_fn_import-JrT3xvdd.js';
|
||||
|
||||
const {createTextVNode:_createTextVNode,resolveComponent:_resolveComponent,withCtx:_withCtx,createVNode:_createVNode,createElementVNode:_createElementVNode,toDisplayString:_toDisplayString,openBlock:_openBlock,createBlock:_createBlock,createCommentVNode:_createCommentVNode,createElementBlock:_createElementBlock,Fragment:_Fragment} = await importShared('vue');
|
||||
|
||||
|
||||
const _hoisted_1 = { class: "oidc-auth-config pa-4" };
|
||||
const _hoisted_2 = { class: "rounded-lg border pa-4 mt-4" };
|
||||
const _hoisted_3 = { class: "d-flex align-center gap-2 mb-3" };
|
||||
const _hoisted_4 = { class: "d-flex gap-3 mb-2" };
|
||||
const _hoisted_5 = { class: "text-body-2" };
|
||||
const _hoisted_6 = {
|
||||
key: 1,
|
||||
class: "text-medium-emphasis"
|
||||
};
|
||||
const _hoisted_7 = { class: "d-flex flex-wrap gap-3 mt-4" };
|
||||
|
||||
const {computed,onMounted,onUnmounted,ref} = await importShared('vue');
|
||||
|
||||
|
||||
|
||||
const _sfc_main = {
|
||||
__name: 'ConfigPage',
|
||||
props: {
|
||||
api: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
pluginId: {
|
||||
type: String,
|
||||
default: 'OidcAuth',
|
||||
},
|
||||
},
|
||||
emits: ['close'],
|
||||
setup(__props, { emit: __emit }) {
|
||||
|
||||
const props = __props;
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const testing = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const successMessage = ref('');
|
||||
const status = ref({
|
||||
public: {},
|
||||
});
|
||||
|
||||
const config = ref({
|
||||
enabled: false,
|
||||
provider_name: 'OIDC 登录',
|
||||
issuer: '',
|
||||
client_id: '',
|
||||
client_secret: '',
|
||||
scopes: 'openid profile email',
|
||||
redirect_uri: '',
|
||||
username_claim: 'preferred_username',
|
||||
email_claim: 'email',
|
||||
allow_auto_bind_by_username: false,
|
||||
});
|
||||
|
||||
const copied = ref(false);
|
||||
let copyTimer = null;
|
||||
|
||||
const pluginBase = computed(() => `plugin/${props.pluginId || 'OidcAuth'}`);
|
||||
|
||||
const displayRedirectUri = computed(() => {
|
||||
const raw = status.value.public?.redirect_uri || '';
|
||||
if (!raw) return ''
|
||||
if (/^https?:\/\//i.test(raw)) return raw
|
||||
return `${window.location.origin}${raw}`
|
||||
});
|
||||
|
||||
async function copyRedirectUri() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(displayRedirectUri.value);
|
||||
copied.value = true;
|
||||
clearTimeout(copyTimer);
|
||||
copyTimer = setTimeout(() => { copied.value = false; }, 2000);
|
||||
} catch { /* 忽略 */ }
|
||||
}
|
||||
|
||||
function unwrap(response) {
|
||||
if (response && Object.prototype.hasOwnProperty.call(response, 'data')) {
|
||||
return response.data
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
function clearMessages() {
|
||||
errorMessage.value = '';
|
||||
successMessage.value = '';
|
||||
}
|
||||
|
||||
async function loadStatus() {
|
||||
loading.value = true;
|
||||
clearMessages();
|
||||
try {
|
||||
const response = await props.api.get(`${pluginBase.value}/status`);
|
||||
status.value = unwrap(response) || status.value;
|
||||
if (status.value.config) {
|
||||
config.value = { ...config.value, ...status.value.config };
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.message || '加载失败';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
saving.value = true;
|
||||
clearMessages();
|
||||
try {
|
||||
const response = await props.api.post(`${pluginBase.value}/config`, config.value);
|
||||
const data = unwrap(response) || {};
|
||||
if (data.config) {
|
||||
config.value = { ...config.value, ...data.config };
|
||||
}
|
||||
await loadStatus();
|
||||
successMessage.value = '配置已保存,即将刷新页面...';
|
||||
setTimeout(() => window.location.reload(), 1000);
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.message || '保存失败';
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
testing.value = true;
|
||||
clearMessages();
|
||||
try {
|
||||
const response = await props.api.post(`${pluginBase.value}/test`, config.value);
|
||||
if (response?.success) {
|
||||
successMessage.value = response.message || '连接正常';
|
||||
} else {
|
||||
errorMessage.value = response?.message || '连接失败';
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.message || '连接失败';
|
||||
} finally {
|
||||
testing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadStatus);
|
||||
|
||||
onUnmounted(() => {
|
||||
clearTimeout(copyTimer);
|
||||
});
|
||||
|
||||
return (_ctx, _cache) => {
|
||||
const _component_VCardTitle = _resolveComponent("VCardTitle");
|
||||
const _component_VCardItem = _resolveComponent("VCardItem");
|
||||
const _component_VSwitch = _resolveComponent("VSwitch");
|
||||
const _component_VTextField = _resolveComponent("VTextField");
|
||||
const _component_VCol = _resolveComponent("VCol");
|
||||
const _component_VRow = _resolveComponent("VRow");
|
||||
const _component_VIcon = _resolveComponent("VIcon");
|
||||
const _component_VChip = _resolveComponent("VChip");
|
||||
const _component_VBtn = _resolveComponent("VBtn");
|
||||
const _component_VAlert = _resolveComponent("VAlert");
|
||||
const _component_VCardText = _resolveComponent("VCardText");
|
||||
const _component_VCard = _resolveComponent("VCard");
|
||||
|
||||
return (_openBlock(), _createElementBlock("div", _hoisted_1, [
|
||||
_createVNode(_component_VCard, { loading: loading.value }, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VCardItem, null, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VCardTitle, null, {
|
||||
default: _withCtx(() => [...(_cache[10] || (_cache[10] = [
|
||||
_createTextVNode("OIDC Provider 配置", -1)
|
||||
]))]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VCardText, null, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VSwitch, {
|
||||
modelValue: config.value.enabled,
|
||||
"onUpdate:modelValue": _cache[0] || (_cache[0] = $event => ((config.value.enabled) = $event)),
|
||||
label: "启用 OIDC 登录",
|
||||
color: "primary",
|
||||
class: "mb-2"
|
||||
}, null, 8, ["modelValue"]),
|
||||
(config.value.enabled)
|
||||
? (_openBlock(), _createElementBlock(_Fragment, { key: 0 }, [
|
||||
_createVNode(_component_VRow, null, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VCol, {
|
||||
cols: "12",
|
||||
md: "6"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VTextField, {
|
||||
modelValue: config.value.provider_name,
|
||||
"onUpdate:modelValue": _cache[1] || (_cache[1] = $event => ((config.value.provider_name) = $event)),
|
||||
label: "入口名称",
|
||||
"prepend-inner-icon": "mdi-openid"
|
||||
}, null, 8, ["modelValue"])
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VCol, { cols: "12" }, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VTextField, {
|
||||
modelValue: config.value.issuer,
|
||||
"onUpdate:modelValue": _cache[2] || (_cache[2] = $event => ((config.value.issuer) = $event)),
|
||||
label: "Issuer",
|
||||
placeholder: "https://idp.example.com",
|
||||
"prepend-inner-icon": "mdi-web"
|
||||
}, null, 8, ["modelValue"])
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VCol, {
|
||||
cols: "12",
|
||||
md: "6"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VTextField, {
|
||||
modelValue: config.value.client_id,
|
||||
"onUpdate:modelValue": _cache[3] || (_cache[3] = $event => ((config.value.client_id) = $event)),
|
||||
label: "Client ID",
|
||||
"prepend-inner-icon": "mdi-identifier"
|
||||
}, null, 8, ["modelValue"])
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VCol, {
|
||||
cols: "12",
|
||||
md: "6"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VTextField, {
|
||||
modelValue: config.value.client_secret,
|
||||
"onUpdate:modelValue": _cache[4] || (_cache[4] = $event => ((config.value.client_secret) = $event)),
|
||||
label: "Client Secret",
|
||||
type: "password",
|
||||
"prepend-inner-icon": "mdi-key"
|
||||
}, null, 8, ["modelValue"])
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VCol, {
|
||||
cols: "12",
|
||||
md: "6"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VTextField, {
|
||||
modelValue: config.value.scopes,
|
||||
"onUpdate:modelValue": _cache[5] || (_cache[5] = $event => ((config.value.scopes) = $event)),
|
||||
label: "Scopes",
|
||||
placeholder: "openid profile email",
|
||||
"prepend-inner-icon": "mdi-format-list-checks"
|
||||
}, null, 8, ["modelValue"])
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VCol, {
|
||||
cols: "12",
|
||||
md: "6"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VTextField, {
|
||||
modelValue: config.value.redirect_uri,
|
||||
"onUpdate:modelValue": _cache[6] || (_cache[6] = $event => ((config.value.redirect_uri) = $event)),
|
||||
label: "回调地址覆盖",
|
||||
placeholder: "留空自动生成",
|
||||
"prepend-inner-icon": "mdi-call-made"
|
||||
}, null, 8, ["modelValue"])
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VCol, {
|
||||
cols: "12",
|
||||
md: "6"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VTextField, {
|
||||
modelValue: config.value.username_claim,
|
||||
"onUpdate:modelValue": _cache[7] || (_cache[7] = $event => ((config.value.username_claim) = $event)),
|
||||
label: "用户名 Claim",
|
||||
"prepend-inner-icon": "mdi-account"
|
||||
}, null, 8, ["modelValue"])
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VCol, {
|
||||
cols: "12",
|
||||
md: "6"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VTextField, {
|
||||
modelValue: config.value.email_claim,
|
||||
"onUpdate:modelValue": _cache[8] || (_cache[8] = $event => ((config.value.email_claim) = $event)),
|
||||
label: "邮箱 Claim",
|
||||
"prepend-inner-icon": "mdi-email"
|
||||
}, null, 8, ["modelValue"])
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VCol, { cols: "12" }, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VSwitch, {
|
||||
modelValue: config.value.allow_auto_bind_by_username,
|
||||
"onUpdate:modelValue": _cache[9] || (_cache[9] = $event => ((config.value.allow_auto_bind_by_username) = $event)),
|
||||
label: "允许按用户名 Claim 自动绑定已有用户",
|
||||
color: "primary"
|
||||
}, null, 8, ["modelValue"])
|
||||
]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createElementVNode("div", _hoisted_2, [
|
||||
_createElementVNode("div", _hoisted_3, [
|
||||
_createVNode(_component_VIcon, {
|
||||
size: "20",
|
||||
color: "primary"
|
||||
}, {
|
||||
default: _withCtx(() => [...(_cache[11] || (_cache[11] = [
|
||||
_createTextVNode("mdi-information-outline", -1)
|
||||
]))]),
|
||||
_: 1
|
||||
}),
|
||||
_cache[12] || (_cache[12] = _createElementVNode("span", { class: "text-subtitle-2 font-weight-medium" }, "使用指南", -1))
|
||||
]),
|
||||
_cache[17] || (_cache[17] = _createElementVNode("div", { class: "d-flex gap-3 mb-2" }, [
|
||||
_createElementVNode("div", {
|
||||
class: "text-medium-emphasis",
|
||||
style: {"min-width":"16px"}
|
||||
}, "1."),
|
||||
_createElementVNode("div", { class: "text-body-2" }, "在您的 OIDC 提供商(如 Keycloak、Authentik、Okta 等)中创建一个客户端,协议类型选择 \"OAuth2/OpenID Provider\",授权流程使用 \"Authorize Application\"。")
|
||||
], -1)),
|
||||
_createElementVNode("div", _hoisted_4, [
|
||||
_cache[16] || (_cache[16] = _createElementVNode("div", {
|
||||
class: "text-medium-emphasis",
|
||||
style: {"min-width":"16px"}
|
||||
}, "2.", -1)),
|
||||
_createElementVNode("div", _hoisted_5, [
|
||||
_cache[15] || (_cache[15] = _createTextVNode(" 将回调地址设置为: ", -1)),
|
||||
(displayRedirectUri.value)
|
||||
? (_openBlock(), _createBlock(_component_VChip, {
|
||||
key: 0,
|
||||
color: "info",
|
||||
variant: "tonal",
|
||||
size: "small",
|
||||
class: "cursor-pointer ml-1",
|
||||
onClick: copyRedirectUri
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createTextVNode(_toDisplayString(displayRedirectUri.value) + " ", 1),
|
||||
(copied.value)
|
||||
? (_openBlock(), _createBlock(_component_VIcon, {
|
||||
key: 0,
|
||||
end: "",
|
||||
size: "14",
|
||||
color: "success"
|
||||
}, {
|
||||
default: _withCtx(() => [...(_cache[13] || (_cache[13] = [
|
||||
_createTextVNode("mdi-check", -1)
|
||||
]))]),
|
||||
_: 1
|
||||
}))
|
||||
: (_openBlock(), _createBlock(_component_VIcon, {
|
||||
key: 1,
|
||||
end: "",
|
||||
size: "14"
|
||||
}, {
|
||||
default: _withCtx(() => [...(_cache[14] || (_cache[14] = [
|
||||
_createTextVNode("mdi-content-copy", -1)
|
||||
]))]),
|
||||
_: 1
|
||||
}))
|
||||
]),
|
||||
_: 1
|
||||
}))
|
||||
: (_openBlock(), _createElementBlock("span", _hoisted_6, "加载中..."))
|
||||
])
|
||||
]),
|
||||
_cache[18] || (_cache[18] = _createElementVNode("div", { class: "d-flex gap-3 mb-2" }, [
|
||||
_createElementVNode("div", {
|
||||
class: "text-medium-emphasis",
|
||||
style: {"min-width":"16px"}
|
||||
}, "3."),
|
||||
_createElementVNode("div", { class: "text-body-2" }, [
|
||||
_createTextVNode(" 填写签发者 URL、客户端 ID 和客户端密钥,保存设置。 "),
|
||||
_createElementVNode("div", { class: "text-medium-emphasis text-caption mt-1" }, [
|
||||
_createTextVNode("如果 IdP 与 MoviePilot 不在同一网络、需要指定不同的回调地址,可在「回调地址覆盖」中手动填写完整地址(如 "),
|
||||
_createElementVNode("code", { class: "text-caption" }, "https://another-domain.com/api/v1/plugin/OidcAuth/callback"),
|
||||
_createTextVNode("),正常情况下留空即可。")
|
||||
])
|
||||
])
|
||||
], -1)),
|
||||
_cache[19] || (_cache[19] = _createElementVNode("div", { class: "d-flex gap-3 mb-2" }, [
|
||||
_createElementVNode("div", {
|
||||
class: "text-medium-emphasis",
|
||||
style: {"min-width":"16px"}
|
||||
}, "4."),
|
||||
_createElementVNode("div", { class: "text-body-2" }, "保存后登录页面将显示 OIDC 登录按钮。")
|
||||
], -1)),
|
||||
_cache[20] || (_cache[20] = _createElementVNode("div", { class: "d-flex gap-3" }, [
|
||||
_createElementVNode("div", {
|
||||
class: "text-medium-emphasis",
|
||||
style: {"min-width":"16px"}
|
||||
}, "5."),
|
||||
_createElementVNode("div", { class: "text-body-2" }, "已登录用户可在左侧菜单「OIDC 认证」中绑定/解绑 OIDC 账号。")
|
||||
], -1))
|
||||
])
|
||||
], 64))
|
||||
: _createCommentVNode("", true),
|
||||
_createElementVNode("div", _hoisted_7, [
|
||||
_createVNode(_component_VBtn, {
|
||||
color: "primary",
|
||||
"prepend-icon": "mdi-content-save",
|
||||
loading: saving.value,
|
||||
onClick: saveConfig
|
||||
}, {
|
||||
default: _withCtx(() => [...(_cache[21] || (_cache[21] = [
|
||||
_createTextVNode("保存", -1)
|
||||
]))]),
|
||||
_: 1
|
||||
}, 8, ["loading"]),
|
||||
(config.value.enabled)
|
||||
? (_openBlock(), _createBlock(_component_VBtn, {
|
||||
key: 0,
|
||||
color: "info",
|
||||
variant: "tonal",
|
||||
"prepend-icon": "mdi-connection",
|
||||
loading: testing.value,
|
||||
onClick: testConnection
|
||||
}, {
|
||||
default: _withCtx(() => [...(_cache[22] || (_cache[22] = [
|
||||
_createTextVNode("测试连接", -1)
|
||||
]))]),
|
||||
_: 1
|
||||
}, 8, ["loading"]))
|
||||
: _createCommentVNode("", true)
|
||||
]),
|
||||
(errorMessage.value)
|
||||
? (_openBlock(), _createBlock(_component_VAlert, {
|
||||
key: 1,
|
||||
type: "error",
|
||||
variant: "tonal",
|
||||
class: "mt-4"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createTextVNode(_toDisplayString(errorMessage.value), 1)
|
||||
]),
|
||||
_: 1
|
||||
}))
|
||||
: _createCommentVNode("", true),
|
||||
(successMessage.value)
|
||||
? (_openBlock(), _createBlock(_component_VAlert, {
|
||||
key: 2,
|
||||
type: "success",
|
||||
variant: "tonal",
|
||||
class: "mt-4"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createTextVNode(_toDisplayString(successMessage.value), 1)
|
||||
]),
|
||||
_: 1
|
||||
}))
|
||||
: _createCommentVNode("", true)
|
||||
]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
}, 8, ["loading"])
|
||||
]))
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
export { _sfc_main as default };
|
||||
406
plugins.v2/oidcauth/assets/__federation_expose_Page-B5ZFHZ5P.js
Normal file
406
plugins.v2/oidcauth/assets/__federation_expose_Page-B5ZFHZ5P.js
Normal file
@@ -0,0 +1,406 @@
|
||||
import { importShared } from './__federation_fn_import-JrT3xvdd.js';
|
||||
|
||||
const {createElementVNode:_createElementVNode,openBlock:_openBlock,createElementBlock:_createElementBlock,resolveComponent:_resolveComponent,withCtx:_withCtx,createVNode:_createVNode,createTextVNode:_createTextVNode,toDisplayString:_toDisplayString,createCommentVNode:_createCommentVNode,createBlock:_createBlock} = await importShared('vue');
|
||||
|
||||
|
||||
const _hoisted_1 = { class: "oidc-auth-page pa-4" };
|
||||
const _hoisted_2 = {
|
||||
key: 0,
|
||||
class: "text-success"
|
||||
};
|
||||
const _hoisted_3 = {
|
||||
key: 1,
|
||||
class: "text-medium-emphasis"
|
||||
};
|
||||
const _hoisted_4 = { class: "d-flex flex-wrap gap-3 align-center" };
|
||||
|
||||
const {computed,onMounted,onUnmounted,ref} = await importShared('vue');
|
||||
|
||||
|
||||
|
||||
const _sfc_main = {
|
||||
__name: 'Page',
|
||||
props: {
|
||||
api: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
pluginId: {
|
||||
type: String,
|
||||
default: 'OidcAuth',
|
||||
},
|
||||
},
|
||||
emits: ['close', 'switch'],
|
||||
setup(__props, { emit: __emit }) {
|
||||
|
||||
const props = __props;
|
||||
|
||||
const emit = __emit;
|
||||
|
||||
const loading = ref(false);
|
||||
const binding = ref(false);
|
||||
const bindErrorMessage = ref('');
|
||||
const bindSuccessMessage = ref('');
|
||||
const status = ref({ public: {}, binding: {} });
|
||||
|
||||
let bindPopupTimer = null;
|
||||
let bindMessageReceived = false;
|
||||
let bindPollingLock = false;
|
||||
|
||||
const pluginBase = computed(() => `plugin/${props.pluginId || 'OidcAuth'}`);
|
||||
const isBound = computed(() => Boolean(status.value.binding?.bound));
|
||||
const isAdmin = computed(() => status.value.is_superuser);
|
||||
|
||||
function unwrap(response) {
|
||||
if (response && Object.prototype.hasOwnProperty.call(response, 'data')) {
|
||||
return response.data
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
async function loadStatus() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await props.api.get(`${pluginBase.value}/status`);
|
||||
status.value = unwrap(response) || status.value;
|
||||
} catch (error) {
|
||||
bindErrorMessage.value = error?.message || '加载失败';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function clearBindPopupTimer() {
|
||||
if (bindPopupTimer) {
|
||||
clearInterval(bindPopupTimer);
|
||||
bindPopupTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBindMessage(event) {
|
||||
if (event.origin !== window.location.origin) return
|
||||
if (event.data?.type !== 'oidcauth_bind_callback') return
|
||||
bindMessageReceived = true;
|
||||
window.removeEventListener('message', handleBindMessage);
|
||||
clearBindPopupTimer();
|
||||
binding.value = false;
|
||||
if (event.data.success) {
|
||||
await loadStatus();
|
||||
bindSuccessMessage.value = 'OIDC 账号已绑定';
|
||||
bindErrorMessage.value = '';
|
||||
} else {
|
||||
bindErrorMessage.value = event.data?.message || '绑定失败';
|
||||
}
|
||||
}
|
||||
|
||||
async function bindAccount() {
|
||||
binding.value = true;
|
||||
bindErrorMessage.value = '';
|
||||
bindSuccessMessage.value = '';
|
||||
bindMessageReceived = false;
|
||||
bindPollingLock = false;
|
||||
try {
|
||||
const response = await props.api.post(`${pluginBase.value}/bind/start`, {});
|
||||
const authorizeUrl = response?.data?.authorize_url;
|
||||
if (!response?.success || !authorizeUrl) {
|
||||
throw new Error(response?.message || '无法发起绑定')
|
||||
}
|
||||
window.addEventListener('message', handleBindMessage);
|
||||
const popup = window.open(authorizeUrl, 'moviepilot_oidc_bind', 'width=600,height=720,left=200,top=80');
|
||||
if (!popup) {
|
||||
window.removeEventListener('message', handleBindMessage);
|
||||
throw new Error('浏览器阻止了认证弹窗')
|
||||
}
|
||||
bindPopupTimer = setInterval(async () => {
|
||||
// 防止上一次轮询还未完成
|
||||
if (bindPollingLock) return
|
||||
bindPollingLock = true;
|
||||
try {
|
||||
// 弹窗未关闭时,偷偷检查绑定状态(PostMessage 可能因 opener 丢失而失效)
|
||||
if (!popup.closed && !bindMessageReceived) {
|
||||
await loadStatus();
|
||||
if (isBound.value) {
|
||||
// 绑定已生效,关闭弹窗并标记成功
|
||||
bindMessageReceived = true;
|
||||
clearBindPopupTimer();
|
||||
window.removeEventListener('message', handleBindMessage);
|
||||
binding.value = false;
|
||||
bindSuccessMessage.value = 'OIDC 账号已绑定';
|
||||
bindErrorMessage.value = '';
|
||||
try { popup.close(); } catch (_) { /* 忽略跨域关闭错误 */ }
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!popup.closed) return
|
||||
// 弹窗已关闭
|
||||
clearBindPopupTimer();
|
||||
window.removeEventListener('message', handleBindMessage);
|
||||
if (!binding.value) return
|
||||
binding.value = false;
|
||||
if (bindMessageReceived) return
|
||||
// postMessage 丢失,重试轮询状态(最多 6 次,每次间隔 1.5 秒)
|
||||
for (let attempt = 0; attempt < 6; attempt++) {
|
||||
await loadStatus();
|
||||
if (isBound.value) {
|
||||
bindSuccessMessage.value = 'OIDC 账号已绑定';
|
||||
bindErrorMessage.value = '';
|
||||
return
|
||||
}
|
||||
if (attempt < 5) {
|
||||
await new Promise(r => setTimeout(r, 1500));
|
||||
}
|
||||
}
|
||||
bindErrorMessage.value = '绑定失败:未检测到绑定状态,请重试';
|
||||
} finally {
|
||||
bindPollingLock = false;
|
||||
}
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
binding.value = false;
|
||||
bindErrorMessage.value = error?.message || '绑定失败';
|
||||
}
|
||||
}
|
||||
|
||||
async function unbindAccount() {
|
||||
binding.value = true;
|
||||
bindErrorMessage.value = '';
|
||||
bindSuccessMessage.value = '';
|
||||
try {
|
||||
const response = await props.api.post(`${pluginBase.value}/unbind`, {});
|
||||
if (response?.success) {
|
||||
await loadStatus();
|
||||
bindSuccessMessage.value = 'OIDC 账号已解绑';
|
||||
bindErrorMessage.value = '';
|
||||
} else {
|
||||
bindErrorMessage.value = response?.message || '解绑失败';
|
||||
}
|
||||
} catch (error) {
|
||||
bindErrorMessage.value = error?.message || '解绑失败';
|
||||
} finally {
|
||||
binding.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadStatus);
|
||||
|
||||
onUnmounted(() => {
|
||||
clearBindPopupTimer();
|
||||
window.removeEventListener('message', handleBindMessage);
|
||||
});
|
||||
|
||||
return (_ctx, _cache) => {
|
||||
const _component_VAvatar = _resolveComponent("VAvatar");
|
||||
const _component_VCardTitle = _resolveComponent("VCardTitle");
|
||||
const _component_VIcon = _resolveComponent("VIcon");
|
||||
const _component_VCardSubtitle = _resolveComponent("VCardSubtitle");
|
||||
const _component_VCardItem = _resolveComponent("VCardItem");
|
||||
const _component_VBtn = _resolveComponent("VBtn");
|
||||
const _component_VAlert = _resolveComponent("VAlert");
|
||||
const _component_VCardText = _resolveComponent("VCardText");
|
||||
const _component_VCard = _resolveComponent("VCard");
|
||||
|
||||
return (_openBlock(), _createElementBlock("div", _hoisted_1, [
|
||||
(status.value.public?.enabled)
|
||||
? (_openBlock(), _createBlock(_component_VCard, {
|
||||
key: 0,
|
||||
loading: loading.value,
|
||||
class: "mb-4"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VCardItem, null, {
|
||||
prepend: _withCtx(() => [
|
||||
_createVNode(_component_VAvatar, {
|
||||
color: "primary",
|
||||
size: "40"
|
||||
}, {
|
||||
default: _withCtx(() => [...(_cache[1] || (_cache[1] = [
|
||||
_createElementVNode("svg", {
|
||||
viewBox: "0 0 1024 1024",
|
||||
width: "24",
|
||||
height: "24",
|
||||
fill: "white",
|
||||
xmlns: "http://www.w3.org/2000/svg"
|
||||
}, [
|
||||
_createElementVNode("path", { d: "M468.064 866.08v91.616c-81.408-7.168-155.328-25.376-221.792-54.656-66.432-29.28-118.752-66.496-156.96-111.68C51.104 746.176 32 697.536 32 645.408c0-50.016 17.952-97.056 53.856-141.184 35.904-44.096 84.992-80.8 147.328-110.08s132.224-48.576 209.728-57.856v92.128c-77.504 13.568-141.152 40.352-190.976 80.352-49.824 40-74.72 85.536-74.72 136.64 0 54.272 27.584 101.952 82.752 143.04 55.168 41.056 124.544 66.944 208.096 77.632zM992 587.008l-19.808-208.928-75.008 42.304c-72.864-44.288-158.752-72.32-257.696-84.096v92.128c57.504 10.368 107.488 28.032 150.016 53.056l-78.752 44.48L992 587.008z" }),
|
||||
_createElementVNode("path", { d: "M613.792 889.152l-145.728 68.576V137.536l145.728-71.264v822.88z" })
|
||||
], -1)
|
||||
]))]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VCardTitle, null, {
|
||||
default: _withCtx(() => [...(_cache[2] || (_cache[2] = [
|
||||
_createTextVNode("OIDC 账号绑定", -1)
|
||||
]))]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VCardSubtitle, null, {
|
||||
default: _withCtx(() => [
|
||||
(isBound.value)
|
||||
? (_openBlock(), _createElementBlock("span", _hoisted_2, [
|
||||
_createVNode(_component_VIcon, {
|
||||
size: "14",
|
||||
color: "success",
|
||||
class: "mr-1"
|
||||
}, {
|
||||
default: _withCtx(() => [...(_cache[3] || (_cache[3] = [
|
||||
_createTextVNode("mdi-check-circle", -1)
|
||||
]))]),
|
||||
_: 1
|
||||
}),
|
||||
_createTextVNode(" 已绑定 " + _toDisplayString(status.value.binding?.sub || status.value.binding?.masked_sub), 1)
|
||||
]))
|
||||
: (_openBlock(), _createElementBlock("span", _hoisted_3, "当前账号尚未绑定 OIDC"))
|
||||
]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VCardText, null, {
|
||||
default: _withCtx(() => [
|
||||
_createElementVNode("div", _hoisted_4, [
|
||||
(!isBound.value)
|
||||
? (_openBlock(), _createBlock(_component_VBtn, {
|
||||
key: 0,
|
||||
color: "primary",
|
||||
loading: binding.value,
|
||||
onClick: bindAccount
|
||||
}, {
|
||||
prepend: _withCtx(() => [...(_cache[4] || (_cache[4] = [
|
||||
_createElementVNode("svg", {
|
||||
viewBox: "0 0 1024 1024",
|
||||
width: "20",
|
||||
height: "20",
|
||||
fill: "currentColor",
|
||||
xmlns: "http://www.w3.org/2000/svg"
|
||||
}, [
|
||||
_createElementVNode("path", { d: "M468.064 866.08v91.616c-81.408-7.168-155.328-25.376-221.792-54.656-66.432-29.28-118.752-66.496-156.96-111.68C51.104 746.176 32 697.536 32 645.408c0-50.016 17.952-97.056 53.856-141.184 35.904-44.096 84.992-80.8 147.328-110.08s132.224-48.576 209.728-57.856v92.128c-77.504 13.568-141.152 40.352-190.976 80.352-49.824 40-74.72 85.536-74.72 136.64 0 54.272 27.584 101.952 82.752 143.04 55.168 41.056 124.544 66.944 208.096 77.632zM992 587.008l-19.808-208.928-75.008 42.304c-72.864-44.288-158.752-72.32-257.696-84.096v92.128c57.504 10.368 107.488 28.032 150.016 53.056l-78.752 44.48L992 587.008z" }),
|
||||
_createElementVNode("path", { d: "M613.792 889.152l-145.728 68.576V137.536l145.728-71.264v822.88z" })
|
||||
], -1)
|
||||
]))]),
|
||||
default: _withCtx(() => [
|
||||
_cache[5] || (_cache[5] = _createTextVNode(" 绑定 OIDC 账号 ", -1))
|
||||
]),
|
||||
_: 1
|
||||
}, 8, ["loading"]))
|
||||
: (_openBlock(), _createBlock(_component_VBtn, {
|
||||
key: 1,
|
||||
color: "error",
|
||||
variant: "tonal",
|
||||
"prepend-icon": "mdi-link-off",
|
||||
loading: binding.value,
|
||||
onClick: unbindAccount
|
||||
}, {
|
||||
default: _withCtx(() => [...(_cache[6] || (_cache[6] = [
|
||||
_createTextVNode(" 解绑 OIDC 账号 ", -1)
|
||||
]))]),
|
||||
_: 1
|
||||
}, 8, ["loading"])),
|
||||
(isAdmin.value)
|
||||
? (_openBlock(), _createBlock(_component_VBtn, {
|
||||
key: 2,
|
||||
color: "primary",
|
||||
variant: "tonal",
|
||||
"prepend-icon": "mdi-cog",
|
||||
onClick: _cache[0] || (_cache[0] = $event => (emit('switch')))
|
||||
}, {
|
||||
default: _withCtx(() => [...(_cache[7] || (_cache[7] = [
|
||||
_createTextVNode(" 配置 ", -1)
|
||||
]))]),
|
||||
_: 1
|
||||
}))
|
||||
: _createCommentVNode("", true)
|
||||
]),
|
||||
(bindErrorMessage.value)
|
||||
? (_openBlock(), _createBlock(_component_VAlert, {
|
||||
key: 0,
|
||||
type: "error",
|
||||
variant: "tonal",
|
||||
class: "mt-3"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createTextVNode(_toDisplayString(bindErrorMessage.value), 1)
|
||||
]),
|
||||
_: 1
|
||||
}))
|
||||
: _createCommentVNode("", true),
|
||||
(bindSuccessMessage.value)
|
||||
? (_openBlock(), _createBlock(_component_VAlert, {
|
||||
key: 1,
|
||||
type: "success",
|
||||
variant: "tonal",
|
||||
class: "mt-3"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createTextVNode(_toDisplayString(bindSuccessMessage.value), 1)
|
||||
]),
|
||||
_: 1
|
||||
}))
|
||||
: _createCommentVNode("", true)
|
||||
]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
}, 8, ["loading"]))
|
||||
: (_openBlock(), _createBlock(_component_VCard, {
|
||||
key: 1,
|
||||
class: "mb-4"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VCardItem, null, {
|
||||
prepend: _withCtx(() => [
|
||||
_createVNode(_component_VAvatar, {
|
||||
color: "grey-lighten-2",
|
||||
size: "40"
|
||||
}, {
|
||||
default: _withCtx(() => [...(_cache[8] || (_cache[8] = [
|
||||
_createElementVNode("svg", {
|
||||
viewBox: "0 0 1024 1024",
|
||||
width: "24",
|
||||
height: "24",
|
||||
fill: "#9E9E9E",
|
||||
xmlns: "http://www.w3.org/2000/svg"
|
||||
}, [
|
||||
_createElementVNode("path", { d: "M468.064 866.08v91.616c-81.408-7.168-155.328-25.376-221.792-54.656-66.432-29.28-118.752-66.496-156.96-111.68C51.104 746.176 32 697.536 32 645.408c0-50.016 17.952-97.056 53.856-141.184 35.904-44.096 84.992-80.8 147.328-110.08s132.224-48.576 209.728-57.856v92.128c-77.504 13.568-141.152 40.352-190.976 80.352-49.824 40-74.72 85.536-74.72 136.64 0 54.272 27.584 101.952 82.752 143.04 55.168 41.056 124.544 66.944 208.096 77.632zM992 587.008l-19.808-208.928-75.008 42.304c-72.864-44.288-158.752-72.32-257.696-84.096v92.128c57.504 10.368 107.488 28.032 150.016 53.056l-78.752 44.48L992 587.008z" }),
|
||||
_createElementVNode("path", { d: "M613.792 889.152l-145.728 68.576V137.536l145.728-71.264v822.88z" })
|
||||
], -1)
|
||||
]))]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VCardTitle, null, {
|
||||
default: _withCtx(() => [...(_cache[9] || (_cache[9] = [
|
||||
_createTextVNode("OIDC 认证", -1)
|
||||
]))]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VCardSubtitle, { class: "text-medium-emphasis" }, {
|
||||
default: _withCtx(() => [...(_cache[10] || (_cache[10] = [
|
||||
_createTextVNode("OIDC 认证尚未启用", -1)
|
||||
]))]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VCardText, null, {
|
||||
default: _withCtx(() => [...(_cache[11] || (_cache[11] = [
|
||||
_createElementVNode("p", { class: "text-body-2 text-medium-emphasis" }, "请联系管理员在插件设置中配置 OIDC Provider。", -1)
|
||||
]))]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
}))
|
||||
]))
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
export { _sfc_main as default };
|
||||
418
plugins.v2/oidcauth/assets/__federation_fn_import-JrT3xvdd.js
Normal file
418
plugins.v2/oidcauth/assets/__federation_fn_import-JrT3xvdd.js
Normal file
@@ -0,0 +1,418 @@
|
||||
const buildIdentifier = "[0-9A-Za-z-]+";
|
||||
const build = `(?:\\+(${buildIdentifier}(?:\\.${buildIdentifier})*))`;
|
||||
const numericIdentifier = "0|[1-9]\\d*";
|
||||
const numericIdentifierLoose = "[0-9]+";
|
||||
const nonNumericIdentifier = "\\d*[a-zA-Z-][a-zA-Z0-9-]*";
|
||||
const preReleaseIdentifierLoose = `(?:${numericIdentifierLoose}|${nonNumericIdentifier})`;
|
||||
const preReleaseLoose = `(?:-?(${preReleaseIdentifierLoose}(?:\\.${preReleaseIdentifierLoose})*))`;
|
||||
const preReleaseIdentifier = `(?:${numericIdentifier}|${nonNumericIdentifier})`;
|
||||
const preRelease = `(?:-(${preReleaseIdentifier}(?:\\.${preReleaseIdentifier})*))`;
|
||||
const xRangeIdentifier = `${numericIdentifier}|x|X|\\*`;
|
||||
const xRangePlain = `[v=\\s]*(${xRangeIdentifier})(?:\\.(${xRangeIdentifier})(?:\\.(${xRangeIdentifier})(?:${preRelease})?${build}?)?)?`;
|
||||
const hyphenRange = `^\\s*(${xRangePlain})\\s+-\\s+(${xRangePlain})\\s*$`;
|
||||
const mainVersionLoose = `(${numericIdentifierLoose})\\.(${numericIdentifierLoose})\\.(${numericIdentifierLoose})`;
|
||||
const loosePlain = `[v=\\s]*${mainVersionLoose}${preReleaseLoose}?${build}?`;
|
||||
const gtlt = "((?:<|>)?=?)";
|
||||
const comparatorTrim = `(\\s*)${gtlt}\\s*(${loosePlain}|${xRangePlain})`;
|
||||
const loneTilde = "(?:~>?)";
|
||||
const tildeTrim = `(\\s*)${loneTilde}\\s+`;
|
||||
const loneCaret = "(?:\\^)";
|
||||
const caretTrim = `(\\s*)${loneCaret}\\s+`;
|
||||
const star = "(<|>)?=?\\s*\\*";
|
||||
const caret = `^${loneCaret}${xRangePlain}$`;
|
||||
const mainVersion = `(${numericIdentifier})\\.(${numericIdentifier})\\.(${numericIdentifier})`;
|
||||
const fullPlain = `v?${mainVersion}${preRelease}?${build}?`;
|
||||
const tilde = `^${loneTilde}${xRangePlain}$`;
|
||||
const xRange = `^${gtlt}\\s*${xRangePlain}$`;
|
||||
const comparator = `^${gtlt}\\s*(${fullPlain})$|^$`;
|
||||
const gte0 = "^\\s*>=\\s*0.0.0\\s*$";
|
||||
function parseRegex(source) {
|
||||
return new RegExp(source);
|
||||
}
|
||||
function isXVersion(version) {
|
||||
return !version || version.toLowerCase() === "x" || version === "*";
|
||||
}
|
||||
function pipe(...fns) {
|
||||
return (x) => {
|
||||
return fns.reduce((v, f) => f(v), x);
|
||||
};
|
||||
}
|
||||
function extractComparator(comparatorString) {
|
||||
return comparatorString.match(parseRegex(comparator));
|
||||
}
|
||||
function combineVersion(major, minor, patch, preRelease2) {
|
||||
const mainVersion2 = `${major}.${minor}.${patch}`;
|
||||
if (preRelease2) {
|
||||
return `${mainVersion2}-${preRelease2}`;
|
||||
}
|
||||
return mainVersion2;
|
||||
}
|
||||
function parseHyphen(range) {
|
||||
return range.replace(
|
||||
parseRegex(hyphenRange),
|
||||
(_range, from, fromMajor, fromMinor, fromPatch, _fromPreRelease, _fromBuild, to, toMajor, toMinor, toPatch, toPreRelease) => {
|
||||
if (isXVersion(fromMajor)) {
|
||||
from = "";
|
||||
} else if (isXVersion(fromMinor)) {
|
||||
from = `>=${fromMajor}.0.0`;
|
||||
} else if (isXVersion(fromPatch)) {
|
||||
from = `>=${fromMajor}.${fromMinor}.0`;
|
||||
} else {
|
||||
from = `>=${from}`;
|
||||
}
|
||||
if (isXVersion(toMajor)) {
|
||||
to = "";
|
||||
} else if (isXVersion(toMinor)) {
|
||||
to = `<${+toMajor + 1}.0.0-0`;
|
||||
} else if (isXVersion(toPatch)) {
|
||||
to = `<${toMajor}.${+toMinor + 1}.0-0`;
|
||||
} else if (toPreRelease) {
|
||||
to = `<=${toMajor}.${toMinor}.${toPatch}-${toPreRelease}`;
|
||||
} else {
|
||||
to = `<=${to}`;
|
||||
}
|
||||
return `${from} ${to}`.trim();
|
||||
}
|
||||
);
|
||||
}
|
||||
function parseComparatorTrim(range) {
|
||||
return range.replace(parseRegex(comparatorTrim), "$1$2$3");
|
||||
}
|
||||
function parseTildeTrim(range) {
|
||||
return range.replace(parseRegex(tildeTrim), "$1~");
|
||||
}
|
||||
function parseCaretTrim(range) {
|
||||
return range.replace(parseRegex(caretTrim), "$1^");
|
||||
}
|
||||
function parseCarets(range) {
|
||||
return range.trim().split(/\s+/).map((rangeVersion) => {
|
||||
return rangeVersion.replace(
|
||||
parseRegex(caret),
|
||||
(_, major, minor, patch, preRelease2) => {
|
||||
if (isXVersion(major)) {
|
||||
return "";
|
||||
} else if (isXVersion(minor)) {
|
||||
return `>=${major}.0.0 <${+major + 1}.0.0-0`;
|
||||
} else if (isXVersion(patch)) {
|
||||
if (major === "0") {
|
||||
return `>=${major}.${minor}.0 <${major}.${+minor + 1}.0-0`;
|
||||
} else {
|
||||
return `>=${major}.${minor}.0 <${+major + 1}.0.0-0`;
|
||||
}
|
||||
} else if (preRelease2) {
|
||||
if (major === "0") {
|
||||
if (minor === "0") {
|
||||
return `>=${major}.${minor}.${patch}-${preRelease2} <${major}.${minor}.${+patch + 1}-0`;
|
||||
} else {
|
||||
return `>=${major}.${minor}.${patch}-${preRelease2} <${major}.${+minor + 1}.0-0`;
|
||||
}
|
||||
} else {
|
||||
return `>=${major}.${minor}.${patch}-${preRelease2} <${+major + 1}.0.0-0`;
|
||||
}
|
||||
} else {
|
||||
if (major === "0") {
|
||||
if (minor === "0") {
|
||||
return `>=${major}.${minor}.${patch} <${major}.${minor}.${+patch + 1}-0`;
|
||||
} else {
|
||||
return `>=${major}.${minor}.${patch} <${major}.${+minor + 1}.0-0`;
|
||||
}
|
||||
}
|
||||
return `>=${major}.${minor}.${patch} <${+major + 1}.0.0-0`;
|
||||
}
|
||||
}
|
||||
);
|
||||
}).join(" ");
|
||||
}
|
||||
function parseTildes(range) {
|
||||
return range.trim().split(/\s+/).map((rangeVersion) => {
|
||||
return rangeVersion.replace(
|
||||
parseRegex(tilde),
|
||||
(_, major, minor, patch, preRelease2) => {
|
||||
if (isXVersion(major)) {
|
||||
return "";
|
||||
} else if (isXVersion(minor)) {
|
||||
return `>=${major}.0.0 <${+major + 1}.0.0-0`;
|
||||
} else if (isXVersion(patch)) {
|
||||
return `>=${major}.${minor}.0 <${major}.${+minor + 1}.0-0`;
|
||||
} else if (preRelease2) {
|
||||
return `>=${major}.${minor}.${patch}-${preRelease2} <${major}.${+minor + 1}.0-0`;
|
||||
}
|
||||
return `>=${major}.${minor}.${patch} <${major}.${+minor + 1}.0-0`;
|
||||
}
|
||||
);
|
||||
}).join(" ");
|
||||
}
|
||||
function parseXRanges(range) {
|
||||
return range.split(/\s+/).map((rangeVersion) => {
|
||||
return rangeVersion.trim().replace(
|
||||
parseRegex(xRange),
|
||||
(ret, gtlt2, major, minor, patch, preRelease2) => {
|
||||
const isXMajor = isXVersion(major);
|
||||
const isXMinor = isXMajor || isXVersion(minor);
|
||||
const isXPatch = isXMinor || isXVersion(patch);
|
||||
if (gtlt2 === "=" && isXPatch) {
|
||||
gtlt2 = "";
|
||||
}
|
||||
preRelease2 = "";
|
||||
if (isXMajor) {
|
||||
if (gtlt2 === ">" || gtlt2 === "<") {
|
||||
return "<0.0.0-0";
|
||||
} else {
|
||||
return "*";
|
||||
}
|
||||
} else if (gtlt2 && isXPatch) {
|
||||
if (isXMinor) {
|
||||
minor = 0;
|
||||
}
|
||||
patch = 0;
|
||||
if (gtlt2 === ">") {
|
||||
gtlt2 = ">=";
|
||||
if (isXMinor) {
|
||||
major = +major + 1;
|
||||
minor = 0;
|
||||
patch = 0;
|
||||
} else {
|
||||
minor = +minor + 1;
|
||||
patch = 0;
|
||||
}
|
||||
} else if (gtlt2 === "<=") {
|
||||
gtlt2 = "<";
|
||||
if (isXMinor) {
|
||||
major = +major + 1;
|
||||
} else {
|
||||
minor = +minor + 1;
|
||||
}
|
||||
}
|
||||
if (gtlt2 === "<") {
|
||||
preRelease2 = "-0";
|
||||
}
|
||||
return `${gtlt2 + major}.${minor}.${patch}${preRelease2}`;
|
||||
} else if (isXMinor) {
|
||||
return `>=${major}.0.0${preRelease2} <${+major + 1}.0.0-0`;
|
||||
} else if (isXPatch) {
|
||||
return `>=${major}.${minor}.0${preRelease2} <${major}.${+minor + 1}.0-0`;
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
);
|
||||
}).join(" ");
|
||||
}
|
||||
function parseStar(range) {
|
||||
return range.trim().replace(parseRegex(star), "");
|
||||
}
|
||||
function parseGTE0(comparatorString) {
|
||||
return comparatorString.trim().replace(parseRegex(gte0), "");
|
||||
}
|
||||
function compareAtom(rangeAtom, versionAtom) {
|
||||
rangeAtom = +rangeAtom || rangeAtom;
|
||||
versionAtom = +versionAtom || versionAtom;
|
||||
if (rangeAtom > versionAtom) {
|
||||
return 1;
|
||||
}
|
||||
if (rangeAtom === versionAtom) {
|
||||
return 0;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
function comparePreRelease(rangeAtom, versionAtom) {
|
||||
const { preRelease: rangePreRelease } = rangeAtom;
|
||||
const { preRelease: versionPreRelease } = versionAtom;
|
||||
if (rangePreRelease === void 0 && !!versionPreRelease) {
|
||||
return 1;
|
||||
}
|
||||
if (!!rangePreRelease && versionPreRelease === void 0) {
|
||||
return -1;
|
||||
}
|
||||
if (rangePreRelease === void 0 && versionPreRelease === void 0) {
|
||||
return 0;
|
||||
}
|
||||
for (let i = 0, n = rangePreRelease.length; i <= n; i++) {
|
||||
const rangeElement = rangePreRelease[i];
|
||||
const versionElement = versionPreRelease[i];
|
||||
if (rangeElement === versionElement) {
|
||||
continue;
|
||||
}
|
||||
if (rangeElement === void 0 && versionElement === void 0) {
|
||||
return 0;
|
||||
}
|
||||
if (!rangeElement) {
|
||||
return 1;
|
||||
}
|
||||
if (!versionElement) {
|
||||
return -1;
|
||||
}
|
||||
return compareAtom(rangeElement, versionElement);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
function compareVersion(rangeAtom, versionAtom) {
|
||||
return compareAtom(rangeAtom.major, versionAtom.major) || compareAtom(rangeAtom.minor, versionAtom.minor) || compareAtom(rangeAtom.patch, versionAtom.patch) || comparePreRelease(rangeAtom, versionAtom);
|
||||
}
|
||||
function eq(rangeAtom, versionAtom) {
|
||||
return rangeAtom.version === versionAtom.version;
|
||||
}
|
||||
function compare(rangeAtom, versionAtom) {
|
||||
switch (rangeAtom.operator) {
|
||||
case "":
|
||||
case "=":
|
||||
return eq(rangeAtom, versionAtom);
|
||||
case ">":
|
||||
return compareVersion(rangeAtom, versionAtom) < 0;
|
||||
case ">=":
|
||||
return eq(rangeAtom, versionAtom) || compareVersion(rangeAtom, versionAtom) < 0;
|
||||
case "<":
|
||||
return compareVersion(rangeAtom, versionAtom) > 0;
|
||||
case "<=":
|
||||
return eq(rangeAtom, versionAtom) || compareVersion(rangeAtom, versionAtom) > 0;
|
||||
case void 0: {
|
||||
return true;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
function parseComparatorString(range) {
|
||||
return pipe(
|
||||
parseCarets,
|
||||
parseTildes,
|
||||
parseXRanges,
|
||||
parseStar
|
||||
)(range);
|
||||
}
|
||||
function parseRange(range) {
|
||||
return pipe(
|
||||
parseHyphen,
|
||||
parseComparatorTrim,
|
||||
parseTildeTrim,
|
||||
parseCaretTrim
|
||||
)(range.trim()).split(/\s+/).join(" ");
|
||||
}
|
||||
function satisfy(version, range) {
|
||||
if (!version) {
|
||||
return false;
|
||||
}
|
||||
const parsedRange = parseRange(range);
|
||||
const parsedComparator = parsedRange.split(" ").map((rangeVersion) => parseComparatorString(rangeVersion)).join(" ");
|
||||
const comparators = parsedComparator.split(/\s+/).map((comparator2) => parseGTE0(comparator2));
|
||||
const extractedVersion = extractComparator(version);
|
||||
if (!extractedVersion) {
|
||||
return false;
|
||||
}
|
||||
const [
|
||||
,
|
||||
versionOperator,
|
||||
,
|
||||
versionMajor,
|
||||
versionMinor,
|
||||
versionPatch,
|
||||
versionPreRelease
|
||||
] = extractedVersion;
|
||||
const versionAtom = {
|
||||
version: combineVersion(
|
||||
versionMajor,
|
||||
versionMinor,
|
||||
versionPatch,
|
||||
versionPreRelease
|
||||
),
|
||||
major: versionMajor,
|
||||
minor: versionMinor,
|
||||
patch: versionPatch,
|
||||
preRelease: versionPreRelease == null ? void 0 : versionPreRelease.split(".")
|
||||
};
|
||||
for (const comparator2 of comparators) {
|
||||
const extractedComparator = extractComparator(comparator2);
|
||||
if (!extractedComparator) {
|
||||
return false;
|
||||
}
|
||||
const [
|
||||
,
|
||||
rangeOperator,
|
||||
,
|
||||
rangeMajor,
|
||||
rangeMinor,
|
||||
rangePatch,
|
||||
rangePreRelease
|
||||
] = extractedComparator;
|
||||
const rangeAtom = {
|
||||
operator: rangeOperator,
|
||||
version: combineVersion(
|
||||
rangeMajor,
|
||||
rangeMinor,
|
||||
rangePatch,
|
||||
rangePreRelease
|
||||
),
|
||||
major: rangeMajor,
|
||||
minor: rangeMinor,
|
||||
patch: rangePatch,
|
||||
preRelease: rangePreRelease == null ? void 0 : rangePreRelease.split(".")
|
||||
};
|
||||
if (!compare(rangeAtom, versionAtom)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
const moduleMap = {};
|
||||
const moduleCache = Object.create(null);
|
||||
async function importShared(name, shareScope = 'default') {
|
||||
return moduleCache[name]
|
||||
? new Promise((r) => r(moduleCache[name]))
|
||||
: (await getSharedFromRuntime(name, shareScope)) || getSharedFromLocal(name)
|
||||
}
|
||||
async function getSharedFromRuntime(name, shareScope) {
|
||||
let module = null;
|
||||
if (globalThis?.__federation_shared__?.[shareScope]?.[name]) {
|
||||
const versionObj = globalThis.__federation_shared__[shareScope][name];
|
||||
const requiredVersion = moduleMap[name]?.requiredVersion;
|
||||
const hasRequiredVersion = !!requiredVersion;
|
||||
if (hasRequiredVersion) {
|
||||
const versionKey = Object.keys(versionObj).find((version) =>
|
||||
satisfy(version, requiredVersion)
|
||||
);
|
||||
if (versionKey) {
|
||||
const versionValue = versionObj[versionKey];
|
||||
module = await (await versionValue.get())();
|
||||
} else {
|
||||
console.log(
|
||||
`provider support ${name}(${versionKey}) is not satisfied requiredVersion(\${moduleMap[name].requiredVersion})`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const versionKey = Object.keys(versionObj)[0];
|
||||
const versionValue = versionObj[versionKey];
|
||||
module = await (await versionValue.get())();
|
||||
}
|
||||
}
|
||||
if (module) {
|
||||
return flattenModule(module, name)
|
||||
}
|
||||
}
|
||||
async function getSharedFromLocal(name) {
|
||||
if (moduleMap[name]?.import) {
|
||||
let module = await (await moduleMap[name].get())();
|
||||
return flattenModule(module, name)
|
||||
} else {
|
||||
console.error(
|
||||
`consumer config import=false,so cant use callback shared module`
|
||||
);
|
||||
}
|
||||
}
|
||||
function flattenModule(module, name) {
|
||||
// use a shared module which export default a function will getting error 'TypeError: xxx is not a function'
|
||||
if (typeof module.default === 'function') {
|
||||
Object.keys(module).forEach((key) => {
|
||||
if (key !== 'default') {
|
||||
module.default[key] = module[key];
|
||||
}
|
||||
});
|
||||
moduleCache[name] = module.default;
|
||||
return module.default
|
||||
}
|
||||
if (module.default) module = Object.assign({}, module.default, module);
|
||||
moduleCache[name] = module;
|
||||
return module
|
||||
}
|
||||
|
||||
export { importShared, getSharedFromLocal as importSharedLocal, getSharedFromRuntime as importSharedRuntime };
|
||||
44
plugins.v2/oidcauth/assets/index-Cqb41JMs.js
Normal file
44
plugins.v2/oidcauth/assets/index-Cqb41JMs.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import { importShared } from './__federation_fn_import-JrT3xvdd.js';
|
||||
import AppPage from './__federation_expose_AppPage-BuslU8xE.js';
|
||||
|
||||
true&&(function polyfill() {
|
||||
const relList = document.createElement("link").relList;
|
||||
if (relList && relList.supports && relList.supports("modulepreload")) {
|
||||
return;
|
||||
}
|
||||
for (const link of document.querySelectorAll('link[rel="modulepreload"]')) {
|
||||
processPreload(link);
|
||||
}
|
||||
new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
if (mutation.type !== "childList") {
|
||||
continue;
|
||||
}
|
||||
for (const node of mutation.addedNodes) {
|
||||
if (node.tagName === "LINK" && node.rel === "modulepreload")
|
||||
processPreload(node);
|
||||
}
|
||||
}
|
||||
}).observe(document, { childList: true, subtree: true });
|
||||
function getFetchOpts(link) {
|
||||
const fetchOpts = {};
|
||||
if (link.integrity) fetchOpts.integrity = link.integrity;
|
||||
if (link.referrerPolicy) fetchOpts.referrerPolicy = link.referrerPolicy;
|
||||
if (link.crossOrigin === "use-credentials")
|
||||
fetchOpts.credentials = "include";
|
||||
else if (link.crossOrigin === "anonymous") fetchOpts.credentials = "omit";
|
||||
else fetchOpts.credentials = "same-origin";
|
||||
return fetchOpts;
|
||||
}
|
||||
function processPreload(link) {
|
||||
if (link.ep)
|
||||
return;
|
||||
link.ep = true;
|
||||
const fetchOpts = getFetchOpts(link);
|
||||
fetch(link.href, fetchOpts);
|
||||
}
|
||||
}());
|
||||
|
||||
const {createApp} = await importShared('vue');
|
||||
|
||||
createApp(AppPage).mount('#app');
|
||||
90
plugins.v2/oidcauth/assets/remoteEntry.js
Normal file
90
plugins.v2/oidcauth/assets/remoteEntry.js
Normal file
@@ -0,0 +1,90 @@
|
||||
const currentImports = {};
|
||||
const exportSet = new Set(['Module', '__esModule', 'default', '_export_sfc']);
|
||||
let moduleMap = {
|
||||
"./AuthPage":()=>{
|
||||
dynamicLoadingCss([], false, './AuthPage');
|
||||
return __federation_import('./__federation_expose_AuthPage-ByDbUb5c.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},
|
||||
"./AppPage":()=>{
|
||||
dynamicLoadingCss(["__federation_expose_AppPage-CCcTxdR8.css"], false, './AppPage');
|
||||
return __federation_import('./__federation_expose_AppPage-BuslU8xE.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},
|
||||
"./Page":()=>{
|
||||
dynamicLoadingCss([], false, './Page');
|
||||
return __federation_import('./__federation_expose_Page-B5ZFHZ5P.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},
|
||||
"./Config":()=>{
|
||||
dynamicLoadingCss([], false, './Config');
|
||||
return __federation_import('./__federation_expose_Config-CHWKv43_.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},};
|
||||
const seen = {};
|
||||
const dynamicLoadingCss = (cssFilePaths, dontAppendStylesToHead, exposeItemName) => {
|
||||
const metaUrl = import.meta.url;
|
||||
if (typeof metaUrl === 'undefined') {
|
||||
console.warn('The remote style takes effect only when the build.target option in the vite.config.ts file is higher than that of "es2020".');
|
||||
return;
|
||||
}
|
||||
|
||||
const curUrl = metaUrl.substring(0, metaUrl.lastIndexOf('remoteEntry.js'));
|
||||
const base = '/';
|
||||
'assets';
|
||||
|
||||
cssFilePaths.forEach(cssPath => {
|
||||
let href = '';
|
||||
const baseUrl = base || curUrl;
|
||||
if (baseUrl) {
|
||||
const trimmer = {
|
||||
trailing: (path) => (path.endsWith('/') ? path.slice(0, -1) : path),
|
||||
leading: (path) => (path.startsWith('/') ? path.slice(1) : path)
|
||||
};
|
||||
const isAbsoluteUrl = (url) => url.startsWith('http') || url.startsWith('//');
|
||||
|
||||
const cleanBaseUrl = trimmer.trailing(baseUrl);
|
||||
const cleanCssPath = trimmer.leading(cssPath);
|
||||
const cleanCurUrl = trimmer.trailing(curUrl);
|
||||
|
||||
if (isAbsoluteUrl(baseUrl)) {
|
||||
href = [cleanBaseUrl, cleanCssPath].filter(Boolean).join('/');
|
||||
} else {
|
||||
if (cleanCurUrl.includes(cleanBaseUrl)) {
|
||||
href = [cleanCurUrl, cleanCssPath].filter(Boolean).join('/');
|
||||
} else {
|
||||
href = [cleanCurUrl + cleanBaseUrl, cleanCssPath].filter(Boolean).join('/');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
href = cssPath;
|
||||
}
|
||||
|
||||
if (dontAppendStylesToHead) {
|
||||
const key = 'css__OidcAuth__' + exposeItemName;
|
||||
window[key] = window[key] || [];
|
||||
window[key].push(href);
|
||||
return;
|
||||
}
|
||||
|
||||
if (href in seen) return;
|
||||
seen[href] = true;
|
||||
|
||||
const element = document.createElement('link');
|
||||
element.rel = 'stylesheet';
|
||||
element.href = href;
|
||||
document.head.appendChild(element);
|
||||
});
|
||||
};
|
||||
async function __federation_import(name) {
|
||||
currentImports[name] ??= import(name);
|
||||
return currentImports[name]
|
||||
} const get =(module) => {
|
||||
if(!moduleMap[module]) throw new Error('Can not find remote module ' + module)
|
||||
return moduleMap[module]();
|
||||
};
|
||||
const init =(shareScope) => {
|
||||
globalThis.__federation_shared__= globalThis.__federation_shared__|| {};
|
||||
Object.entries(shareScope).forEach(([key, value]) => {
|
||||
for (const [versionKey, versionValue] of Object.entries(value)) {
|
||||
const scope = versionValue.scope || 'default';
|
||||
globalThis.__federation_shared__[scope] = globalThis.__federation_shared__[scope] || {};
|
||||
const shared= globalThis.__federation_shared__[scope];
|
||||
(shared[key] = shared[key]||{})[versionKey] = versionValue;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export { dynamicLoadingCss, get, init };
|
||||
5
plugins.v2/oidcauth/index.html
Normal file
5
plugins.v2/oidcauth/index.html
Normal file
@@ -0,0 +1,5 @@
|
||||
<script type="module" crossorigin src="./assets/index-Cqb41JMs.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="./assets/__federation_fn_import-JrT3xvdd.js">
|
||||
<link rel="modulepreload" crossorigin href="./assets/__federation_expose_AppPage-BuslU8xE.js">
|
||||
<link rel="stylesheet" crossorigin href="./assets/__federation_expose_AppPage-CCcTxdR8.css">
|
||||
<div id="app"></div>
|
||||
@@ -9,7 +9,7 @@ from pathlib import Path
|
||||
from typing import Any, List, Dict, Tuple, Optional
|
||||
|
||||
import pytz
|
||||
import zhconv
|
||||
from zhconv_rs import zhconv as zhconv_convert
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from requests import RequestException
|
||||
@@ -38,7 +38,7 @@ class PersonMeta(_PluginBase):
|
||||
# 插件图标
|
||||
plugin_icon = "actor.png"
|
||||
# 插件版本
|
||||
plugin_version = "2.2.2"
|
||||
plugin_version = "2.2.4"
|
||||
# 插件作者
|
||||
plugin_author = "jxxghp"
|
||||
# 作者主页
|
||||
@@ -62,6 +62,10 @@ class PersonMeta(_PluginBase):
|
||||
_type = "all"
|
||||
_remove_nozh = False
|
||||
_mediaservers = []
|
||||
_rt_lock = threading.Lock()
|
||||
_rt_running_keys = set()
|
||||
_rt_recent_keys: Dict[str, float] = {}
|
||||
_rt_dedup_seconds = 600
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
|
||||
@@ -297,7 +301,8 @@ class PersonMeta(_PluginBase):
|
||||
"cron": "",
|
||||
"type": "all",
|
||||
"delay": 30,
|
||||
"remove_nozh": False
|
||||
"remove_nozh": False,
|
||||
"mediaservers": []
|
||||
}
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
@@ -329,37 +334,97 @@ class PersonMeta(_PluginBase):
|
||||
|
||||
return active_services
|
||||
|
||||
@eventmanager.register(EventType.TransferComplete)
|
||||
def __try_lock_rt_item(self, key: str, title: str = "") -> bool:
|
||||
"""
|
||||
登记实时刮削中的媒体项,避免同一媒体项在短时间内反复处理
|
||||
"""
|
||||
now = time.time()
|
||||
with self._rt_lock:
|
||||
if self._rt_dedup_seconds > 0:
|
||||
for item_key, timestamp in list(self._rt_recent_keys.items()):
|
||||
if now - timestamp >= self._rt_dedup_seconds:
|
||||
self._rt_recent_keys.pop(item_key, None)
|
||||
recent_time = self._rt_recent_keys.get(key)
|
||||
if recent_time and now - recent_time < self._rt_dedup_seconds:
|
||||
logger.info(f"{title or key} 最近已触发演职人员刮削,跳过重复处理")
|
||||
return False
|
||||
if key in self._rt_running_keys:
|
||||
logger.info(f"{title or key} 正在执行演职人员刮削,跳过重复处理")
|
||||
return False
|
||||
self._rt_running_keys.add(key)
|
||||
return True
|
||||
|
||||
def __unlock_rt_item(self, key: str, completed: bool = True):
|
||||
"""
|
||||
释放实时刮削占用,并在完成后记录防重时间
|
||||
"""
|
||||
with self._rt_lock:
|
||||
self._rt_running_keys.discard(key)
|
||||
if completed and self._rt_dedup_seconds > 0:
|
||||
self._rt_recent_keys[key] = time.time()
|
||||
|
||||
@eventmanager.register([EventType.MetadataScrape, EventType.TransferComplete])
|
||||
def scrap_rt(self, event: Event):
|
||||
"""
|
||||
根据事件实时刮削演员信息
|
||||
根据刮削事件实时刮削演员信息
|
||||
"""
|
||||
if not self._enabled:
|
||||
return
|
||||
# 事件数据
|
||||
if not event or not event.event_data:
|
||||
logger.warn("TransferComplete事件数据为空")
|
||||
logger.warn("演职人员刮削事件数据为空")
|
||||
return
|
||||
mediainfo: MediaInfo = event.event_data.get("mediainfo")
|
||||
meta: MetaBase = event.event_data.get("meta")
|
||||
if not mediainfo or not meta:
|
||||
event_data = event.event_data
|
||||
# 已开启元数据刮削的整理事件由 MetadataScrape 统一处理,避免 TransferComplete 逐文件重复触发
|
||||
if event.event_type == EventType.TransferComplete:
|
||||
transferinfo = event_data.get("transferinfo")
|
||||
if transferinfo and getattr(transferinfo, "need_scrape", False):
|
||||
logger.debug("整理完成事件已开启元数据刮削,等待 MetadataScrape 事件处理演职人员")
|
||||
return
|
||||
mediainfo: MediaInfo = event_data.get("mediainfo")
|
||||
meta: MetaBase = event_data.get("meta")
|
||||
if not mediainfo:
|
||||
return
|
||||
# 延迟
|
||||
if self._delay:
|
||||
time.sleep(int(self._delay))
|
||||
# 查询媒体服务器中的条目
|
||||
existsinfo = self.chain.media_exists(mediainfo=mediainfo)
|
||||
if not existsinfo or not existsinfo.itemid:
|
||||
logger.warn(f"{mediainfo.title_year} 在媒体库中不存在")
|
||||
# 查询已配置媒体服务器中的条目
|
||||
service_infos = self.service_infos()
|
||||
if not service_infos:
|
||||
return
|
||||
# 查询条目详情
|
||||
iteminfo = MediaServerChain().iteminfo(server=existsinfo.server, item_id=existsinfo.itemid)
|
||||
if not iteminfo:
|
||||
logger.warn(f"{mediainfo.title_year} 条目详情获取失败")
|
||||
return
|
||||
# 刮削演职人员信息
|
||||
self.__update_item(server=existsinfo.server, server_type=existsinfo.server_type,
|
||||
item=iteminfo, mediainfo=mediainfo, season=meta.begin_season)
|
||||
matched = False
|
||||
season = getattr(meta, "begin_season", None)
|
||||
mediaserverchain = MediaServerChain()
|
||||
title = getattr(mediainfo, "title_year", None) or getattr(mediainfo, "title", "") or "未知媒体"
|
||||
for server, service in service_infos.items():
|
||||
try:
|
||||
existsinfo = self.chain.media_exists(mediainfo=mediainfo, server=server)
|
||||
except Exception as err:
|
||||
logger.error(f"查询媒体服务器 {server} 中的 {title} 失败:{err}")
|
||||
continue
|
||||
if not existsinfo or not existsinfo.itemid:
|
||||
continue
|
||||
matched = True
|
||||
exists_server = existsinfo.server or server
|
||||
server_type = existsinfo.server_type or service.type
|
||||
item_key = f"{exists_server}:{server_type}:{existsinfo.itemid}"
|
||||
if not self.__try_lock_rt_item(item_key, title):
|
||||
continue
|
||||
completed = False
|
||||
try:
|
||||
# 查询条目详情
|
||||
iteminfo = mediaserverchain.iteminfo(server=exists_server, item_id=existsinfo.itemid)
|
||||
if not iteminfo:
|
||||
logger.warn(f"{title} 条目详情获取失败")
|
||||
continue
|
||||
# 刮削演职人员信息
|
||||
self.__update_item(server=exists_server, server_type=server_type,
|
||||
item=iteminfo, mediainfo=mediainfo, season=season)
|
||||
completed = True
|
||||
finally:
|
||||
self.__unlock_rt_item(item_key, completed=completed)
|
||||
if not matched:
|
||||
logger.warn(f"{title} 在已配置媒体服务器中不存在")
|
||||
|
||||
def scrap_library(self):
|
||||
"""
|
||||
@@ -426,7 +491,7 @@ class PersonMeta(_PluginBase):
|
||||
elif not self._remove_nozh:
|
||||
peoples.append(people)
|
||||
# 保存媒体项信息
|
||||
if peoples:
|
||||
if peoples or self._remove_nozh:
|
||||
iteminfo["People"] = peoples
|
||||
self.set_iteminfo(server=server, server_type=server_type,
|
||||
itemid=itemid, iteminfo=iteminfo)
|
||||
@@ -717,7 +782,10 @@ class PersonMeta(_PluginBase):
|
||||
获得媒体项详情
|
||||
"""
|
||||
|
||||
service = self.service_infos(server_type).get(server)
|
||||
services = self.service_infos(server_type)
|
||||
if not services:
|
||||
return {}
|
||||
service = services.get(server)
|
||||
if not service:
|
||||
logger.warn(f"未找到媒体服务器 {server} 的实例")
|
||||
return {}
|
||||
@@ -797,7 +865,10 @@ class PersonMeta(_PluginBase):
|
||||
"""
|
||||
获得媒体的所有子媒体项
|
||||
"""
|
||||
service = self.service_infos(server_type).get(server)
|
||||
services = self.service_infos(server_type)
|
||||
if not services:
|
||||
return {}
|
||||
service = services.get(server)
|
||||
if not service:
|
||||
logger.warn(f"未找到媒体服务器 {server} 的实例")
|
||||
return {}
|
||||
@@ -912,7 +983,10 @@ class PersonMeta(_PluginBase):
|
||||
更新媒体项详情
|
||||
"""
|
||||
|
||||
service = self.service_infos(server_type).get(server)
|
||||
services = self.service_infos(server_type)
|
||||
if not services:
|
||||
return {}
|
||||
service = services.get(server)
|
||||
if not service:
|
||||
logger.warn(f"未找到媒体服务器 {server} 的实例")
|
||||
return {}
|
||||
@@ -990,7 +1064,10 @@ class PersonMeta(_PluginBase):
|
||||
更新媒体项图片
|
||||
"""
|
||||
|
||||
service = self.service_infos(server_type).get(server)
|
||||
services = self.service_infos(server_type)
|
||||
if not services:
|
||||
return {}
|
||||
service = services.get(server)
|
||||
if not service:
|
||||
logger.warn(f"未找到媒体服务器 {server} 的实例")
|
||||
return {}
|
||||
@@ -1092,8 +1169,8 @@ class PersonMeta(_PluginBase):
|
||||
if also_known_as:
|
||||
for name in also_known_as:
|
||||
if name and StringUtils.is_chinese(name):
|
||||
# 使用cn2an将繁体转化为简体
|
||||
return zhconv.convert(name, "zh-hans")
|
||||
# 将繁体别名统一转为简体,便于媒体库名称匹配。
|
||||
return zhconv_convert(name, "zh-hans")
|
||||
except Exception as err:
|
||||
logger.error(f"获取人物中文名失败:{err}")
|
||||
return ""
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
fast-bencode~=1.1.7
|
||||
1723
plugins.v2/traktcleaner/__init__.py
Normal file
1723
plugins.v2/traktcleaner/__init__.py
Normal file
File diff suppressed because it is too large
Load Diff
821
plugins.v2/updatewechatip/__init__.py
Normal file
821
plugins.v2/updatewechatip/__init__.py
Normal file
@@ -0,0 +1,821 @@
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Tuple
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
import requests
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.event import eventmanager, Event
|
||||
from app.log import logger
|
||||
from app.plugins import _PluginBase
|
||||
from app.schemas.types import EventType
|
||||
|
||||
|
||||
class UpdateWeChatIp(_PluginBase):
|
||||
# 插件在界面中的展示名称
|
||||
plugin_name = "动态企微可信IP"
|
||||
# 插件描述
|
||||
plugin_desc = "修改企微应用可信IP,可本地扫码刷新Cookie"
|
||||
# 插件图标
|
||||
plugin_icon = "Wecom_A.png"
|
||||
# 插件版本,必须和 package.v2.json 中保持一致
|
||||
plugin_version = "1.0.8"
|
||||
# 作者信息
|
||||
plugin_author = "书小白"
|
||||
author_url = "https://github.com/thshu/MoviePilot-Plugins"
|
||||
# 配置项前缀,建议保持唯一,避免与其他插件冲突
|
||||
plugin_config_prefix = "UpdateWeChatIp_"
|
||||
# 插件加载顺序,数值越小越早
|
||||
plugin_order = 50
|
||||
# 插件可见权限级别
|
||||
auth_level = 1
|
||||
|
||||
# 运行时状态字段
|
||||
_enabled = False
|
||||
_se = None
|
||||
_qrcode_key = None
|
||||
_tl_key = None
|
||||
_captcha = {}
|
||||
_wwrtx_sid = None
|
||||
_party_cache_data = None
|
||||
_app_id = ""
|
||||
_ip = None
|
||||
_is_login = False
|
||||
onlyonce = False
|
||||
_cron = ""
|
||||
|
||||
_UpdateLogKey = 'UpdateLog'
|
||||
|
||||
_ip_urls = ["https://myip.ipip.net", "https://ddns.oray.com/checkip", "https://ip.3322.net", "https://4.ipw.cn",
|
||||
'http://v4.666666.host:66/ip', 'https://ipv4.ddnspod.com', 'https://v4.66666.host:66/ip',
|
||||
'https://4.ipw.cn', 'https://ip.3322.net', 'https://6.66666.host:66/ip']
|
||||
_ip_pattern = r'\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b'
|
||||
|
||||
_headers = {
|
||||
'User-Agent': "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36",
|
||||
'Accept-Encoding': "gzip, deflate, br, zstd",
|
||||
'pragma': "no-cache",
|
||||
'cache-control': "no-cache",
|
||||
'sec-ch-ua-platform': "\"Windows\"",
|
||||
'x-requested-with': "XMLHttpRequest",
|
||||
'sec-ch-ua': "\"Chromium\";v=\"148\", \"Google Chrome\";v=\"148\", \"Not/A)Brand\";v=\"99\"",
|
||||
'sec-ch-ua-mobile': "?0",
|
||||
'sec-fetch-site': "same-origin",
|
||||
'sec-fetch-mode': "cors",
|
||||
'sec-fetch-dest': "empty",
|
||||
'referer': "https://work.weixin.qq.com/wework_admin/wwqrlogin/mng/login_qrcode",
|
||||
'accept-language': "zh-CN,zh;q=0.9,ja;q=0.8,en;q=0.7",
|
||||
'priority': "u=1, i",
|
||||
}
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
"""根据当前配置初始化插件。"""
|
||||
config = config or {}
|
||||
self._enabled = bool(config.get("_enabled"))
|
||||
self._wwrtx_sid = config.get("_wwrtx_sid")
|
||||
self._app_id = config.get("_app_id")
|
||||
self._cron = config.get("_cron")
|
||||
self._party_cache_data = config.get("_party_cache_data")
|
||||
|
||||
self._se = requests.Session()
|
||||
self._se.cookies.set('wwrtx.sid', self._wwrtx_sid)
|
||||
|
||||
def _save_current_config(self):
|
||||
self._login_success()
|
||||
|
||||
def get_state(self) -> bool:
|
||||
"""返回插件当前是否启用。"""
|
||||
return self._enabled
|
||||
|
||||
def get_service(self) -> List[Dict[str, Any]]:
|
||||
if self._enabled and self._cron:
|
||||
return [
|
||||
{
|
||||
"id": self.__class__.__name__,
|
||||
"name": f"{self.__class__.__name__}_{self.plugin_name}服务",
|
||||
"trigger": CronTrigger.from_crontab(self._cron),
|
||||
"func": self.check,
|
||||
"kwargs": {}
|
||||
},
|
||||
]
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def get_command() -> List[Dict[str, Any]]:
|
||||
"""
|
||||
注册插件远程命令
|
||||
"""
|
||||
return [{
|
||||
"cmd": "/update_wechat_ip",
|
||||
"event": EventType.PluginAction,
|
||||
"desc": "获取企业微信二维码",
|
||||
"category": "获取企业微信二维码",
|
||||
"data": {
|
||||
"action": "update_wechat_ip"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@eventmanager.register(EventType.PluginAction)
|
||||
def command_action(self, event: Event):
|
||||
"""
|
||||
远程命令响应
|
||||
"""
|
||||
event_data = event.event_data
|
||||
if not event_data or event_data.get("action") not in [i['data']['action'] for i in self.get_command()]:
|
||||
return
|
||||
|
||||
# 获取用户信息
|
||||
channel = event_data.get("channel")
|
||||
arg_str = event_data.get("arg_str")
|
||||
source = event_data.get("source")
|
||||
user = event_data.get("user")
|
||||
if arg_str is not None:
|
||||
if arg_str == '扫码完成':
|
||||
self._login(channel, user)
|
||||
elif len(re.findall('[0-9]', arg_str)) == 6:
|
||||
self._captcha[self._qrcode_key] = arg_str
|
||||
self._confirm_captcha(self._tl_key, self._captcha.get(self._qrcode_key))
|
||||
self._wwrtx_sid = self._se.cookies.get_dict().get('wwrtx.sid')
|
||||
if self._party_cache():
|
||||
self._login_success()
|
||||
self.post_message(
|
||||
channel=channel,
|
||||
title="登录成功",
|
||||
userid=user,
|
||||
text=f"成功登录企业:{self._party_cache_data.get('party_list', {}).get('list', [{}])[0].get('name')}",
|
||||
)
|
||||
else:
|
||||
self.post_message(
|
||||
channel=channel,
|
||||
title="登录失败",
|
||||
userid=user,
|
||||
text=f"登录失败,返回值:{self._party_cache_data}",
|
||||
)
|
||||
else:
|
||||
self.post_message(
|
||||
channel=channel,
|
||||
title="无效的输入",
|
||||
userid=user,
|
||||
content="无效的输入",
|
||||
)
|
||||
else:
|
||||
# 初始化变量
|
||||
self._qrcode_key = None
|
||||
self._tl_key = None
|
||||
self._captcha = {}
|
||||
|
||||
self._qrcode_key = self._get_key()
|
||||
image_url = self._qrcode(self._qrcode_key)
|
||||
|
||||
self.post_message(
|
||||
channel=channel,
|
||||
title="登录二维码",
|
||||
text='\n'.join(
|
||||
[
|
||||
"请选择要执行的操作:",
|
||||
f"如果按钮不可用,可回复:\n```\n/update_wechat_ip 扫码完成\n```"
|
||||
]
|
||||
),
|
||||
userid=user,
|
||||
buttons=[[{"text": f'扫码完成',
|
||||
"callback_data": f"[PLUGIN]{self.__class__.__name__}|扫码完成|{self._qrcode_key}"}]],
|
||||
image=image_url
|
||||
)
|
||||
|
||||
@eventmanager.register(EventType.MessageAction)
|
||||
def message_action(self, event: Event):
|
||||
"""
|
||||
处理消息按钮回调
|
||||
"""
|
||||
event_data = event.event_data
|
||||
if not event_data:
|
||||
return
|
||||
|
||||
# 检查是否为本插件的回调
|
||||
plugin_id = event_data.get("plugin_id")
|
||||
if plugin_id != self.__class__.__name__:
|
||||
return
|
||||
|
||||
# 获取回调数据
|
||||
channel = event_data.get("channel")
|
||||
source = event_data.get("source")
|
||||
userid = event_data.get("userid")
|
||||
# 获取原始消息ID和聊天ID(用于直接更新原消息)
|
||||
original_message_id = event_data.get("original_message_id")
|
||||
original_chat_id = event_data.get("original_chat_id")
|
||||
|
||||
callback_text = event_data.get("text", "")
|
||||
if "|" not in callback_text:
|
||||
self.post_message(
|
||||
channel=channel,
|
||||
title="登录失败",
|
||||
userid=userid,
|
||||
text=f"未获取到本地登录对应的qrcode_key",
|
||||
)
|
||||
return
|
||||
text, qrcode_key = callback_text.split("|", 1)
|
||||
|
||||
if text == "扫码完成":
|
||||
self._qrcode_key = qrcode_key
|
||||
self._login(channel, userid)
|
||||
if text == "输入完毕":
|
||||
self._confirm_captcha(self._tl_key, self._captcha.get(self._qrcode_key))
|
||||
if self._party_cache():
|
||||
self._login_success()
|
||||
self.post_message(
|
||||
channel=channel,
|
||||
title="登录成功",
|
||||
userid=userid,
|
||||
text=f"成功登录企业:{self._party_cache_data.get('party_list', {}).get('list', [{}])[0].get('name')}",
|
||||
)
|
||||
else:
|
||||
self.post_message(
|
||||
channel=channel,
|
||||
title="登录失败",
|
||||
userid=userid,
|
||||
text=f"登录失败,返回值:{self._party_cache_data}",
|
||||
)
|
||||
elif len(re.findall('[0-9]', text)) != 0:
|
||||
if qrcode_key not in self._captcha.keys():
|
||||
self._captcha[qrcode_key] = ""
|
||||
self._captcha[qrcode_key] += text
|
||||
self.post_message(
|
||||
channel=channel,
|
||||
title="短信验证码",
|
||||
userid=userid,
|
||||
buttons=self._get_buttons(),
|
||||
text='\n'.join(
|
||||
[
|
||||
"触发验证码:",
|
||||
f"验证码内容:{self._captcha[qrcode_key]}\n"
|
||||
f"如果按钮不可用,可回复:\n```\n/update_wechat_ip 验证码内容\n```"
|
||||
]
|
||||
),
|
||||
original_message_id=original_message_id,
|
||||
original_chat_id=original_chat_id
|
||||
)
|
||||
else:
|
||||
self.post_message(
|
||||
channel=channel,
|
||||
title="无效的输入",
|
||||
userid=userid,
|
||||
content="无效的输入",
|
||||
)
|
||||
|
||||
def get_api(self) -> List[Dict[str, Any]]:
|
||||
"""没有插件 API 时直接返回空列表。"""
|
||||
return [
|
||||
{
|
||||
"path": "/img/{uuid}",
|
||||
"endpoint": self.get_img,
|
||||
"methods": ["GET"],
|
||||
# 前端插件页面通过 api 模块调用时,通常使用 bear
|
||||
"auth": "apikey",
|
||||
"summary": "获取图片",
|
||||
"description": "获取图片",
|
||||
},
|
||||
{
|
||||
"path": "/UpdateIP",
|
||||
"endpoint": self.UpdateIp,
|
||||
"methods": ["GET"],
|
||||
# 前端插件页面通过 api 模块调用时,通常使用 bear
|
||||
"auth": "apikey",
|
||||
"summary": "更新企业微信IP白名单",
|
||||
"description": "更新企业微信IP白名单,需要传递查询参数,参数名为:ip",
|
||||
},
|
||||
]
|
||||
|
||||
def UpdateIp(self, ip):
|
||||
self._ip = ip
|
||||
self._save_ip_config()
|
||||
|
||||
def get_img(self, uuid):
|
||||
save_path: Path = self.get_data_path() / f"WeChatQr.jpg"
|
||||
return FileResponse(
|
||||
save_path,
|
||||
media_type="image/jpeg"
|
||||
)
|
||||
|
||||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
"""返回配置页 JSON 和默认配置模型。"""
|
||||
return [
|
||||
{
|
||||
'component': 'VForm',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': '_enabled',
|
||||
'label': '启用插件',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'onlyonce',
|
||||
'label': '立即检测一次',
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': '_cron',
|
||||
'label': '[必填]检测周期',
|
||||
'placeholder': '*/10 * * * *'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextarea',
|
||||
'props': {
|
||||
'model': '_app_id',
|
||||
'label': '[必填]应用ID',
|
||||
'rows': 1,
|
||||
'placeholder': '输入应用ID,多个使用(,)英文逗号隔开,在企业微信应用页面URL末尾获取'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
], {
|
||||
"_enabled": False,
|
||||
"_wwrtx_sid": "",
|
||||
"_app_id": "",
|
||||
"_party_cache_data": {},
|
||||
"_cron": '*/10 * * * *'
|
||||
}
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
"""返回详情页 JSON。"""
|
||||
# ---------- 获取并排序更新日志 ----------
|
||||
raw_data = self.get_data(self._UpdateLogKey) or []
|
||||
update_log: List[UpdateLogDto] = [UpdateLogDto.from_dict(i) for i in raw_data]
|
||||
data_list = sorted(update_log, key=lambda x: x.UpdateTime, reverse=True)
|
||||
|
||||
update_log_trs = [
|
||||
{
|
||||
"component": "tr",
|
||||
"props": {"class": "text-sm"},
|
||||
"content": [
|
||||
{
|
||||
"component": "td",
|
||||
"props": {
|
||||
"style": {"color": "red"} if not data.status else {}
|
||||
},
|
||||
"text": "成功" if data.status else "失败",
|
||||
},
|
||||
{"component": "td", "text": data.app_id},
|
||||
{"component": "td", "text": data.ip},
|
||||
{"component": "td", "text": data.result},
|
||||
{"component": "td",
|
||||
"text": data.UpdateTime.strftime('%Y-%m-%d %H:%M:%S') if data.UpdateTime else ""},
|
||||
],
|
||||
}
|
||||
for data in data_list
|
||||
]
|
||||
|
||||
# ---------- 安全获取 party 名称 ----------
|
||||
party_cache = self._party_cache_data or {}
|
||||
party_list = party_cache.get("party_list", {}).get("list") or [{}]
|
||||
party_name = party_list[0].get("name", "未知")
|
||||
|
||||
# ---------- 构建页面结构 ----------
|
||||
return [
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12},
|
||||
"content": [
|
||||
# 顶部状态标题
|
||||
{
|
||||
"component": "div",
|
||||
"props": {
|
||||
"style": {
|
||||
"display": "flex",
|
||||
"justifyContent": "center",
|
||||
"alignItems": "center",
|
||||
"flexDirection": "column",
|
||||
"gap": "10px",
|
||||
"marginBottom": "20px", # 增加与表格的间距
|
||||
}
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"component": "div",
|
||||
"text": f"{party_name}已登录" if self._is_login else "登录失效",
|
||||
"props": {
|
||||
"style": {
|
||||
"fontSize": "22px",
|
||||
"fontWeight": "bold",
|
||||
"color": "#ffffff",
|
||||
"backgroundColor": "#9B50FF",
|
||||
"padding": "8px 16px",
|
||||
"borderRadius": "5px",
|
||||
"textAlign": "center",
|
||||
"display": "inline-block",
|
||||
}
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
# 日志表格
|
||||
{
|
||||
"component": "VTable",
|
||||
"props": {"hover": True},
|
||||
"content": [
|
||||
{
|
||||
"component": "thead",
|
||||
"props": {"class": "text-no-wrap"},
|
||||
"content": [
|
||||
{
|
||||
"component": "th",
|
||||
"props": {"class": "text-start ps-4"},
|
||||
"text": "状态",
|
||||
},
|
||||
{
|
||||
"component": "th",
|
||||
"props": {"class": "text-start ps-4"},
|
||||
"text": "appId",
|
||||
},
|
||||
{
|
||||
"component": "th",
|
||||
"props": {"class": "text-start ps-4"},
|
||||
"text": "更新IP",
|
||||
},
|
||||
{
|
||||
"component": "th",
|
||||
"props": {"class": "text-start ps-4"},
|
||||
"text": "返回值",
|
||||
},
|
||||
{
|
||||
"component": "th",
|
||||
"props": {"class": "text-start ps-4"},
|
||||
"text": "更新时间",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "tbody",
|
||||
"content": update_log_trs,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
def stop_service(self):
|
||||
"""没有后台任务时可以留空。"""
|
||||
pass
|
||||
|
||||
def _get_key(self):
|
||||
logger.info("开始获取登录二维码key")
|
||||
url = "https://work.weixin.qq.com/wework_admin/wwqrlogin/mng/get_key"
|
||||
current_ts = int(time.time() * 1000)
|
||||
params = {
|
||||
'r': str(random.random()),
|
||||
'login_type': "login_admin",
|
||||
'callback': f"wwqrloginCallback_{current_ts}",
|
||||
'redirect_uri': "https://work.weixin.qq.com/wework_admin/loginpage_wx?_r=234&redirect_uri=https%3A%2F%2Fwork.weixin.qq.com%2Fwework_admin%2Fframe&url_hash=%23%2Fapps#/apps",
|
||||
'crossorigin': "1"
|
||||
}
|
||||
response = self._se.get(url, params=params, headers=self._headers)
|
||||
logger.info(f"获取登录二维码key成功,返回值:{response.text}")
|
||||
|
||||
return response.json().get('data', {}).get('qrcode_key')
|
||||
|
||||
def _qrcode(self, key) -> str:
|
||||
logger.info("开始获取登录二维码图片")
|
||||
url = "https://work.weixin.qq.com/wework_admin/wwqrlogin/mng/qrcode"
|
||||
params = {
|
||||
'qrcode_key': key,
|
||||
'login_type': "login_admin"
|
||||
}
|
||||
response = self._se.get(url, params=params, headers=self._headers)
|
||||
logger.info("登录二维码图片获取成功")
|
||||
img_path: Path = self.get_data_path() / f"WeChatQr.jpg"
|
||||
img_path.write_bytes(response.content)
|
||||
logger.info(f"登录二维码已写入文件,路径:{img_path}")
|
||||
uri = f"/api/v1/plugin/{self.__class__.__name__}/img/{uuid.uuid4().__str__().replace('-', '')}?apikey={settings.API_TOKEN}"
|
||||
img_url = settings.MP_DOMAIN(uri) or f"http://127.0.0.1:{settings.PORT}{uri}"
|
||||
logger.info(f"构建二维码地址为:{img_url}")
|
||||
return img_url
|
||||
|
||||
def _check(self, key) -> Dict:
|
||||
logger.info(f"开始获取扫码结果")
|
||||
for _ in range(2):
|
||||
url = "https://work.weixin.qq.com/wework_admin/wwqrlogin/mng/check"
|
||||
params = {
|
||||
'qrcode_key': key,
|
||||
'status': "QRCODE_SCAN_ING"
|
||||
}
|
||||
response = self._se.get(url, params=params, headers=self._headers)
|
||||
data = response.json().get('data', {})
|
||||
logger.info(f"扫码结果获取完成:{response.text}")
|
||||
if data.get("status") == "QRCODE_SCAN_SUCC":
|
||||
return data
|
||||
time.sleep(1)
|
||||
logger.info(f"获取扫码结果超时")
|
||||
return None
|
||||
|
||||
def _loginpage_wx(self, key, code) -> requests.Response:
|
||||
logger.info(f"开始登录")
|
||||
url = "https://work.weixin.qq.com/wework_admin/loginpage_wx"
|
||||
params = {
|
||||
'_r': "234",
|
||||
'redirect_uri': "https://work.weixin.qq.com/wework_admin/frame",
|
||||
'url_hash': "#/apps",
|
||||
'code': code,
|
||||
'auth_redirect_time': "1780446137000",
|
||||
'getauth_time': "1780446137000",
|
||||
'wwqrlogin': "1",
|
||||
'qrcode_key': key,
|
||||
'auth_source': "SOURCE_FROM_WEWORK",
|
||||
'confirm_type': "0"
|
||||
}
|
||||
response = self._se.get(url, params=params, headers=self._headers)
|
||||
logger.info(f"登录完成,返回值:{response.text}")
|
||||
return response
|
||||
|
||||
def _confirm_captcha(self, tl_key, captcha):
|
||||
logger.info(f"开始提交验证码")
|
||||
_url = "https://work.weixin.qq.com/wework_admin/mobile_confirm/confirm_captcha?ajax=1&f=json&d2st="
|
||||
_data = {
|
||||
"captcha": captcha,
|
||||
"tl_key": tl_key
|
||||
}
|
||||
res = self._se.post(_url, json=_data, headers=self._headers)
|
||||
logger.info(f"提交验证码返回值:{res.text}")
|
||||
res = self._se.get(f"https://work.weixin.qq.com/wework_admin/login/choose_corp?tl_key={tl_key}")
|
||||
logger.info(f"choose_corp接口返回值:{res.text}")
|
||||
|
||||
def _party_cache(self):
|
||||
logger.info(f"开始获取企业信息,判断是否登录成功")
|
||||
if not self._wwrtx_sid:
|
||||
return False
|
||||
url = "https://work.weixin.qq.com/wework_admin/contacts/party/cache"
|
||||
params = {
|
||||
'lang': "zh_CN",
|
||||
'f': "json",
|
||||
'ajax': "1",
|
||||
'timeZoneInfo[zone_offset]': "-8",
|
||||
}
|
||||
self._se.cookies.set('wwrtx.sid', self._wwrtx_sid)
|
||||
try:
|
||||
res = self._se.post(url, params=params, headers=self._headers, timeout=10)
|
||||
if res.status_code == 200:
|
||||
data = res.json()
|
||||
if 'errCode' not in res.text:
|
||||
self._party_cache_data = data.get('data')
|
||||
self._is_login = True
|
||||
return True
|
||||
else:
|
||||
self._party_cache_data = data
|
||||
else:
|
||||
logger.error(f"获取企业微信部门缓存失败,HTTP状态码:{res.status_code}")
|
||||
except Exception as e:
|
||||
logger.error(f"获取企业微信部门缓存异常: {e}")
|
||||
self._is_login = False
|
||||
return False
|
||||
|
||||
def _login(self, channel, userid):
|
||||
logger.info(f"触发登录回调,开始执行登录步骤")
|
||||
check_data = self._check(self._qrcode_key)
|
||||
if check_data:
|
||||
code = check_data.get('auth_code')
|
||||
res = self._loginpage_wx(self._qrcode_key, code)
|
||||
if 'tl_key' in res.url:
|
||||
logger.info(f"返回值中获取到tl_key,触发短信验证码")
|
||||
self.post_message(
|
||||
channel=channel,
|
||||
title="短信验证码",
|
||||
userid=userid,
|
||||
buttons=self._get_buttons(),
|
||||
text='\n'.join(
|
||||
[
|
||||
"触发验证码:",
|
||||
f"如果按钮不可用,可回复:\n```\n/update_wechat_ip 验证码内容\n```"
|
||||
]
|
||||
),
|
||||
)
|
||||
parsed = urlparse(res.url)
|
||||
query_params = parse_qs(parsed.query)
|
||||
# 获取 tl_key 的值(parse_qs 返回字典,每个键对应一个列表)
|
||||
self._tl_key = query_params.get('tl_key', [None])[0]
|
||||
else:
|
||||
self._wwrtx_sid = self._se.cookies.get_dict().get('wwrtx.sid')
|
||||
if self._party_cache():
|
||||
logger.info(f"登录成功")
|
||||
self._login_success()
|
||||
self.post_message(
|
||||
channel=channel,
|
||||
title="登录成功",
|
||||
userid=userid,
|
||||
text=f"成功登录企业:{self._party_cache_data.get('party_list', {}).get('list', [{}])[0].get('name')}",
|
||||
)
|
||||
else:
|
||||
logger.error(f"登录失败,返回值:{self._party_cache_data}")
|
||||
self.post_message(
|
||||
channel=channel,
|
||||
title="登录失败",
|
||||
userid=userid,
|
||||
text=f"登录失败,返回值:{self._party_cache_data}",
|
||||
)
|
||||
|
||||
def _save_ip_config(self):
|
||||
logger.info(f"更新IP为:{self._ip}")
|
||||
_update_log = []
|
||||
url = 'https://work.weixin.qq.com/wework_admin/apps/saveIpConfig?lang=zh_CN&f=json&ajax=1'
|
||||
for appId in self._app_id.split(','):
|
||||
appId = appId.strip()
|
||||
if not appId:
|
||||
continue
|
||||
data = {
|
||||
'app_id': appId,
|
||||
'ipList[]': self._ip
|
||||
}
|
||||
res = self._se.post(url, data=data, headers=self._headers)
|
||||
if 'err' in res.text:
|
||||
logger.error(f"{appId}更新IP白名单失败,返回值:{res.text}")
|
||||
else:
|
||||
logger.info(f'{appId}更新白名单成功,更新IP为:{self._ip},接口返回值:{res.text}')
|
||||
|
||||
_update_log.append(UpdateLogDto(
|
||||
status='err' not in res.text,
|
||||
ip=self._ip,
|
||||
app_id=appId,
|
||||
result=res.text
|
||||
))
|
||||
|
||||
update_log: List[UpdateLogDto] = [UpdateLogDto.from_dict(i) for i in self.get_data(self._UpdateLogKey) or []]
|
||||
self.save_data(self._UpdateLogKey, [i.to_dict() for i in update_log + _update_log])
|
||||
|
||||
def _login_success(self):
|
||||
logger.info("保存配置文件")
|
||||
self.update_config({
|
||||
'_enabled': self._enabled,
|
||||
'_wwrtx_sid': self._wwrtx_sid,
|
||||
'_app_id': self._app_id,
|
||||
'_party_cache_data': self._party_cache_data,
|
||||
'_cron': self._cron,
|
||||
})
|
||||
|
||||
def _get_buttons(self):
|
||||
buttons = [
|
||||
[
|
||||
{
|
||||
"text": str(j),
|
||||
"callback_data": f"[PLUGIN]{self.__class__.__name__}|{j}|{self._qrcode_key}"
|
||||
}
|
||||
for j in range(i * 5, (i + 1) * 5)
|
||||
]
|
||||
for i in range(2)
|
||||
]
|
||||
buttons.append(
|
||||
[{"text": f'输入完毕',
|
||||
"callback_data": f"[PLUGIN]{self.__class__.__name__}|输入完毕|{self._qrcode_key}"}]
|
||||
)
|
||||
return buttons
|
||||
|
||||
def get_ip_from_url(self):
|
||||
urls = self._ip_urls
|
||||
for url in urls:
|
||||
try:
|
||||
response = requests.get(url, timeout=3)
|
||||
if response.status_code == 200:
|
||||
ip_address = re.search(self._ip_pattern, response.text)
|
||||
if ip_address:
|
||||
return ip_address.group()
|
||||
except Exception as e:
|
||||
if "104" not in str(e) and 'Read timed out' not in str(e): # 忽略网络波动,都失败会返回None, "获取IP失败"
|
||||
logger.warning(f"{url} 获取IP失败, Error: {e}")
|
||||
return "获取IP失败"
|
||||
|
||||
def _get_corp_app_v2(self):
|
||||
logger.info(f"开始获取企业应用配置")
|
||||
if not self._app_id:
|
||||
logger.error("未配置应用ID")
|
||||
return {}
|
||||
app_id = self._app_id.split(",")[0].strip()
|
||||
url = f'https://work.weixin.qq.com/wework_admin/apps/getCorpAppV2?lang=zh_CN&f=json&ajax=1&app_id={app_id}'
|
||||
try:
|
||||
res = self._se.get(url, timeout=10)
|
||||
if res.status_code == 200:
|
||||
return res.json().get('data', {})
|
||||
else:
|
||||
logger.error(f"获取企业应用配置失败,HTTP状态码:{res.status_code}")
|
||||
except Exception as e:
|
||||
logger.error(f"获取企业应用配置异常: {e}")
|
||||
return {}
|
||||
|
||||
def check(self):
|
||||
if not self._enabled:
|
||||
logger.error("插件未开启")
|
||||
return
|
||||
self._party_cache()
|
||||
if not self._is_login:
|
||||
logger.error("未登录")
|
||||
self.post_message(
|
||||
title="企业微信登录状态失效",
|
||||
text='企业微信登录状态失效,请重新操作登录'
|
||||
)
|
||||
return
|
||||
self._ip = self.get_ip_from_url()
|
||||
if not self._ip or self._ip == "获取IP失败":
|
||||
logger.error("获取当前公网IP失败,跳过本次检测")
|
||||
return
|
||||
app_config = self._get_corp_app_v2()
|
||||
app_config_ips = app_config.get('app', {}).get('white_ip_list', {}).get('ip', [])
|
||||
if self._ip not in app_config_ips:
|
||||
self._save_ip_config()
|
||||
self.post_message(
|
||||
title='企业微信IP更新',
|
||||
text="出发IP更新,最新IP为:" + self._ip
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class UpdateLogDto:
|
||||
status: bool
|
||||
ip: str
|
||||
app_id: str
|
||||
result: str
|
||||
UpdateTime: datetime = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.UpdateTime is None:
|
||||
self.UpdateTime = datetime.now()
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"status": self.status,
|
||||
"ip": self.ip,
|
||||
"app_id": self.app_id,
|
||||
"result": self.result,
|
||||
"UpdateTime": self.UpdateTime.isoformat()
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict):
|
||||
# 深拷贝一份,避免修改原字典
|
||||
kwargs = dict(data)
|
||||
# 将 'UpdateTime' 字符串转为 datetime,注意参数名对应 __init__ 的 update_time
|
||||
kwargs['UpdateTime'] = datetime.fromisoformat(kwargs.pop('UpdateTime'))
|
||||
return cls(**kwargs)
|
||||
1
plugins.v2/updatewechatip/requirements.txt
Normal file
1
plugins.v2/updatewechatip/requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
requests>=2.34.2
|
||||
@@ -1,4 +1,6 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
from collections import deque
|
||||
import threading
|
||||
import time
|
||||
@@ -44,6 +46,8 @@ class WechatClawBot(_PluginBase):
|
||||
_LOGIN_WATCH_SECONDS = 240
|
||||
_LOGIN_WATCH_INTERVAL_SECONDS = 3
|
||||
_MAX_API_RETRY_FAILURES = 10
|
||||
_INCOMING_DEDUP_TTL_SECONDS = 120
|
||||
_MAX_INCOMING_CACHE_ITEMS = 4096
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
@@ -61,6 +65,9 @@ class WechatClawBot(_PluginBase):
|
||||
self._qrcode_prepare_lock = threading.Lock()
|
||||
self._command_login_wait_threads: Dict[str, threading.Thread] = {}
|
||||
self._command_login_wait_lock = threading.Lock()
|
||||
self._incoming_seen_cache: Dict[str, int] = {}
|
||||
self._incoming_seen_order = deque()
|
||||
self._incoming_seen_lock = threading.Lock()
|
||||
|
||||
def _log(self, level: str, message: str):
|
||||
"""记录插件日志到内存并输出到全局日志。"""
|
||||
@@ -1351,6 +1358,71 @@ class WechatClawBot(_PluginBase):
|
||||
except Exception as err:
|
||||
self._log("warning", f"入站消息写入记录失败: {err}")
|
||||
|
||||
def _cleanup_incoming_seen_cache_locked(self, now_ts: int) -> None:
|
||||
"""清理过期/溢出的入站消息去重缓存。"""
|
||||
ttl = self._INCOMING_DEDUP_TTL_SECONDS
|
||||
|
||||
while self._incoming_seen_order:
|
||||
key, ts = self._incoming_seen_order[0]
|
||||
if (
|
||||
now_ts - ts <= ttl
|
||||
and len(self._incoming_seen_order) <= self._MAX_INCOMING_CACHE_ITEMS
|
||||
):
|
||||
break
|
||||
self._incoming_seen_order.popleft()
|
||||
cached_ts = self._incoming_seen_cache.get(key)
|
||||
if cached_ts == ts:
|
||||
self._incoming_seen_cache.pop(key, None)
|
||||
|
||||
def _build_incoming_dedup_key(
|
||||
self, msg: ILinkIncomingMessage, text: str
|
||||
) -> Optional[str]:
|
||||
"""构建入站消息去重 key。优先使用 message_id,缺失时退化为消息指纹。"""
|
||||
user_id = str(msg.user_id or "").strip()
|
||||
message_id = str(msg.message_id or "").strip()
|
||||
if user_id and message_id:
|
||||
return f"id:{user_id}:{message_id}"
|
||||
|
||||
if not user_id:
|
||||
return None
|
||||
|
||||
raw_payload = msg.raw if isinstance(msg.raw, dict) else {"raw": str(msg.raw)}
|
||||
payload = {
|
||||
"user_id": user_id,
|
||||
"chat_id": str(msg.chat_id or ""),
|
||||
"context_token": str(msg.context_token or ""),
|
||||
"text": text or "",
|
||||
"raw": raw_payload,
|
||||
}
|
||||
try:
|
||||
fingerprint = hashlib.sha1(
|
||||
json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str).encode("utf-8")
|
||||
).hexdigest()
|
||||
except Exception:
|
||||
return None
|
||||
return f"fp:{fingerprint}"
|
||||
|
||||
def _is_duplicate_incoming(self, msg: ILinkIncomingMessage, text: str) -> bool:
|
||||
"""判断入站消息是否为短时间内重复投递。"""
|
||||
key = self._build_incoming_dedup_key(msg=msg, text=text)
|
||||
if not key:
|
||||
return False
|
||||
|
||||
now_ts = int(time.time())
|
||||
with self._incoming_seen_lock:
|
||||
self._cleanup_incoming_seen_cache_locked(now_ts)
|
||||
last_ts = self._incoming_seen_cache.get(key)
|
||||
if (
|
||||
last_ts is not None
|
||||
and now_ts - last_ts <= self._INCOMING_DEDUP_TTL_SECONDS
|
||||
):
|
||||
return True
|
||||
|
||||
self._incoming_seen_cache[key] = now_ts
|
||||
self._incoming_seen_order.append((key, now_ts))
|
||||
self._cleanup_incoming_seen_cache_locked(now_ts)
|
||||
return False
|
||||
|
||||
def _is_plugin_command(self, text: str) -> bool:
|
||||
"""判断是否为插件内置命令(需要插件自行处理)。"""
|
||||
if not text or not text.startswith("/"):
|
||||
@@ -1435,6 +1507,12 @@ class WechatClawBot(_PluginBase):
|
||||
text = (msg.text or "").strip()
|
||||
if not text:
|
||||
return
|
||||
if self._is_duplicate_incoming(msg=msg, text=text):
|
||||
self._log(
|
||||
"warning",
|
||||
f"检测到重复入站消息,已忽略: user={msg.user_id}, message_id={msg.message_id or '-'}",
|
||||
)
|
||||
return
|
||||
|
||||
self._log("info", f"收到入站消息: user={msg.user_id}, text={text[:64]}")
|
||||
self._touch_user(msg.user_id, msg.context_token)
|
||||
|
||||
@@ -847,37 +847,17 @@ class ILinkClient:
|
||||
"events",
|
||||
"msg_list",
|
||||
"msgList",
|
||||
"msgs",
|
||||
"add_msgs",
|
||||
"addMsgs",
|
||||
"records",
|
||||
"list",
|
||||
]
|
||||
|
||||
candidates = [
|
||||
data.get("msgs"),
|
||||
data.get("updates"),
|
||||
data.get("messages"),
|
||||
data.get("items"),
|
||||
data.get("events"),
|
||||
data.get("msg_list"),
|
||||
data.get("msgList"),
|
||||
data.get("msgs"),
|
||||
data.get("add_msgs"),
|
||||
data.get("addMsgs"),
|
||||
payload.get("msgs"),
|
||||
payload.get("updates"),
|
||||
payload.get("messages"),
|
||||
payload.get("events"),
|
||||
payload.get("msg_list"),
|
||||
payload.get("msgList"),
|
||||
payload.get("msgs"),
|
||||
payload.get("add_msgs"),
|
||||
payload.get("addMsgs"),
|
||||
]
|
||||
for item in candidates:
|
||||
if isinstance(item, list):
|
||||
return item, sync_buf
|
||||
for obj in [data, payload]:
|
||||
for key in list_keys:
|
||||
value = obj.get(key)
|
||||
if isinstance(value, list):
|
||||
return value, sync_buf
|
||||
|
||||
nested = self._find_first_list(data, prefer_keys=list_keys)
|
||||
if isinstance(nested, list):
|
||||
|
||||
@@ -33,7 +33,7 @@ except Exception:
|
||||
|
||||
_LARK_IMPORT_LOCK = threading.Lock()
|
||||
_LARK_AUTO_INSTALL_ATTEMPTED = False
|
||||
_LARK_PACKAGE_SPEC = "lark-oapi==1.5.3"
|
||||
_LARK_PACKAGE_SPEC = "lark-oapi>=1.4.0"
|
||||
|
||||
try:
|
||||
from app.chain.download import DownloadChain
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
requests
|
||||
cloudscraper
|
||||
lark-oapi==1.5.3
|
||||
lark-oapi>=1.4.0
|
||||
p115client==0.0.8.4.8
|
||||
|
||||
@@ -50,6 +50,36 @@
|
||||
- `request_timeout`
|
||||
- `max_retries`
|
||||
- `save_failed_samples`
|
||||
- `save_title_only_samples`
|
||||
- `max_failed_samples`
|
||||
- `auto_remove_applied_sample`
|
||||
- `clear_failed_samples_once`
|
||||
|
||||
## 新增数据面
|
||||
|
||||
### 可处理失败样本
|
||||
|
||||
用于承载低置信度、可继续分析和出队的样本数据,支持:
|
||||
|
||||
- 摘要列表
|
||||
- 洞察汇总
|
||||
- 重放复查
|
||||
- 批量复查
|
||||
- 批量建议
|
||||
- 批量写入
|
||||
- 清空与按索引出队
|
||||
|
||||
### LLM 错误诊断记录
|
||||
|
||||
用于承载超时、网络错误、模型不可用等 LLM 调用失败信息,和可处理失败样本分开存储,避免噪音污染主样本池。
|
||||
|
||||
### 样本来源标注
|
||||
|
||||
失败样本与诊断记录都会保留轻量 provenance 标记,便于区分:
|
||||
|
||||
- 路径样本
|
||||
- 仅标题样本
|
||||
- 来自哪个 source plugin
|
||||
|
||||
## 二期规划
|
||||
|
||||
@@ -78,6 +108,8 @@
|
||||
- 已支持失败样本复查:按当前识别词和当前识别器重跑,并可自动把已修复样本出队
|
||||
- 已支持失败样本批量复查:可批量重跑并按结果批量出队
|
||||
- 已支持失败样本批量建议与批量写入:可批量生成建议并批量落库
|
||||
- 已支持 LLM 错误诊断记录独立存储,避免污染可处理样本池
|
||||
- 已支持样本来源标注,便于区分路径样本与仅标题样本
|
||||
- 已支持低 token 精简摘要输出,适合作为智能体批处理入口
|
||||
- 已支持识别词建议模型退化时自动切换到精确规则兜底,优先保证稳定落地
|
||||
- 下一步重点会放在提示词打磨、失败样本回放和识别词建议质量提升
|
||||
|
||||
@@ -45,18 +45,22 @@ MoviePilot 原版智能体已经提供“整理失败后自动接管再试一次
|
||||
- 用当前 LLM 结构化判断标题、年份、类型、季集
|
||||
- 回写 `name / year / season / episode`
|
||||
- 交回 MoviePilot 原生链路继续二次识别
|
||||
- 保存低置信度失败样本
|
||||
- 保存低置信度失败样本(可处理)
|
||||
- 保存 LLM 调用错误诊断记录(独立存储,不污染可处理样本池)
|
||||
- 失败样本和 LLM 诊断记录附带来源标注(`sample_source_kind` / `sample_source_plugin`)
|
||||
- 可配置是否保存仅标题样本(无真实文件路径),默认关闭以减少噪音
|
||||
- 提供失败样本工作清单、洞察、重放、删除和清空能力
|
||||
- 生成并应用 `CustomIdentifiers` 建议
|
||||
- 设置页提供“保存时清空失败样本(一次性)”开关,可在保存配置时顺手重置失败样本池
|
||||
|
||||
## 主要接口
|
||||
|
||||
- `GET /api/v1/plugin/AIRecognizerEnhancer/health`
|
||||
- 查看插件状态、LLM 提供方、模型、阈值和超时配置
|
||||
- `POST /api/v1/plugin/AIRecognizerEnhancer/recognize`
|
||||
- 对单个标题做一次本地结构化识别测试
|
||||
### 可处理失败样本接口
|
||||
|
||||
这些接口只返回因置信度不足或名称为空而落盘的识别失败记录,可用于生成识别词建议、复查和出队。
|
||||
|
||||
- `GET /api/v1/plugin/AIRecognizerEnhancer/failed_samples`
|
||||
- 查看最近保存的失败样本
|
||||
- 查看最近保存的可处理失败样本
|
||||
- `GET /api/v1/plugin/AIRecognizerEnhancer/sample_worklist`
|
||||
- 返回适合继续处理的失败样本摘要列表
|
||||
- `GET /api/v1/plugin/AIRecognizerEnhancer/sample_insights`
|
||||
@@ -68,12 +72,23 @@ MoviePilot 原版智能体已经提供“整理失败后自动接管再试一次
|
||||
- `POST /api/v1/plugin/AIRecognizerEnhancer/apply_suggested_identifier`
|
||||
- 把建议规则写入系统 `CustomIdentifiers`
|
||||
|
||||
### LLM 诊断错误接口
|
||||
|
||||
这些接口返回因 LLM 调用异常(如超时、网络错误、模型不可用)而产生的诊断记录。它们不参与识别词生成流程,仅供排查 LLM 问题使用。
|
||||
|
||||
- `GET /api/v1/plugin/AIRecognizerEnhancer/llm_errors`
|
||||
- 查看 LLM 调用失败的诊断记录
|
||||
- `POST /api/v1/plugin/AIRecognizerEnhancer/clear_llm_errors`
|
||||
- 清空 LLM 错误诊断记录
|
||||
|
||||
其余批量接口和清理接口可以按需要继续使用,详细路径以插件 `get_api()` 暴露结果为准。
|
||||
|
||||
## 配置建议
|
||||
|
||||
- 先确认 MoviePilot 本身已经配置好可用的 LLM
|
||||
- 建议保持“保存失败样本”开启
|
||||
- 建议保持”保存失败样本”开启
|
||||
- 默认情况下”保存仅标题样本”是关闭的,这可以减少没有真实文件路径的低价值噪音;如果你的使用场景以纯标题匹配为主,可以在设置中手动开启
|
||||
- 如果失败样本池已经积累了大量历史噪音,可在设置页勾选“一次性清空”后保存
|
||||
- 如果你经常处理历史资源或网盘资源,建议定期查看:
|
||||
- `failed_samples`
|
||||
- `sample_worklist`
|
||||
@@ -81,9 +96,9 @@ MoviePilot 原版智能体已经提供“整理失败后自动接管再试一次
|
||||
|
||||
## 已验证情况
|
||||
|
||||
当前版本:`0.1.12`
|
||||
当前版本:`0.1.13`
|
||||
|
||||
当前 Release:https://github.com/liuyuexi1987/MoviePilot-Plugins/releases/tag/v0.2.68
|
||||
当前 Release:https://github.com/liuyuexi1987/MoviePilot-Plugins/releases/tag/v0.2.73
|
||||
|
||||
这版已经验证过:
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ class AIRecognizerEnhancer(_PluginBase):
|
||||
plugin_name = "AI识别增强"
|
||||
plugin_desc = "直接复用 MoviePilot 当前 LLM 配置,在原生识别失败后做本地结构化识别兜底,并交回原生链路继续二次识别。"
|
||||
plugin_icon = "https://raw.githubusercontent.com/liuyuexi1987/MoviePilot-Plugins/main/icons/airecognizerenhancer.png"
|
||||
plugin_version = "0.1.12"
|
||||
plugin_version = "0.1.13"
|
||||
plugin_author = "liuyuexi1987"
|
||||
plugin_level = 1
|
||||
author_url = "https://github.com/liuyuexi1987"
|
||||
@@ -67,8 +67,10 @@ class AIRecognizerEnhancer(_PluginBase):
|
||||
_request_timeout = 25
|
||||
_max_retries = 2
|
||||
_save_failed_samples = True
|
||||
_save_title_only_samples = False
|
||||
_max_failed_samples = 200
|
||||
_auto_remove_applied_sample = True
|
||||
_clear_failed_samples_once = False
|
||||
_systemconfig: Optional[SystemConfigOper] = None
|
||||
|
||||
def init_plugin(self, config: Optional[Dict[str, Any]] = None):
|
||||
@@ -79,10 +81,17 @@ class AIRecognizerEnhancer(_PluginBase):
|
||||
self._request_timeout = self._safe_int(config.get("request_timeout"), 25)
|
||||
self._max_retries = max(1, min(5, self._safe_int(config.get("max_retries"), 2)))
|
||||
self._save_failed_samples = bool(config.get("save_failed_samples", True))
|
||||
self._save_title_only_samples = bool(config.get("save_title_only_samples", False))
|
||||
self._max_failed_samples = max(20, min(1000, self._safe_int(config.get("max_failed_samples"), 200)))
|
||||
self._auto_remove_applied_sample = bool(config.get("auto_remove_applied_sample", True))
|
||||
self._clear_failed_samples_once = bool(config.get("clear_failed_samples_once", False))
|
||||
self._systemconfig = SystemConfigOper()
|
||||
self._register_events()
|
||||
if self._clear_failed_samples_once:
|
||||
cleared = self._clear_failed_samples()
|
||||
self._clear_failed_samples_once = False
|
||||
self.update_config(self._build_config({"clear_failed_samples_once": False}))
|
||||
logger.info(f"[AI识别增强] 已按配置清空失败样本 {cleared} 条")
|
||||
|
||||
def get_state(self) -> bool:
|
||||
return self._enabled
|
||||
@@ -117,11 +126,28 @@ class AIRecognizerEnhancer(_PluginBase):
|
||||
if header.lower().startswith("bearer "):
|
||||
return header.split(" ", 1)[1].strip()
|
||||
if body:
|
||||
for key in ("apikey", "api_key"):
|
||||
for key in ("apikey", "api_key", "token"):
|
||||
token = str(body.get(key) or "").strip()
|
||||
if token:
|
||||
return token
|
||||
return str(request.query_params.get("apikey") or "").strip()
|
||||
return str(request.query_params.get("apikey") or request.query_params.get("token") or "").strip()
|
||||
|
||||
def _build_config(self, overrides: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
config = {
|
||||
"enabled": self._enabled,
|
||||
"debug": self._debug,
|
||||
"confidence_threshold": self._confidence_threshold,
|
||||
"request_timeout": self._request_timeout,
|
||||
"max_retries": self._max_retries,
|
||||
"save_failed_samples": self._save_failed_samples,
|
||||
"save_title_only_samples": self._save_title_only_samples,
|
||||
"max_failed_samples": self._max_failed_samples,
|
||||
"auto_remove_applied_sample": self._auto_remove_applied_sample,
|
||||
"clear_failed_samples_once": self._clear_failed_samples_once,
|
||||
}
|
||||
if overrides:
|
||||
config.update(overrides)
|
||||
return config
|
||||
|
||||
def _check_api_access(self, request: Request, body: Optional[Dict[str, Any]] = None) -> Tuple[bool, str]:
|
||||
expected = str(getattr(settings, "API_TOKEN", "") or "").strip()
|
||||
@@ -174,6 +200,30 @@ class AIRecognizerEnhancer(_PluginBase):
|
||||
)
|
||||
return str(title or "").strip(), str(path or "").strip()
|
||||
|
||||
@staticmethod
|
||||
def _extract_provenance(event_data: Any) -> Dict[str, str]:
|
||||
"""Extract lightweight provenance metadata from event data for sample recording."""
|
||||
source_plugin = ""
|
||||
if isinstance(event_data, dict):
|
||||
source_plugin = str(event_data.get("source_plugin") or "").strip()
|
||||
else:
|
||||
source_plugin = str(getattr(event_data, "source_plugin", "") or "").strip()
|
||||
|
||||
title = ""
|
||||
path = ""
|
||||
if isinstance(event_data, dict):
|
||||
title = str(event_data.get("title") or event_data.get("name") or event_data.get("org_string") or "").strip()
|
||||
path = str(event_data.get("path") or event_data.get("file_path") or event_data.get("org_string") or "").strip()
|
||||
else:
|
||||
title = str(getattr(event_data, "title", "") or getattr(event_data, "name", "") or getattr(event_data, "org_string", "") or "").strip()
|
||||
path = str(getattr(event_data, "path", "") or getattr(event_data, "file_path", "") or getattr(event_data, "org_string", "") or "").strip()
|
||||
|
||||
is_path_backed = bool(path) and path != title and ("/" in path or "\\" in path)
|
||||
return {
|
||||
"sample_source_kind": "path_backed" if is_path_backed else "title_only",
|
||||
"sample_source_plugin": source_plugin,
|
||||
}
|
||||
|
||||
def _build_meta_hint(self, raw_text: str) -> Dict[str, Any]:
|
||||
try:
|
||||
meta = MetaInfo(raw_text)
|
||||
@@ -221,6 +271,12 @@ class AIRecognizerEnhancer(_PluginBase):
|
||||
def _sample_path(self) -> Path:
|
||||
return self.get_data_path() / "failed_samples.jsonl"
|
||||
|
||||
def _llm_errors_path(self) -> Path:
|
||||
return self.get_data_path() / "llm_errors.jsonl"
|
||||
|
||||
def _failed_sample_cap(self) -> int:
|
||||
return max(20, min(1000, self._safe_int(self._max_failed_samples, 200)))
|
||||
|
||||
@staticmethod
|
||||
def _sample_identity(payload: Dict[str, Any]) -> str:
|
||||
return json.dumps(
|
||||
@@ -236,7 +292,8 @@ class AIRecognizerEnhancer(_PluginBase):
|
||||
def _write_failed_samples(self, rows: List[Dict[str, Any]]) -> None:
|
||||
sample_path = self._sample_path()
|
||||
sample_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
trimmed = rows[-self._max_failed_samples:]
|
||||
filtered = [row for row in rows if not str(row.get("reason") or "").startswith("llm_error:")]
|
||||
trimmed = filtered[-self._failed_sample_cap():]
|
||||
with sample_path.open("w", encoding="utf-8") as f:
|
||||
for row in trimmed:
|
||||
f.write(json.dumps(row, ensure_ascii=False) + "\n")
|
||||
@@ -254,6 +311,69 @@ class AIRecognizerEnhancer(_PluginBase):
|
||||
except Exception as exc:
|
||||
logger.warning(f"[AI识别增强] 写入失败样本失败: {exc}")
|
||||
|
||||
def _record_llm_error(self, title: str, path: str, meta_hint: Dict[str, Any], error: Any, provenance: Optional[Dict[str, str]] = None) -> None:
|
||||
try:
|
||||
error_path = self._llm_errors_path()
|
||||
error_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
provenance = provenance or {}
|
||||
entry = {
|
||||
"title": title,
|
||||
"path": path,
|
||||
"meta_hint": meta_hint,
|
||||
"reason": f"llm_error:{error}",
|
||||
"timestamp": __import__("datetime").datetime.now().isoformat(),
|
||||
"sample_source_kind": provenance.get("sample_source_kind", "unknown"),
|
||||
"sample_source_plugin": provenance.get("sample_source_plugin", ""),
|
||||
}
|
||||
existing = self._read_llm_errors(limit=1000)
|
||||
existing.reverse()
|
||||
new_identity = {"title": title, "path": path, "reason": entry["reason"]}
|
||||
existing = [
|
||||
row for row in existing
|
||||
if {
|
||||
"title": row.get("title"),
|
||||
"path": row.get("path"),
|
||||
"reason": row.get("reason"),
|
||||
} != new_identity
|
||||
]
|
||||
existing.append(entry)
|
||||
trimmed = existing[-self._failed_sample_cap():]
|
||||
with error_path.open("w", encoding="utf-8") as f:
|
||||
for row in trimmed:
|
||||
f.write(json.dumps(row, ensure_ascii=False) + "\n")
|
||||
except Exception as exc:
|
||||
logger.warning(f"[AI识别增强] 写入 LLM 错误诊断记录失败: {exc}")
|
||||
|
||||
def _read_llm_errors(self, limit: int = 20) -> List[Dict[str, Any]]:
|
||||
error_path = self._llm_errors_path()
|
||||
if not error_path.exists():
|
||||
return []
|
||||
rows: List[Dict[str, Any]] = []
|
||||
try:
|
||||
with error_path.open("r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
rows.append(json.loads(line))
|
||||
except Exception:
|
||||
continue
|
||||
except Exception as exc:
|
||||
logger.warning(f"[AI识别增强] 读取 LLM 错误诊断记录失败: {exc}")
|
||||
return []
|
||||
if limit > 0:
|
||||
rows = rows[-limit:]
|
||||
rows.reverse()
|
||||
return rows
|
||||
|
||||
def _clear_llm_errors(self) -> int:
|
||||
rows = self._read_llm_errors(limit=10000)
|
||||
error_path = self._llm_errors_path()
|
||||
if error_path.exists():
|
||||
error_path.unlink()
|
||||
return len(rows)
|
||||
|
||||
def _read_failed_samples(self, limit: int = 20) -> List[Dict[str, Any]]:
|
||||
sample_path = self._sample_path()
|
||||
if not sample_path.exists():
|
||||
@@ -353,7 +473,7 @@ class AIRecognizerEnhancer(_PluginBase):
|
||||
sample_index: Optional[Any] = None,
|
||||
limit: int = 100,
|
||||
) -> Tuple[Optional[int], Optional[Dict[str, Any]], str]:
|
||||
samples = self._read_failed_samples(limit=max(1, min(limit, 200)))
|
||||
samples = self._read_failed_samples(limit=max(1, min(limit, self._failed_sample_cap())))
|
||||
if not samples:
|
||||
return None, None, "暂无失败样本"
|
||||
index = self._safe_int(sample_index, 0)
|
||||
@@ -369,9 +489,13 @@ class AIRecognizerEnhancer(_PluginBase):
|
||||
self,
|
||||
sample_indexes: Optional[List[Any]] = None,
|
||||
limit: int = 10,
|
||||
pool_limit: int = 200,
|
||||
pool_limit: int = 0,
|
||||
) -> Tuple[List[int], List[Dict[str, Any]], str]:
|
||||
current_samples = self._inject_sample_indices(self._read_failed_samples(limit=max(1, min(pool_limit, 1000))))
|
||||
if pool_limit <= 0:
|
||||
pool_limit = self._failed_sample_cap()
|
||||
current_samples = self._inject_sample_indices(
|
||||
self._read_failed_samples(limit=max(1, min(pool_limit, self._failed_sample_cap())))
|
||||
)
|
||||
if not current_samples:
|
||||
return [], [], "暂无失败样本"
|
||||
if isinstance(sample_indexes, list) and sample_indexes:
|
||||
@@ -414,6 +538,8 @@ class AIRecognizerEnhancer(_PluginBase):
|
||||
"title": sample.get("title"),
|
||||
"path": sample.get("path"),
|
||||
"reason": sample.get("reason"),
|
||||
"sample_source_kind": sample.get("sample_source_kind", ""),
|
||||
"sample_source_plugin": sample.get("sample_source_plugin", ""),
|
||||
"guess_name": guess.get("name"),
|
||||
"guess_confidence": self._safe_float(guess.get("confidence"), 0.0),
|
||||
"verified_title": verified.get("title"),
|
||||
@@ -551,7 +677,10 @@ class AIRecognizerEnhancer(_PluginBase):
|
||||
label = self._sample_display_name(summary)
|
||||
confidence = round(self._safe_float(summary.get("guess_confidence"), 0.0), 2)
|
||||
can_suggest = "可建议" if summary.get("can_auto_suggest") else "需人工"
|
||||
lines.append(f"{summary.get('sample_index')}. {label} | 置信度 {confidence} | {can_suggest}")
|
||||
source_tag = "有路径" if summary.get("sample_source_kind") == "path_backed" else "仅标题"
|
||||
source_plugin = summary.get("sample_source_plugin") or ""
|
||||
source_info = f" | {source_tag}" + (f" ({source_plugin})" if source_plugin else "")
|
||||
lines.append(f"{summary.get('sample_index')}. {label} | 置信度 {confidence} | {can_suggest}{source_info}")
|
||||
lines.append("下一步:可直接调用批量建议或批量复查接口。")
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -937,7 +1066,7 @@ AI 识别增强结果:
|
||||
selected_indexes, _, message = self._select_failed_sample_indexes(
|
||||
sample_indexes=body.get("sample_indexes"),
|
||||
limit=limit,
|
||||
pool_limit=200,
|
||||
pool_limit=self._failed_sample_cap(),
|
||||
)
|
||||
if not selected_indexes:
|
||||
return {"success": False, "message": message}
|
||||
@@ -1006,7 +1135,7 @@ AI 识别增强结果:
|
||||
selected_indexes, _, message = self._select_failed_sample_indexes(
|
||||
sample_indexes=body.get("sample_indexes"),
|
||||
limit=limit,
|
||||
pool_limit=200,
|
||||
pool_limit=self._failed_sample_cap(),
|
||||
)
|
||||
if not selected_indexes:
|
||||
return {"success": False, "message": message}
|
||||
@@ -1102,7 +1231,7 @@ AI 识别增强结果:
|
||||
selected_indexes, _, message = self._select_failed_sample_indexes(
|
||||
sample_indexes=body.get("sample_indexes"),
|
||||
limit=limit,
|
||||
pool_limit=200,
|
||||
pool_limit=self._failed_sample_cap(),
|
||||
)
|
||||
if not selected_indexes:
|
||||
return {"success": False, "message": message}
|
||||
@@ -1356,40 +1485,49 @@ AI 识别增强结果:
|
||||
logger.warning(f"[AI识别增强] 二次校验失败: {exc}")
|
||||
return None
|
||||
|
||||
def _recognize(self, title: str, path: str = "", record_failed_sample: bool = True) -> Dict[str, Any]:
|
||||
def _recognize(
|
||||
self, title: str, path: str = "", record_failed_sample: bool = True,
|
||||
provenance: Optional[Dict[str, str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
title = str(title or "").strip()
|
||||
path = str(path or "").strip()
|
||||
if not title and path:
|
||||
title = Path(path).name
|
||||
if not title:
|
||||
return {"success": False, "message": "标题为空"}
|
||||
provenance = provenance or {}
|
||||
sample_source_kind = provenance.get("sample_source_kind")
|
||||
is_title_only = sample_source_kind == "title_only" if sample_source_kind else not path
|
||||
try:
|
||||
guess = self._invoke_llm(title, path)
|
||||
except Exception as exc:
|
||||
if record_failed_sample:
|
||||
self._record_failed_sample(
|
||||
{
|
||||
"title": title,
|
||||
"path": path,
|
||||
"meta_hint": self._build_meta_hint(path or title),
|
||||
"reason": f"llm_error:{exc}",
|
||||
}
|
||||
)
|
||||
if is_title_only and not self._save_title_only_samples:
|
||||
if self._debug:
|
||||
logger.info(f"[AI识别增强] 跳过保存仅标题 LLM 错误: {title} (save_title_only_samples=False)")
|
||||
else:
|
||||
self._record_llm_error(title, path, self._build_meta_hint(path or title), exc, provenance=provenance)
|
||||
return {"success": False, "message": f"LLM 调用失败: {exc}"}
|
||||
|
||||
verified = self._verify_guess(title, path, guess)
|
||||
passed = bool(guess.name and guess.confidence >= self._confidence_threshold)
|
||||
if not passed and record_failed_sample:
|
||||
self._record_failed_sample(
|
||||
{
|
||||
"title": title,
|
||||
"path": path,
|
||||
"meta_hint": self._build_meta_hint(path or title),
|
||||
"guess": guess.model_dump(),
|
||||
"verified_media_info": self._compact_verified_summary(verified),
|
||||
"reason": "low_confidence_or_empty_name",
|
||||
}
|
||||
)
|
||||
if is_title_only and not self._save_title_only_samples:
|
||||
if self._debug:
|
||||
logger.info(f"[AI识别增强] 跳过保存仅标题样本: {title} (save_title_only_samples=False)")
|
||||
else:
|
||||
self._record_failed_sample(
|
||||
{
|
||||
"title": title,
|
||||
"path": path,
|
||||
"meta_hint": self._build_meta_hint(path or title),
|
||||
"guess": guess.model_dump(),
|
||||
"verified_media_info": self._compact_verified_summary(verified),
|
||||
"reason": "low_confidence_or_empty_name",
|
||||
"sample_source_kind": provenance.get("sample_source_kind", "unknown"),
|
||||
"sample_source_plugin": provenance.get("sample_source_plugin", ""),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"success": passed,
|
||||
"message": "success" if passed else "识别结果置信度不足,已放弃注入",
|
||||
@@ -1404,7 +1542,8 @@ AI 识别增强结果:
|
||||
title, path = self._extract_title_path(event_data)
|
||||
if not title and not path:
|
||||
return
|
||||
result = self._recognize(title=title, path=path)
|
||||
provenance = self._extract_provenance(event_data)
|
||||
result = self._recognize(title=title, path=path, provenance=provenance)
|
||||
if not result.get("success"):
|
||||
if self._debug:
|
||||
logger.info(f"[AI识别增强] 跳过注入: {title or path} - {result.get('message')}")
|
||||
@@ -1496,7 +1635,7 @@ AI 识别增强结果:
|
||||
if not ok:
|
||||
return {"success": False, "message": message}
|
||||
limit = self._safe_int(request.query_params.get("limit"), 50)
|
||||
limit = max(1, min(limit, 200))
|
||||
limit = max(1, min(limit, self._failed_sample_cap()))
|
||||
top = self._safe_int(request.query_params.get("top"), 10)
|
||||
top = max(1, min(top, 20))
|
||||
samples = self._inject_sample_indices(self._read_failed_samples(limit=limit))
|
||||
@@ -1512,7 +1651,7 @@ AI 识别增强结果:
|
||||
return {"success": False, "message": message}
|
||||
limit = self._safe_int(request.query_params.get("limit"), 5)
|
||||
limit = max(1, min(limit, 20))
|
||||
samples = self._inject_sample_indices(self._read_failed_samples(limit=100))
|
||||
samples = self._inject_sample_indices(self._read_failed_samples(limit=self._failed_sample_cap()))
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
@@ -1558,6 +1697,34 @@ AI 识别增强结果:
|
||||
},
|
||||
}
|
||||
|
||||
async def api_llm_errors(self, request: Request):
|
||||
ok, message = self._check_api_access(request)
|
||||
if not ok:
|
||||
return {"success": False, "message": message}
|
||||
limit = self._safe_int(request.query_params.get("limit"), 20)
|
||||
limit = max(1, min(limit, 100))
|
||||
errors = self._read_llm_errors(limit=limit)
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"count": len(errors),
|
||||
"errors": errors,
|
||||
},
|
||||
}
|
||||
|
||||
async def api_clear_llm_errors(self, request: Request):
|
||||
ok, message = self._check_api_access(request)
|
||||
if not ok:
|
||||
return {"success": False, "message": message}
|
||||
cleared = self._clear_llm_errors()
|
||||
return {
|
||||
"success": True,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"cleared_count": cleared,
|
||||
},
|
||||
}
|
||||
|
||||
async def api_remove_failed_sample(self, request: Request):
|
||||
body = await request.json()
|
||||
ok, message = self._check_api_access(request, body)
|
||||
@@ -1697,6 +1864,18 @@ AI 识别增强结果:
|
||||
"methods": ["POST"],
|
||||
"summary": "清空失败样本文件",
|
||||
},
|
||||
{
|
||||
"path": "/llm_errors",
|
||||
"endpoint": self.api_llm_errors,
|
||||
"methods": ["GET"],
|
||||
"summary": "查看 LLM 调用失败的诊断记录",
|
||||
},
|
||||
{
|
||||
"path": "/clear_llm_errors",
|
||||
"endpoint": self.api_clear_llm_errors,
|
||||
"methods": ["POST"],
|
||||
"summary": "清空 LLM 错误诊断记录",
|
||||
},
|
||||
{
|
||||
"path": "/remove_failed_sample",
|
||||
"endpoint": self.api_remove_failed_sample,
|
||||
@@ -1731,7 +1910,8 @@ AI 识别增强结果:
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
llm_ready = bool(getattr(settings, "LLM_API_KEY", None))
|
||||
failed_samples_count = len(self._read_failed_samples(limit=200))
|
||||
failed_samples_count = len(self._read_failed_samples(limit=self._failed_sample_cap()))
|
||||
llm_errors_count = len(self._read_llm_errors(limit=self._max_failed_samples))
|
||||
custom_identifiers_count = len(self._get_custom_identifiers())
|
||||
llm_provider = getattr(settings, "LLM_PROVIDER", "—")
|
||||
llm_model = getattr(settings, "LLM_MODEL", "—")
|
||||
@@ -1784,22 +1964,27 @@ AI 识别增强结果:
|
||||
"content": [
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 3},
|
||||
"props": {"cols": 12, "sm": 6, "md": 2},
|
||||
"content": [stat_card("当前状态", "已启用" if self._enabled else "未启用")],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 3},
|
||||
"props": {"cols": 12, "sm": 6, "md": 2},
|
||||
"content": [stat_card("LLM 可用", "是" if llm_ready else "否", f"{llm_provider} / {llm_model}")],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 3},
|
||||
"content": [stat_card("失败样本", f"{failed_samples_count} 条", f"上限 {self._max_failed_samples} 条")],
|
||||
"props": {"cols": 12, "sm": 6, "md": 3},
|
||||
"content": [stat_card("可处理失败样本", f"{failed_samples_count} 条", f"上限 {self._max_failed_samples} 条")],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 3},
|
||||
"props": {"cols": 12, "sm": 6, "md": 2},
|
||||
"content": [stat_card("LLM 错误", f"{llm_errors_count} 条", "诊断记录")],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "sm": 6, "md": 3},
|
||||
"content": [stat_card("自定义识别词", f"{custom_identifiers_count} 条", "系统 CustomIdentifiers")],
|
||||
},
|
||||
],
|
||||
@@ -1810,34 +1995,7 @@ AI 识别增强结果:
|
||||
"content": [
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 6},
|
||||
"content": [
|
||||
{
|
||||
"component": "VCard",
|
||||
"props": {"variant": "outlined", "class": "pa-4 h-100"},
|
||||
"content": [
|
||||
{
|
||||
"component": "div",
|
||||
"props": {"class": "text-subtitle-1 font-weight-bold mb-2"},
|
||||
"text": "识别兜底",
|
||||
},
|
||||
{
|
||||
"component": "div",
|
||||
"props": {"class": "text-body-2 text-medium-emphasis"},
|
||||
"text": "在 Chain NameRecognize 阶段回写 name / year / season / episode,供 MoviePilot 继续原生二次识别。",
|
||||
},
|
||||
{
|
||||
"component": "div",
|
||||
"props": {"class": "text-caption text-medium-emphasis mt-3"},
|
||||
"text": f"置信度阈值:{self._confidence_threshold};请求超时:{self._request_timeout} 秒",
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 6},
|
||||
"props": {"cols": 12, "md": 12},
|
||||
"content": [
|
||||
{
|
||||
"component": "VCard",
|
||||
@@ -1873,6 +2031,7 @@ AI 识别增强结果:
|
||||
return "vuetify", None
|
||||
|
||||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
failed_samples_count = len(self._read_failed_samples(limit=self._failed_sample_cap()))
|
||||
form = [
|
||||
{
|
||||
"component": "VForm",
|
||||
@@ -1896,6 +2055,25 @@ AI 识别增强结果:
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12},
|
||||
"content": [
|
||||
{
|
||||
"component": "VAlert",
|
||||
"props": {
|
||||
"type": "warning",
|
||||
"variant": "tonal",
|
||||
"text": f"当前累计 {failed_samples_count} 条失败样本。如需重置噪音数据,请勾选下方“一次性清空”开关后点击保存。该操作只清空失败样本,不会删除已写入的 CustomIdentifiers。",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [
|
||||
@@ -1929,6 +2107,19 @@ AI 识别增强结果:
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 4},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSwitch",
|
||||
"props": {
|
||||
"model": "save_title_only_samples",
|
||||
"label": "保存仅标题样本",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -2010,6 +2201,24 @@ AI 识别增强结果:
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSwitch",
|
||||
"props": {
|
||||
"model": "clear_failed_samples_once",
|
||||
"label": "保存时清空失败样本(一次性)",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [
|
||||
@@ -2038,6 +2247,8 @@ AI 识别增强结果:
|
||||
"request_timeout": 25,
|
||||
"max_retries": 2,
|
||||
"save_failed_samples": True,
|
||||
"save_title_only_samples": False,
|
||||
"max_failed_samples": 200,
|
||||
"auto_remove_applied_sample": True,
|
||||
"clear_failed_samples_once": False,
|
||||
}
|
||||
|
||||
@@ -180,7 +180,7 @@ class CrossSeed(_PluginBase):
|
||||
# 插件图标
|
||||
plugin_icon = "qingwa.png"
|
||||
# 插件版本
|
||||
plugin_version = "2.4"
|
||||
plugin_version = "2.4.1"
|
||||
# 插件作者
|
||||
plugin_author = "233@qingwa"
|
||||
# 作者主页
|
||||
@@ -224,6 +224,8 @@ class CrossSeed(_PluginBase):
|
||||
_success_caches = []
|
||||
# 辅种缓存,出错的种子不再重复辅种,且无法清除。种子被删除404等情况
|
||||
_permanent_error_caches = []
|
||||
# 辅种缓存最大保存条数,避免长期运行时配置缓存无限增长
|
||||
_seed_cache_max_items = 10000
|
||||
_torrentpaths = []
|
||||
_site_cs_infos = []
|
||||
# 辅种计数
|
||||
@@ -238,6 +240,11 @@ class CrossSeed(_PluginBase):
|
||||
self.sites = SitesHelper()
|
||||
self.siteoper = SiteOper()
|
||||
self.torrent = TorrentHelper()
|
||||
self._error_caches = []
|
||||
self._success_caches = []
|
||||
self._permanent_error_caches = []
|
||||
self._torrentpaths = []
|
||||
self._site_cs_infos = []
|
||||
# 读取配置
|
||||
if config:
|
||||
self._enabled = config.get("enabled")
|
||||
@@ -253,9 +260,14 @@ class CrossSeed(_PluginBase):
|
||||
self._nolabels = config.get("nolabels")
|
||||
self._nopaths = config.get("nopaths")
|
||||
self._clearcache = config.get("clearcache")
|
||||
self._permanent_error_caches = [] if self._clearcache else config.get("permanent_error_caches") or []
|
||||
self._error_caches = [] if self._clearcache else config.get("error_caches") or []
|
||||
self._success_caches = [] if self._clearcache else config.get("success_caches") or []
|
||||
self._permanent_error_caches = (
|
||||
[] if self._clearcache else list(config.get("permanent_error_caches") or [])
|
||||
)
|
||||
self._error_caches = [] if self._clearcache else list(config.get("error_caches") or [])
|
||||
self._success_caches = [] if self._clearcache else list(config.get("success_caches") or [])
|
||||
self.__trim_seed_cache(self._permanent_error_caches)
|
||||
self.__trim_seed_cache(self._error_caches)
|
||||
self.__trim_seed_cache(self._success_caches)
|
||||
|
||||
# 过滤掉已删除的站点
|
||||
inner_site_list = self.siteoper.list_order_by_pri()
|
||||
@@ -325,6 +337,8 @@ class CrossSeed(_PluginBase):
|
||||
|
||||
# 停止现有任务
|
||||
self.stop_service()
|
||||
# 重新初始化运行期校验队列,避免类级字典跨插件重载残留。
|
||||
self._recheck_torrents = {}
|
||||
|
||||
# 启动定时任务 & 立即运行一次
|
||||
if self.get_state() or self._onlyonce:
|
||||
@@ -741,6 +755,32 @@ class CrossSeed(_PluginBase):
|
||||
"permanent_error_caches": self._permanent_error_caches
|
||||
})
|
||||
|
||||
def __trim_seed_cache(self, cache: list):
|
||||
"""
|
||||
去重并限制辅种缓存大小,避免长期任务把配置缓存无限撑大。
|
||||
"""
|
||||
if not cache:
|
||||
return
|
||||
unique_cache = []
|
||||
seen = set()
|
||||
for item in reversed(cache):
|
||||
if not item or item in seen:
|
||||
continue
|
||||
seen.add(item)
|
||||
unique_cache.append(item)
|
||||
unique_cache.reverse()
|
||||
cache[:] = unique_cache[-self._seed_cache_max_items:]
|
||||
|
||||
def __append_seed_cache(self, cache: list, value: str):
|
||||
"""
|
||||
写入辅种缓存并保持上限,重复值只保留一份。
|
||||
"""
|
||||
if not value:
|
||||
return
|
||||
if value not in cache:
|
||||
cache.append(value)
|
||||
self.__trim_seed_cache(cache)
|
||||
|
||||
def __get_downloader(self, dtype: str):
|
||||
"""
|
||||
根据类型返回下载器实例
|
||||
@@ -1087,10 +1127,10 @@ class CrossSeed(_PluginBase):
|
||||
self.cached += 1
|
||||
# 加入失败缓存
|
||||
if error_msg and ('无法打开链接' in error_msg or '触发站点流控' in error_msg):
|
||||
self._error_caches.append(tor.get_name_id_tag())
|
||||
self.__append_seed_cache(self._error_caches, tor.get_name_id_tag())
|
||||
else:
|
||||
# 种子不存在的情况
|
||||
self._permanent_error_caches.append(tor.get_name_id_tag())
|
||||
self.__append_seed_cache(self._permanent_error_caches, tor.get_name_id_tag())
|
||||
logger.error(f"下载种子文件失败:{tor.get_name_id_tag()}")
|
||||
return False
|
||||
|
||||
@@ -1100,7 +1140,7 @@ class CrossSeed(_PluginBase):
|
||||
tors, msg = self.__get_downloader(downloader).get_torrents(ids=[tmp_tor_info.info_hash])
|
||||
if tors:
|
||||
self.exist += 1
|
||||
self._success_caches.append(tor.get_name_id_tag())
|
||||
self.__append_seed_cache(self._success_caches, tor.get_name_id_tag())
|
||||
logger.info(f"下载的种子{tor.get_name_id_tag()}已存在, 跳过")
|
||||
return True
|
||||
else:
|
||||
@@ -1116,7 +1156,7 @@ class CrossSeed(_PluginBase):
|
||||
self.fail += 1
|
||||
self.cached += 1
|
||||
# 加入失败缓存
|
||||
self._error_caches.append(tor.get_name_id_tag())
|
||||
self.__append_seed_cache(self._error_caches, tor.get_name_id_tag())
|
||||
return False
|
||||
else:
|
||||
self.success += 1
|
||||
@@ -1132,7 +1172,7 @@ class CrossSeed(_PluginBase):
|
||||
# 开始校验种子
|
||||
self.__get_downloader(downloader).recheck_torrents(ids=[download_id])
|
||||
# 成功也加入缓存,有一些改了路径校验不通过的,手动删除后,下一次又会辅上
|
||||
self._success_caches.append(tor.get_name_id_tag())
|
||||
self.__append_seed_cache(self._success_caches, tor.get_name_id_tag())
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user