mirror of
https://github.com/the1812/Bilibili-Evolved.git
synced 2025-09-26 22:49:14 +08:00
Merge branch 'preview-fixes'
This commit is contained in:
commit
4b3a1a3870
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
@ -99,6 +99,7 @@
|
||||
"superchat",
|
||||
"Swiper",
|
||||
"tabindex",
|
||||
"tagid",
|
||||
"Tampermonkey",
|
||||
"Touhou",
|
||||
"truetype",
|
||||
|
63
CHANGELOG.md
63
CHANGELOG.md
@ -1,6 +1,69 @@
|
||||
<!-- spell-checker: disable -->
|
||||
# 更新日志
|
||||
|
||||
## v2.10.3-preview
|
||||
|
||||
包含 [v2.10.3](https://github.com/the1812/Bilibili-Evolved/releases/tag/v2.10.3) 的所有更新内容.
|
||||
|
||||
- 新增 `深色模式` 组件, 用于跟随 b 站官方的内测深色模式. (#5272)
|
||||
> 脚本自身的元素配色也会逐步适配官方的配色, 目前仅适配设置面板、自定义顶栏和动态首页中的元素
|
||||
|
||||
- `插件 - 下载视频 - WASM 混流输出` 修正下载限制为 2GB, 新增下载速度和剩余时间显示. (#5102, PR #5233 by [WakelessSloth56](https://github.com/WakelessSloth56))
|
||||
- `弹幕空降` 支持识别中文数字, 修复对特殊弹幕未触发的问题. (PR #5265 by [fixfeat](https://github.com/fixfeat))
|
||||
- 新增 `视频预览放大` 组件. (PR #5281 by [wsgh0202](https://github.com/wsgh0202))
|
||||
> 放大鼠标悬停于视频卡片时播放的 5 分钟预览
|
||||
>
|
||||
> - 点击放大镜图标放大预览
|
||||
> - 点击缩小图标或者预览框外空白处关闭预览
|
||||
|
||||
- 修复 `隐藏首页轮播图` 失效, 并增加了一些局部元素控制功能. (PR #5287 [wsgh0202](https://github.com/wsgh0202))
|
||||
|
||||
|
||||
## v2.10.3
|
||||
`2025-07-22`
|
||||
|
||||
<details>
|
||||
<summary>正式版用户将获得 v2.10.1-preview ~ v2.10.2-preview 的所有改动, 点击展开查看</summary>
|
||||
|
||||
- 新增 `稍后再看页面-重定向` 组件. (PR #5205 by [undefined](https://github.com/magicFeirl))
|
||||
> 重定向**稍后再看页面**到普通视频页面,和`稍后再看重定向`组件的区别在于,该组件是在进入稍后再看的视频页面后进行重定向,而非直接替换链接到普通视频的链接,用于补充`稍后再看重定向`组件的功能。
|
||||
|
||||
- `隐藏热搜` 支持隐藏直播间搜索框的热搜. (PR #5207 by [undefined](https://github.com/magicFeirl))
|
||||
- 修复 `视频链接增强` 对 niconico 链接的处理. (PR #5215 by [Alan Ye](https://github.com/at-wr))
|
||||
- 新增 `动态分组过滤` 组件. (PR #5217 by [Rinne](https://github.com/OharaRinneY))
|
||||
> 按照关注分组筛选动态
|
||||
|
||||
- `直播信息扩充` 支持配置置顶和隐藏的关注列表. (#5183)
|
||||
- 新增组件 `一键点亮直播间粉丝勋章`. (PR #5171 by [undefined](https://github.com/magicFeirl))
|
||||
> 在直播间页面的功能面板添加一键点亮粉丝勋章功能,仅适用于有粉丝勋章且正在直播的直播间。原理:发送一个 300 次点赞的请求点亮粉丝勋章。
|
||||
|
||||
- 新增组件 `下载表情`. (PR #5176 by [Pencil](https://github.com/pencilqaq))
|
||||
> 支持下载 up 主专属表情包,处于任意直播间页面时,下载按钮会在 `功能` 面板显示,以压缩包形式保存。
|
||||
|
||||
- 新增组件 `视频链接增强`. (PR #5197 by [Alan Ye](https://github.com/at-wr))
|
||||
> 这个组件会将视频简介中的普通网址转换为可点击的链接,并将<del>被 Bilibili 抛弃</del>已失效的 `acg.tv` 跳转链接修复为 `nicovideo.jp` 链接。
|
||||
>
|
||||
> 例如: `https://acg.tv/sm37507315` → `https://www.nicovideo.jp/watch/sm37507315`
|
||||
>
|
||||
> <i>本组件不会保证目标链接的安全性,因此在点击前请自行验证其是否可信</i>
|
||||
|
||||
- `自定义顶栏` 新增 `使用主题色 Logo` 选项, 关闭时可以使用黑白纯色的 Logo. (#4996)
|
||||
- `自定义顶栏` 更新 `游戏中心`, `漫画`, `赛事` 的弹窗, 废弃 `直播` 的弹窗. (#5055)
|
||||
|
||||
</details>
|
||||
|
||||
(以下其实都是 6 月就更新了, 只要你的 `自动更新器` 正常使用, 应该早已收到更新了, 这些改动无需更新本体)
|
||||
|
||||
✨新增
|
||||
- `显示视频投稿时间` 支持在合集类页面显示. (#5247, PR #5266 by [呼乎户](https://github.com/wisokey))
|
||||
- `动态过滤` 支持过滤新出现的热搜面板. (#5273, PR #5276 by [Whalko](https://github.com/RtYkk))
|
||||
|
||||
🐛修复
|
||||
- 修复 `夜间模式` 在首页 svg 图标的颜色. (PR #5240 by [hyrious](https://github.com/hyrious))
|
||||
- 修复 `自动移出稍后再看` 重复调用接口的 bug. (PR #5241 by [sunfkny](https://github.com/sunfkny))
|
||||
- 修复 `关注时间显示` 失效. (#5096, #5212, PR 5282 by [CNOCM](https://github.com/CNOCM))
|
||||
|
||||
|
||||
## v2.10.2-preview
|
||||
`2025-05-30`
|
||||
|
||||
|
@ -30,6 +30,9 @@ https://afdian.com/a/the1812?tab=sponsor
|
||||
|
||||
| 时间 | 用户名 | 单号后4位 | 金额 |
|
||||
| ------------------- | --------------------- | --------- | ------- |
|
||||
| 2025.07.12 16:44:24 | 爱音allin | 3957 | ¥5.00 |
|
||||
| 2025.06.29 10:53:44 | f*x | 9352 | ¥10.00 |
|
||||
| 2025.06.10 01:22:23 | *匆 | 5332 | ¥5.00 |
|
||||
| 2025.05.21 19:55:02 | mobmaster69 | 7644 | ¥5.00 |
|
||||
| 2025.05.20 02:38:45 | Duane | 3937 | ¥5.00 |
|
||||
| 2025.05.15 22:12:22 | 匿名 | 3901 | ¥23.33 |
|
||||
|
@ -71,6 +71,14 @@
|
||||
"fullRelativePath": "../../registry/dist/components/feeds/full-title.js",
|
||||
"fullAbsolutePath": "registry/dist/components/feeds/full-title.js"
|
||||
},
|
||||
{
|
||||
"type": "component",
|
||||
"name": "feedsGroupFilter",
|
||||
"displayName": "动态分组过滤",
|
||||
"description": "by [@Rinne](https://github.com/OharaRinneY)\n\n按照关注分组筛选动态\r\n",
|
||||
"fullRelativePath": "../../registry/dist/components/feeds/group-filter.js",
|
||||
"fullAbsolutePath": "registry/dist/components/feeds/group-filter.js"
|
||||
},
|
||||
{
|
||||
"type": "component",
|
||||
"name": "hideFeedsCommentPreview",
|
||||
@ -111,6 +119,14 @@
|
||||
"fullRelativePath": "../../registry/dist/components/live/badge-helper.js",
|
||||
"fullAbsolutePath": "registry/dist/components/live/badge-helper.js"
|
||||
},
|
||||
{
|
||||
"type": "component",
|
||||
"name": "badgeKeepalive",
|
||||
"displayName": "一键点亮直播间粉丝勋章",
|
||||
"description": "by [@magicFeirl](https://github.com/magicFeirl)\n\n在直播间页面的功能面板添加一键点亮粉丝勋章功能,仅适用于有粉丝勋章且正在直播的直播间。原理:发送一个 300 次点赞的请求点亮粉丝勋章。",
|
||||
"fullRelativePath": "../../registry/dist/components/live/badge-keepalive.js",
|
||||
"fullAbsolutePath": "registry/dist/components/live/badge-keepalive.js"
|
||||
},
|
||||
{
|
||||
"type": "component",
|
||||
"name": "liveChatPanelFit",
|
||||
@ -503,6 +519,14 @@
|
||||
"fullRelativePath": "../../registry/dist/components/touch/player-gestures.js",
|
||||
"fullAbsolutePath": "registry/dist/components/touch/player-gestures.js"
|
||||
},
|
||||
{
|
||||
"type": "component",
|
||||
"name": "activeVideoLinks",
|
||||
"displayName": "视频链接增强",
|
||||
"description": "by [@Alan Ye](https://github.com/at-wr)\n\n这个组件会将视频简介中的普通网址转换为可点击的链接,并将<del>被 Bilibili 抛弃</del>已失效的 `acg.tv` 跳转链接修复为 `nicovideo.jp` 链接。\r\n\r\n例如: `https://acg.tv/sm37507315` → `https://www.nicovideo.jp/watch/sm37507315`\r\n\r\n<i>本组件不会保证目标链接的安全性,因此在点击前请自行验证其是否可信</i>\r\n",
|
||||
"fullRelativePath": "../../registry/dist/components/utils/active-video-links.js",
|
||||
"fullAbsolutePath": "registry/dist/components/utils/active-video-links.js"
|
||||
},
|
||||
{
|
||||
"type": "component",
|
||||
"name": "albumPubTimeShow",
|
||||
@ -591,6 +615,14 @@
|
||||
"fullRelativePath": "../../registry/dist/components/utils/download-audio.js",
|
||||
"fullAbsolutePath": "registry/dist/components/utils/download-audio.js"
|
||||
},
|
||||
{
|
||||
"type": "component",
|
||||
"name": "downloadEmoticons",
|
||||
"displayName": "下载表情",
|
||||
"description": "by [@Pencilqaq](https://github.com/pencilqaq)\n\n## 下载up主专属表情包\r\n\r\n支持下载up主专属表情包,处于任意直播间页面时,下载按钮会在`功能`面板显示,以压缩包形式保存。",
|
||||
"fullRelativePath": "../../registry/dist/components/utils/download-emoticons.js",
|
||||
"fullAbsolutePath": "registry/dist/components/utils/download-emoticons.js"
|
||||
},
|
||||
{
|
||||
"type": "component",
|
||||
"name": "imageExporter",
|
||||
@ -643,7 +675,7 @@
|
||||
"type": "component",
|
||||
"name": "subscribeTimeShow",
|
||||
"displayName": "关注时间显示",
|
||||
"description": "by [@Light_Quanta](https://github.com/LightQuanta)\n\n在粉丝/关注列表显示关注的具体时间",
|
||||
"description": "by [@CNOCM](https://github.com/CNOCM)\n\n在粉丝/关注列表及用户主页显示关注的具体时间。",
|
||||
"fullRelativePath": "../../registry/dist/components/utils/subscribe-time-show.js",
|
||||
"fullAbsolutePath": "registry/dist/components/utils/subscribe-time-show.js"
|
||||
},
|
||||
@ -671,6 +703,14 @@
|
||||
"fullRelativePath": "../../registry/dist/components/utils/view-cover.js",
|
||||
"fullAbsolutePath": "registry/dist/components/utils/view-cover.js"
|
||||
},
|
||||
{
|
||||
"type": "component",
|
||||
"name": "watchlaterPageRedirect",
|
||||
"displayName": "稍后再看页面-重定向",
|
||||
"description": "by [@magicFeirl](https://github.com/magicFeirl)\n\n重定向**稍后再看页面**到普通视频页面,和`稍后再看重定向`组件的区别在于,该组件是在进入稍后再看的视频页面后进行重定向,而非直接替换链接到普通视频的链接,用于补充`稍后再看重定向`组件的功能。\r\n",
|
||||
"fullRelativePath": "../../registry/dist/components/utils/watchlater-page-redirect.js",
|
||||
"fullAbsolutePath": "registry/dist/components/utils/watchlater-page-redirect.js"
|
||||
},
|
||||
{
|
||||
"type": "component",
|
||||
"name": "watchlaterRedirect",
|
||||
|
@ -85,6 +85,17 @@
|
||||
|
||||
在顶栏的视频动态中, 无论标题多长总是完全展开.
|
||||
|
||||
### [动态分组过滤](../../registry/dist/components/feeds/group-filter.js)
|
||||
`feedsGroupFilter`
|
||||
|
||||
**jsDelivr:** [`Stable`](https://cdn.jsdelivr.net/gh/the1812/Bilibili-Evolved@master/registry/dist/components/feeds/group-filter.js) / [`Preview`](https://cdn.jsdelivr.net/gh/the1812/Bilibili-Evolved@preview/registry/dist/components/feeds/group-filter.js)
|
||||
|
||||
**GitHub:** [`Stable`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/master/registry/dist/components/feeds/group-filter.js) / [`Preview`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/preview/registry/dist/components/feeds/group-filter.js)
|
||||
|
||||
by [@Rinne](https://github.com/OharaRinneY)
|
||||
|
||||
按照关注分组筛选动态
|
||||
|
||||
### [隐藏动态评论预览](../../registry/dist/components/feeds/hide-comment-preview.js)
|
||||
`hideFeedsCommentPreview`
|
||||
|
||||
@ -136,6 +147,17 @@
|
||||
|
||||
在直播区中, 可从功能面板中直接切换勋章和头衔. 默认显示 256 个 (同时也是上限), 可在选项中修改.
|
||||
|
||||
### [一键点亮直播间粉丝勋章](../../registry/dist/components/live/badge-keepalive.js)
|
||||
`badgeKeepalive`
|
||||
|
||||
**jsDelivr:** [`Stable`](https://cdn.jsdelivr.net/gh/the1812/Bilibili-Evolved@master/registry/dist/components/live/badge-keepalive.js) / [`Preview`](https://cdn.jsdelivr.net/gh/the1812/Bilibili-Evolved@preview/registry/dist/components/live/badge-keepalive.js)
|
||||
|
||||
**GitHub:** [`Stable`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/master/registry/dist/components/live/badge-keepalive.js) / [`Preview`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/preview/registry/dist/components/live/badge-keepalive.js)
|
||||
|
||||
by [@magicFeirl](https://github.com/magicFeirl)
|
||||
|
||||
在直播间页面的功能面板添加一键点亮粉丝勋章功能,仅适用于有粉丝勋章且正在直播的直播间。原理:发送一个 300 次点赞的请求点亮粉丝勋章。
|
||||
|
||||
### [直播间网页全屏自适应](../../registry/dist/components/live/chat-panel-fit.js)
|
||||
`liveChatPanelFit`
|
||||
|
||||
@ -657,6 +679,21 @@ by [@RieN7](https://github.com/rien7),[@ZiuChen](https://github.com/ZiuChen),[@L
|
||||
- 进度调整可在左上角和右上角取消
|
||||
- 进度调整时在不同位置滑动, 可以使用3档不同的灵敏度.
|
||||
|
||||
### [视频链接增强](../../registry/dist/components/utils/active-video-links.js)
|
||||
`activeVideoLinks`
|
||||
|
||||
**jsDelivr:** [`Stable`](https://cdn.jsdelivr.net/gh/the1812/Bilibili-Evolved@master/registry/dist/components/utils/active-video-links.js) / [`Preview`](https://cdn.jsdelivr.net/gh/the1812/Bilibili-Evolved@preview/registry/dist/components/utils/active-video-links.js)
|
||||
|
||||
**GitHub:** [`Stable`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/master/registry/dist/components/utils/active-video-links.js) / [`Preview`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/preview/registry/dist/components/utils/active-video-links.js)
|
||||
|
||||
by [@Alan Ye](https://github.com/at-wr)
|
||||
|
||||
这个组件会将视频简介中的普通网址转换为可点击的链接,并将<del>被 Bilibili 抛弃</del>已失效的 `acg.tv` 跳转链接修复为 `nicovideo.jp` 链接。
|
||||
|
||||
例如: `https://acg.tv/sm37507315` → `https://www.nicovideo.jp/watch/sm37507315`
|
||||
|
||||
<i>本组件不会保证目标链接的安全性,因此在点击前请自行验证其是否可信</i>
|
||||
|
||||
### [相簿发布时间显示](../../registry/dist/components/utils/album-time-show.js)
|
||||
`albumPubTimeShow`
|
||||
|
||||
@ -783,6 +820,19 @@ by [@snowraincloud](https://github.com/snowraincloud)
|
||||
|
||||
> 需要进入音频的详细信息页面才能下载, 在其他页面中此按钮将不可点击.
|
||||
|
||||
### [下载表情](../../registry/dist/components/utils/download-emoticons.js)
|
||||
`downloadEmoticons`
|
||||
|
||||
**jsDelivr:** [`Stable`](https://cdn.jsdelivr.net/gh/the1812/Bilibili-Evolved@master/registry/dist/components/utils/download-emoticons.js) / [`Preview`](https://cdn.jsdelivr.net/gh/the1812/Bilibili-Evolved@preview/registry/dist/components/utils/download-emoticons.js)
|
||||
|
||||
**GitHub:** [`Stable`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/master/registry/dist/components/utils/download-emoticons.js) / [`Preview`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/preview/registry/dist/components/utils/download-emoticons.js)
|
||||
|
||||
by [@Pencilqaq](https://github.com/pencilqaq)
|
||||
|
||||
## 下载up主专属表情包
|
||||
|
||||
支持下载up主专属表情包,处于任意直播间页面时,下载按钮会在`功能`面板显示,以压缩包形式保存。
|
||||
|
||||
### [图片批量导出](../../registry/dist/components/utils/image-exporter.js)
|
||||
`imageExporter`
|
||||
|
||||
@ -896,9 +946,9 @@ by [@Light_Quanta](https://github.com/LightQuanta)
|
||||
|
||||
**GitHub:** [`Stable`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/master/registry/dist/components/utils/subscribe-time-show.js) / [`Preview`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/preview/registry/dist/components/utils/subscribe-time-show.js)
|
||||
|
||||
by [@Light_Quanta](https://github.com/LightQuanta)
|
||||
by [@CNOCM](https://github.com/CNOCM)
|
||||
|
||||
在粉丝/关注列表显示关注的具体时间
|
||||
在粉丝/关注列表及用户主页显示关注的具体时间。
|
||||
|
||||
### [网址参数清理](../../registry/dist/components/utils/url-params-clean.js)
|
||||
`urlParamsClean`
|
||||
@ -927,6 +977,17 @@ by [@Light_Quanta](https://github.com/LightQuanta)
|
||||
|
||||
在视频页面中, 可从功能面板中查看封面.
|
||||
|
||||
### [稍后再看页面-重定向](../../registry/dist/components/utils/watchlater-page-redirect.js)
|
||||
`watchlaterPageRedirect`
|
||||
|
||||
**jsDelivr:** [`Stable`](https://cdn.jsdelivr.net/gh/the1812/Bilibili-Evolved@master/registry/dist/components/utils/watchlater-page-redirect.js) / [`Preview`](https://cdn.jsdelivr.net/gh/the1812/Bilibili-Evolved@preview/registry/dist/components/utils/watchlater-page-redirect.js)
|
||||
|
||||
**GitHub:** [`Stable`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/master/registry/dist/components/utils/watchlater-page-redirect.js) / [`Preview`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/preview/registry/dist/components/utils/watchlater-page-redirect.js)
|
||||
|
||||
by [@magicFeirl](https://github.com/magicFeirl)
|
||||
|
||||
重定向**稍后再看页面**到普通视频页面,和`稍后再看重定向`组件的区别在于,该组件是在进入稍后再看的视频页面后进行重定向,而非直接替换链接到普通视频的链接,用于补充`稍后再看重定向`组件的功能。
|
||||
|
||||
### [稍后再看重定向](../../registry/dist/components/utils/watchlater-redirect.js)
|
||||
`watchlaterRedirect`
|
||||
|
||||
|
57
registry/lib/components/feeds/extend-live/ExtraOptions.vue
Normal file
57
registry/lib/components/feeds/extend-live/ExtraOptions.vue
Normal file
@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<div class="extend-feeds-live-extra-options">
|
||||
<div class="extend-feeds-live-extra-option">
|
||||
<div class="extend-feeds-live-extra-option-header">置顶列表</div>
|
||||
<div class="extend-feeds-live-extra-option-description">
|
||||
配置直播信息扩充要展示的置顶列表,置顶列表中的用户会排序到上方。
|
||||
</div>
|
||||
<FollowingListSelect :value="pinnedListID" @change="handlePinnedListChange" />
|
||||
</div>
|
||||
<div class="extend-feeds-live-extra-option">
|
||||
<div class="extend-feeds-live-extra-option-header">隐藏列表</div>
|
||||
<div class="extend-feeds-live-extra-option-description">
|
||||
配置直播信息扩充要展示的隐藏列表,隐藏列表中的用户会被隐藏,优先级高于置顶列表。
|
||||
</div>
|
||||
<FollowingListSelect :value="hiddenListID" @change="handleHiddenListChange" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import FollowingListSelect from './FollowingListSelect.vue'
|
||||
import { getComponentSettings } from '@/core/settings'
|
||||
import { ExtendFeedsLiveOptions } from './options'
|
||||
import { FollowingListID } from './types'
|
||||
|
||||
const { options } = getComponentSettings<ExtendFeedsLiveOptions>('extendFeedsLive')
|
||||
|
||||
const pinnedListID = ref(options.pinnedListID)
|
||||
const handlePinnedListChange = (value: FollowingListID) => {
|
||||
options.pinnedListID = value
|
||||
pinnedListID.value = value
|
||||
}
|
||||
|
||||
const hiddenListID = ref(options.hiddenListID)
|
||||
const handleHiddenListChange = (value: FollowingListID) => {
|
||||
options.hiddenListID = value
|
||||
hiddenListID.value = value
|
||||
}
|
||||
</script>
|
||||
<style lang="scss">
|
||||
@import 'common';
|
||||
|
||||
.extend-feeds-live-extra-options {
|
||||
margin-top: 8px;
|
||||
@include v-stretch(12px);
|
||||
.extend-feeds-live-extra-option {
|
||||
@include v-stretch(8px);
|
||||
&-header {
|
||||
@include semi-bold();
|
||||
}
|
||||
&-description {
|
||||
font-size: 12px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<VDropdown
|
||||
:items="followingList"
|
||||
:key-mapper="item => item.id"
|
||||
class="be-live-following-list-select"
|
||||
:value="selectedItem"
|
||||
@change="handleFollowingListChange"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<span class="be-live-following-list-item"> {{ item.displayName }} </span>
|
||||
</template>
|
||||
</VDropdown>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { VDropdown } from '@/ui'
|
||||
import { bilibiliApi, getJsonWithCredentials } from '@/core/ajax'
|
||||
import { FollowingListID, FollowingListItem, RawFollowingListItem } from './types'
|
||||
import { UnselectedListID } from './options'
|
||||
|
||||
const props = defineProps<{
|
||||
value: FollowingListID
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'change', value: FollowingListID): void
|
||||
}>()
|
||||
|
||||
const followingListID = computed<FollowingListID>(() => props.value)
|
||||
const handleFollowingListChange = (value: FollowingListItem) => {
|
||||
emit('change', value.id)
|
||||
}
|
||||
|
||||
const unselectedListItem = reactive<FollowingListItem>({
|
||||
id: UnselectedListID,
|
||||
displayName: '<未选择>',
|
||||
count: 0,
|
||||
})
|
||||
|
||||
const followingList = ref<FollowingListItem[]>([])
|
||||
const selectedItem = computed(() => {
|
||||
return followingList.value.find(item => item.id === followingListID.value) ?? unselectedListItem
|
||||
})
|
||||
const loadFollowingList = async () => {
|
||||
const list = await bilibiliApi<RawFollowingListItem[]>(
|
||||
getJsonWithCredentials('https://api.bilibili.com/x/relation/tags'),
|
||||
)
|
||||
followingList.value = [
|
||||
unselectedListItem,
|
||||
...list.map(item => ({
|
||||
id: item.tagid,
|
||||
displayName: item.name,
|
||||
count: item.count,
|
||||
})),
|
||||
]
|
||||
if (
|
||||
props.value !== UnselectedListID &&
|
||||
!followingList.value.some(item => item.id === props.value)
|
||||
) {
|
||||
followingList.value.unshift({
|
||||
id: props.value,
|
||||
displayName: '<已删除的分组>',
|
||||
count: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
loadFollowingList()
|
||||
</script>
|
@ -6,7 +6,7 @@
|
||||
<div class="be-live-list-count">({{ items.length }})</div>
|
||||
</div>
|
||||
<div class="be-live-list-actions">
|
||||
<div v-if="loaded" class="be-live-list-refresh" title="刷新" @click="refresh">
|
||||
<div v-if="loaded" class="be-live-list-refresh" title="刷新" @click="fetchLiveList">
|
||||
<VIcon icon="mdi-refresh" :size="16" />
|
||||
</div>
|
||||
<a
|
||||
@ -49,21 +49,17 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { VIcon, TextBox, DpiImage, VEmpty, VLoading } from '@/ui'
|
||||
import { getJsonWithCredentials, responsiveGetPages } from '@/core/ajax'
|
||||
|
||||
interface LiveInfo {
|
||||
cover: string
|
||||
face: string
|
||||
uname: string
|
||||
title: string
|
||||
roomid: number
|
||||
pic: string
|
||||
online: number
|
||||
uid: number
|
||||
link: string
|
||||
}
|
||||
import { bilibiliApi, getJsonWithCredentials, getPages, responsiveGetPages } from '@/core/ajax'
|
||||
import { FollowingListID, FollowingUserInfo, LiveInfo, RawFollowingListItem } from './types'
|
||||
import { getUID } from '@/core/utils'
|
||||
import { ExtendFeedsLiveOptions, UnselectedListID } from './options'
|
||||
import {
|
||||
addComponentListener,
|
||||
getComponentSettings,
|
||||
removeComponentListener,
|
||||
} from '@/core/settings'
|
||||
|
||||
const decodeTitle = (title: string) => {
|
||||
const textArea = document.createElement('textarea')
|
||||
@ -71,7 +67,11 @@ const decodeTitle = (title: string) => {
|
||||
return textArea.value
|
||||
}
|
||||
|
||||
const FollowingListPageSize = 500
|
||||
const { options } = getComponentSettings<ExtendFeedsLiveOptions>('extendFeedsLive')
|
||||
const items = ref<LiveInfo[]>([])
|
||||
const pinnedUsers = ref<FollowingUserInfo[]>([])
|
||||
const hiddenUsers = ref<FollowingUserInfo[]>([])
|
||||
const loaded = ref(false)
|
||||
const keyword = ref('')
|
||||
|
||||
@ -88,37 +88,95 @@ const filteredItems = computed(() => {
|
||||
return items.value
|
||||
})
|
||||
|
||||
const fetchRecommendItems = async (): Promise<LiveInfo[]> => {
|
||||
const fetchRecommendLiveInfos = async (): Promise<LiveInfo[]> => {
|
||||
// 动态 portal 接口会获取推荐的 top30 直播。同时这个接口不会忽略悄悄关注的 up 的直播。
|
||||
const portalList = await getJsonWithCredentials(
|
||||
'https://api.bilibili.com/x/polymer/web-dynamic/v1/portal',
|
||||
)
|
||||
const recommendLiveItems = portalList.data.live_users.items.map(item => {
|
||||
const { jump_url, room_id, face, title, uname, mid } = item
|
||||
return {
|
||||
cover: face,
|
||||
face,
|
||||
uname,
|
||||
title,
|
||||
roomid: room_id,
|
||||
pic: '', // portal 接口没有
|
||||
online: 0, // portal 接口没有
|
||||
uid: mid,
|
||||
link: jump_url,
|
||||
}
|
||||
})
|
||||
const recommendLiveItems =
|
||||
portalList.data.live_users?.items?.map(item => {
|
||||
const { jump_url, room_id, face, title, uname, mid } = item
|
||||
return {
|
||||
cover: face,
|
||||
face,
|
||||
uname,
|
||||
title,
|
||||
roomid: room_id,
|
||||
pic: '', // portal 接口没有
|
||||
online: 0, // portal 接口没有
|
||||
uid: mid,
|
||||
link: jump_url,
|
||||
}
|
||||
}) ?? []
|
||||
return recommendLiveItems
|
||||
}
|
||||
|
||||
const sortByRecommend = (feedItems: LiveInfo[], recommendItems: LiveInfo[]) => {
|
||||
const mergeRecommendLiveInfos = (liveInfos: LiveInfo[], recommendLiveInfos: LiveInfo[]) => {
|
||||
// recommendItems 里的 pic 和 online 为默认值,但是 ui 也没用到。
|
||||
// 所以方便起见,直接拼接没出现在 recommendItems 里的 item
|
||||
const recommendRoomIds = recommendItems.map(item => item.roomid)
|
||||
const feedConcatItems = feedItems.filter(item => !recommendRoomIds.includes(item.roomid))
|
||||
return lodash.concat(recommendItems, feedConcatItems)
|
||||
const recommendRoomIds = recommendLiveInfos.map(item => item.roomid)
|
||||
const feedConcatItems = liveInfos.filter(item => !recommendRoomIds.includes(item.roomid))
|
||||
return lodash.concat(recommendLiveInfos, feedConcatItems)
|
||||
}
|
||||
|
||||
const refresh = async () => {
|
||||
const sortByUsers = (users: FollowingUserInfo[], descending = false) => {
|
||||
return (a: LiveInfo, b: LiveInfo) => {
|
||||
const aPinnedIndex = users.findIndex(it => it.mid === a.uid)
|
||||
const bPinnedIndex = users.findIndex(it => it.mid === b.uid)
|
||||
if (bPinnedIndex === -1 && aPinnedIndex === -1) {
|
||||
return 0
|
||||
}
|
||||
if (aPinnedIndex === -1) {
|
||||
return descending ? -1 : 1
|
||||
}
|
||||
if (bPinnedIndex === -1) {
|
||||
return descending ? 1 : -1
|
||||
}
|
||||
return aPinnedIndex - bPinnedIndex
|
||||
}
|
||||
}
|
||||
|
||||
const fetchFollowingLists = async () => {
|
||||
const fetchFollowingList = async (id: FollowingListID) => {
|
||||
if (id === UnselectedListID || id === undefined) {
|
||||
return []
|
||||
}
|
||||
const list = await bilibiliApi<RawFollowingListItem[]>(
|
||||
getJsonWithCredentials('https://api.bilibili.com/x/relation/tags'),
|
||||
)
|
||||
if (!list.some(it => it.tagid === id)) {
|
||||
return []
|
||||
}
|
||||
const response = await getPages<FollowingUserInfo>({
|
||||
api: page =>
|
||||
getJsonWithCredentials(
|
||||
`https://api.bilibili.com/x/relation/tag?tagid=${id}&pn=${page}&ps=${FollowingListPageSize}&mid=${getUID()}`,
|
||||
),
|
||||
getList: json => lodash.get(json, 'data', []),
|
||||
getTotal: () => list.find(it => it.tagid === id)?.count ?? 0,
|
||||
})
|
||||
return response
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
fetchFollowingList(options.pinnedListID)
|
||||
.then(users => {
|
||||
pinnedUsers.value = users
|
||||
})
|
||||
.catch(() => {
|
||||
pinnedUsers.value = []
|
||||
}),
|
||||
fetchFollowingList(options.hiddenListID)
|
||||
.then(users => {
|
||||
hiddenUsers.value = users
|
||||
})
|
||||
.catch(() => {
|
||||
hiddenUsers.value = []
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
const fetchLiveList = async () => {
|
||||
try {
|
||||
items.value = []
|
||||
loaded.value = false
|
||||
@ -145,15 +203,28 @@ const refresh = async () => {
|
||||
getTotal: json => lodash.get(json, 'data.count', 0),
|
||||
})
|
||||
|
||||
const [allItems, recommendItems] = await Promise.all([promise, fetchRecommendItems()])
|
||||
items.value = sortByRecommend(allItems, recommendItems)
|
||||
const [allItems, recommendItems] = await Promise.all([promise, fetchRecommendLiveInfos()])
|
||||
const sortedItems = mergeRecommendLiveInfos(allItems, recommendItems)
|
||||
.sort(sortByUsers(pinnedUsers.value, false))
|
||||
.filter(item => !hiddenUsers.value.some(user => user.mid === item.uid))
|
||||
items.value = sortedItems
|
||||
} finally {
|
||||
loaded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const initializeLiveList = async () => {
|
||||
await fetchFollowingLists()
|
||||
await fetchLiveList()
|
||||
}
|
||||
addComponentListener('extendFeedsLive.pinnedListID', initializeLiveList)
|
||||
addComponentListener('extendFeedsLive.hiddenListID', initializeLiveList)
|
||||
onMounted(() => {
|
||||
refresh()
|
||||
initializeLiveList()
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
removeComponentListener('extendFeedsLive.pinnedListID', initializeLiveList)
|
||||
removeComponentListener('extendFeedsLive.hiddenListID', initializeLiveList)
|
||||
})
|
||||
</script>
|
||||
<style lang="scss">
|
||||
|
@ -3,6 +3,7 @@ import { styledComponentEntry } from '@/components/styled-component'
|
||||
import { select } from '@/core/spin-query'
|
||||
import { mountVueComponent } from '@/core/utils'
|
||||
import { useScopedConsole } from '@/core/utils/log'
|
||||
import { options } from './options'
|
||||
|
||||
const entry = async () => {
|
||||
const console = useScopedConsole('extendFeedsLive')
|
||||
@ -46,4 +47,6 @@ export const component = defineComponentMetadata({
|
||||
entry: styledComponentEntry(() => import('./extend-feeds-live.scss'), entry),
|
||||
tags: [componentsTags.feeds, componentsTags.live],
|
||||
urlInclude: [/^https:\/\/t\.bilibili\.com\/$/],
|
||||
extraOptions: () => import('./ExtraOptions.vue').then(m => m.default),
|
||||
options,
|
||||
})
|
||||
|
19
registry/lib/components/feeds/extend-live/options.ts
Normal file
19
registry/lib/components/feeds/extend-live/options.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { defineOptionsMetadata, OptionsOfMetadata } from '@/components/define'
|
||||
import { FollowingListID } from './types'
|
||||
|
||||
export const UnselectedListID: FollowingListID = -1
|
||||
|
||||
export const options = defineOptionsMetadata({
|
||||
pinnedListID: {
|
||||
defaultValue: UnselectedListID as FollowingListID,
|
||||
displayName: '置顶列表 ID',
|
||||
hidden: true,
|
||||
},
|
||||
hiddenListID: {
|
||||
defaultValue: UnselectedListID as FollowingListID,
|
||||
displayName: '隐藏列表 ID',
|
||||
hidden: true,
|
||||
},
|
||||
})
|
||||
|
||||
export type ExtendFeedsLiveOptions = OptionsOfMetadata<typeof options>
|
69
registry/lib/components/feeds/extend-live/types.ts
Normal file
69
registry/lib/components/feeds/extend-live/types.ts
Normal file
@ -0,0 +1,69 @@
|
||||
export type FollowingListID = number
|
||||
|
||||
export interface LiveInfo {
|
||||
cover: string
|
||||
face: string
|
||||
uname: string
|
||||
title: string
|
||||
roomid: number
|
||||
pic: string
|
||||
online: number
|
||||
uid: number
|
||||
link: string
|
||||
}
|
||||
|
||||
export interface FollowingUserInfo {
|
||||
mid: number
|
||||
attribute: number
|
||||
special: number
|
||||
contract_info: Record<string, unknown>
|
||||
uname: string
|
||||
face: string
|
||||
sign: string
|
||||
face_nft: number
|
||||
official_verify: {
|
||||
type: number
|
||||
desc: string
|
||||
}
|
||||
vip: {
|
||||
vipType: number
|
||||
vipDueDate: number
|
||||
dueRemark: string
|
||||
accessStatus: number
|
||||
vipStatus: number
|
||||
vipStatusWarn: string
|
||||
themeType: number
|
||||
label: {
|
||||
path: string
|
||||
text: string
|
||||
label_theme: string
|
||||
text_color: string
|
||||
bg_style: number
|
||||
bg_color: string
|
||||
border_color: string
|
||||
}
|
||||
avatar_subscript: number
|
||||
nickname_color: string
|
||||
avatar_subscript_url: string
|
||||
}
|
||||
name_render: Record<string, unknown>
|
||||
live: {
|
||||
live_status: number
|
||||
jump_url: string
|
||||
}
|
||||
nft_icon: string
|
||||
rec_reason: string
|
||||
track_id: string
|
||||
follow_time: string
|
||||
}
|
||||
export interface RawFollowingListItem {
|
||||
tagid: number
|
||||
name: string
|
||||
count: number
|
||||
tip: string
|
||||
}
|
||||
export interface FollowingListItem {
|
||||
id: FollowingListID
|
||||
displayName: string
|
||||
count: number
|
||||
}
|
156
registry/lib/components/feeds/group-filter/FilterPanel.vue
Normal file
156
registry/lib/components/feeds/group-filter/FilterPanel.vue
Normal file
@ -0,0 +1,156 @@
|
||||
<template>
|
||||
<div class="group-filter-panel">
|
||||
<div class="group-filter-header">
|
||||
<h1>分组</h1>
|
||||
<switch-box v-model="allChecked" />
|
||||
</div>
|
||||
<div v-for="(group, index) in groups" :key="index" class="group-item">
|
||||
<CheckBox v-model="group.checked">
|
||||
{{ group.name }}
|
||||
</CheckBox>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { CheckBox, SwitchBox } from '@/ui'
|
||||
import { bilibiliApi, getJsonWithCredentials, getPages } from '@/core/ajax'
|
||||
import { FeedsCard, forEachFeedsCard } from '@/components/feeds/api'
|
||||
import { getUID } from '@/core/utils'
|
||||
|
||||
interface Group {
|
||||
name: string
|
||||
checked: boolean
|
||||
id: number
|
||||
}
|
||||
|
||||
let cardsManager: typeof import('@/components/feeds/api').feedsCardsManager
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
SwitchBox,
|
||||
CheckBox,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
groups: [],
|
||||
followingMap: new Map<string, number[]>(), // username -> tagid[]
|
||||
selectedGroupIds: [],
|
||||
allChecked: true,
|
||||
} as {
|
||||
groups: Group[]
|
||||
followingMap: Map<string, number[]>
|
||||
selectedGroupIds: number[]
|
||||
allChecked: boolean
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
groups: {
|
||||
handler(newGroups: Group[]) {
|
||||
this.selectedGroupIds = newGroups.filter(group => group.checked).map(group => group.id)
|
||||
cardsManager.cards.forEach(card => {
|
||||
this.updateCard(lodash.clone(card))
|
||||
})
|
||||
},
|
||||
deep: true,
|
||||
immediate: true,
|
||||
},
|
||||
allChecked: {
|
||||
handler(newChecked: boolean) {
|
||||
for (const group of this.groups) {
|
||||
group.checked = newChecked
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
async mounted() {
|
||||
cardsManager = await forEachFeedsCard({
|
||||
added: card => {
|
||||
this.updateCard(lodash.clone(card))
|
||||
},
|
||||
})
|
||||
// fetch groups
|
||||
this.groups = await bilibiliApi<Array<any>>(
|
||||
getJsonWithCredentials('https://api.bilibili.com/x/relation/tags'),
|
||||
'分组信息获取失败',
|
||||
).then(res =>
|
||||
res.map(value => ({
|
||||
name: value.name,
|
||||
checked: true,
|
||||
id: value.tagid,
|
||||
})),
|
||||
)
|
||||
|
||||
const uid = getUID()
|
||||
// fetch following
|
||||
const allPages = await getPages({
|
||||
api: page =>
|
||||
getJsonWithCredentials(
|
||||
`https://api.bilibili.com/x/relation/followings?vmid=${uid}&pn=${page}&ps=50`,
|
||||
),
|
||||
getList: json => json.data.list,
|
||||
getTotal: json => json.data.total,
|
||||
})
|
||||
allPages.forEach(user => {
|
||||
this.followingMap.set(user.uname, user.tag)
|
||||
})
|
||||
},
|
||||
methods: {
|
||||
updateCard(card: FeedsCard) {
|
||||
// 从username获得tag
|
||||
const userTagIds: number[] = this.followingMap.get(card.username)
|
||||
if (!userTagIds || !this.selectedGroupIds) {
|
||||
return
|
||||
}
|
||||
if (!userTagIds.some(item => this.selectedGroupIds.includes(item))) {
|
||||
card.element.classList.add('group-filter-hide-feed')
|
||||
} else {
|
||||
card.element.classList.remove('group-filter-hide-feed')
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.group-filter-hide-feed {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.group-filter-panel {
|
||||
background-color: white;
|
||||
font-size: 12px;
|
||||
width: 100%;
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
flex-direction: column;
|
||||
padding: 12px 16px;
|
||||
|
||||
.group-filter-header {
|
||||
cursor: pointer;
|
||||
padding-bottom: 14px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
h1 {
|
||||
font-weight: normal;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.group-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
body.dark & {
|
||||
color: #eee;
|
||||
background-color: #444;
|
||||
}
|
||||
}
|
||||
</style>
|
1
registry/lib/components/feeds/group-filter/index.md
Normal file
1
registry/lib/components/feeds/group-filter/index.md
Normal file
@ -0,0 +1 @@
|
||||
按照关注分组筛选动态
|
34
registry/lib/components/feeds/group-filter/index.ts
Normal file
34
registry/lib/components/feeds/group-filter/index.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { defineComponentMetadata } from '@/components/define'
|
||||
import { feedsCardsManager } from '@/components/feeds/api'
|
||||
|
||||
const entry = async () => {
|
||||
const { select } = await import('@/core/spin-query')
|
||||
let leftPanel: HTMLElement
|
||||
if (feedsCardsManager.managerType === 'v2') {
|
||||
const leftAside = await select('.bili-dyn-home--member aside.left')
|
||||
const section = document.createElement('section')
|
||||
section.classList.add('group-filter-section')
|
||||
leftAside.insertAdjacentElement('afterbegin', section)
|
||||
leftPanel = section
|
||||
} else {
|
||||
leftPanel = await select('.home-container .left-panel')
|
||||
}
|
||||
if (leftPanel === null) {
|
||||
return
|
||||
}
|
||||
const FilterPanel = await import('./FilterPanel.vue')
|
||||
const { mountVueComponent } = await import('@/core/utils')
|
||||
leftPanel.insertAdjacentElement('afterbegin', mountVueComponent(FilterPanel).$el)
|
||||
}
|
||||
|
||||
export const component = defineComponentMetadata({
|
||||
name: 'feedsGroupFilter',
|
||||
entry,
|
||||
displayName: '动态分组过滤',
|
||||
author: { name: 'Rinne', link: 'https://github.com/OharaRinneY' },
|
||||
urlInclude: [
|
||||
// 仅动态首页
|
||||
/^https:\/\/t\.bilibili\.com\/$/,
|
||||
],
|
||||
tags: [componentsTags.feeds],
|
||||
})
|
@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<p class="title">一键点亮粉丝勋章</p>
|
||||
<TextBox
|
||||
placeholder="直播间ID"
|
||||
:text="roomid"
|
||||
@change="handleRoomIdChange"
|
||||
:changeOnBlur="true"
|
||||
></TextBox>
|
||||
<AsyncButton @click="handleKeepAliveRequest">点亮!</AsyncButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { TextBox, AsyncButton } from '@/ui'
|
||||
|
||||
import { validateRoomId, getLiveRoomId, keepAliveRequest } from './utils'
|
||||
import { Toast } from '@/core/toast'
|
||||
|
||||
const roomid = ref(getLiveRoomId())
|
||||
const handleRoomIdChange = (value: string) => {
|
||||
if (validateRoomId(value)) {
|
||||
roomid.value = value
|
||||
} else {
|
||||
roomid.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeepAliveRequest = async () => {
|
||||
if (!roomid.value) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await keepAliveRequest(roomid.value)
|
||||
Toast.success('发送点亮勋章请求成功', '提示')
|
||||
} catch ({ message }) {
|
||||
Toast.error(`勋章点亮失败,原因: ${message}`, '提示')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import 'common';
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
|
||||
box-shadow: 0 0 0 1px #8884;
|
||||
order: -2;
|
||||
border-radius: 4px;
|
||||
padding: 6px 6px 6px 10px;
|
||||
@include v-stretch(6px);
|
||||
|
||||
.title {
|
||||
@include semi-bold();
|
||||
}
|
||||
}
|
||||
</style>
|
1
registry/lib/components/live/badge-keepalive/index.md
Normal file
1
registry/lib/components/live/badge-keepalive/index.md
Normal file
@ -0,0 +1 @@
|
||||
在直播间页面的功能面板添加一键点亮粉丝勋章功能,仅适用于有粉丝勋章且正在直播的直播间。原理:发送一个 300 次点赞的请求点亮粉丝勋章。
|
20
registry/lib/components/live/badge-keepalive/index.ts
Normal file
20
registry/lib/components/live/badge-keepalive/index.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { defineComponentMetadata } from '@/components/define'
|
||||
import { getUID } from '@/core/utils'
|
||||
|
||||
export const component = defineComponentMetadata({
|
||||
name: 'badgeKeepalive',
|
||||
displayName: '一键点亮直播间粉丝勋章',
|
||||
entry: none,
|
||||
reload: none,
|
||||
unload: none,
|
||||
author: {
|
||||
name: 'magicFeirl',
|
||||
link: 'https://github.com/magicFeirl',
|
||||
},
|
||||
tags: [componentsTags.live],
|
||||
widget: {
|
||||
component: () => import('./BadgeKeepalive.vue').then(m => m.default),
|
||||
condition: () => Boolean(getUID()),
|
||||
},
|
||||
urlInclude: ['//live.bilibili.com'],
|
||||
})
|
55
registry/lib/components/live/badge-keepalive/utils.ts
Normal file
55
registry/lib/components/live/badge-keepalive/utils.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { postTextWithCredentials, getJsonWithCredentials } from '@/core/ajax'
|
||||
import { getCsrf, getUID } from '@/core/utils'
|
||||
|
||||
// 获取当前直播间号
|
||||
export function getLiveRoomId(): string {
|
||||
const matched = location.href.match(/live.bilibili.com\/(\d+)/)
|
||||
return matched ? matched[1] : ''
|
||||
}
|
||||
|
||||
export function validateRoomId(value: string): boolean {
|
||||
return /^\d+$/.test(value)
|
||||
}
|
||||
|
||||
function validateJSON(data) {
|
||||
if (data.code !== 0) {
|
||||
throw new Error(data.message)
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function getLiveRoomUserInfo(room_id: string) {
|
||||
const data = await getJsonWithCredentials(
|
||||
`https://api.live.bilibili.com/xlive/web-room/v1/index/getInfoByUser?room_id=${room_id}`,
|
||||
)
|
||||
|
||||
return validateJSON(data)
|
||||
}
|
||||
|
||||
export async function keepAliveRequest(room_id: string, click_time = '300') {
|
||||
// 需要先获取直播间房主的 UID
|
||||
const data = await getLiveRoomUserInfo(room_id)
|
||||
|
||||
const { curr_weared } = data.medal
|
||||
if (!curr_weared) {
|
||||
throw new Error(`暂未获得直播间 ${room_id} 的粉丝勋章`)
|
||||
}
|
||||
|
||||
const anchor_id = curr_weared.target_id
|
||||
|
||||
const params = {
|
||||
click_time,
|
||||
room_id,
|
||||
anchor_id,
|
||||
uid: getUID(),
|
||||
csrf: getCsrf(),
|
||||
}
|
||||
|
||||
const baseURL =
|
||||
'https://api.live.bilibili.com/xlive/app-ucenter/v1/like_info_v3/like/likeReportV3'
|
||||
|
||||
return validateJSON(
|
||||
JSON.parse(await postTextWithCredentials(baseURL, new URLSearchParams(params))),
|
||||
)
|
||||
}
|
@ -4,9 +4,10 @@ import { ranking } from './ranking/ranking'
|
||||
import { userInfo } from './user-info/user-info'
|
||||
import { logo } from './logo/logo'
|
||||
import { home } from './home/home'
|
||||
import { gamesIframe, livesIframe, mangaIframe } from './iframe/iframe'
|
||||
import { games } from './games/games'
|
||||
import { blanks } from './flexible-blank/flexible-blank'
|
||||
import { bangumi, music, shop, match, creations } from './simple-links/simple-links'
|
||||
import { bangumi, music, shop, creations, lives } from './simple-links/simple-links'
|
||||
import { match } from './match/match'
|
||||
import { upload } from './upload/upload'
|
||||
import { search } from './search/search'
|
||||
import { feeds } from './feeds/feeds'
|
||||
@ -14,6 +15,7 @@ import { subscriptions } from './subscriptions/subscriptions'
|
||||
import { watchlater } from './watchlater/watchlater'
|
||||
import { favorites } from './favorites/favorites'
|
||||
import { history } from './history/history'
|
||||
import { manga } from './manga/manga'
|
||||
|
||||
const [blank1, blank2, blank3, blank4] = blanks
|
||||
export const getBuiltInItems = (): CustomNavbarItemInit[] => [
|
||||
@ -23,11 +25,11 @@ export const getBuiltInItems = (): CustomNavbarItemInit[] => [
|
||||
bangumi,
|
||||
ranking,
|
||||
music,
|
||||
gamesIframe,
|
||||
livesIframe,
|
||||
games,
|
||||
lives,
|
||||
shop,
|
||||
match,
|
||||
mangaIframe,
|
||||
manga,
|
||||
blank2,
|
||||
search,
|
||||
blank3,
|
||||
|
@ -3,6 +3,7 @@ import { addComponentListener } from '@/core/settings'
|
||||
import { isIframe, isNotHtml, matchUrlPattern, mountVueComponent } from '@/core/utils'
|
||||
import { setupNotifyStyle } from './notify-style'
|
||||
import { setupLinkPopupContentAlignStyle } from './link-popup-content-align-style'
|
||||
import { migrateOrders } from './orders-migration'
|
||||
|
||||
export const entry: ComponentEntry = async ({ metadata: { name } }) => {
|
||||
// const url = document.URL.replace(location.search, '')
|
||||
@ -51,6 +52,7 @@ export const entry: ComponentEntry = async ({ metadata: { name } }) => {
|
||||
if (matchUrlPattern('https://www.bilibili.com/account/history')) {
|
||||
document.body.classList.add('history-page')
|
||||
}
|
||||
migrateOrders()
|
||||
const CustomNavbar = await import('./CustomNavbar.vue')
|
||||
const customNavbar: Vue & {
|
||||
styles: string[]
|
||||
|
179
registry/lib/components/style/custom-navbar/games/GamesPopup.vue
Normal file
179
registry/lib/components/style/custom-navbar/games/GamesPopup.vue
Normal file
@ -0,0 +1,179 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { VLoading } from '@/ui'
|
||||
import type { GameInfo } from './types'
|
||||
import { bilibiliApi, getJson } from '@/core/ajax'
|
||||
import { usePopper } from '../mixins'
|
||||
import { CustomNavbarItem } from '../custom-navbar-item'
|
||||
|
||||
const props = defineProps<{
|
||||
item: CustomNavbarItem
|
||||
container: HTMLElement
|
||||
}>()
|
||||
const popper = usePopper(props)
|
||||
|
||||
const gameInfo = ref<GameInfo | undefined>(undefined)
|
||||
const loading = ref(true)
|
||||
const loadGameInfo = async () => {
|
||||
if (gameInfo.value !== undefined) {
|
||||
return
|
||||
}
|
||||
const response = await bilibiliApi<GameInfo>(
|
||||
getJson('https://le3-api.game.bilibili.com/pc/game/nav/info'),
|
||||
)
|
||||
gameInfo.value = response
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
popupShow() {
|
||||
popper.popupShow()
|
||||
loadGameInfo()
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div class="custom-navbar-games-popup">
|
||||
<div v-if="loading" class="loading-container">
|
||||
<VLoading />
|
||||
</div>
|
||||
<div v-if="gameInfo" class="custom-navbar-games-panel">
|
||||
<div class="custom-navbar-games-left-panel">
|
||||
<a class="banner-game-card" :href="gameInfo.banner.href" :title="gameInfo.banner.title">
|
||||
<img :src="gameInfo.banner.poster" :alt="gameInfo.banner.title" />
|
||||
<div class="banner-game-card-title">
|
||||
{{ gameInfo.banner.title }}
|
||||
</div>
|
||||
</a>
|
||||
<div class="panel-game-cards">
|
||||
<a
|
||||
v-for="card of gameInfo.panel"
|
||||
:key="card.href"
|
||||
class="panel-game-card"
|
||||
:href="card.href"
|
||||
:title="card.title"
|
||||
>
|
||||
<img :src="card.poster" :alt="card.title" />
|
||||
<div class="panel-game-card-title">
|
||||
{{ card.title }}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="custom-navbar-games-panel-separator"></div>
|
||||
<div class="custom-navbar-games-right-panel">
|
||||
<div class="list-game-cards-title">新游预告</div>
|
||||
<div class="list-game-cards">
|
||||
<a
|
||||
v-for="card of gameInfo.list.slice(0, 8)"
|
||||
:key="card.href"
|
||||
class="list-game-card"
|
||||
:href="card.href"
|
||||
:title="card.title"
|
||||
>
|
||||
{{ card.title }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style lang="scss">
|
||||
@import 'common';
|
||||
|
||||
.custom-navbar-games-popup {
|
||||
width: 420px;
|
||||
font-size: 12px;
|
||||
$gap: 12px;
|
||||
@include v-stretch();
|
||||
.loading-container {
|
||||
flex-grow: 1;
|
||||
@include v-center();
|
||||
justify-content: center;
|
||||
height: 240px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.custom-navbar-games-panel {
|
||||
@include h-stretch($gap);
|
||||
padding: $gap;
|
||||
}
|
||||
.custom-navbar-games-left-panel {
|
||||
flex-basis: 60%;
|
||||
min-width: 0;
|
||||
@include v-stretch($gap);
|
||||
.banner-game-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background-color: #8882;
|
||||
@include v-stretch();
|
||||
@include round-corner();
|
||||
img {
|
||||
transition: transform 0.2s ease-out;
|
||||
aspect-ratio: 15 / 8;
|
||||
}
|
||||
&:hover img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
&-title {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
color: #fff;
|
||||
padding: 8px;
|
||||
background-image: linear-gradient(to top, #0008, transparent);
|
||||
}
|
||||
}
|
||||
.panel-game-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: $gap;
|
||||
.panel-game-card {
|
||||
@include v-stretch(6px);
|
||||
min-width: 0;
|
||||
transition: color 0.2s ease-out;
|
||||
img {
|
||||
background-color: #8882;
|
||||
border-radius: 16px;
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
&:hover {
|
||||
color: var(--theme-color) !important;
|
||||
}
|
||||
&-title {
|
||||
@include max-line(2);
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.custom-navbar-games-right-panel {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
@include v-stretch($gap);
|
||||
padding: 6px 4px;
|
||||
.list-game-cards-title {
|
||||
font-size: large;
|
||||
@include semi-bold();
|
||||
}
|
||||
.list-game-cards {
|
||||
@include v-stretch();
|
||||
.list-game-card {
|
||||
@include single-line();
|
||||
padding: 4px 0;
|
||||
opacity: 0.6;
|
||||
transition: all 0.2s ease-out;
|
||||
&:hover {
|
||||
color: var(--theme-color) !important;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.custom-navbar-games-panel-separator {
|
||||
width: 1px;
|
||||
background-color: #8882;
|
||||
}
|
||||
}
|
||||
</style>
|
14
registry/lib/components/style/custom-navbar/games/games.ts
Normal file
14
registry/lib/components/style/custom-navbar/games/games.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { CustomNavbarItemInit } from '../custom-navbar-item'
|
||||
|
||||
export const games: CustomNavbarItemInit = {
|
||||
name: 'games',
|
||||
displayName: '游戏中心',
|
||||
content: '游戏中心',
|
||||
|
||||
touch: true,
|
||||
href: 'https://game.bilibili.com/',
|
||||
|
||||
boundingWidth: 420,
|
||||
noPopupPadding: true,
|
||||
popupContent: () => import('./GamesPopup.vue').then(m => m.default),
|
||||
}
|
11
registry/lib/components/style/custom-navbar/games/types.ts
Normal file
11
registry/lib/components/style/custom-navbar/games/types.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export interface GameListItem {
|
||||
href: string
|
||||
title: string
|
||||
poster: string
|
||||
}
|
||||
|
||||
export interface GameInfo {
|
||||
panel: GameListItem[]
|
||||
list: GameListItem[]
|
||||
banner: GameListItem
|
||||
}
|
@ -1,21 +0,0 @@
|
||||
<template>
|
||||
<iframe :src="item.src" frameborder="0" :width="item.width" :height="item.height"></iframe>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import type { PropType } from 'vue'
|
||||
import type { NavbarIframeConfig } from './iframe'
|
||||
import { popperMixin } from '../mixins'
|
||||
import { CustomNavbarItem } from '../custom-navbar-item'
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'IframePopup',
|
||||
mixins: [popperMixin],
|
||||
props: {
|
||||
item: {
|
||||
type: CustomNavbarItem as unknown as PropType<CustomNavbarItem & NavbarIframeConfig>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
@ -1,50 +0,0 @@
|
||||
import { CustomNavbarItemInit } from '../custom-navbar-item'
|
||||
|
||||
export interface NavbarIframeConfig {
|
||||
src: string
|
||||
href: string
|
||||
width: number
|
||||
height: number
|
||||
lazy: boolean
|
||||
displayName: string
|
||||
iframeName: string
|
||||
}
|
||||
const getIframeItem = (config: NavbarIframeConfig): CustomNavbarItemInit & NavbarIframeConfig => ({
|
||||
...config,
|
||||
name: `${config.iframeName}Iframe`,
|
||||
content: config.displayName,
|
||||
|
||||
touch: true,
|
||||
|
||||
popupContent: () => import('./IframePopup.vue').then(m => m.default),
|
||||
boundingWidth: config.width,
|
||||
noPopupPadding: true,
|
||||
transparentPopup: true,
|
||||
})
|
||||
export const gamesIframe = getIframeItem({
|
||||
src: 'https://www.bilibili.com/page-proxy/game-nav.html',
|
||||
href: 'https://game.bilibili.com/',
|
||||
width: 680,
|
||||
height: 260,
|
||||
lazy: true,
|
||||
displayName: '游戏中心',
|
||||
iframeName: 'games',
|
||||
})
|
||||
export const livesIframe = getIframeItem({
|
||||
src: 'https://live.bilibili.com/blackboard/dropdown-menu.html',
|
||||
href: 'https://live.bilibili.com',
|
||||
width: 528,
|
||||
height: 266,
|
||||
lazy: true,
|
||||
displayName: '直播',
|
||||
iframeName: 'lives',
|
||||
})
|
||||
export const mangaIframe = getIframeItem({
|
||||
src: 'https://manga.bilibili.com/eden/bilibili-nav-panel.html',
|
||||
href: 'https://manga.bilibili.com',
|
||||
width: 720,
|
||||
height: 266,
|
||||
lazy: true,
|
||||
displayName: '漫画',
|
||||
iframeName: 'manga',
|
||||
})
|
@ -58,7 +58,11 @@ const options = defineOptionsMetadata({
|
||||
},
|
||||
seasonLogo: {
|
||||
defaultValue: false,
|
||||
displayName: '使用季节Logo',
|
||||
displayName: '使用季节 Logo',
|
||||
},
|
||||
themeLogo: {
|
||||
defaultValue: true,
|
||||
displayName: '使用主题色 Logo',
|
||||
},
|
||||
touch: {
|
||||
defaultValue: false,
|
||||
|
@ -1,6 +1,11 @@
|
||||
<template>
|
||||
<div class="custom-navbar-logo-container">
|
||||
<VIcon v-if="!seasonLogoUrl" icon="logo" class="custom-navbar-logo"></VIcon>
|
||||
<VIcon
|
||||
v-if="!seasonLogoUrl"
|
||||
icon="logo"
|
||||
class="custom-navbar-logo"
|
||||
:class="{ theme: useThemeColor }"
|
||||
></VIcon>
|
||||
<div
|
||||
v-else
|
||||
class="custom-navbar-logo season"
|
||||
@ -22,6 +27,7 @@ export default Vue.extend({
|
||||
data() {
|
||||
return {
|
||||
seasonLogoUrl: '',
|
||||
useThemeColor: true,
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@ -46,6 +52,13 @@ export default Vue.extend({
|
||||
},
|
||||
true,
|
||||
)
|
||||
addComponentListener(
|
||||
'customNavbar.themeLogo',
|
||||
async (value: boolean) => {
|
||||
this.useThemeColor = value
|
||||
},
|
||||
true,
|
||||
)
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@ -63,9 +76,15 @@ export default Vue.extend({
|
||||
width: calc(var(--navbar-height) * 1.3);
|
||||
}
|
||||
.custom-navbar-logo {
|
||||
.custom-navbar:not(.fill) & {
|
||||
&:not(.season) {
|
||||
.custom-navbar:not(.fill) &:not(.season) {
|
||||
body.dark & {
|
||||
color: #fff;
|
||||
}
|
||||
&.theme {
|
||||
color: var(--theme-color);
|
||||
body.dark & {
|
||||
color: var(--theme-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
&.be-icon {
|
||||
|
231
registry/lib/components/style/custom-navbar/manga/MangaPopup.vue
Normal file
231
registry/lib/components/style/custom-navbar/manga/MangaPopup.vue
Normal file
@ -0,0 +1,231 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { VLoading } from '@/ui'
|
||||
import { bilibiliApi } from '@/core/ajax'
|
||||
import { CustomNavbarItem } from '../custom-navbar-item'
|
||||
import { usePopper } from '../mixins'
|
||||
import type { MangaRecommendItem, MangaHotItem } from './types'
|
||||
|
||||
const props = defineProps<{
|
||||
item: CustomNavbarItem
|
||||
container: HTMLElement
|
||||
}>()
|
||||
const popper = usePopper(props)
|
||||
|
||||
const loading = ref(true)
|
||||
const recommendItems = ref<MangaRecommendItem[] | undefined>(undefined)
|
||||
const hotItems = ref<MangaHotItem[] | undefined>(undefined)
|
||||
const previewCard = ref<MangaHotItem | undefined>(undefined)
|
||||
const loadRecommendItems = async () => {
|
||||
if (recommendItems.value !== undefined) {
|
||||
return
|
||||
}
|
||||
recommendItems.value = await bilibiliApi<MangaRecommendItem[]>(
|
||||
fetch('https://manga.bilibili.com/twirp/comic.v1.Comic/Recommend?device=pc&platform=web', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
JsonData: JSON.stringify([
|
||||
{
|
||||
pool_id: 500003,
|
||||
num: 4,
|
||||
},
|
||||
]),
|
||||
}),
|
||||
}).then(r => r.json()),
|
||||
)
|
||||
}
|
||||
const loadHotItems = async () => {
|
||||
if (hotItems.value !== undefined) {
|
||||
return
|
||||
}
|
||||
hotItems.value = await bilibiliApi<MangaHotItem[]>(
|
||||
fetch('https://manga.bilibili.com/twirp/comic.v1.Comic/HomeHot?device=pc&platform=web', {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ type: 0 }),
|
||||
method: 'POST',
|
||||
}).then(r => r.json()),
|
||||
)
|
||||
}
|
||||
const loadMangaInfo = async () => {
|
||||
await Promise.all([loadRecommendItems(), loadHotItems()])
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
popupShow() {
|
||||
popper.popupShow()
|
||||
loadMangaInfo()
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div class="custom-navbar-manga-popup">
|
||||
<div v-if="loading" class="loading-container">
|
||||
<VLoading />
|
||||
</div>
|
||||
<div v-if="!loading" class="custom-navbar-manga-panel">
|
||||
<div class="custom-navbar-manga-left-panel">
|
||||
<a
|
||||
v-for="card of recommendItems"
|
||||
:key="card.season_id"
|
||||
class="recommend-manga-card"
|
||||
:href="'https://manga.bilibili.com/detail/mc' + card.season_id"
|
||||
>
|
||||
<div class="recommend-manga-card-image">
|
||||
<img :src="card.horizontal_cover + '@300w.jpg'" :alt="card.title" />
|
||||
</div>
|
||||
<div class="recommend-manga-card-title" :title="card.title">{{ card.title }}</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="custom-navbar-manga-panel-separator"></div>
|
||||
<div class="custom-navbar-manga-right-panel">
|
||||
<div class="custom-navbar-manga-hot-title">人气漫画</div>
|
||||
<div class="custom-navbar-manga-hot-list">
|
||||
<a
|
||||
v-for="card of hotItems.slice(0, 7)"
|
||||
:key="card.comic_id"
|
||||
class="hot-manga-card"
|
||||
:title="card.title"
|
||||
:href="'https://manga.bilibili.com/detail/mc' + card.comic_id"
|
||||
@mouseenter="previewCard = card"
|
||||
@mouseleave="previewCard = undefined"
|
||||
>
|
||||
<div class="hot-manga-card-title">
|
||||
{{ card.title }}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div v-if="previewCard" class="hot-manga-card-preview">
|
||||
<img :src="previewCard.vertical_cover + '@450h.jpg'" :alt="previewCard.title" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style lang="scss">
|
||||
@import 'common';
|
||||
|
||||
.custom-navbar-manga-popup {
|
||||
width: 500px;
|
||||
font-size: 12px;
|
||||
$gap: 12px;
|
||||
@include v-stretch();
|
||||
.loading-container {
|
||||
flex-grow: 1;
|
||||
@include v-center();
|
||||
justify-content: center;
|
||||
height: 240px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.custom-navbar-manga-panel {
|
||||
@include h-stretch($gap);
|
||||
padding: $gap;
|
||||
.custom-navbar-manga-left-panel {
|
||||
min-width: 0;
|
||||
flex: 0 0 65%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: $gap;
|
||||
.recommend-manga-card {
|
||||
@include v-stretch(8px);
|
||||
.recommend-manga-card-image {
|
||||
@include v-stretch();
|
||||
@include round-corner();
|
||||
background: #8882;
|
||||
aspect-ratio: 16 / 9;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
&:hover img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
&-title {
|
||||
@include single-line();
|
||||
}
|
||||
&:hover {
|
||||
color: var(--theme-color) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
.custom-navbar-manga-right-panel {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
@include v-stretch($gap);
|
||||
padding: 6px 4px;
|
||||
.custom-navbar-manga-hot-title {
|
||||
font-size: large;
|
||||
@include semi-bold();
|
||||
}
|
||||
.custom-navbar-manga-hot-list {
|
||||
@include v-stretch();
|
||||
counter-reset: manga-item;
|
||||
.hot-manga-card {
|
||||
@include h-center(6px);
|
||||
padding: 4px 0;
|
||||
&-title {
|
||||
@include single-line();
|
||||
transition: color 0.2s ease-out, opacity 0.2s ease-out;
|
||||
opacity: 0.6;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
flex: 1 0 0;
|
||||
}
|
||||
&::before {
|
||||
counter-increment: manga-item;
|
||||
content: counter(manga-item);
|
||||
@include semi-bold();
|
||||
opacity: 0.6;
|
||||
font-style: italic;
|
||||
font-size: 14px;
|
||||
}
|
||||
&:hover .hot-manga-card-title {
|
||||
color: var(--theme-color) !important;
|
||||
opacity: 1;
|
||||
}
|
||||
&:nth-child(1)::before {
|
||||
opacity: 1;
|
||||
color: #e64747;
|
||||
}
|
||||
&:nth-child(2)::before {
|
||||
opacity: 1;
|
||||
color: #ff6e42;
|
||||
}
|
||||
&:nth-child(3)::before {
|
||||
opacity: 1;
|
||||
color: #ff9900;
|
||||
}
|
||||
}
|
||||
}
|
||||
.hot-manga-card-preview {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
top: calc(-1 * #{$gap});
|
||||
right: calc(100% + #{$gap} + 1px);
|
||||
height: calc(100% - #{$gap});
|
||||
background-image: linear-gradient(to left, #fff 75%, transparent);
|
||||
padding: 18px 18px 18px 60px;
|
||||
body.dark & {
|
||||
background-image: linear-gradient(to left, #222 75%, transparent);
|
||||
}
|
||||
img {
|
||||
display: flex;
|
||||
background: #8882;
|
||||
height: 100%;
|
||||
aspect-ratio: 169 / 225;
|
||||
@include round-corner();
|
||||
}
|
||||
}
|
||||
}
|
||||
.custom-navbar-manga-panel-separator {
|
||||
width: 1px;
|
||||
background-color: #8882;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
14
registry/lib/components/style/custom-navbar/manga/manga.ts
Normal file
14
registry/lib/components/style/custom-navbar/manga/manga.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { CustomNavbarItemInit } from '../custom-navbar-item'
|
||||
|
||||
export const manga: CustomNavbarItemInit = {
|
||||
name: 'manga',
|
||||
displayName: '漫画',
|
||||
content: '漫画',
|
||||
|
||||
touch: true,
|
||||
href: 'https://manga.bilibili.com/',
|
||||
|
||||
boundingWidth: 500,
|
||||
noPopupPadding: true,
|
||||
popupContent: () => import('./MangaPopup.vue').then(m => m.default),
|
||||
}
|
52
registry/lib/components/style/custom-navbar/manga/types.ts
Normal file
52
registry/lib/components/style/custom-navbar/manga/types.ts
Normal file
@ -0,0 +1,52 @@
|
||||
export interface MangaRecommendItem {
|
||||
season_id: number
|
||||
title: string
|
||||
horizontal_cover: string
|
||||
square_cover: string
|
||||
vertical_cover: string
|
||||
is_finish: number
|
||||
status: number
|
||||
last_ord: number
|
||||
total: number
|
||||
release_time: string
|
||||
last_short_title: string
|
||||
ext: string
|
||||
recommend_reason: string
|
||||
discount_type: number
|
||||
allow_wait_free: boolean
|
||||
styles: string[]
|
||||
tags: string[]
|
||||
rank_info: {
|
||||
name: string
|
||||
rank: number
|
||||
}
|
||||
score: number
|
||||
update_day: number
|
||||
good_review: number
|
||||
search_num: number
|
||||
author_name: string[]
|
||||
rec_tag_id: number
|
||||
rec_tag: string
|
||||
rec_tag_type: number
|
||||
vertical_cover_info: {
|
||||
cover_id: string
|
||||
cover_type: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface MangaHotItem {
|
||||
comic_id: number
|
||||
title: string
|
||||
author: string[]
|
||||
vertical_cover: string
|
||||
is_finish: number
|
||||
last_ord: number
|
||||
last_short_title: string
|
||||
styles: {
|
||||
id: number
|
||||
name: string
|
||||
}[]
|
||||
total: number
|
||||
last_rank: number
|
||||
last_ep: number
|
||||
}
|
252
registry/lib/components/style/custom-navbar/match/MatchPopup.vue
Normal file
252
registry/lib/components/style/custom-navbar/match/MatchPopup.vue
Normal file
@ -0,0 +1,252 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { VLoading, VButton } from '@/ui'
|
||||
import { bilibiliApi, getJsonWithCredentials, postTextWithCredentials } from '@/core/ajax'
|
||||
import { CustomNavbarItem } from '../custom-navbar-item'
|
||||
import { usePopper } from '../mixins'
|
||||
import { type MatchInfo, type MatchPreviewItem, MatchHotItemStatus } from './types'
|
||||
import { getCsrf } from '@/core/utils'
|
||||
import { logError } from '@/core/utils/log'
|
||||
|
||||
const props = defineProps<{
|
||||
item: CustomNavbarItem
|
||||
container: HTMLElement
|
||||
}>()
|
||||
const popper = usePopper(props)
|
||||
|
||||
const loading = ref(true)
|
||||
const matchInfo = ref<MatchInfo | undefined>(undefined)
|
||||
const bannerItems = computed(() => matchInfo.value?.banner)
|
||||
const hotItems = computed(() => matchInfo.value?.hot)
|
||||
const previewItems = computed(() => matchInfo.value?.preview)
|
||||
const loadMatchInfo = async () => {
|
||||
matchInfo.value = reactive(
|
||||
await bilibiliApi<MatchInfo>(
|
||||
getJsonWithCredentials('https://api.bilibili.com/x/web-interface/nav/game'),
|
||||
),
|
||||
)
|
||||
loading.value = false
|
||||
}
|
||||
const toggleSubscribe = async (card: MatchPreviewItem) => {
|
||||
const isSubscribed = card.fav_status !== 0
|
||||
try {
|
||||
await postTextWithCredentials(
|
||||
isSubscribed
|
||||
? 'https://api.bilibili.com/x/esports/fav/del'
|
||||
: 'https://api.bilibili.com/x/esports/fav/add',
|
||||
new URLSearchParams({
|
||||
cid: card.cid.toString(),
|
||||
csrf: getCsrf(),
|
||||
}).toString(),
|
||||
)
|
||||
if (isSubscribed) {
|
||||
card.fav_status = 0
|
||||
} else {
|
||||
card.fav_status = 1
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error)
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
popupShow() {
|
||||
popper.popupShow()
|
||||
loadMatchInfo()
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div class="custom-navbar-match-popup">
|
||||
<div v-if="loading" class="loading-container">
|
||||
<VLoading />
|
||||
</div>
|
||||
<div v-if="!loading" class="custom-navbar-match-panel">
|
||||
<div class="custom-navbar-match-left-panel">
|
||||
<a
|
||||
v-for="card of bannerItems"
|
||||
:key="card.jump_url"
|
||||
class="banner-match-card"
|
||||
:href="card.jump_url"
|
||||
>
|
||||
<img :src="card.cover" />
|
||||
</a>
|
||||
</div>
|
||||
<div class="custom-navbar-match-panel-separator"></div>
|
||||
<div class="custom-navbar-match-right-panel">
|
||||
<div class="hot-match-list">
|
||||
<div class="hot-match-list-title">热门赛事</div>
|
||||
<a
|
||||
v-for="card of hotItems"
|
||||
:key="card.jump_url"
|
||||
:href="card.jump_url"
|
||||
:title="card.name"
|
||||
class="hot-match-card"
|
||||
>
|
||||
<div
|
||||
class="hot-match-card-status"
|
||||
:class="{ 'in-progress': card.status === MatchHotItemStatus.InProgress }"
|
||||
>
|
||||
<template v-if="card.status === MatchHotItemStatus.NotStarted">未开始</template>
|
||||
<template v-if="card.status === MatchHotItemStatus.InProgress">进行中</template>
|
||||
<template v-if="card.status === MatchHotItemStatus.Finished">已结束</template>
|
||||
</div>
|
||||
<div class="hot-match-card-title">
|
||||
{{ card.name }}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="preview-match-list">
|
||||
<div class="preview-match-list-title">精彩预告</div>
|
||||
<a
|
||||
v-for="card of previewItems"
|
||||
:key="card.jump_url"
|
||||
:href="card.jump_url"
|
||||
:title="card.name"
|
||||
class="preview-match-card"
|
||||
>
|
||||
<!-- spell-checker: disable-next-line -->
|
||||
<div class="preview-match-date">{{ card.stime }}</div>
|
||||
<div class="preview-match-title">{{ card.name }}</div>
|
||||
<VButton
|
||||
class="preview-match-subscribe"
|
||||
:type="card.fav_status === 0 ? 'primary' : 'light'"
|
||||
@click.prevent="toggleSubscribe(card)"
|
||||
>
|
||||
{{ card.fav_status === 0 ? '订阅' : '已订阅' }}
|
||||
</VButton>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style lang="scss">
|
||||
@import 'common';
|
||||
|
||||
.custom-navbar-match-popup {
|
||||
width: 650px;
|
||||
font-size: 12px;
|
||||
$gap: 12px;
|
||||
@include v-stretch();
|
||||
|
||||
.loading-container {
|
||||
flex-grow: 1;
|
||||
@include v-center();
|
||||
justify-content: center;
|
||||
height: 240px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.custom-navbar-match-panel {
|
||||
@include h-stretch($gap);
|
||||
padding: $gap;
|
||||
.custom-navbar-match-left-panel {
|
||||
min-width: 0;
|
||||
flex: 0 0 45%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: $gap;
|
||||
.banner-match-card {
|
||||
overflow: hidden;
|
||||
@include round-corner(6px);
|
||||
&:first-child {
|
||||
grid-column: 1 / 3;
|
||||
}
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
background: #8882;
|
||||
aspect-ratio: 16 / 9;
|
||||
transition: transform 0.2s ease-out;
|
||||
}
|
||||
&:hover img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
.custom-navbar-match-panel-separator {
|
||||
width: 1px;
|
||||
background-color: #8882;
|
||||
}
|
||||
.custom-navbar-match-right-panel {
|
||||
@include v-stretch($gap);
|
||||
justify-content: space-between;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
padding: 6px 4px;
|
||||
|
||||
.hot-match-list-title,
|
||||
.preview-match-list-title {
|
||||
font-size: large;
|
||||
@include semi-bold();
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.hot-match-list,
|
||||
.preview-match-list {
|
||||
@include v-stretch();
|
||||
counter-reset: match-item;
|
||||
}
|
||||
.hot-match-card {
|
||||
@include h-center(6px);
|
||||
padding: 5px 0;
|
||||
&-status {
|
||||
font-size: 11px;
|
||||
background-color: #8882;
|
||||
padding: 1px 4px;
|
||||
border-radius: 4px;
|
||||
&.in-progress {
|
||||
background-color: var(--theme-color);
|
||||
color: var(--foreground-color);
|
||||
}
|
||||
}
|
||||
&-title {
|
||||
@include single-line();
|
||||
transition: color 0.2s ease-out;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
flex: 1 0 0;
|
||||
}
|
||||
&::before {
|
||||
counter-increment: match-item;
|
||||
content: counter(match-item);
|
||||
@include semi-bold();
|
||||
font-style: italic;
|
||||
font-size: 14px;
|
||||
}
|
||||
&:hover .hot-match-card-title {
|
||||
color: var(--theme-color) !important;
|
||||
}
|
||||
&:nth-child(2)::before {
|
||||
color: #e64747;
|
||||
}
|
||||
&:nth-child(3)::before {
|
||||
color: #ff6e42;
|
||||
}
|
||||
&:nth-child(4)::before {
|
||||
color: #ff9900;
|
||||
}
|
||||
}
|
||||
.preview-match-card {
|
||||
@include h-center(4px);
|
||||
padding: 5px 0;
|
||||
.preview-match-date {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.preview-match-title {
|
||||
flex: 1 0 0;
|
||||
@include single-line();
|
||||
}
|
||||
.preview-match-subscribe.be-button {
|
||||
padding: 2px 6px;
|
||||
font-size: 11px;
|
||||
}
|
||||
&:hover .preview-match-title {
|
||||
color: var(--theme-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
14
registry/lib/components/style/custom-navbar/match/match.ts
Normal file
14
registry/lib/components/style/custom-navbar/match/match.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { CustomNavbarItemInit } from '../custom-navbar-item'
|
||||
|
||||
export const match: CustomNavbarItemInit = {
|
||||
name: 'match',
|
||||
displayName: '赛事',
|
||||
content: '赛事',
|
||||
|
||||
touch: true,
|
||||
href: 'https://www.bilibili.com/v/game/match/',
|
||||
|
||||
boundingWidth: 650,
|
||||
noPopupPadding: true,
|
||||
popupContent: () => import('./MatchPopup.vue').then(m => m.default),
|
||||
}
|
37
registry/lib/components/style/custom-navbar/match/types.ts
Normal file
37
registry/lib/components/style/custom-navbar/match/types.ts
Normal file
@ -0,0 +1,37 @@
|
||||
export interface MatchInfo {
|
||||
hot: MatchHotItem[]
|
||||
banner: MatchBannerItem[]
|
||||
preview: MatchPreviewItem[]
|
||||
}
|
||||
|
||||
export enum MatchHotItemStatus {
|
||||
NotStarted = 1,
|
||||
InProgress,
|
||||
Finished,
|
||||
}
|
||||
|
||||
export interface MatchHotItem {
|
||||
index: number
|
||||
cid: number
|
||||
desc: string
|
||||
status: MatchHotItemStatus
|
||||
name: string
|
||||
jump_url: string
|
||||
}
|
||||
|
||||
export interface MatchBannerItem {
|
||||
index: number
|
||||
cover: string
|
||||
jump_url: string
|
||||
}
|
||||
|
||||
export interface MatchPreviewItem {
|
||||
index: number
|
||||
cid: number
|
||||
name: string
|
||||
desc: string
|
||||
fav_status: number
|
||||
jump_url: string
|
||||
// spell-checker: disable-next-line
|
||||
stime: string
|
||||
}
|
@ -1,3 +1,4 @@
|
||||
import { onMounted } from 'vue'
|
||||
import { CustomNavbarItem } from './custom-navbar-item'
|
||||
|
||||
export const popperMixin = Vue.extend({
|
||||
@ -25,3 +26,23 @@ export const popperMixin = Vue.extend({
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export interface UsePopperProps {
|
||||
item: CustomNavbarItem
|
||||
container: HTMLElement
|
||||
}
|
||||
export const usePopper = (props: UsePopperProps) => {
|
||||
onMounted(() => {
|
||||
const navBarItem = props.item
|
||||
const containerElement = props.container
|
||||
if (containerElement) {
|
||||
navBarItem?.usePopper(containerElement, containerElement.children[0] as HTMLElement)
|
||||
}
|
||||
})
|
||||
return {
|
||||
popupShow() {
|
||||
const navBarItem = props.item
|
||||
navBarItem?.popper?.update()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,19 @@
|
||||
import { getComponentSettings } from '@/core/settings'
|
||||
import type { CustomNavbarOptions } from '.'
|
||||
|
||||
export const migrateOrders = () => {
|
||||
const migrations = [
|
||||
['gamesIframe', 'games'],
|
||||
['livesIframe', 'lives'],
|
||||
['mangaIframe', 'manga'],
|
||||
]
|
||||
|
||||
const { options } = getComponentSettings<CustomNavbarOptions>('customNavbar')
|
||||
migrations.forEach(([from, to]) => {
|
||||
const oldOrder = options.order[from]
|
||||
if (oldOrder !== undefined) {
|
||||
options.order[to] = oldOrder
|
||||
delete options.order[from]
|
||||
}
|
||||
})
|
||||
}
|
@ -32,13 +32,13 @@ export const shop = getSimpleLinkItem({
|
||||
displayName: '会员购',
|
||||
href: 'https://show.bilibili.com',
|
||||
})
|
||||
export const match = getSimpleLinkItem({
|
||||
name: 'match',
|
||||
displayName: '赛事',
|
||||
href: 'https://www.bilibili.com/v/game/match/',
|
||||
})
|
||||
export const creations = getSimpleLinkItem({
|
||||
name: 'creations',
|
||||
displayName: '创作中心',
|
||||
href: 'https://member.bilibili.com/platform/home',
|
||||
})
|
||||
export const lives = getSimpleLinkItem({
|
||||
name: 'lives',
|
||||
displayName: '直播',
|
||||
href: 'https://live.bilibili.com',
|
||||
})
|
||||
|
@ -1,9 +1,8 @@
|
||||
.search-panel {
|
||||
.search-panel, .search-pannel {
|
||||
|
||||
> .trending {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
&:not(:has(.history)) {
|
||||
padding: 0 !important;
|
||||
border: none !important;
|
||||
|
@ -9,6 +9,26 @@ const resetPlaceholder = (input: HTMLInputElement) => {
|
||||
input.title = DEFAULT_PLACEHOLDER
|
||||
}
|
||||
|
||||
const isInLiveRoomPage = () => {
|
||||
return window.location.host === 'live.bilibili.com'
|
||||
}
|
||||
|
||||
const getSearchInputClassName = () => {
|
||||
return isInLiveRoomPage() ? 'input.nav-search-content' : 'input.nav-search-input'
|
||||
}
|
||||
|
||||
const isSearchInput = (target: Node) => {
|
||||
if (!(target instanceof HTMLInputElement)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const isSearch = ['nav-search-content', 'nav-search-input'].some(n =>
|
||||
target.classList.contains(n),
|
||||
)
|
||||
|
||||
return target.placeholder !== DEFAULT_PLACEHOLDER && isSearch
|
||||
}
|
||||
|
||||
export const component = defineComponentMetadata({
|
||||
name: 'hideTrendingSearch',
|
||||
displayName: '隐藏热搜',
|
||||
@ -20,7 +40,8 @@ export const component = defineComponentMetadata({
|
||||
},
|
||||
],
|
||||
entry: async () => {
|
||||
const input: HTMLInputElement = await select('input.nav-search-input', {
|
||||
const clsName = getSearchInputClassName()
|
||||
const input: HTMLInputElement = await select(clsName, {
|
||||
queryInterval: 500,
|
||||
})
|
||||
|
||||
@ -32,12 +53,8 @@ export const component = defineComponentMetadata({
|
||||
// Fallback to observer
|
||||
allMutations(records => {
|
||||
records.forEach(record => {
|
||||
if (
|
||||
record.target instanceof HTMLInputElement &&
|
||||
record.target.classList.contains('nav-search-input') &&
|
||||
record.target.placeholder !== DEFAULT_PLACEHOLDER
|
||||
) {
|
||||
resetPlaceholder(record.target)
|
||||
if (isSearchInput(record.target)) {
|
||||
resetPlaceholder(record.target as HTMLInputElement)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
@ -0,0 +1,5 @@
|
||||
这个组件会将视频简介中的普通网址转换为可点击的链接,并将<del>被 Bilibili 抛弃</del>已失效的 `acg.tv` 跳转链接修复为 `nicovideo.jp` 链接。
|
||||
|
||||
例如: `https://acg.tv/sm37507315` → `https://www.nicovideo.jp/watch/sm37507315`
|
||||
|
||||
<i>本组件不会保证目标链接的安全性,因此在点击前请自行验证其是否可信</i>
|
151
registry/lib/components/utils/active-video-links/index.ts
Normal file
151
registry/lib/components/utils/active-video-links/index.ts
Normal file
@ -0,0 +1,151 @@
|
||||
import { defineComponentMetadata } from '@/components/define'
|
||||
import { useScopedConsole } from '@/core/utils/log'
|
||||
import { videoUrls } from '@/core/utils/urls'
|
||||
|
||||
const console = useScopedConsole('activeVideoLinks')
|
||||
let observer: MutationObserver | null = null
|
||||
const webRegex = /(?<!(\>|'|"|\/))(http:\/\/|https:\/\/|www\.)[^(\s,;())]+/g
|
||||
|
||||
const processAcgLinks = () => {
|
||||
const links = document.querySelectorAll('a[href*="acg.tv"][href*="sm"]')
|
||||
links.forEach(link => {
|
||||
const originalHref = link.getAttribute('href')
|
||||
if (originalHref) {
|
||||
const newHref = originalHref.replace('acg.tv', 'nicovideo.jp/watch')
|
||||
link.setAttribute('href', newHref)
|
||||
console.log(`Niconico Fix: ${originalHref} → ${newHref}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const processDescLinks = () => {
|
||||
const descContainer = document.querySelector('.desc-info-text')
|
||||
if (!descContainer) {
|
||||
return
|
||||
}
|
||||
const walker = document.createTreeWalker(descContainer, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode: node => {
|
||||
if (node.parentElement?.closest('a')) {
|
||||
return NodeFilter.FILTER_REJECT
|
||||
}
|
||||
return webRegex.test(node.textContent || '')
|
||||
? NodeFilter.FILTER_ACCEPT
|
||||
: NodeFilter.FILTER_REJECT
|
||||
},
|
||||
})
|
||||
const textNodes: Text[] = []
|
||||
let currentNode: Node | null = walker.nextNode()
|
||||
while (currentNode !== null) {
|
||||
textNodes.push(currentNode as Text)
|
||||
currentNode = walker.nextNode()
|
||||
}
|
||||
textNodes.forEach(textNode => {
|
||||
const frag = document.createDocumentFragment()
|
||||
const text = textNode.textContent || ''
|
||||
let lastIndex = 0
|
||||
let match: RegExpExecArray | null
|
||||
webRegex.lastIndex = 0
|
||||
match = webRegex.exec(text)
|
||||
while (match !== null) {
|
||||
const start = match.index
|
||||
if (lastIndex < start) {
|
||||
frag.appendChild(document.createTextNode(text.slice(lastIndex, start)))
|
||||
}
|
||||
const urlText = match[0]
|
||||
const linkHref = urlText.replace(/^https?:\/\//, '//').replace(/^www\./, '//')
|
||||
const a = document.createElement('a')
|
||||
a.href = linkHref
|
||||
a.target = '_blank'
|
||||
a.textContent = urlText
|
||||
frag.appendChild(a)
|
||||
lastIndex = start + urlText.length
|
||||
match = webRegex.exec(text)
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
frag.appendChild(document.createTextNode(text.slice(lastIndex)))
|
||||
}
|
||||
textNode.parentNode?.replaceChild(frag, textNode)
|
||||
})
|
||||
}
|
||||
|
||||
const normalizeNicoDescLinks = () => {
|
||||
const descContainer = document.querySelector('.desc-info-text')
|
||||
if (!descContainer) {
|
||||
return
|
||||
}
|
||||
const anchors = Array.from(descContainer.querySelectorAll('a'))
|
||||
for (let i = 0; i < anchors.length - 1; i++) {
|
||||
const a1 = anchors[i]
|
||||
const a2 = anchors[i + 1]
|
||||
const href1 = a1.getAttribute('href') || ''
|
||||
const text2 = a2.textContent?.trim() || ''
|
||||
const href2 = a2.getAttribute('href') || ''
|
||||
// first link is base watch URL, second has sm ID text
|
||||
if (
|
||||
/^(https?:)?\/\/(www\.)?nicovideo\.jp\/watch\/?$/.test(href1) &&
|
||||
/^sm\d+$/.test(text2) &&
|
||||
href2.includes(`/watch/${text2}`)
|
||||
) {
|
||||
const newHref = href2.replace(/^(https?:)?/, '//')
|
||||
const newA = document.createElement('a')
|
||||
newA.href = newHref
|
||||
newA.target = '_blank'
|
||||
newA.textContent = text2
|
||||
a1.replaceWith(newA)
|
||||
a2.remove()
|
||||
console.log(`Merged Nico link: ${text2}`)
|
||||
// skip the next element
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const processLinks = () => {
|
||||
try {
|
||||
processAcgLinks()
|
||||
processDescLinks()
|
||||
normalizeNicoDescLinks()
|
||||
} catch (error) {
|
||||
console.error('处理链接时遇到 Error:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const setupObservers = () => {
|
||||
if (observer) {
|
||||
observer.disconnect()
|
||||
}
|
||||
|
||||
observer = new MutationObserver(processLinks)
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
})
|
||||
|
||||
console.log('已设置 Observer')
|
||||
}
|
||||
|
||||
const entry = () => {
|
||||
console.log('视频链接增强已启用')
|
||||
processLinks()
|
||||
setupObservers()
|
||||
}
|
||||
|
||||
export const component = defineComponentMetadata({
|
||||
name: 'activeVideoLinks',
|
||||
displayName: '视频链接增强',
|
||||
tags: [componentsTags.utils],
|
||||
entry,
|
||||
reload: entry,
|
||||
unload: () => {
|
||||
if (observer) {
|
||||
observer.disconnect()
|
||||
observer = null
|
||||
}
|
||||
},
|
||||
urlInclude: videoUrls,
|
||||
author: {
|
||||
name: 'Alan Ye',
|
||||
link: 'https://github.com/at-wr',
|
||||
},
|
||||
})
|
64
registry/lib/components/utils/download-emoticons/Widget.vue
Normal file
64
registry/lib/components/utils/download-emoticons/Widget.vue
Normal file
@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<DefaultWidget
|
||||
:disabled="downloading"
|
||||
name="下载up主表情包"
|
||||
icon="mdi-emoticon-outline"
|
||||
@click="download()"
|
||||
></DefaultWidget>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { DefaultWidget } from '@/ui'
|
||||
import { logError } from '@/core/utils/log'
|
||||
import { Emoticons } from './emoticons'
|
||||
import { DownloadPackage } from '@/core/download'
|
||||
import { Toast } from '@/core/toast'
|
||||
|
||||
const downloader = new Emoticons()
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
DefaultWidget,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
downloading: false,
|
||||
midRegex: /mid=(\d+)/,
|
||||
roomIdRegex: /^https:\/\/live\.bilibili\.com\/(\d+)(?:\?.*)?$/,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async download() {
|
||||
this.downloading = true
|
||||
try {
|
||||
const url = window.location.href
|
||||
if (url.startsWith('https://live.bilibili.com/')) {
|
||||
const rid = this.roomIdRegex.exec(url)[1]
|
||||
const emoticonsArray = await downloader.getEmoticonsArray(rid)
|
||||
if (emoticonsArray.length === 0) {
|
||||
Toast.info('该up主没有专属表情包', '表情包下载')
|
||||
return
|
||||
}
|
||||
const downloadPromises = await downloader.downloadEmoticons(emoticonsArray)
|
||||
const results = await downloader.batchDownload(downloadPromises)
|
||||
const dpackage = new DownloadPackage()
|
||||
dpackage.noEscape = true
|
||||
for (const result of results) {
|
||||
if (result !== null) {
|
||||
for (const emoticon of result.emoticons) {
|
||||
dpackage.add(`${result.pkg_name}/${emoticon.emoji}.png`, emoticon.blob)
|
||||
}
|
||||
}
|
||||
}
|
||||
await dpackage.emit(`emoticons-${rid}.zip`)
|
||||
}
|
||||
} catch (error) {
|
||||
Toast.error('下载失败详情查看控制台', '表情包下载')
|
||||
logError(error)
|
||||
} finally {
|
||||
this.downloading = false
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
106
registry/lib/components/utils/download-emoticons/emoticons.ts
Normal file
106
registry/lib/components/utils/download-emoticons/emoticons.ts
Normal file
@ -0,0 +1,106 @@
|
||||
import { monkey } from '@/core/ajax'
|
||||
import { useScopedConsole } from '@/core/utils/log'
|
||||
|
||||
const logger = useScopedConsole('表情包下载')
|
||||
|
||||
interface DownloadPromise {
|
||||
pkg_name: string // 表情包名称
|
||||
emoticons: { emoji: string; blob: Promise<Blob> }[]
|
||||
}
|
||||
|
||||
export class Emoticons {
|
||||
private async getEmoticonsByRoomId(roomId: string) {
|
||||
const response = await this.apiRequest(
|
||||
`https://api.live.bilibili.com/xlive/web-ucenter/v2/emoticon/GetEmoticons?platform=pc&room_id=${roomId}`,
|
||||
)
|
||||
return response
|
||||
}
|
||||
async downloadEmoticons(emoticonsArray) {
|
||||
const downloadPromises: DownloadPromise[] = emoticonsArray.map(obj => {
|
||||
const emoticons = obj.emoticons.map(item => {
|
||||
try {
|
||||
const blob = this.getImageBlob(item.url)
|
||||
return {
|
||||
emoji: item.emoji,
|
||||
blob,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`下载表情 ${item.emoji} 失败:`, error)
|
||||
return null
|
||||
}
|
||||
})
|
||||
return {
|
||||
emoticons,
|
||||
pkg_name: obj.pkg_name,
|
||||
}
|
||||
})
|
||||
return downloadPromises
|
||||
}
|
||||
|
||||
async getEmoticonsArray(roomId: string): Promise<Array<any>> {
|
||||
try {
|
||||
let response = await this.getEmoticonsByRoomId(roomId)
|
||||
if (response.data.data.length === 0) {
|
||||
logger.log('获取表情包失败,尝试获取真实roomId')
|
||||
const realRoomId = await this.getRealRoomId(roomId)
|
||||
response = await this.getEmoticonsByRoomId(realRoomId)
|
||||
}
|
||||
return response.data.data.slice(2)
|
||||
} catch (error) {
|
||||
throw new Error('获取表情包失败')
|
||||
}
|
||||
}
|
||||
|
||||
async batchDownload(tasks: DownloadPromise[]) {
|
||||
const results: any[] = []
|
||||
for (const task of tasks) {
|
||||
const promise = (async () => {
|
||||
const blobs: Blob[] = []
|
||||
const blobPromises = task.emoticons.map(e => e.blob)
|
||||
for (const blobPromise of blobPromises) {
|
||||
const blob = await blobPromise
|
||||
blobs.push(blob)
|
||||
}
|
||||
return {
|
||||
pkg_name: task.pkg_name,
|
||||
emoticons: task.emoticons.map((e, i) => ({ emoji: e.emoji, blob: blobs[i] })),
|
||||
}
|
||||
})()
|
||||
results.push(promise)
|
||||
}
|
||||
return Promise.all(results)
|
||||
}
|
||||
|
||||
// 有的直播间id是超短id,需要获取到原始的roomid才能通过api获取到表情包
|
||||
async getRealRoomId(rid: string): Promise<string> {
|
||||
try {
|
||||
const response = await this.apiRequest(
|
||||
`https://api.live.bilibili.com/room/v1/Room/get_info?room_id=${rid}`,
|
||||
)
|
||||
if (Object.keys(response.data).length === 0) {
|
||||
throw new Error('获取直播间id失败')
|
||||
}
|
||||
return response.data.room_id
|
||||
} catch (error) {
|
||||
throw new Error('获取直播间id失败')
|
||||
}
|
||||
}
|
||||
|
||||
apiRequest = (url: string): Promise<any> => {
|
||||
const details: MonkeyXhrBasicDetails = {
|
||||
url,
|
||||
method: 'GET',
|
||||
responseType: 'json',
|
||||
}
|
||||
return monkey(details)
|
||||
}
|
||||
|
||||
getImageBlob = async (url: string) => {
|
||||
const details: MonkeyXhrBasicDetails = {
|
||||
url,
|
||||
method: 'GET',
|
||||
responseType: 'blob',
|
||||
}
|
||||
return monkey<Blob>(details)
|
||||
}
|
||||
}
|
@ -0,0 +1,3 @@
|
||||
## 下载up主专属表情包
|
||||
|
||||
支持下载up主专属表情包,处于任意直播间页面时,下载按钮会在`功能`面板显示,以压缩包形式保存。
|
16
registry/lib/components/utils/download-emoticons/index.ts
Normal file
16
registry/lib/components/utils/download-emoticons/index.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { defineComponentMetadata } from '@/components/define'
|
||||
|
||||
export const component = defineComponentMetadata({
|
||||
name: 'downloadEmoticons',
|
||||
displayName: '下载表情',
|
||||
tags: [componentsTags.utils, componentsTags.live],
|
||||
entry: none,
|
||||
author: {
|
||||
name: 'Pencilqaq',
|
||||
link: 'https://github.com/pencilqaq',
|
||||
},
|
||||
widget: {
|
||||
component: () => import('./Widget.vue').then(m => m.default),
|
||||
},
|
||||
urlInclude: [/^https:\/\/live\.bilibili\.com\/(\d+)/],
|
||||
})
|
@ -1,6 +1,9 @@
|
||||
import { defineComponentMetadata } from '@/components/define'
|
||||
import { useScopedConsole } from '@/core/utils/log'
|
||||
import { bilibiliApi, getJsonWithCredentials } from '@/core/ajax'
|
||||
import { select } from '@/core/spin-query'
|
||||
import { allMutations } from '@/core/observer'
|
||||
import { getUserInfo } from '@/core/user-info'
|
||||
|
||||
const displayName = '关注时间显示'
|
||||
const console = useScopedConsole(displayName)
|
||||
@ -22,20 +25,16 @@ type FollowTimeInfo = {
|
||||
const midMap: Record<number, FollowTimeInfo> = {}
|
||||
|
||||
const insertFollowTimeToCard = (mid: number, dateStr: string, label: string) => {
|
||||
const anchor = document.querySelector<HTMLAnchorElement>(
|
||||
`${SELECTORS.relationCardAnchor}[href*="/${mid}"]`,
|
||||
)
|
||||
const anchor = dq(`${SELECTORS.relationCardAnchor}[href*="/${mid}"]`)
|
||||
const card = anchor?.closest(SELECTORS.relationCardContainer)
|
||||
if (!card) {
|
||||
return
|
||||
}
|
||||
if (card.querySelector(`.${SELECTORS.relationCardTimeClass}`)) {
|
||||
if (!card || card.querySelector(`.${SELECTORS.relationCardTimeClass}`)) {
|
||||
return
|
||||
}
|
||||
|
||||
const div = document.createElement('div')
|
||||
div.className = SELECTORS.relationCardTimeClass
|
||||
div.textContent = `${label}:${dateStr}`
|
||||
div.dataset.mid = mid.toString()
|
||||
|
||||
const sign = card.querySelector(SELECTORS.relationCardSign)
|
||||
if (sign?.parentNode) {
|
||||
@ -43,57 +42,37 @@ const insertFollowTimeToCard = (mid: number, dateStr: string, label: string) =>
|
||||
}
|
||||
}
|
||||
|
||||
let fetchWrapped = false
|
||||
let pendingUpdate = false
|
||||
|
||||
const updateCards = () => {
|
||||
if (pendingUpdate) {
|
||||
return
|
||||
}
|
||||
pendingUpdate = true
|
||||
requestAnimationFrame(() => {
|
||||
pendingUpdate = false
|
||||
const mids = Array.from(
|
||||
document.querySelectorAll<HTMLAnchorElement>(SELECTORS.relationCardAnchor),
|
||||
)
|
||||
.map(el => el.href.match(/\/(\d+)/)?.[1])
|
||||
.filter(Boolean)
|
||||
.map(Number)
|
||||
|
||||
mids.forEach(mid => {
|
||||
const data = midMap[mid]
|
||||
if (data) {
|
||||
const dateStr = new Date(data.mtime * 1000).toLocaleString()
|
||||
insertFollowTimeToCard(mid, dateStr, data.label)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const waitForProfileContainer = () =>
|
||||
new Promise<Element>(resolve => {
|
||||
const check = () => document.querySelector(SELECTORS.profileFollowContainer)
|
||||
let container = check()
|
||||
if (container) {
|
||||
resolve(container)
|
||||
} else {
|
||||
const observer = new MutationObserver(() => {
|
||||
container = check()
|
||||
if (container) {
|
||||
observer.disconnect()
|
||||
resolve(container)
|
||||
const updateCards = (() => {
|
||||
let scheduled = false
|
||||
return () => {
|
||||
if (scheduled) {
|
||||
return
|
||||
}
|
||||
scheduled = true
|
||||
requestAnimationFrame(() => {
|
||||
scheduled = false
|
||||
dqa(SELECTORS.relationCardAnchor).forEach(el => {
|
||||
const match = (el as HTMLAnchorElement).href.match(/\/(\d+)/)
|
||||
if (!match) {
|
||||
return
|
||||
}
|
||||
const mid = Number(match[1])
|
||||
if (Number.isNaN(mid)) {
|
||||
return
|
||||
}
|
||||
const data = midMap[mid]
|
||||
if (data) {
|
||||
const dateStr = new Date(data.mtime * 1000).toLocaleString()
|
||||
insertFollowTimeToCard(mid, dateStr, data.label)
|
||||
}
|
||||
})
|
||||
observer.observe(document.body, { childList: true, subtree: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
})()
|
||||
|
||||
const insertSubscribeTime = (followTimeStr: string) => {
|
||||
const container = document.querySelector(SELECTORS.profileFollowContainer)
|
||||
if (!container) {
|
||||
return
|
||||
}
|
||||
if (container.querySelector(`.${SELECTORS.profileTextClass}`)) {
|
||||
const container = dq(SELECTORS.profileFollowContainer)
|
||||
if (!container || container.querySelector(`.${SELECTORS.profileTextClass}`)) {
|
||||
return
|
||||
}
|
||||
|
||||
@ -107,25 +86,22 @@ const insertSubscribeTime = (followTimeStr: string) => {
|
||||
container.appendChild(infoEl)
|
||||
}
|
||||
|
||||
const entry = async () => {
|
||||
try {
|
||||
const { addImportantStyle } = await import('@/core/style')
|
||||
const { default: style } = await import('./subscribe-time.scss')
|
||||
addImportantStyle(style, 'subscribe-time-style')
|
||||
} catch (e) {
|
||||
console.error('样式加载失败:', e)
|
||||
}
|
||||
if (!(unsafeWindow as any).subscribeTimeHooked) {
|
||||
;(unsafeWindow as any).subscribeTimeHooked = true
|
||||
const originalFetch = unsafeWindow.fetch
|
||||
unsafeWindow.fetch = new Proxy(originalFetch, {
|
||||
apply(target, thisArg, args) {
|
||||
const url = typeof args[0] === 'string' ? args[0] : args[0].url
|
||||
const urlObj = new URL(url, location.origin)
|
||||
|
||||
new MutationObserver(updateCards).observe(document.body, { childList: true, subtree: true })
|
||||
|
||||
if (!fetchWrapped) {
|
||||
fetchWrapped = true
|
||||
const originalFetch = unsafeWindow.fetch
|
||||
unsafeWindow.fetch = new Proxy(originalFetch, {
|
||||
apply(target, thisArg, args) {
|
||||
const url = typeof args[0] === 'string' ? args[0] : args[0].url
|
||||
if (url.includes('/x/relation/fans') || url.includes('/x/relation/followings')) {
|
||||
return target.apply(thisArg, args).then(res => {
|
||||
if (
|
||||
urlObj.hostname === 'api.bilibili.com' &&
|
||||
(urlObj.pathname.includes('/x/relation/fans') ||
|
||||
urlObj.pathname.includes('/x/relation/followings'))
|
||||
) {
|
||||
return target
|
||||
.apply(thisArg, args)
|
||||
.then(res => {
|
||||
res
|
||||
.clone()
|
||||
.json()
|
||||
@ -137,54 +113,63 @@ const entry = async () => {
|
||||
}
|
||||
list.forEach(user => {
|
||||
if (typeof user.mid === 'number' && typeof user.mtime === 'number') {
|
||||
const label = url.includes('/x/relation/fans')
|
||||
? 'Ta 关注你的时间'
|
||||
: '你关注 Ta 的时间'
|
||||
const label = url.includes('/fans') ? 'Ta 关注你的时间' : '你关注 Ta 的时间'
|
||||
midMap[user.mid] = { mtime: user.mtime, label }
|
||||
insertFollowTimeToCard(
|
||||
user.mid,
|
||||
new Date(user.mtime * 1000).toLocaleString(),
|
||||
label,
|
||||
)
|
||||
const dateStr = new Date(user.mtime * 1000).toLocaleString()
|
||||
insertFollowTimeToCard(user.mid, dateStr, label)
|
||||
}
|
||||
})
|
||||
})
|
||||
.catch(err => console.warn('JSON 解析失败:', err))
|
||||
.catch(err => {
|
||||
console.warn('JSON 解析失败:', err)
|
||||
})
|
||||
return res
|
||||
})
|
||||
}
|
||||
return target.apply(thisArg, args)
|
||||
},
|
||||
})
|
||||
.catch(err => {
|
||||
console.warn('fetch 请求失败:', err)
|
||||
throw err
|
||||
})
|
||||
}
|
||||
|
||||
return target.apply(thisArg, args)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const entry = async () => {
|
||||
try {
|
||||
const { addImportantStyle } = await import('@/core/style')
|
||||
const { default: style } = await import('./subscribe-time.scss')
|
||||
addImportantStyle(style, 'subscribe-time-style')
|
||||
} catch (e) {
|
||||
console.error('样式加载失败:', e)
|
||||
}
|
||||
|
||||
allMutations(updateCards)
|
||||
|
||||
const userMid = (await getUserInfo()).mid
|
||||
const match = location.href.match(/space\.bilibili\.com\/(\d+)/)
|
||||
if (!match) {
|
||||
console.warn('无法提取 mid')
|
||||
return
|
||||
}
|
||||
|
||||
const mid = Number(match[1])
|
||||
if (mid === userMid) {
|
||||
console.log('当前为本人空间,跳过关注时间显示')
|
||||
return
|
||||
}
|
||||
|
||||
const info = await bilibiliApi(
|
||||
getJsonWithCredentials(`https://api.bilibili.com/x/web-interface/relation?mid=${mid}`),
|
||||
)
|
||||
if (!info?.relation) {
|
||||
console.warn('未获取到 relation 信息')
|
||||
return
|
||||
}
|
||||
if (!info.relation.mtime) {
|
||||
console.log('当前未关注或关注时间无效')
|
||||
|
||||
const mtime = info?.relation?.mtime
|
||||
if (!mtime) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await waitForProfileContainer()
|
||||
} catch (e) {
|
||||
console.warn('等待关注容器超时,跳过插入关注时间')
|
||||
return
|
||||
}
|
||||
|
||||
insertSubscribeTime(new Date(info.relation.mtime * 1000).toLocaleString())
|
||||
await select(SELECTORS.profileFollowContainer)
|
||||
insertSubscribeTime(new Date(mtime * 1000).toLocaleString())
|
||||
}
|
||||
|
||||
export const component = defineComponentMetadata({
|
||||
@ -200,5 +185,7 @@ export const component = defineComponentMetadata({
|
||||
/https:\/\/space\.bilibili\.com\/\d+/,
|
||||
],
|
||||
entry,
|
||||
description: { 'zh-CN': '在粉丝/关注列表及用户主页显示关注的具体时间。' },
|
||||
description: {
|
||||
'zh-CN': '在粉丝/关注列表及用户主页显示关注的具体时间。',
|
||||
},
|
||||
})
|
||||
|
@ -0,0 +1 @@
|
||||
重定向**稍后再看页面**到普通视频页面,和`稍后再看重定向`组件的区别在于,该组件是在进入稍后再看的视频页面后进行重定向,而非直接替换链接到普通视频的链接,用于补充`稍后再看重定向`组件的功能。
|
@ -0,0 +1,34 @@
|
||||
import { ComponentEntry } from '@/components/types'
|
||||
import { defineComponentMetadata } from '@/components/define'
|
||||
|
||||
const getBvidFromWatcherLaterVideoPage = () => {
|
||||
const { pathname, search } = window.location
|
||||
const oidAndBvid = search.match(/\?oid=(\d+)&bvid=(\w+)/) ?? []
|
||||
|
||||
if (pathname === '/list/watchlater' && oidAndBvid.length === 3) {
|
||||
return oidAndBvid[2]
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const entry: ComponentEntry = async () => {
|
||||
// 判断是否是稍后再看链接格式的视频页面,如果是则重定向
|
||||
const bvid = getBvidFromWatcherLaterVideoPage()
|
||||
|
||||
if (bvid) {
|
||||
location.href = `https://www.bilibili.com/video/${bvid}`
|
||||
}
|
||||
}
|
||||
|
||||
export const component = defineComponentMetadata({
|
||||
name: 'watchlaterPageRedirect',
|
||||
displayName: '稍后再看页面-重定向',
|
||||
entry,
|
||||
author: {
|
||||
name: 'magicFeirl',
|
||||
link: 'https://github.com/magicFeirl',
|
||||
},
|
||||
urlInclude: ['https://www.bilibili.com/list/watchlater'],
|
||||
tags: [componentsTags.utils, componentsTags.video],
|
||||
})
|
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "2.10.2",
|
||||
"version": "2.10.3",
|
||||
"author": "Grant Howard, Coulomb-G",
|
||||
"copyright": "[year], Grant Howard (https://github.com/the1812) & Coulomb-G (https://github.com/Coulomb-G)",
|
||||
"license": "MIT",
|
||||
|
@ -225,7 +225,7 @@ export class LiveSocket extends EventTarget {
|
||||
|
||||
if (
|
||||
this.webSocket &&
|
||||
[WebSocket.CONNECTING, WebSocket.OPEN].includes(this.webSocket.readyState)
|
||||
([WebSocket.CONNECTING, WebSocket.OPEN] as number[]).includes(this.webSocket.readyState)
|
||||
) {
|
||||
this.stop()
|
||||
}
|
||||
|
@ -194,32 +194,45 @@ export const responsiveGetPages = <T = any>(config: {
|
||||
responsivePromise = new Promise(resolveResponsive => {
|
||||
;(async () => {
|
||||
const { api, getList, getTotal } = config
|
||||
let page = 1
|
||||
let total = Infinity
|
||||
const result = []
|
||||
while (result.length < total) {
|
||||
const json = await api(page)
|
||||
const result: T[] = []
|
||||
|
||||
const fetchPage = async (p: number): Promise<T[]> => {
|
||||
const json = await api(p)
|
||||
if (json.code !== 0) {
|
||||
console.warn(
|
||||
`api failed in ajax.getPages. message = ${json.message}, page = ${page}, total = ${total}, api = `,
|
||||
`api failed in ajax.getPages. message = ${json.message}, page = ${p}, total = ${total}, api = `,
|
||||
api,
|
||||
)
|
||||
return []
|
||||
}
|
||||
const list = getList(json)
|
||||
result.push(...list)
|
||||
if (page === 1) {
|
||||
resolveResponsive(result)
|
||||
}
|
||||
return getList(json)
|
||||
}
|
||||
|
||||
if (list.length === 0) {
|
||||
resolveTotal(result)
|
||||
break
|
||||
}
|
||||
page++
|
||||
if (total === Infinity) {
|
||||
total = getTotal(json)
|
||||
// 请求第一次获得 total
|
||||
const firstReq = await api(1)
|
||||
result.push(...getList(firstReq))
|
||||
|
||||
total = getTotal(firstReq)
|
||||
const pageSize = getList(firstReq).length || 1 // 防止为 0
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
resolveResponsive(result) // 第一页
|
||||
if (totalPages === 1) {
|
||||
resolveTotal(result)
|
||||
return
|
||||
}
|
||||
// 收集 Promise,每 5 个为一个 batch 运行
|
||||
const batchSize = 5
|
||||
let batch: Promise<T[]>[] = []
|
||||
for (let i = 2; i <= totalPages; i++) {
|
||||
batch.push(fetchPage(i))
|
||||
if (batch.length === batchSize || i === totalPages) {
|
||||
const lists = await Promise.all(batch)
|
||||
result.push(...lists.flat())
|
||||
batch = []
|
||||
}
|
||||
}
|
||||
// 等所有并发完成
|
||||
resolveTotal(result)
|
||||
})()
|
||||
})
|
||||
@ -242,6 +255,7 @@ export const getPages = async <T = any>(config: {
|
||||
const result = await total
|
||||
return result
|
||||
}
|
||||
|
||||
/** bilibili API 标准响应 */
|
||||
export interface BilibiliApiResponse {
|
||||
code: number
|
||||
@ -251,6 +265,7 @@ export interface BilibiliApiResponse {
|
||||
data: any
|
||||
result?: any
|
||||
}
|
||||
|
||||
/**
|
||||
* 进行 bilibili API 标准响应处理
|
||||
* @param apiPromise 运行中的 API Promise
|
||||
|
Loading…
Reference in New Issue
Block a user