Compare commits

...

92 Commits

Author SHA1 Message Date
jxxghp
9b753a8f5b fix 2024-01-05 20:40:44 +08:00
jxxghp
11e82582b8 fix 2024-01-05 17:34:52 +08:00
jxxghp
419358863e feat: dashboard 2024-01-05 17:32:30 +08:00
jxxghp
1d0d7f9975 dashboard cards 2024-01-05 15:59:03 +08:00
jxxghp
c5f564372b Merge pull request #66 from thofx/thofx_fix_ui 2024-01-04 21:38:30 +08:00
thofx
a50f0cd727 fix: 列表使用useDefer渲染 2024-01-04 21:35:35 +08:00
jxxghp
96f6f55138 fix type 2024-01-04 20:50:51 +08:00
jxxghp
6a45c8b358 fix service.js 2024-01-04 20:46:42 +08:00
jxxghp
165937596e fix safari window.open 2024-01-04 08:12:32 +08:00
jxxghp
fb976f043b fix 2024-01-03 21:30:00 +08:00
jxxghp
ecb9c4e51a fix safari 2024-01-03 21:29:40 +08:00
jxxghp
9e8c3b495c 更新 MediaDetailView.vue 2024-01-03 21:11:56 +08:00
jxxghp
24a37fc33c 更新 MediaDetailView.vue 2024-01-03 18:58:16 +08:00
jxxghp
d09a21114d fix 播放跳转 2024-01-03 18:39:39 +08:00
jxxghp
6e2b12501f feat:订阅弹窗开关 2024-01-03 18:11:13 +08:00
jxxghp
2a56e116cf fix 2024-01-03 17:30:49 +08:00
jxxghp
6de4f238d8 feat:size limit 2024-01-03 17:05:01 +08:00
jxxghp
1b426c5957 fix bug 2024-01-03 16:52:35 +08:00
jxxghp
82454a650c feat:dashboard可编辑 2024-01-03 13:09:28 +08:00
jxxghp
227b6bd7ef v1.5.7 2024-01-03 12:46:23 +08:00
jxxghp
9554025daf feat:在线播放 2024-01-03 12:45:44 +08:00
jxxghp
0eb5d607bf v1.5.6 2024-01-01 20:06:37 +08:00
jxxghp
750f4bc276 fix ui 2023-12-30 10:07:02 +08:00
jxxghp
d0aada1d3d 更新 package.json 2023-12-29 15:41:50 +08:00
jxxghp
8a4848387c Merge pull request #65 from thofx/thofx_fix_ui 2023-12-28 20:07:51 +08:00
thofx
6904fc7da3 fix: 文件管理初始化两次的bug 2023-12-28 20:05:50 +08:00
jxxghp
28c55a05e6 Merge pull request #64 from thofx/thofx_fix_ui 2023-12-28 07:04:14 +08:00
jxxghp
562c829267 Merge pull request #63 from honue/main 2023-12-28 07:04:04 +08:00
thofx
b200ed242d fix: 解决历史记录不居中的问题
历史历史错误信息使用tooltip展示
fix:解决当搜索结果过多导致页面卡顿问题
2023-12-27 23:39:12 +08:00
honue
815cfe55df fix 2023-12-27 17:18:58 +08:00
honue
40a1094d74 fix 更新数量为1时不显示角标 2023-12-27 13:39:23 +08:00
honue
346650c091 feat:更改多集展示样式 2023-12-27 13:26:26 +08:00
jxxghp
7f74715f51 fix 2023-12-23 19:29:02 +08:00
jxxghp
b6fcee517d fix 2023-12-23 18:59:43 +08:00
jxxghp
4f62551f6b fix 历史记录路径 2023-12-16 12:10:30 +08:00
jxxghp
3980249271 Merge branch 'main' of https://github.com/jxxghp/MoviePilot-Frontend 2023-12-14 07:10:56 +08:00
jxxghp
e3b11b1130 fix 整理默认路径 2023-12-14 07:08:39 +08:00
jxxghp
f866f23af1 Merge pull request #62 from thsrite/main 2023-12-14 06:30:15 +08:00
thsrite
c793bc24f0 feat 订阅增加保存路径设置 2023-12-12 14:01:35 +08:00
jxxghp
591a46d559 add icon 2023-12-10 13:40:27 +08:00
jxxghp
2852f26702 fix 站点限流设置Bug 2023-12-07 15:44:01 +08:00
jxxghp
fc818fdfd6 v1.4.9 2023-12-06 10:24:43 +08:00
jxxghp
5566ef87f8 feat 插件跳转Github 2023-11-30 16:57:01 +08:00
jxxghp
366fe34d6f fix 2023-11-30 15:25:28 +08:00
jxxghp
37a0e83124 fix 规则导入 2023-11-30 15:18:10 +08:00
jxxghp
061a3f393a fix 2023-11-30 14:10:39 +08:00
jxxghp
1dff22aeab fix 2023-11-30 13:30:51 +08:00
jxxghp
78e6fd4809 fix clipboard 2023-11-30 12:58:25 +08:00
jxxghp
96bbf3d0f2 fix bug 2023-11-30 12:49:55 +08:00
jxxghp
a842eaba4e build 2023-11-30 11:59:52 +08:00
jxxghp
37565bf8e4 feat 优先级规则分享&导入 2023-11-30 11:59:26 +08:00
jxxghp
beb158b387 Merge pull request #59 from jjjokin/feat-history-search-hint 2023-11-29 12:33:03 +08:00
jokin
408eb06f8d feat 添加历史记录搜索提示 2023-11-29 12:17:02 +08:00
jxxghp
abe0e44635 fix typing 2023-11-29 12:08:13 +08:00
jxxghp
cfaf414f1c feat 自动计算图标颜色 2023-11-29 12:05:55 +08:00
jxxghp
f9c4dc616b fix 2023-11-29 09:27:39 +08:00
jxxghp
bf845bab6b feat 插件默认图标与背景色 2023-11-29 08:34:25 +08:00
jxxghp
bae9c85990 fix 2023-11-29 08:11:47 +08:00
jxxghp
56bbb8d0ff fix README.md 2023-11-28 15:08:11 +08:00
jxxghp
60d3565231 build icons 2023-11-28 10:15:24 +08:00
jxxghp
81340fd287 Merge branch 'main' of https://github.com/jxxghp/MoviePilot-Frontend 2023-11-27 08:27:51 +08:00
jxxghp
c10c348c73 feat 插件操作确认 2023-11-27 08:27:49 +08:00
jxxghp
65cb7d9674 更新 PluginAppCard.vue 2023-11-27 07:06:43 +08:00
jxxghp
24f1a10ff7 feat 插件重置 2023-11-26 21:40:27 +08:00
jxxghp
767d11182a add plugin icon 2023-11-26 20:07:13 +08:00
jxxghp
cf363f667e fix plugin ui 2023-11-24 09:56:05 +08:00
jxxghp
0d1046b8c7 v1.4.4 2023-11-20 19:55:52 +08:00
jxxghp
2c05f5779e v1.4.3 2023-11-19 13:49:16 +08:00
jxxghp
9af200f89e feat 支持更多豆瓣详情展示 2023-11-18 21:52:35 +08:00
jxxghp
7e221cfd46 fix douban check subscribe 2023-11-13 20:23:13 +08:00
jxxghp
640882d178 v1.4.2 2023-11-13 12:05:26 +08:00
jxxghp
3a1436abef fix #1100 2023-11-10 21:41:57 +08:00
jxxghp
d431f0490d fix user add 2023-11-10 20:41:01 +08:00
jxxghp
4c2a6c92a6 fix 2023-11-10 12:22:06 +08:00
jxxghp
086c230e9e fix text 2023-11-09 23:26:46 +08:00
jxxghp
27e2ff50f2 fix ui 2023-11-09 12:31:00 +08:00
jxxghp
3134e5596b fix 2023-11-07 09:57:46 +08:00
jxxghp
315274abf9 fix ui 2023-11-06 11:42:13 +08:00
jxxghp
52bbf65fa8 fix #1046 2023-11-05 21:45:42 +08:00
jxxghp
9c018ec63b Merge branch 'main' of https://github.com/jxxghp/MoviePilot-Frontend 2023-11-05 21:43:33 +08:00
jxxghp
bd7e457cdb fix ui 2023-11-05 21:43:27 +08:00
jxxghp
36a0f8515b 更新 package.json 2023-11-05 09:14:17 +08:00
jxxghp
cac10a337d fix 2023-11-04 22:27:56 +08:00
jxxghp
edb53cc58f fix plugin market icon 2023-11-02 12:50:57 +08:00
jxxghp
1dceeecdad fix ui 2023-11-02 11:16:38 +08:00
jxxghp
f8071ada0b feat 插件支持在线图标 2023-11-01 22:03:46 +08:00
jxxghp
21bc8edbd8 feat 在线插件市场 2023-11-01 21:05:31 +08:00
jxxghp
2a8aeb5041 feat 插件市场显示版本号 2023-11-01 18:02:51 +08:00
jxxghp
1a7760cf6d fix build 2023-11-01 17:05:12 +08:00
jxxghp
aee4eed5ac feat 拆分插件图标 2023-11-01 17:02:57 +08:00
jxxghp
87215fb590 add icons 2023-11-01 16:23:50 +08:00
jxxghp
5409126187 add versions 2023-11-01 12:23:08 +08:00
102 changed files with 2464 additions and 384 deletions

View File

@@ -27,6 +27,13 @@ jobs:
node-version: '18'
cache: 'yarn'
- name: Download Icons
run: |
pwd
curl -sL "https://github.com/jxxghp/MoviePilot-Plugins/archive/refs/heads/main.zip" | busybox unzip -d /tmp -
mv /tmp/MoviePilot-Plugins-main/icons public/plugin_icon
rm -rf /tmp/MoviePilot-Plugins-main
- name: Build frontend
id: build_frontend
run: |

1
.gitignore vendored
View File

@@ -32,3 +32,4 @@ dist-ssr
# iconify dist files
src/@iconify/*.js
public/plugin_icon/**

View File

@@ -31,8 +31,8 @@
"volar.preview.port": 3000,
"volar.completion.preferredTagNameCase": "pascal",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true,
"source.fixAll.stylelint": true
"source.fixAll.eslint": "explicit",
"source.fixAll.stylelint": "explicit"
},
"eslint.alwaysShowStatus": true,
"eslint.format.enable": true,

View File

@@ -1,35 +1,39 @@
# MoviePilot-Frontend
This template should help get you started developing with Vue 3 in Vite.
[MoviePilot](https://github.com/jxxghp/MoviePilot) 的前端项目。
## Recommended IDE Setup
## 推荐的IDE设置
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=johnsoncodehk.volar) (and disable Vetur).
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=johnsoncodehk.volar) (并禁用 Vetur).
## Type Support for `.vue` Imports in TS
## 配置Vite
Since TypeScript cannot handle type information for `.vue` imports, they are shimmed to be a generic Vue component type by default. In most cases this is fine if you don't really care about component prop types outside of templates.
请参阅 [Vite 配置参考](https://vitejs.dev/config/).
However, if you wish to get actual prop types in `.vue` imports (for example to get props validation when using manual `h(...)` calls), you can run `Volar: Switch TS Plugin on/off` from VSCode command palette.
## Customize configuration
See [Vite Configuration Reference](https://vitejs.dev/config/).
## Project Setup
## 依赖安装
```sh
yarn
```
### Compile and Hot-Reload for Development
### 开发运行
```sh
yarn dev
```
### Type-Check, Compile and Minify for Production
### 编译打包
```sh
yarn build
```
### 静态运行
1. 使用 `nginx` 等Web服务器托管 `dist` 静态文件nginx配置参考 `public/nginx.conf`
2. 使用 `node` 命令直接运行`service.js`,默认监听 `3000` 端口,设置环境变量 `NGINX_PORT` 来调整运行端口。
```shell
node dist/service.js
```

View File

@@ -1,6 +1,6 @@
{
"name": "moviepilot",
"version": "1.3.8",
"version": "1.5.7-1",
"private": true,
"bin": "dist/service.js",
"scripts": {
@@ -28,6 +28,7 @@
"axios": "1.4.0",
"axios-mock-adapter": "^1.21.4",
"chart.js": "^4.1.2",
"colorthief": "^2.4.0",
"express": "^4.18.2",
"express-http-proxy": "^2.0.0",
"jwt-decode": "^3.1.2",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

View File

@@ -28,7 +28,12 @@ app.use(
// 处理根路径的请求
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'index.html')) // 指向你的前端入口文件
res.sendFile(path.join(__dirname, 'index.html'))
})
// 处理所有其他请求,重定向到前端入口文件
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'index.html'))
})
app.listen(port, () => {

21
src/@core/utils/dom.ts Normal file
View File

@@ -0,0 +1,21 @@
export function removeEl(selector: string) {
if (selector) {
const el = document.querySelector(selector)
el?.parentNode?.removeChild(el)
}
}
export function useDefer(maxFrameCount = 1) {
const frameCount = ref(0)
const refreshFrameCount = () => {
requestAnimationFrame(() => {
frameCount.value++
if (frameCount.value < maxFrameCount)
refreshFrameCount()
})
}
refreshFrameCount()
return function (showInFrameCount: number) {
return frameCount.value >= showInFrameCount
}
}

View File

@@ -55,7 +55,7 @@ export function formatFileSize(bytes: number) {
if (bytes < 0)
throw new Error('字节数不能为负数。')
const units = ['B', 'K', 'M', 'G', 'T']
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let size = bytes
let unitIndex = 0
@@ -109,3 +109,41 @@ export function formatBytes(bytes: number, decimals = 2) {
return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`
}
// 格式化剧集列表
export function formatEp(nums: number[]): string {
if (!nums.length)
return ''
if (nums.length === 1)
return nums[0].toString()
// 将数组升序排序
nums.sort((a, b) => a - b)
const formattedRanges: string[] = []
let start = nums[0]
let end = nums[0]
for (let i = 1; i < nums.length; i++) {
if (nums[i] === end + 1) {
end = nums[i]
}
else {
if (start === end)
formattedRanges.push(start.toString())
else
formattedRanges.push(`${start.toString()}-${end.toString()}`)
start = end = nums[i]
}
}
if (start === end)
formattedRanges.push(start.toString())
else
formattedRanges.push(`${start.toString()}-${end.toString()}`)
return formattedRanges.join('、')
}

23
src/@core/utils/image.ts Normal file
View File

@@ -0,0 +1,23 @@
import ColorThief from 'colorthief'
// 将 RGB 转换为十六进制
function rgbStringToHex(rgbArray: number[]): string {
if (rgbArray.length !== 3 || rgbArray.some(isNaN))
throw new Error('Invalid RGB string format')
const [r, g, b] = rgbArray
const toHex = (c: number): string => {
const hex = c.toString(16)
return hex.length === 1 ? `0${hex}` : hex
}
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
}
// 提取主要颜色
export async function getDominantColor(image: HTMLImageElement): Promise<string> {
const colorThief = new ColorThief()
const dominantColor = colorThief.getColor(image)
return rgbStringToHex(dominantColor)
}

View File

@@ -33,7 +33,7 @@ export function isToday(date: Date) {
)
}
// 计算时间差返回xx天xx小时xx分钟
// 计算时间差返回xx天/xx小时/xx分钟/xx秒
export function calculateTimeDifference(inputTime: string): string {
if (!inputTime)
return ''
@@ -64,6 +64,38 @@ export function calculateTimeDifference(inputTime: string): string {
}
}
// 计算时间差返回xx天xx小时xx分钟
export function calculateTimeDiff(inputTime: string): string {
if (!inputTime)
return ''
// 使用当前时区
const inputDate = new Date(inputTime)
const currentDate = new Date()
const timeDifference = currentDate.getTime() - inputDate.getTime()
const secondsDifference = Math.floor(timeDifference / 1000)
const days = Math.floor(secondsDifference / 86400)
const hours = Math.floor(secondsDifference % 86400 / 3600)
const minutes = Math.floor(secondsDifference % 86400 % 3600 / 60)
const secones = Math.floor(secondsDifference % 60)
if (days > 0)
return `${days}${hours}小时${minutes}分钟`
else if (hours > 0)
return `${hours}小时${minutes}分钟`
else if (minutes > 0)
return `${minutes}分钟`
else if (secones > 0)
return `${secones}`
return ''
}
// 判断一个数组subArray是不是在另一个数组mainArray中
export function isContained(subArray: any[], mainArray: any[]): boolean {
return subArray.every(element => mainArray.includes(element))

View File

@@ -0,0 +1,30 @@
// 请求和获取剪贴板内容
export async function getClipboardContent() {
if (navigator.clipboard && window.isSecureContext) {
return await navigator.clipboard.readText()
}
else {
const input = document.createElement('textarea')
document.body.appendChild(input)
input.select()
document.execCommand('paste')
const content = input.value
document.body.removeChild(input)
return content
}
}
// 将内容复制到剪贴板,兼容非安全域场景
export async function copyToClipboard(content: string) {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(content)
}
else {
const input = document.createElement('textarea')
input.value = content
document.body.appendChild(input)
input.select()
document.execCommand('copy')
document.body.removeChild(input)
}
}

View File

@@ -82,6 +82,9 @@ export interface Subscribe {
// 当前优先级
current_priority: number
// 保存目录
save_path: string
}
// 历史记录
@@ -337,7 +340,7 @@ export interface TmdbEpisode {
guest_stars: Object[]
}
// TMDB人信息
// TMDB人信息
export interface TmdbPerson {
// ID
id?: number
@@ -388,6 +391,34 @@ export interface TmdbPerson {
biography?: string
}
// 豆瓣人物信息
export interface DoubanPerson {
// ID
id?: string
// 名称
name?: string
// 角色
roles?: string[]
// 简介
title?: string
// 详情页面
url?: string
// 饰演
character?: string
// 图片 large/normal
avatar?: { [key: string]: string }
// 别名
latin_name?: string
}
// 站点
export interface Site {
@@ -512,9 +543,6 @@ export interface Plugin {
// 插件图标
plugin_icon?: string
// 主题色
plugin_color?: string
// 插件版本
plugin_version?: string
@@ -541,6 +569,15 @@ export interface Plugin {
// 是否有详情页面
has_page?: boolean
// 是否有新版本
has_update?: boolean
// 是否本地插件
is_local?: boolean
// 插件仓库地址
repo_url?: string
}
// 种子信息
@@ -614,6 +651,13 @@ export interface TorrentInfo {
// 促销描述
volume_factor: string
// 免费时间
freedate: string
// 剩余免费时间
freedate_diff: string
}
// 识别元数据
@@ -880,3 +924,26 @@ export interface FileItem {
children: FileItem[]
modify_time: number
}
// 媒体服务器播放条目
export interface MediaServerPlayItem {
id?: string | number
title: string
subtitle?: string
type?: string
image?: string
link?: string
percent?: number
}
// 媒体服务器媒体库
export interface MediaServerLibrary {
server: string
id?: string | number
name: string
path?: string
type?: string
image?: string
image_list?: string[]
link?: string
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View File

@@ -1,10 +1,10 @@
<script lang="ts" setup>
import type { Axios } from 'axios'
import axios from 'axios'
import List from './filebrowser/List.vue'
import Toolbar from './filebrowser/Toolbar.vue'
import Tree from './filebrowser/Tree.vue'
import List from './filebrowser/List.vue'
import type { EndPoints } from '@/api/types'
// 输入参数
@@ -70,10 +70,12 @@ const storagesArray = computed(() => {
// 方法
function loadingChanged(loading: number) {
if (loading)
if (loading) {
loading++
else if (loading > 0)
}
else if (loading > 0) {
loading--
}
}
function storageChanged(storage: string) {
@@ -92,56 +94,58 @@ function sortChanged(s: string) {
}
// 初始化
onBeforeMount(() => {
onMounted(() => {
activeStorage.value = props.storage ?? 'local'
axiosInstance.value = props.axios ?? axios.create(props.axiosconfig)
})
</script>
<template>
<VCard class="mx-auto" :loading="loading > 0">
<Toolbar
:path="props.path"
:storages="storagesArray"
:storage="activeStorage"
:endpoints="props.endpoints"
:axios="axiosInstance"
@storagechanged="storageChanged"
@pathchanged="pathChanged"
@foldercreated="refreshPending = true"
@sortchanged="sortChanged"
/>
<VRow no-gutters>
<VCol v-if="tree" sm="auto" class="d-none d-md-block">
<Tree
:path="props.path"
:storage="activeStorage"
:icons="fileIcons"
:endpoints="endpoints"
:axios="axiosInstance"
:refreshpending="refreshPending"
@pathchanged="pathChanged"
@loading="loadingChanged"
@refreshed="refreshPending = false"
/>
</VCol>
<VDivider v-if="tree" vertical />
<VCol>
<List
:path="props.path"
:storage="activeStorage"
:icons="fileIcons"
:endpoints="endpoints"
:axios="axiosInstance"
:refreshpending="refreshPending"
:sort="sort"
@pathchanged="pathChanged"
@loading="loadingChanged"
@refreshed="refreshPending = false"
@filedeleted="refreshPending = true"
@renamed="refreshPending = true"
/>
</VCol>
</VRow>
<VCard class="mx-auto" :loading="loading > 0 || !path">
<div v-if="path">
<Toolbar
:path="path"
:storages="storagesArray"
:storage="activeStorage"
:endpoints="endpoints"
:axios="axiosInstance"
@storagechanged="storageChanged"
@pathchanged="pathChanged"
@foldercreated="refreshPending = true"
@sortchanged="sortChanged"
/>
<VRow no-gutters>
<VCol v-if="tree" sm="auto" class="d-none d-md-block">
<Tree
:path="path"
:storage="activeStorage"
:icons="fileIcons"
:endpoints="endpoints"
:axios="axiosInstance"
:refreshpending="refreshPending"
@pathchanged="pathChanged"
@loading="loadingChanged"
@refreshed="refreshPending = false"
/>
</VCol>
<VDivider v-if="tree" vertical />
<VCol>
<List
:path="path"
:storage="activeStorage"
:icons="fileIcons"
:endpoints="endpoints"
:axios="axiosInstance"
:refreshpending="refreshPending"
:sort="sort"
@pathchanged="pathChanged"
@loading="loadingChanged"
@refreshed="refreshPending = false"
@filedeleted="refreshPending = true"
@renamed="refreshPending = true"
/>
</VCol>
</VRow>
</div>
</VCard>
</template>

View File

@@ -0,0 +1,77 @@
<script lang="ts" setup>
import type { MediaServerPlayItem } from '@/api/types'
// 输入参数
const props = defineProps({
media: Object as PropType<MediaServerPlayItem>,
width: String,
height: String,
})
// 图片是否加载完成
const imageLoaded = ref(false)
// 图片加载完成响应
function imageLoadHandler() {
imageLoaded.value = true
}
// 跳转播放
function goPlay() {
if (props.media?.link)
window.open(props.media?.link, '_blank')
}
</script>
<template>
<VHover
v-bind="props"
:height="props.height"
:width="props.width"
>
<template #default="hover">
<VCard
v-bind="hover.props"
:height="props.height"
:width="props.width"
class="ring-gray-500"
:class="{
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
'ring-1': imageLoaded,
}"
@click="goPlay"
>
<template #image>
<VImg
:src="props.media?.image"
aspect-ratio="2/3"
cover
@load="imageLoadHandler"
>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
</div>
</template>
<VCardText
class="w-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2"
>
<h1 class="mb-1 text-white font-extrabold text-xl line-clamp-2 overflow-hidden text-ellipsis ...">
{{ props.media?.title }}
</h1>
<span>{{ props.media?.subtitle }}</span>
</VCardText>
</VImg>
</template>
<div class="w-full absolute bottom-0">
<VProgressLinear
v-if="props.media?.percent"
:model-value="props.media?.percent"
bg-color="success"
color="success"
/>
</div>
</VCard>
</template>
</VHover>
</template>

View File

@@ -0,0 +1,88 @@
<script lang="ts" setup>
import personIcon from '@images/misc/person-icon.png'
import type { DoubanPerson } from '@/api/types'
const personProps = defineProps({
person: Object as PropType<DoubanPerson>,
width: String,
height: String,
})
// 当前人物
const personInfo = ref(personProps.person)
// 人物图片是否加载
const isImageLoaded = ref(false)
// 人物图片地址
function getPersonImage() {
if (!personInfo.value?.avatar)
return personIcon
return personInfo.value?.avatar?.large
}
// 打开人物详情
function goPersonDetail() {
if (!personInfo.value?.id)
return
window.open(`https://movie.douban.com/celebrity/${personInfo.value?.id}/`, '_blank')
}
</script>
<template>
<VHover v-bind="personProps">
<template #default="hover">
<VCard
v-bind="hover.props"
:height="personProps.height"
:width="personProps.width"
class="rounded-lg"
:class="{
'transition transform-cpu duration-300 scale-105': hover.isHovering,
}"
@click.stop="goPersonDetail"
>
<div
class="person-card relative transform-gpu cursor-pointer rounded shadow ring-1 transition duration-150 ease-in-out scale-100 ring-gray-700"
>
<div style="padding-bottom: 150%;">
<div class="absolute inset-0 flex h-full w-full flex-col items-center p-2">
<div class="relative mt-2 mb-4 flex h-1/2 w-full justify-center">
<VAvatar
size="120"
:class="{
'ring-1 ring-gray-700': isImageLoaded,
}"
>
<VImg
v-img
:src="getPersonImage()"
cover
@load="isImageLoaded = true"
/>
</VAvatar>
</div>
<div class="w-full truncate text-center font-bold">
{{ personInfo?.name }}
</div>
<div class="overflow-hidden whitespace-normal text-center text-sm" style=" display: -webkit-box; overflow: hidden; -webkit-box-orient: vertical;-webkit-line-clamp: 2;">
{{ personInfo?.character }}
</div>
<div class="absolute bottom-0 left-0 right-0 h-12 rounded-b" />
</div>
</div>
</div>
</VCard>
</template>
</VHover>
</template>
<style lang="scss">
.person-card {
background-image: linear-gradient(45deg, rgb(var(--v-theme-background)), rgb(var(--v-theme-surface)) 60%);
}
.person-card:hover {
background-image: linear-gradient(45deg, rgb(var(--v-theme-background)), rgb(var(--v-custom-background)) 60%);
}
</style>

View File

@@ -0,0 +1,71 @@
<script lang="ts" setup>
import type { MediaServerLibrary } from '@/api/types'
// 输入参数
const props = defineProps({
media: Object as PropType<MediaServerLibrary>,
width: String,
height: String,
})
// 图片是否加载完成
const imageLoaded = ref(false)
// 图片加载完成响应
function imageLoadHandler() {
imageLoaded.value = true
}
// 计算图片地址
const getImgUrl = computed(() => {
return props.media?.image || props.media?.image_list?.[0]
})
// 跳转播放
function goPlay() {
if (props.media?.link)
window.open(props.media?.link, '_blank')
}
</script>
<template>
<VHover
v-bind="props"
:height="props.height"
:width="props.width"
>
<template #default="hover">
<VCard
v-bind="hover.props"
:height="props.height"
:width="props.width"
:class="{
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
}"
@click="goPlay"
>
<template #image>
<VImg
:src="getImgUrl"
aspect-ratio="2/3"
cover
@load="imageLoadHandler"
>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
</div>
</template>
<VCardText
class="w-full flex flex-col flex-wrap justify-end align-center text-white absolute bottom-0 cursor-pointer pa-2"
>
<h1 class="mb-1 text-white font-bold line-clamp-2 overflow-hidden text-ellipsis ...">
{{ props.media?.name }}
</h1>
</VCardText>
</VImg>
</template>
</VCard>
</template>
</VHover>
</template>

View File

@@ -16,6 +16,11 @@ const props = defineProps({
height: String,
})
// 订阅规则
const subscribeRules = ref({
show_edit_dialog: false,
})
// 提示框
const $toast = useToast()
@@ -34,7 +39,7 @@ const isSubscribed = ref(false)
// 本地存在状态
const isExists = ref(false)
// 各季缺失状态0-已存在 1-部分缺失 2-全部缺失,没有数据也是已存在
// 各季缺失状态0-已入库 1-部分缺失 2-全部缺失,没有数据也是已入库
const seasonsNotExisted = ref<{ [key: number]: number }>({})
// 订阅季弹窗
@@ -146,7 +151,7 @@ async function addSubscribe(season = 0) {
)
// 弹出订阅编辑弹窗
if (result.success && seasonsSelected.value.length <= 1) {
if (result.success && seasonsSelected.value.length <= 1 && subscribeRules.value.show_edit_dialog) {
subscribeId.value = result.data.id
subscribeEditDialog.value = true
}
@@ -220,10 +225,10 @@ async function handleCheckSubscribe() {
}
}
// 查询当前媒体是否已存在
// 查询当前媒体是否已入库
async function handleCheckExists() {
try {
const result: { [key: string]: any } = await api.get('media/exists', {
const result: { [key: string]: any } = await api.get('mediaserver/exists', {
params: {
tmdbid: props.media?.tmdb_id,
title: props.media?.title,
@@ -251,6 +256,7 @@ async function checkSubscribe(season = 0) {
const result: Subscribe = await api.get(`subscribe/media/${mediaid}`, {
params: {
season,
title: props.media?.title,
},
})
@@ -268,10 +274,10 @@ async function checkSeasonsNotExists() {
// 开始处理
startNProgress()
try {
const result: NotExistMediaInfo[] = await api.post('download/notexists', props.media)
const result: NotExistMediaInfo[] = await api.post('mediaserver/notexists', props.media)
if (result) {
result.forEach((item) => {
// 0-已存在 1-部分缺失 2-全部缺失
// 0-已入库 1-部分缺失 2-全部缺失
let state = 0
if (item.episodes.length === 0)
state = 2
@@ -301,6 +307,20 @@ async function getMediaSeasons() {
}
}
// 查询订阅弹窗规则
async function querySubscribeRules() {
try {
const result: { [key: string]: any } = await api.get(
'system/setting/DefaultFilterRules',
)
if (result.data?.value)
subscribeRules.value = result.data?.value
}
catch (error) {
console.log(error)
}
}
// 爱心订阅按钮响应
function handleSubscribe() {
if (isSubscribed.value)
@@ -327,14 +347,14 @@ function getExistColor(season: number) {
function getExistText(season: number) {
const state = seasonsNotExisted.value[season]
if (!state)
return '已存在'
return '已入库'
if (state === 1)
return '部分缺失'
else if (state === 2)
return '缺失'
else
return '已存在'
return '已入库'
}
// 打开详情页
@@ -372,6 +392,7 @@ function handleSearch() {
onBeforeMount(() => {
handleCheckSubscribe()
handleCheckExists()
querySubscribeRules()
})
// 计算图片地址

View File

@@ -2,6 +2,8 @@
import { useToast } from 'vue-toast-notification'
import api from '@/api'
import type { Plugin } from '@/api/types'
import noImage from '@images/logos/plugin.png'
import { getDominantColor } from '@/@core/utils/image'
// 输入参数
const props = defineProps({
@@ -13,19 +15,55 @@ const props = defineProps({
// 定义触发的自定义事件
const emit = defineEmits(['install'])
// 背景颜色
const backgroundColor = ref('#28A9E1')
// 图片对象
const imageRef = ref<any>()
// 提示框
const $toast = useToast()
// 进度框
const progressDialog = ref(false)
// 进度框文本
const progressText = ref('正在安装插件...')
// 图片是否加载完成
const isImageLoaded = ref(false)
// 图片是否加载失败
const imageLoadError = ref(false)
// 图片加载完成
async function imageLoaded() {
isImageLoaded.value = true
const imageElement = imageRef.value?.$el.querySelector('img') as HTMLImageElement
// 从图片中提取背景色
backgroundColor.value = await getDominantColor(imageElement)
}
// 安装插件
async function installPlugin() {
try {
// 显示等待提示框
progressDialog.value = true
progressText.value = `正在安装 ${props.plugin?.plugin_name} ${props?.plugin?.plugin_version} 插件...`
const result: { [key: string]: any } = await api.get(
`plugin/install/${props.plugin?.id}`,
{
params: {
repo_url: props.plugin?.repo_url,
force: props.plugin?.has_update,
},
},
)
// 隐藏等待提示框
progressDialog.value = false
if (result.success) {
$toast.success(`插件 ${props.plugin?.plugin_name} 安装成功!`)
@@ -33,13 +71,63 @@ async function installPlugin() {
emit('install')
}
else {
$toast.error(`插件 ${props.plugin?.plugin_name} 安装失败:${result.message}}`)
$toast.error(`插件 ${props.plugin?.plugin_name} 安装失败:${result.message}`)
}
}
catch (error) {
console.error(error)
}
}
// 计算图标路径
const iconPath: Ref<string> = computed(() => {
if (imageLoadError.value)
return noImage
// 如果是网络图片则使用代理后返回
if (props.plugin?.plugin_icon?.startsWith('http'))
return `${import.meta.env.VITE_API_BASE_URL}system/img/${encodeURIComponent(props.plugin?.plugin_icon)}`
return `/plugin_icon/${props.plugin?.plugin_icon}`
})
// 访问插件页面
function visitPluginPage() {
// 将raw.githubusercontent.com转换为项目地址
let repoUrl = props.plugin?.repo_url
if (repoUrl) {
if (repoUrl.includes('raw.githubusercontent.com')) {
if (!repoUrl.endsWith('/'))
repoUrl += '/'
if (repoUrl.split('/').length < 6)
repoUrl = `${repoUrl}main/`
try {
const [user, repo] = repoUrl.split('/').slice(-4, -2)
repoUrl = `https://github.com/${user}/${repo}`
}
catch (error) {
return
}
}
}
else {
repoUrl = props.plugin?.author_url
}
window.open(repoUrl, '_blank')
}
// 弹出菜单
const dropdownItems = ref([
{
title: '查看详情',
value: 1,
props: {
prependIcon: 'mdi-information-outline',
click: visitPluginPage,
},
},
])
</script>
<template>
@@ -50,17 +138,51 @@ async function installPlugin() {
>
<div
class="relative pa-4 text-center card-cover-blurred"
:style="{ background: `${props.plugin?.plugin_color}` }"
:style="{ background: `${backgroundColor}` }"
>
<div class="me-n3 absolute top-0 right-3">
<IconBtn>
<VIcon icon="mdi-dots-vertical" class="text-white" />
<VMenu
activator="parent"
close-on-content-click
>
<VList>
<VListItem
v-for="(item, i) in dropdownItems"
:key="i"
variant="plain"
@click="item.props.click"
>
<template #prepend>
<VIcon :icon="item.props.prependIcon" />
</template>
<VListItemTitle v-text="item.title" />
</VListItem>
</VList>
</VMenu>
</IconBtn>
</div>
<div
v-if="props.plugin?.has_update"
class="me-n3 absolute top-0 left-1"
>
<VIcon
icon="mdi-new-box"
class="text-white"
/>
</div>
<VAvatar
size="8rem"
:class="{ shadow: isImageLoaded }"
>
<VImg
:src="`/plugin_icon/${props.plugin?.plugin_icon}`"
ref="imageRef"
:src="iconPath"
aspect-ratio="4/3"
cover
@load="isImageLoaded = true"
:class="{ shadow: isImageLoaded }"
@load="imageLoaded"
@error="imageLoadError = true"
/>
</VAvatar>
</div>
@@ -76,9 +198,29 @@ async function installPlugin() {
@click.stop
>
{{ props.plugin?.plugin_author }}
</a>
</a><br>
版本{{ props.plugin?.plugin_version }}
</VCardText>
</VCard>
<!-- 安装插件进度框 -->
<VDialog
v-model="progressDialog"
:scrim="false"
width="25rem"
>
<VCard
color="primary"
>
<VCardText class="text-center">
{{ progressText }}
<VProgressLinear
indeterminate
color="white"
class="mb-0 mt-1"
/>
</VCardText>
</VCard>
</VDialog>
</template>
<style lang="scss" scoped>

View File

@@ -1,10 +1,13 @@
<script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import { useConfirm } from 'vuetify-use-dialog'
import api from '@/api'
import type { Plugin } from '@/api/types'
import FormRender from '@/components/render/FormRender.vue'
import PageRender from '@/components/render/PageRender.vue'
import { isNullOrEmptyObject } from '@core/utils'
import noImage from '@images/logos/plugin.png'
import { getDominantColor } from '@/@core/utils/image'
// 输入参数
const props = defineProps({
@@ -16,9 +19,18 @@ const props = defineProps({
// 定义触发的自定义事件
const emit = defineEmits(['remove', 'save'])
// 背景颜色
const backgroundColor = ref('#28A9E1')
// 图片对象
const imageRef = ref<any>()
// 提示框
const $toast = useToast()
// 确认框
const createConfirm = useConfirm()
// 本身是否可见
const isVisible = ref(true)
@@ -31,17 +43,44 @@ const pluginConfigForm = ref({})
// 插件表单配置项
let pluginFormItems = reactive([])
// 插件详情页面
// 插件数据页面
const pluginInfoDialog = ref(false)
// 插件详情页面配置项
// 插件数据页面配置项
let pluginPageItems = reactive([])
// 图片是否加载完成
const isImageLoaded = ref(false)
// 图片是否加载失败
const imageLoadError = ref(false)
// 图片加载完成
async function imageLoaded() {
isImageLoaded.value = true
const imageElement = imageRef.value?.$el.querySelector('img') as HTMLImageElement
// 从图片中提取背景色
backgroundColor.value = await getDominantColor(imageElement)
}
// 调用API卸载插件
async function uninstallPlugin() {
const isConfirmed = await createConfirm({
title: '确认',
content: `是否确认卸载插件 ${props.plugin?.plugin_name} ?`,
confirmationText: '确认',
cancellationText: '取消',
dialogProps: {
maxWidth: '50rem',
},
confirmationButtonProps: {
variant: 'tonal',
},
})
if (!isConfirmed)
return
try {
const result: { [key: string]: any } = await api.delete(`plugin/${props.plugin?.id}`)
if (result.success) {
@@ -74,7 +113,7 @@ async function loadPluginForm() {
}
}
// 调用API读取详情页面
// 调用API读取数据页面
async function loadPluginPage() {
try {
const result: [] = await api.get(`plugin/page/${props.plugin?.id}`)
@@ -117,9 +156,9 @@ async function savePluginConf() {
}
}
// 显示插件详情
// 显示插件数据
async function showPluginInfo() {
// 加载详情
// 加载数据
await loadPluginPage()
pluginConfigDialog.value = false
pluginInfoDialog.value = true
@@ -136,10 +175,60 @@ async function showPluginConfig() {
pluginConfigDialog.value = true
}
// 计算图标路径
const iconPath: Ref<string> = computed(() => {
if (imageLoadError.value)
return noImage
// 如果是网络图片则使用代理后返回
if (props.plugin?.plugin_icon?.startsWith('http'))
return `${import.meta.env.VITE_API_BASE_URL}system/img/${encodeURIComponent(props.plugin?.plugin_icon)}`
return `/plugin_icon/${props.plugin?.plugin_icon}`
})
// 重置插件
async function resetPlugin() {
const isConfirmed = await createConfirm({
title: '确认',
content: `是否确认重置插件 ${props.plugin?.plugin_name} 的配置数据?`,
confirmationText: '确认',
cancellationText: '取消',
dialogProps: {
maxWidth: '50rem',
},
confirmationButtonProps: {
variant: 'tonal',
},
})
if (!isConfirmed)
return
try {
const result: { [key: string]: any } = await api.get(`plugin/reset/${props.plugin?.id}`)
if (result.success) {
$toast.success(`插件 ${props.plugin?.plugin_name} 数据已重置`)
// 通知父组件刷新
emit('save')
}
else {
$toast.error(`插件 ${props.plugin?.plugin_name} 重置失败:${result.message}}`)
}
}
catch (error) {
console.error(error)
}
}
// 访问作者主页
function visitAuthorPage() {
window.open(props.plugin?.author_url, '_blank')
}
// 弹出菜单
const dropdownItems = ref([
{
title: '查看详情',
title: '查看数据',
value: 1,
show: props.plugin?.has_page,
props: {
@@ -148,7 +237,7 @@ const dropdownItems = ref([
},
},
{
title: '置',
title: '置',
value: 2,
show: true,
props: {
@@ -157,15 +246,34 @@ const dropdownItems = ref([
},
},
{
title: '卸载',
title: '重置',
value: 3,
show: true,
props: {
prependIcon: 'mdi-cancel',
color: 'warning',
click: resetPlugin,
},
},
{
title: '卸载',
value: 4,
show: true,
props: {
prependIcon: 'mdi-trash-can-outline',
color: 'error',
click: uninstallPlugin,
},
},
{
title: '作者主页',
value: 4,
show: true,
props: {
prependIcon: 'mdi-home-circle-outline',
click: visitAuthorPage,
},
},
])
</script>
@@ -184,7 +292,7 @@ const dropdownItems = ref([
>
<div
class="relative pa-4 text-center card-cover-blurred"
:style="{ background: `${props.plugin?.plugin_color}` }"
:style="{ background: `${backgroundColor}` }"
>
<div class="me-n3 absolute top-0 right-3">
<IconBtn>
@@ -213,20 +321,22 @@ const dropdownItems = ref([
</div>
<VAvatar
size="8rem"
:class="{ shadow: isImageLoaded }"
>
<VImg
:src="`/plugin_icon/${props.plugin?.plugin_icon}`"
ref="imageRef"
:src="iconPath"
aspect-ratio="4/3"
cover
:class="{ shadow: isImageLoaded }"
@load="imageLoaded"
@error="imageLoadError = true"
/>
</VAvatar>
</div>
<VCardItem class="py-2">
<VCardTitle class="flex items-center flex-row">
<VBadge v-if="props.plugin?.state" dot inline color="success" class="me-1 mb-1" />
{{ props.plugin?.plugin_name }}
{{ props.plugin?.plugin_name }}<span class="text-sm ms-2 mt-1 text-gray-500">{{ props.plugin?.plugin_version }}</span>
</VCardTitle>
</VCardItem>
<VCardText>
@@ -254,7 +364,7 @@ const dropdownItems = ref([
</VCardText>
<VCardActions>
<VBtn v-if="pluginPageItems.length > 0" @click="showPluginInfo">
查看详情
查看数据
</VBtn>
<VSpacer />
<VBtn
@@ -267,7 +377,7 @@ const dropdownItems = ref([
</VCard>
</VDialog>
<!-- 插件详情页面 -->
<!-- 插件数据页面 -->
<VDialog
v-model="pluginInfoDialog"
scrollable

View File

@@ -0,0 +1,101 @@
<script lang="ts" setup>
import type { PropType } from 'vue'
import type { MediaServerPlayItem } from '@/api/types'
import noImage from '@images/no-image.jpeg'
// 输入参数
const props = defineProps({
media: Object as PropType<MediaServerPlayItem>,
width: String,
height: String,
})
// 图片加载状态
const isImageLoaded = ref(false)
// 图片加载失败
const imageLoadError = ref(false)
// 角标颜色
function getChipColor(type: string) {
if (type === '电影')
return 'border-blue-500 bg-blue-600'
else if (type === '电视剧')
return ' bg-indigo-500 border-indigo-600'
else
return 'border-purple-600 bg-purple-600'
}
// 计算图片地址
const getImgUrl = computed(() => {
if (imageLoadError.value)
return noImage
return props.media?.image
})
// 跳转播放
function goPlay() {
if (props.media?.link)
window.open(props.media?.link, '_blank')
}
</script>
<template>
<VHover v-bind="props">
<template #default="hover">
<VCard
v-bind="hover.props"
:height="props.height"
:width="props.width"
class="outline-none shadow ring-gray-500 rounded-lg"
:class="{
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
'ring-1': isImageLoaded,
}"
@click.stop="goPlay"
>
<VImg
aspect-ratio="2/3"
:src="getImgUrl"
class="object-cover aspect-w-2 aspect-h-3"
:class="hover.isHovering ? 'on-hover' : ''"
cover
@load="isImageLoaded = true"
@error="imageLoadError = true"
>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
<!-- 类型角标 -->
<VChip
v-show="isImageLoaded"
variant="elevated"
size="small"
:class="getChipColor(props.media?.type || '')"
class="absolute left-2 top-2 bg-opacity-80 shadow-md text-white font-bold"
>
{{ props.media?.type }}
</VChip>
<!-- 详情 -->
<VCardText
v-show="hover.isHovering || imageLoadError"
class="w-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2"
>
<span class="font-bold">{{ props.media?.subtitle }}</span>
<h1 class="mb-1 text-white font-extrabold text-xl line-clamp-2 overflow-hidden text-ellipsis ...">
{{ props.media?.title }}
</h1>
</VCardText>
</VImg>
</VCard>
</template>
</VHover>
</template>
<style lang="scss">
.on-hover img {
@apply brightness-50;
}
</style>

View File

@@ -33,9 +33,6 @@ const testButtonText = ref('测试')
// 测试按钮可用性
const testButtonDisable = ref(false)
// 更新按钮文字
const updateButtonText = ref('更新')
// 更新按钮可用性
const updateButtonDisable = ref(false)
@@ -48,6 +45,12 @@ const siteEditDialog = ref(false)
// 资源浏览弹窗
const resourceDialog = ref(false)
// 进度条
const progressDialog = ref(false)
// 进度文本
const progressText = ref('请稍候 ...')
// 资源浏览表头
const resourceHeaders = [
{ title: '标题', key: 'title', sortable: false },
@@ -138,9 +141,11 @@ async function updateSiteCookie() {
// 更新按钮状态
siteCookieDialog.value = false
updateButtonText.value = '更新中 ...'
updateButtonDisable.value = true
progressDialog.value = true
progressText.value = `正在更新 ${cardProps.site?.name} Cookie & UA ...`
const result: { [key: string]: any } = await api.get(
`site/cookie/${cardProps.site?.id}`,
{
@@ -156,7 +161,7 @@ async function updateSiteCookie() {
else
$toast.error(`${cardProps.site?.name} 更新失败:${result.message}`)
updateButtonText.value = '更新'
progressDialog.value = false
updateButtonDisable.value = false
}
catch (error) {
@@ -299,7 +304,7 @@ onMounted(() => {
<template #prepend>
<VIcon icon="mdi-refresh" />
</template>
{{ updateButtonText }}
更新
</VBtn>
<VBtn
:disabled="testButtonDisable"
@@ -407,6 +412,23 @@ onMounted(() => {
<div class="text-sm my-1">
{{ item.raw.description }}
</div>
<VChip
v-if="item.raw?.hit_and_run"
variant="elevated"
size="small"
class="me-1 mb-1 text-white bg-black"
>
H&R
</VChip>
<VChip
v-if="item.raw?.freedate_diff"
variant="elevated"
color="secondary"
size="small"
class="me-1 mb-1"
>
{{ item.raw?.freedate_diff }}
</VChip>
<VChip
v-for="(label, index) in item.raw?.labels"
:key="index"
@@ -488,6 +510,24 @@ onMounted(() => {
</VCardText>
</VCard>
</VDialog>
<VDialog
v-model="progressDialog"
:scrim="false"
width="25rem"
>
<VCard
color="primary"
>
<VCardText class="text-center">
{{ progressText }}
<VProgressLinear
indeterminate
color="white"
class="mb-0 mt-1"
/>
</VCardText>
</VCard>
</VDialog>
</template>
<style lang="scss">

View File

@@ -25,7 +25,6 @@ const tmdbKeyword = ref<HTMLElement | null>(null)
// 选中条目
function selectMedia(item: TmdbItem) {
console.log(item)
emit('update:modelValue', item.tmdbid)
emit('close')
}

View File

@@ -195,6 +195,23 @@ onMounted(() => {
v-if="torrent?.labels"
class="pb-3 pt-0 pe-12"
>
<VChip
v-if="torrent?.hit_and_run"
variant="elevated"
size="small"
class="me-1 mb-1 text-white bg-black"
>
H&R
</VChip>
<VChip
v-if="torrent?.freedate_diff"
variant="elevated"
color="secondary"
size="small"
class="me-1 mb-1"
>
{{ torrent?.freedate_diff }}
</VChip>
<VChip
v-for="(label, index) in torrent?.labels"
:key="index"

View File

@@ -150,6 +150,23 @@ onMounted(() => {
v-if="torrent?.labels"
class="pt-2"
>
<VChip
v-if="torrent?.hit_and_run"
variant="elevated"
size="small"
class="me-1 mb-1 text-white bg-black"
>
H&R
</VChip>
<VChip
v-if="torrent?.freedate_diff"
variant="elevated"
color="secondary"
size="small"
class="me-1 mb-1"
>
{{ torrent?.freedate_diff }}
</VChip>
<VChip
v-for="(label, index) in torrent?.labels"
:key="index"

View File

@@ -0,0 +1,39 @@
<script lang="ts" setup>
// 输入参数
const props = defineProps({
title: String,
})
// 定义事件
const emit = defineEmits(['update:modelValue', 'close'])
// 代码
const codeString = ref('')
// 导入
function handleImport() {
emit('update:modelValue', codeString.value)
emit('close')
}
</script>
<template>
<VCard
:title="props.title"
class="rounded-t"
>
<DialogCloseBtn @click="emit('close')" />
<VCardText class="pt-2">
<VTextarea v-model="codeString" />
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="tonal"
@click="handleImport"
>
导入
</VBtn>
</VCardActions>
</VCard>
</template>

View File

@@ -199,7 +199,7 @@ async function updateSiteInfo() {
md="4"
>
<VTextField
v-model="siteForm.limit_seconds"
v-model="siteForm.limit_count"
label="访问次数"
:rules="[numberValidator]"
/>

View File

@@ -39,6 +39,7 @@ const subscribeForm = ref<Subscribe>({
last_update: '',
username: '',
current_priority: 0,
save_path: '',
})
// 提示框
@@ -323,7 +324,21 @@ watchEffect(() => {
</VCol>
</VRow>
<VRow>
<VCol cols="12">
<VCol
cols="12"
md="4"
>
<VTextField
v-model="subscribeForm.save_path"
label="保存路径"
/>
</VCol>
</VRow>
<VRow>
<VCol
cols="12"
md="4"
>
<VSwitch
v-model="subscribeForm.best_version"
label="洗版"

View File

@@ -2,6 +2,7 @@ import { createApp } from 'vue'
import '@/@iconify/icons-bundle'
import ToastPlugin from 'vue-toast-notification'
import VuetifyUseDialog from 'vuetify-use-dialog'
import { removeEl } from './@core/utils/dom'
import App from '@/App.vue'
import vuetify from '@/plugins/vuetify'
import { loadFonts } from '@/plugins/webfontloader'
@@ -11,7 +12,6 @@ import '@core/scss/template/index.scss'
import '@layouts/styles/index.scss'
import '@styles/styles.scss'
import 'vue-toast-notification/dist/theme-bootstrap.css'
import { removeEl } from '@/util'
loadFonts()

View File

@@ -13,6 +13,9 @@ const route = useRoute()
// 标题
const title = route.query?.title?.toString()
// 类型
const type = route.query?.type?.toString()
// 计算API路径
function getApiPath(paths: string[] | string) {
if (Array.isArray(paths))
@@ -34,6 +37,7 @@ function getApiPath(paths: string[] | string) {
<PersonCardListView
:apipath="getApiPath(props.paths || '')"
:params="route.query"
:type="type"
/>
</div>
</template>

View File

@@ -6,11 +6,57 @@ import AnalyticsStorage from '@/views/dashboard/AnalyticsStorage.vue'
import AnalyticsWeeklyOverview from '@/views/dashboard/AnalyticsWeeklyOverview.vue'
import AnalyticsCpu from '@/views/dashboard/AnalyticsCpu.vue'
import AnalyticsMemory from '@/views/dashboard/AnalyticsMemory.vue'
import MediaServerLatest from '@/views/dashboard/MediaServerLatest.vue'
import MediaServerLibrary from '@/views/dashboard/MediaServerLibrary.vue'
import MediaServerPlaying from '@/views/dashboard/MediaServerPlaying.vue'
// 仪表盘配置
const dashboard_names = {
storage: '存储空间',
mediaStatistic: '媒体统计',
weeklyOverview: '最近入库',
speed: '实时速率',
scheduler: '后台任务',
cpu: 'CPU',
memory: '内存',
library: '我的媒体库',
playing: '继续观看',
latest: '最近添加',
}
// 弹窗
const dialog = ref(false)
// 从localStorage中获取数据
const default_config = {
mediaStatistic: true,
scheduler: false,
speed: false,
storage: true,
weeklyOverview: false,
cpu: false,
memory: false,
library: true,
playing: true,
latest: true,
}
const config = ref(JSON.parse(localStorage.getItem('MP_DASHBOARD') || '{}'))
if (Object.keys(config.value).length === 0) {
config.value = default_config
localStorage.setItem('MP_DASHBOARD', JSON.stringify(config.value))
}
// 设置项目
function setDashboardConfig() {
localStorage.setItem('MP_DASHBOARD', JSON.stringify(config.value))
dialog.value = false
}
</script>
<template>
<VRow class="match-height">
<VCol
v-if="config.storage"
cols="12"
md="4"
>
@@ -18,6 +64,7 @@ import AnalyticsMemory from '@/views/dashboard/AnalyticsMemory.vue'
</VCol>
<VCol
v-if="config.mediaStatistic"
cols="12"
md="8"
>
@@ -25,6 +72,7 @@ import AnalyticsMemory from '@/views/dashboard/AnalyticsMemory.vue'
</VCol>
<VCol
v-if="config.weeklyOverview"
cols="12"
md="4"
>
@@ -32,6 +80,7 @@ import AnalyticsMemory from '@/views/dashboard/AnalyticsMemory.vue'
</VCol>
<VCol
v-if="config.speed"
cols="12"
md="4"
>
@@ -39,6 +88,7 @@ import AnalyticsMemory from '@/views/dashboard/AnalyticsMemory.vue'
</VCol>
<VCol
v-if="config.scheduler"
cols="12"
md="4"
>
@@ -46,6 +96,7 @@ import AnalyticsMemory from '@/views/dashboard/AnalyticsMemory.vue'
</VCol>
<VCol
v-if="config.cpu"
cols="12"
md="6"
>
@@ -53,10 +104,75 @@ import AnalyticsMemory from '@/views/dashboard/AnalyticsMemory.vue'
</VCol>
<VCol
v-if="config.memory"
cols="12"
md="6"
>
<AnalyticsMemory />
</VCol>
<VCol
v-if="config.library"
cols="12"
>
<MediaServerLibrary />
</VCol>
<VCol
v-if="config.playing"
cols="12"
>
<MediaServerPlaying />
</VCol>
<VCol
v-if="config.latest"
cols="12"
>
<MediaServerLatest />
</VCol>
</VRow>
<!-- 底部操作按钮 -->
<span class="fixed right-5 bottom-5">
<VBtn icon="mdi-view-dashboard-edit" class="me-2" color="primary" size="x-large" @click="dialog = true" />
</span>
<!-- 弹窗根据配置生成选项 -->
<VDialog
v-model="dialog"
max-width="600"
scrollable
>
<VCard title="设置仪表盘">
<VCardText>
<VRow>
<VCol
v-for="(item, key) in dashboard_names"
:key="key"
cols="12"
md="4"
>
<VCheckbox
v-model="config[key]"
:label="dashboard_names[key]"
/>
</VCol>
</VRow>
</VCardText>
<VCardActions>
<VBtn
color="primary"
@click="dialog = false"
>
取消
</VBtn>
<VSpacer />
<VBtn
color="primary"
@click="setDashboardConfig"
>
保存
</VBtn>
</VCardActions>
</VCard>
</vdialog>
</template>

View File

@@ -28,6 +28,18 @@ import MediaCardSlideView from '@/views/discover/MediaCardSlideView.vue'
title="热门电视剧"
/>
<MediaCardSlideView
apipath="douban/movie_hot"
linkurl="/browse/douban/movie_hot?title=热门电影"
title="热门电影"
/>
<MediaCardSlideView
apipath="douban/tv_hot"
linkurl="/browse/douban/tv_hot?title=热门电视剧"
title="热门电视剧"
/>
<MediaCardSlideView
apipath="douban/tv_animation"
linkurl="/browse/douban/tv_animation?title=热门动漫"

View File

@@ -31,7 +31,7 @@ export default {
elevation: 0,
},
VList: {
activeColor: 'primary',
color: 'primary',
},
VPagination: {
activeColor: 'primary',

View File

@@ -1,6 +0,0 @@
export function removeEl(selector: string) {
if (selector) {
const el = document.querySelector(selector)
el?.parentNode?.removeChild(el)
}
}

View File

@@ -1 +0,0 @@
export * from './dom'

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
import api from '@/api'
import type { MediaServerPlayItem } from '@/api/types'
import PosterCard from '@/components/cards/PosterCard.vue'
// 最近入库列表
const latestList = ref<MediaServerPlayItem[]>([])
// 调用API查询
async function loadLatest() {
try {
latestList.value = await api.get('mediaserver/latest')
}
catch (e) {
console.log(e)
}
}
onMounted(() => {
loadLatest()
})
</script>
<template>
<VCard>
<VCardItem>
<VCardTitle>最近添加</VCardTitle>
</VCardItem>
<div
v-if="latestList.length > 0"
class="grid gap-4 grid-media-card mx-3 mb-3"
tabindex="0"
>
<PosterCard
v-for="data in latestList"
:key="data.id"
:media="data"
/>
</div>
</VCard>
</template>
<style lang="scss">
.grid-media-card {
grid-template-columns: repeat(auto-fill, minmax(9.375rem, 1fr));
}
</style>

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
import api from '@/api'
import type { MediaServerPlayItem } from '@/api/types'
import LibraryCard from '@/components/cards/LibraryCard.vue'
// 媒体库列表
const libraryList = ref<MediaServerPlayItem[]>([])
// 调用API查询
async function loadLibrary() {
try {
libraryList.value = await api.get('mediaserver/library')
}
catch (e) {
console.log(e)
}
}
onMounted(() => {
loadLibrary()
})
</script>
<template>
<VCard>
<VCardItem>
<VCardTitle>我的媒体库</VCardTitle>
</VCardItem>
<div
v-if="libraryList.length > 0"
class="grid gap-4 grid-backdrop-card mx-3"
tabindex="0"
>
<LibraryCard
v-for="data in libraryList"
:key="data.id"
:media="data"
height="10rem"
/>
</div>
</VCard>
</template>
<style lang="scss">
.grid-backdrop-card {
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
padding-block-end: 1rem;
}
</style>

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
import api from '@/api'
import type { MediaServerPlayItem } from '@/api/types'
import BackdropCard from '@/components/cards/BackdropCard.vue'
// 继续播放列表
const playingList = ref<MediaServerPlayItem[]>([])
// 调用API查询
async function loadPlayingList() {
try {
playingList.value = await api.get('mediaserver/playing')
}
catch (e) {
console.log(e)
}
}
onMounted(() => {
loadPlayingList()
})
</script>
<template>
<VCard>
<VCardItem>
<VCardTitle>继续观看</VCardTitle>
</VCardItem>
<div
v-if="playingList.length > 0"
class="grid gap-4 grid-backdrop-card mx-3"
tabindex="0"
>
<BackdropCard
v-for="data in playingList"
:key="data.id"
:media="data"
height="10rem"
/>
</div>
</VCard>
</template>
<style lang="scss">
.grid-backdrop-card {
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
padding-block-end: 1rem;
}
</style>

View File

@@ -156,7 +156,7 @@ async function fetchData({ done }: { done: any }) {
v-if="dataList.length === 0 && isRefreshed"
error-code="404"
error-title="没有数据"
error-description="无法获取到TMDB媒体信息"
error-description="无法获取到媒体信息"
/>
</VInfiniteScroll>
</template>

View File

@@ -25,8 +25,8 @@ const mediaDetail = ref<MediaInfo>({} as MediaInfo)
// 订阅编辑弹窗
const subscribeEditDialog = ref(false)
// 本地是否存在
const isExists = ref(false)
// 本地是否存在存在则包括Item信息
const existsItemId = ref('')
// 是否已订阅
const isSubscribed = ref(false)
@@ -37,7 +37,7 @@ const isRefreshed = ref(false)
// 存储每一季的集信息
const seasonEpisodesInfo = ref({} as { [key: number]: TmdbEpisode[] })
// 各季缺失状态0-已存在 1-部分缺失 2-全部缺失,没有数据也是已存在
// 各季缺失状态0-已入库 1-部分缺失 2-全部缺失,没有数据也是已入库
const seasonsNotExisted = ref<{ [key: number]: number }>({})
// 各季的订阅状态
@@ -46,6 +46,11 @@ const seasonsSubscribed = ref<{ [key: number]: boolean }>({})
// 订阅编号
const subscribeId = ref<number>()
// 订阅规则
const subscribeRules = ref({
show_edit_dialog: false,
})
// 调用API查询详情
async function getMediaDetail() {
if (mediaProps.mediaid && mediaProps.type) {
@@ -59,9 +64,8 @@ async function getMediaDetail() {
return
// 检查存在状态
if (mediaDetail.value.type === '电影')
checkMovieExists()
else
checkExists()
if (mediaDetail.value.type === '电视剧')
checkSeasonsNotExists()
// 检查订阅状态
if (mediaDetail.value.type === '电影')
@@ -85,10 +89,10 @@ async function loadSeasonEpisodes(season: number) {
}
}
// 查询当前媒体是否已存在
async function checkMovieExists() {
// 查询当前媒体是否已入库
async function checkExists() {
try {
const result: { [key: string]: any } = await api.get('media/exists', {
const result: { [key: string]: any } = await api.get('mediaserver/exists', {
params: {
tmdbid: mediaDetail.value.tmdb_id,
title: mediaDetail.value.title,
@@ -99,7 +103,7 @@ async function checkMovieExists() {
})
if (result.success)
isExists.value = true
existsItemId.value = result.data.item.id
}
catch (error) {
console.error(error)
@@ -109,11 +113,12 @@ async function checkMovieExists() {
// 查询当前媒体是否已订阅
async function checkSubscribe(season = 0) {
try {
const mediaid = `tmdb:${mediaDetail.value.tmdb_id}`
const mediaid = mediaDetail.value.tmdb_id ? `tmdb:${mediaDetail.value.tmdb_id}` : `douban:${mediaDetail.value.douban_id}`
const result: Subscribe = await api.get(`subscribe/media/${mediaid}`, {
params: {
season,
title: mediaDetail.value.title,
},
})
@@ -132,20 +137,15 @@ async function checkSeasonsNotExists() {
if (mediaDetail.value.type !== '电视剧')
return
try {
const result: NotExistMediaInfo[] = await api.post('download/notexists', mediaDetail.value)
const result: NotExistMediaInfo[] = await api.post('mediaserver/notexists', mediaDetail.value)
if (result) {
if (result.length === 0)
isExists.value = true
result.forEach((item) => {
// 0-已存在 1-部分缺失 2-全部缺失
// 0-已入库 1-部分缺失 2-全部缺失
let state = 0
if (item.episodes.length === 0)
state = 2
else if (item.episodes.length < item.total_episode)
state = 1
if (state !== 2)
isExists.value = true
seasonsNotExisted.value[item.season] = state
})
}
@@ -187,7 +187,7 @@ async function addSubscribe(season = 0) {
startNProgress()
try {
// 是否洗版
let best_version = isExists.value ? 1 : 0
let best_version = existsItemId.value ? 1 : 0
if (season)
// 全部存在时洗版
best_version = !seasonsNotExisted.value[season] ? 1 : 0
@@ -220,7 +220,7 @@ async function addSubscribe(season = 0) {
)
// 显示编辑弹窗
if (result.success) {
if (result.success && subscribeRules.value.show_edit_dialog) {
subscribeId.value = result.data.id
subscribeEditDialog.value = true
}
@@ -282,6 +282,20 @@ async function removeSubscribe(season: number) {
doneNProgress()
}
// 查询订阅弹窗规则
async function querySubscribeRules() {
try {
const result: { [key: string]: any } = await api.get(
'system/setting/DefaultFilterRules',
)
if (result.data?.value)
subscribeRules.value = result.data?.value
}
catch (error) {
console.log(error)
}
}
// 订阅按钮响应
function handleSubscribe(season = 0) {
if (isSubscribed.value)
@@ -358,14 +372,14 @@ function getExistColor(season: number) {
function getExistText(season: number) {
const state = seasonsNotExisted.value[season]
if (!state)
return '已存在'
return '已入库'
if (state === 1)
return '部分缺失'
else if (state === 2)
return '缺失'
else
return '已存在'
return '已入库'
}
// 计算订阅图标
@@ -391,18 +405,40 @@ function joinArray(arr: string[]) {
// 开始搜索
function handleSearch(area: string) {
const keyword = mediaDetail.value.tmdb_id ? `tmdb:${mediaDetail.value.tmdb_id}` : `douban:${mediaDetail.value.douban_id}`
router.push({
path: '/resource',
query: {
keyword: `tmdb:${mediaDetail.value.tmdb_id}`,
keyword,
type: mediaDetail.value.type,
area,
},
})
}
// 跳转播放页面
async function handlePlay() {
// 获取播放链接地址
try {
const result: { [key: string]: any } = await api.get(
`mediaserver/play/${existsItemId.value}`,
)
if (result?.success) {
// 打开链接地址
setTimeout(() => {
window.open(result.data.url, '_blank')
}, 100)
}
else { $toast.error(`获取播放链接失败:${result.message}`) }
}
catch (error) {
console.error(error)
}
}
onBeforeMount(() => {
getMediaDetail()
querySubscribeRules()
})
</script>
@@ -418,9 +454,9 @@ onBeforeMount(() => {
/>
</div>
<div v-if="mediaDetail.tmdb_id || mediaDetail.douban_id" class="max-w-8xl mx-auto px-4">
<template v-if="mediaDetail.backdrop_path">
<template v-if="mediaDetail.backdrop_path || mediaDetail.poster_path">
<div class="vue-media-back absolute left-0 top-0 w-full h-96">
<VImg class="h-96" :src="mediaDetail.backdrop_path" cover />
<VImg class="h-96" :src="mediaDetail.backdrop_path || mediaDetail.poster_path" cover />
</div>
<div class="vue-media-back absolute left-0 top-0 w-full h-96" />
</template>
@@ -436,7 +472,7 @@ onBeforeMount(() => {
</VImg>
</div>
<div class="media-title">
<div v-if="isExists" class="media-status">
<div v-if="existsItemId" class="media-status">
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full whitespace-nowrap transition !no-underline bg-green-500 bg-opacity-80 border border-green-500 !text-green-100 hover:bg-green-500 hover:bg-opacity-100 false overflow-hidden">
<div class="relative z-20 flex items-center false"><span>已入库</span></div>
</span>
@@ -456,7 +492,7 @@ onBeforeMount(() => {
</span>
</div>
<div class="media-actions">
<VBtn v-if="mediaDetail.tmdb_id" variant="tonal" color="info">
<VBtn v-if="mediaDetail.tmdb_id || mediaDetail.douban_id" variant="tonal" color="info" class="mb-2">
<template #prepend>
<VIcon icon="mdi-magnify" />
</template>
@@ -482,12 +518,18 @@ onBeforeMount(() => {
</VList>
</VMenu>
</VBtn>
<VBtn v-if="mediaDetail.type === '电影'" class="ms-2" :color="getSubscribeColor" variant="tonal" @click="handleSubscribe(0)">
<VBtn v-if="mediaDetail.type === '电影' || mediaDetail.douban_id" class="ms-2 mb-2" :color="getSubscribeColor" variant="tonal" @click="handleSubscribe(0)">
<template #prepend>
<VIcon :icon="getSubscribeIcon" />
</template>
{{ isSubscribed ? '已订阅' : '订阅' }}
</VBtn>
<VBtn v-if="existsItemId" class="ms-2 mb-2" variant="tonal" @click="handlePlay()">
<template #prepend>
<VIcon icon="mdi-play" />
</template>
在线播放
</VBtn>
</div>
</div>
<div class="media-overview">
@@ -510,10 +552,6 @@ onBeforeMount(() => {
<span>{{ joinArray(director.roles) }}</span>
<a class="crew-name" :href="`${director.url}`" target="_blank">{{ director.name }}</a>
</li>
<li v-for="director in mediaDetail.actors" :key="director.id">
<span>{{ joinArray(director.roles) }}</span>
<a class="crew-name" :href="`${director.url}`" target="_blank">{{ director.name }}</a>
</li>
</ul>
<div class="mt-6">
<a v-if="mediaDetail.tmdb_id" class="mb-2 mr-2 inline-flex last:mr-0" :href="getTheMovieDbLink()" target="_blank">
@@ -665,12 +703,56 @@ onBeforeMount(() => {
</div>
</div>
</div>
<div v-else-if="mediaDetail.douban_id" class="media-overview-right">
<div class="media-facts">
<div v-if="mediaDetail.vote_average" class="media-ratings">
<VRating
v-model="mediaDetail.vote_average"
density="compact"
length="10"
class="ma-2"
readonly
/>
</div>
<div v-if="mediaDetail.douban_id" class="media-fact">
<span>豆瓣ID</span>
<span class="media-fact-value">{{ mediaDetail.douban_id }}</span>
</div>
<div v-if="mediaDetail.original_title" class="media-fact">
<span>原始标题</span>
<span class="media-fact-value">{{ mediaDetail.original_title }}</span>
</div>
<div v-if="mediaDetail.release_date" class="media-fact">
<span>上映日期</span>
<span class="media-fact-value">
{{ mediaDetail.release_date }}
</span>
</div>
<div v-if="mediaDetail.production_countries" class="media-fact border-b-0">
<span>出品国家</span>
<span class="media-fact-value">
<span v-for="country in getProductionCountries" :key="country" class="flex items-center justify-end text-end">
{{ country }}
</span>
</span>
</div>
</div>
</div>
</div>
<div v-if="mediaDetail.tmdb_id">
<PersonCardSlideView
:apipath="`tmdb/credits/${mediaDetail.tmdb_id}/${mediaProps.type}`"
:linkurl="`/credits/tmdb/credits/${mediaDetail.tmdb_id}/${mediaProps.type}?title=演员阵容`"
:linkurl="`/credits/tmdb/credits/${mediaDetail.tmdb_id}/${mediaProps.type}?title=演员阵容&type=tmdb`"
title="演员阵容"
type="tmdb"
/>
</div>
<div v-else-if="mediaDetail.douban_id">
<PersonCardSlideView
:apipath="`douban/credits/${mediaDetail.douban_id}/${mediaProps.type}`"
:linkurl="`/credits/douban/credits/${mediaDetail.douban_id}/${mediaProps.type}?title=演员阵容&type=douban`"
title="演员阵容"
type="douban"
/>
</div>
<div v-if="mediaDetail.tmdb_id">
@@ -680,6 +762,13 @@ onBeforeMount(() => {
title="推荐"
/>
</div>
<div v-else-if="mediaDetail.douban_id">
<MediaCardSlideView
:apipath="`douban/recommend/${mediaDetail.douban_id}/${mediaProps.type}`"
:linkurl="`/browse/douban/recommend/${mediaDetail.douban_id}/${mediaProps.type}?title=推荐`"
title="推荐"
/>
</div>
<div v-if="mediaDetail.tmdb_id">
<MediaCardSlideView
:apipath="`tmdb/similar/${mediaDetail.tmdb_id}/${mediaProps.type}`"
@@ -693,7 +782,7 @@ onBeforeMount(() => {
v-if="!mediaDetail.tmdb_id && !mediaDetail.douban_id && isRefreshed"
error-code="500"
error-title="出错啦"
error-description="未识别到TMDB媒体信息"
error-description="未识别到媒体信息"
/>
<!-- 订阅编辑弹窗 -->
<SubscribeEditForm

View File

@@ -1,13 +1,14 @@
<script lang="ts" setup>
import api from '@/api'
import type { TmdbPerson } from '@/api/types'
import PersonCard from '@/components/cards/PersonCard.vue'
import DoubanPersonCard from '@/components/cards/DoubanPersonCard.vue'
import TmdbPersonCard from '@/components/cards/TmdbPersonCard.vue'
import NoDataFound from '@/components/NoDataFound.vue'
// 输入参数
const props = defineProps({
apipath: String,
params: Object as PropType<{ [key: string]: any }>,
type: String,
})
// 判断是否有滚动条
@@ -29,8 +30,8 @@ const loading = ref(false)
const isRefreshed = ref(false)
// 数据列表
const dataList = ref<TmdbPerson[]>([])
const currData = ref<TmdbPerson[]>([])
const dataList = ref<any>([])
const currData = ref<any>([])
// 获取列表数据
async function fetchData({ done }: { done: any }) {
@@ -135,11 +136,22 @@ async function fetchData({ done }: { done: any }) {
>
<template #loading />
<div
v-if="dataList.length > 0"
v-if="dataList.length > 0 && props.type === 'tmdb'"
class="grid gap-4 grid-media-card mx-3"
tabindex="0"
>
<PersonCard
<TmdbPersonCard
v-for="data in dataList"
:key="data.id"
:person="data"
/>
</div>
<div
v-if="dataList.length > 0 && props.type === 'douban'"
class="grid gap-4 grid-media-card mx-3"
tabindex="0"
>
<DoubanPersonCard
v-for="data in dataList"
:key="data.id"
:person="data"
@@ -149,7 +161,7 @@ async function fetchData({ done }: { done: any }) {
v-if="dataList.length === 0 && isRefreshed"
error-code="404"
error-title="没有数据"
error-description="无法获取到TMDB媒体信息"
error-description="无法获取到媒体信息"
/>
</VInfiniteScroll>
</template>

View File

@@ -1,21 +1,22 @@
<script lang="ts" setup>
import PersionCard from '@/components/cards/PersonCard.vue'
import TmdbPersonCard from '@/components/cards/TmdbPersonCard.vue'
import api from '@/api'
import type { TmdbPerson } from '@/api/types'
import SlideView from '@/components/slide/SlideView.vue'
import DoubanPersonCard from '@/components/cards/DoubanPersonCard.vue'
// 输入参数
const props = defineProps({
apipath: String,
linkurl: String,
title: String,
type: String,
})
// 组件加载完成
const componentLoaded = ref(false)
// 数据列表
const dataList = ref<TmdbPerson[]>([])
const dataList = ref<any>([])
// 获取订阅列表数据
async function fetchData() {
@@ -46,7 +47,14 @@ onMounted(fetchData)
v-for="data in dataList"
:key="data.id"
>
<PersionCard
<TmdbPersonCard
v-if="props.type === 'tmdb'"
:person="data"
height="15rem"
width="10rem"
/>
<DoubanPersonCard
v-if="props.type === 'douban'"
:person="data"
height="15rem"
width="10rem"

View File

@@ -1,8 +1,8 @@
<script lang="ts" setup>
import { ref } from 'vue'
import _ from 'lodash'
import type { Context } from '@/api/types'
import TorrentCard from '@/components/cards/TorrentCard.vue'
import { useDefer } from '@/@core/utils/dom'
interface SearchTorrent extends Context {
more?: Array<Context>
@@ -48,7 +48,7 @@ const editionFilterOptions = ref<Array<string>>([])
const resolutionFilterOptions = ref<Array<string>>([])
// 数据列表
const dataList = ref <Array<SearchTorrent>>([])
const dataList = ref<Array<SearchTorrent>>([])
// 分组后的数据列表
const groupedDataList = ref<Map<string, Context[]>>()
@@ -69,7 +69,7 @@ function initOptions(data: Context) {
}
// 计算分组后的列表
watchEffect(() => {
onMounted(() => {
// 数据分组
const groupMap = new Map<string, Context[]>()
// 遍历数据
@@ -80,7 +80,7 @@ watchEffect(() => {
// group data
const key = `${torrent_info.title}_${torrent_info.size}`
if (groupMap.has(key)) {
// 已存在相同标题和大小的分组,将当前上下文信息添加到分组中
// 已入库相同标题和大小的分组,将当前上下文信息添加到分组中
const group = groupMap.get(key)
group?.push(item)
}
@@ -92,10 +92,12 @@ watchEffect(() => {
groupedDataList.value = groupMap
})
let defer = (_: number) => true
// 计算过滤后的列表
watchEffect(() => {
// 清空列表
dataList.value.splice(0)
dataList.value = []
// 匹配过滤函数
const match = (filter: Array<string>, value: string | undefined) =>
filter.length === 0 || (value && filter.includes(value))
@@ -126,10 +128,12 @@ watchEffect(() => {
const firstData = _.cloneDeepWith(matchData[0]) as SearchTorrent
if (matchData.length > 1)
firstData.more = matchData.slice(1)
dataList.value.push(firstData)
}
}
})
defer = useDefer(dataList.value.length)
})
</script>
@@ -216,12 +220,9 @@ watchEffect(() => {
</VRow>
</VCard>
<div class="grid gap-3 grid-torrent-card items-start">
<TorrentCard
v-for="(item, index) in dataList"
:key="`${index}_${item.torrent_info.title}_${item.torrent_info.site}`"
:torrent="item"
:more="item.more"
/>
<div v-for="(item, index) in dataList" :key="`${index}_${item.torrent_info.title}_${item.torrent_info.site}`">
<TorrentCard v-if="defer(index)" :torrent="item" :more="item.more" />
</div>
</div>
</template>

View File

@@ -1,6 +1,7 @@
<script lang="ts" setup>
import type { Context } from '@/api/types'
import TorrentItem from '@/components/cards/TorrentItem.vue'
import { useDefer } from '@/@core/utils/dom'
// 定义输入参数
const props = defineProps({
@@ -27,7 +28,7 @@ const filterForm = reactive({
})
// 数据列表
const dataList = ref <Array<Context>>([])
const dataList = ref<Array<Context>>([])
// 获取站点过滤选项
const siteFilterOptions = ref<Array<string>>([])
@@ -59,10 +60,12 @@ function initOptions(data: Context) {
optionValue(resolutionFilterOptions.value, meta_info?.resource_pix)
}
let defer = (_: number) => true
// 计算过滤后的列表
watchEffect(() => {
// 清空列表
dataList.value.splice(0)
dataList.value = []
// 匹配过滤函数
const match = (filter: Array<string>, value: string | undefined) =>
filter.length === 0 || (value && filter.includes(value))
@@ -72,21 +75,22 @@ watchEffect(() => {
if (
// 站点过滤
match(filterForm.site, torrent_info.site_name)
// 促销状态过滤
&& match(filterForm.freeState, torrent_info.volume_factor)
// 季过滤
&& match(filterForm.season, meta_info.season_episode)
// 制作组过滤
&& match(filterForm.releaseGroup, meta_info.resource_team)
// 视频编码过滤
&& match(filterForm.videoCode, meta_info.video_encode)
// 分辨率过滤
&& match(filterForm.resolution, meta_info.resource_pix)
// 质量过滤
&& match(filterForm.edition, meta_info.edition)
// 促销状态过滤
&& match(filterForm.freeState, torrent_info.volume_factor)
// 季过滤
&& match(filterForm.season, meta_info.season_episode)
// 制作组过滤
&& match(filterForm.releaseGroup, meta_info.resource_team)
// 视频编码过滤
&& match(filterForm.videoCode, meta_info.video_encode)
// 分辨率过滤
&& match(filterForm.resolution, meta_info.resource_pix)
// 质量过滤
&& match(filterForm.edition, meta_info.edition)
)
dataList.value.push(data)
})
defer = useDefer(dataList.value.length)
})
// 初始化过滤选项
@@ -100,25 +104,18 @@ onMounted(() => {
<template>
<VRow>
<VCol>
<VList
lines="three"
class="rounded"
>
<TorrentItem
v-for="(item, index) in dataList"
:key="`${index}_${item.torrent_info.title}_${item.torrent_info.site}`"
:torrent="item"
/>
<VListItem v-if="dataList.length === 0">
<VList v-if="dataList.length === 0" lines="three" class="rounded">
<VListItem>
<VListItemTitle>没有附合当前过滤条件的资源</VListItemTitle>
</VListItem>
</VList>
<div>
<div v-for="(item, index) in dataList" :key="`${index}_${item.torrent_info.title}_${item.torrent_info.site}`">
<TorrentItem v-if="defer(index)" :torrent="item" />
</div>
</div>
</VCol>
<VCol
xl="2"
md="3"
class="d-none d-md-block"
>
<VCol xl="2" md="3" class="d-none d-md-block">
<VList lines="one" class="rounded">
<VListSubheader v-if="siteFilterOptions.length > 0">
站点

View File

@@ -19,9 +19,9 @@ const getInstalledPluginList = computed(() => {
return dataList.value.filter(item => item.installed)
})
// 获取未安装的插件列表
// 获取未安装或者有更新的插件列表
const getUninstalledPluginList = computed(() => {
return dataList.value.filter(item => !item.installed)
return dataList.value.filter(item => !item.installed || item.has_update)
})
// 关闭插件市场窗口
@@ -84,13 +84,14 @@ onBeforeMount(fetchData)
<VDialog
v-model="PluginAppDialog"
fullscreen
scrollable
:scrim="false"
transition="dialog-bottom-transition"
>
<!-- Dialog Activator -->
<template #activator="{ props }">
<VBtn
icon="mdi-plus"
icon="mdi-store-plus"
v-bind="props"
size="x-large"
class="fixed right-5 bottom-5"
@@ -119,7 +120,7 @@ onBeforeMount(fetchData)
</VToolbarItems>
</VToolbar>
</div>
<div class="pa-4">
<VCardText>
<div class="grid gap-4 grid-plugin-card">
<PluginAppCard
v-for="data in getUninstalledPluginList"
@@ -134,7 +135,7 @@ onBeforeMount(fetchData)
error-title="没有未安装插件"
error-description="所有可用插件均已安装"
/>
</div>
</VCardText>
</VCard>
</VDialog>
</template>

View File

@@ -3,42 +3,79 @@ import api from '@/api'
import FileBrowser from '@/components/FileBrowser.vue'
const endpoints = {
list: { url: '/filebrowser/list?path={path}&sort={sort}', method: 'get' },
mkdir: { url: '/filebrowser/mkdir?path={path}', method: 'get' },
delete: { url: '/filebrowser/delete?path={path}', method: 'get' },
download: { url: '/filebrowser/download?path={path}', method: 'get' },
image: { url: '/filebrowser/image?path={path}', method: 'get' },
rename: { url: '/filebrowser/rename?path={path}&new_name={newname}', method: 'get' },
list: {
url: '/filebrowser/list?path={path}&sort={sort}',
method: 'get',
},
mkdir: {
url: '/filebrowser/mkdir?path={path}',
method: 'get',
},
delete: {
url: '/filebrowser/delete?path={path}',
method: 'get',
},
download: {
url: '/filebrowser/download?path={path}',
method: 'get',
},
image: {
url: '/filebrowser/image?path={path}',
method: 'get',
},
rename: {
url: '/filebrowser/rename?path={path}&new_name={newname}',
method: 'get',
},
}
// 读取下载目录
const path = ref('/')
const path: Ref<string | undefined> = ref()
// 调用API加载当前系统环境设置
async function loadSystemSettings() {
try {
const result: { [key: string]: any } = await api.get('system/env')
if (result.success)
path.value = result.data?.DOWNLOAD_PATH || '/'
if (path.value && !path.value.endsWith('/'))
path.value += '/'
}
catch (error) {
console.log(error)
}
function loadSystemSettings(): Promise<string> {
return new Promise((resolve, reject) => {
api
.get('system/env')
.then((result: any) => {
let path = '/'
if (result.success)
path = result.data?.DOWNLOAD_PATH || '/'
if (!path.endsWith('/'))
path += '/'
resolve(path)
})
.catch(error => reject(error))
})
}
function pathChanged(_path: string) {
path.value = _path
}
onBeforeMount(async () => {
await loadSystemSettings()
onMounted(() => {
loadSystemSettings()
.then((res) => {
path.value = res
})
.catch((error) => {
console.error(error)
path.value = '/'
})
})
</script>
<template>
<div>
<FileBrowser storages="local" :tree="false" :path="path" :endpoints="endpoints" :axios="api" @pathchanged="pathChanged" />
<FileBrowser
storages="local"
:tree="false"
:path="path"
:endpoints="endpoints"
:axios="api"
@pathchanged="pathChanged"
/>
</div>
</template>

View File

@@ -25,20 +25,46 @@ const selected = ref<TransferHistory[]>([])
// 表头
const headers = [
{ title: '标题', key: 'title', sortable: false },
{ title: '目录', key: 'src', sortable: false },
{ title: '转移方式', key: 'mode', sortable: false },
{ title: '时间', key: 'date', sortable: false },
{ title: '状态', key: 'status', sortable: false },
{ title: '失败原因', key: 'errmsg', sortable: false },
{ title: '', key: 'actions', sortable: false },
{
title: '标题',
key: 'title',
sortable: false,
},
{
title: '目录',
key: 'src',
sortable: false,
},
{
title: '转移方式',
key: 'mode',
sortable: false,
},
{
title: '时间',
key: 'date',
sortable: false,
},
{
title: '状态',
key: 'status',
sortable: false,
},
{
title: '',
key: 'actions',
sortable: false,
},
]
// 数据列表
const dataList = ref<TransferHistory[]>([])
// 搜索
const search = ref('')
const search = ref()
// 搜索提示词列表
const searchHintList = ref<string[]>([])
// 加载状态
const loading = ref(false)
@@ -47,7 +73,7 @@ const loading = ref(false)
const totalItems = ref(0)
// 每页条数
const itemsPerPage = ref(25)
const itemsPerPage = ref(50)
// 当前页码
const currentPage = ref(1)
@@ -68,13 +94,7 @@ const deleteConfirmDialog = ref(false)
const confirmTitle = ref('')
// 获取订阅列表数据
async function fetchData({
page,
itemsPerPage,
}: {
page: number
itemsPerPage: number
}) {
async function fetchData({ page, itemsPerPage }: { page: number; itemsPerPage: number }) {
loading.value = true
try {
currentPage.value = page
@@ -89,6 +109,9 @@ async function fetchData({
dataList.value = result.data.list
totalItems.value = result.data.total
searchHintList.value = ['失败', '成功', ...new Set(dataList.value.map(item => item.title || ''))].filter(
title => title !== '',
)
}
catch (error) {
console.error(error)
@@ -106,11 +129,6 @@ function getIcon(type: string) {
return 'mdi-help-circle'
}
// 计算颜色
function getStatusColor(status: boolean) {
return status ? 'success' : 'error'
}
// 转移方式字典
const TransferDict: { [key: string]: string } = {
copy: '复制',
@@ -132,7 +150,9 @@ async function removeHistory(item: TransferHistory) {
async function remove(item: TransferHistory, deleteSrc: boolean, deleteDest: boolean) {
try {
// 调用删除API
const result: { [key: string]: any } = await api.delete(`history/transfer?deletesrc=${deleteSrc}&deletedest=${deleteDest}`, {
const result: {
[key: string]: any
} = await api.delete(`history/transfer?deletesrc=${deleteSrc}&deletedest=${deleteDest}`, {
data: item,
})
@@ -150,6 +170,7 @@ async function removeSingle(deleteSrc: boolean, deleteDest: boolean) {
deleteConfirmDialog.value = false
if (!currentHistory.value)
return
// 删除
await remove(currentHistory.value, deleteSrc, deleteDest)
// 刷新
@@ -167,6 +188,7 @@ async function removeBatch(deleteSrc: boolean, deleteDest: boolean) {
const total = selected.value.length
if (total === 0)
return
// 已处理条数
let handled = 0
// 显示进度条
@@ -178,7 +200,7 @@ async function removeBatch(deleteSrc: boolean, deleteDest: boolean) {
await remove(item, deleteSrc, deleteDest)
// 删除完成
handled++
progressValue.value = handled / total * 100
progressValue.value = (handled / total) * 100
}
// 清空选中项
selected.value = []
@@ -203,6 +225,7 @@ async function deleteConfirmHandler(deleteSrc: boolean, deleteDest: boolean) {
async function removeHistoryBatch() {
if (selected.value.length === 0)
return
// 清空当前操作记录
currentHistory.value = undefined
confirmTitle.value = `确认删除 ${selected.value.length} 条记录 ?`
@@ -210,19 +233,47 @@ async function removeHistoryBatch() {
deleteConfirmDialog.value = true
}
// 计算根路径
function getRootPath(path: string, type: string, category: string) {
if (!path)
return ''
let index = -2
if (type !== '电影')
index = -3
if (category)
index -= 1
if (path.includes('/'))
return path.split('/').slice(0, index).join('/')
else
return path.split('\\').slice(0, index).join('\\')
}
// 批量重新整理
async function retransferBatch() {
if (selected.value.length === 0)
return
// 清空当前操作记录
currentHistory.value = undefined
// 重新整理IDS
redoIds.value = selected.value.map(item => item.id)
// 重新整理target
if (selected.value.length === 1)
redoTarget.value = selected.value[0].dest ?? ''
else
if (selected.value.length === 1) {
// 目的目录
const dest = selected.value[0].dest ?? ''
// 类型
const mediaType = selected.value[0].type ?? ''
// 分类
const category = selected.value[0].category ?? ''
// 计算根路径
redoTarget.value = getRootPath(dest, mediaType, category)
}
else {
redoTarget.value = ''
}
// 打开识别弹窗
redoDialog.value = true
}
@@ -236,7 +287,7 @@ const dropdownItems = ref([
prependIcon: 'mdi-redo-variant',
click: (item: TransferHistory) => {
redoIds.value = [item.id]
redoTarget.value = item.dest ?? ''
redoTarget.value = getRootPath(item.dest ?? '', item.type ?? '', item.category ?? '')
redoDialog.value = true
},
},
@@ -260,20 +311,24 @@ const dropdownItems = ref([
<VCardItem>
<VCardTitle>
<VRow>
<VCol> 历史记录 </VCol>
<VCol>
<VTextField
<VCol cols="4" md="6">
历史记录
</VCol>
<VCol cols="8" md="6">
<VCombobox
key="search_navbar"
v-model="search"
:items="searchHintList"
class="text-disabled"
density="compact"
label="搜索"
label="搜索标题、状态"
append-inner-icon="mdi-magnify"
variant="solo-filled"
single-line
hide-details
flat
rounded
clearable
/>
</VCol>
</VRow>
@@ -297,58 +352,52 @@ const dropdownItems = ref([
@update:options="fetchData"
>
<template #item.title="{ item }">
<div class="d-flex">
<VAvatar><VIcon :icon="getIcon(item.raw.type || '')" /></VAvatar>
<div class="d-flex align-center">
<VAvatar>
<VIcon :icon="getIcon(item.value.type || '')" />
</VAvatar>
<div class="d-flex flex-column ms-1">
<span class="d-block whitespace-nowrap text-high-emphasis">
{{ item.raw.title }} {{ item.raw.seasons }}{{ item.raw.episodes }}
{{ item.value.title }} {{ item.value.seasons }}{{ item.value.episodes }}
</span>
<small>{{ item.raw.category }}</small>
<small>{{ item.value.category }}</small>
</div>
</div>
</template>
<template #item.src="{ item }">
<small>{{ item.raw.src }} <br>=> {{ item.raw.dest }}</small>
<small>{{ item.value.src }} <br>=> {{ item.value.dest }}</small>
</template>
<template #item.mode="{ item }">
<VChip
variant="outlined"
color="primary"
size="small"
>
{{
TransferDict[item.raw.mode]
}}
<VChip variant="outlined" color="primary" size="small">
{{ TransferDict[item.value.mode] }}
</VChip>
</template>
<template #item.status="{ item }">
<VChip
:color="getStatusColor(item.raw.status)"
size="small"
>
{{ item.raw.status ? "成功" : "失败" }}
<VChip v-if="item.value.status" color="success" size="small">
成功
</VChip>
<v-tooltip v-else :text="item.value.errmsg">
<template #activator="{ props }">
<VChip v-bind="props" color="error" size="small">
失败
</VChip>
</template>
</v-tooltip>
</template>
<template #item.date="{ item }">
<small>{{ item.raw.date }}</small>
</template>
<template #item.errmsg="{ item }">
<small class="text-error">{{ item.raw.errmsg }}</small>
<small>{{ item.value.date }}</small>
</template>
<template #item.actions="{ item }">
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu
activator="parent"
close-on-content-click
>
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem
v-for="(menu, i) in dropdownItems"
:key="i"
variant="plain"
:base-color="menu.props.color"
@click="menu.props.click(item.raw)"
@click="menu.props.click(item.value)"
>
<template #prepend>
<VIcon :icon="menu.props.prependIcon" />
@@ -365,23 +414,9 @@ const dropdownItems = ref([
</VDataTableServer>
</VCard>
<!-- 底部操作按钮 -->
<span
v-if="selected.length > 0"
class="fixed right-5 bottom-5"
>
<VBtn
icon="mdi-redo-variant"
class="me-2"
color="primary"
size="x-large"
@click="retransferBatch"
/>
<VBtn
icon="mdi-trash-can-outline"
color="error"
size="x-large"
@click="removeHistoryBatch"
/>
<span v-if="selected.length > 0" class="fixed right-5 bottom-5">
<VBtn icon="mdi-redo-variant" class="me-2" color="primary" size="x-large" @click="retransferBatch" />
<VBtn icon="mdi-trash-can-outline" color="error" size="x-large" @click="removeHistoryBatch" />
</span>
<!-- 底部弹窗 -->
<VBottomSheet v-model="deleteConfirmDialog" inset>
@@ -390,33 +425,17 @@ const dropdownItems = ref([
<VCardTitle class="pe-10">
{{ confirmTitle }}
</VCardTitle>
<div class="d-flex flex-column flex-lg-row justify-center my-3">
<VBtn
color="primary"
class="mb-2 mx-2"
@click="deleteConfirmHandler(false, false)"
>
<div class="d-flex flex-column flex-lg-row justify-center my-3">
<VBtn color="primary" class="mb-2 mx-2" @click="deleteConfirmHandler(false, false)">
仅删除历史记录
</VBtn>
<VBtn
color="warning"
class="mb-2 mx-2"
@click="deleteConfirmHandler(true, false)"
>
<VBtn color="warning" class="mb-2 mx-2" @click="deleteConfirmHandler(true, false)">
删除历史记录和源文件
</VBtn>
<VBtn
color="info"
class="mb-2 mx-2"
@click="deleteConfirmHandler(false, true)"
>
<VBtn color="info" class="mb-2 mx-2" @click="deleteConfirmHandler(false, true)">
删除历史记录和媒体库文件
</VBtn>
<VBtn
color="error"
class="mb-2 mx-2"
@click="deleteConfirmHandler(true, true)"
>
<VBtn color="error" class="mb-2 mx-2" @click="deleteConfirmHandler(true, true)">
删除历史记录源文件和媒体库文件
</VBtn>
</div>
@@ -427,17 +446,19 @@ const dropdownItems = ref([
v-model="redoDialog"
:logids="redoIds"
:target="redoTarget"
@done="() => {
redoDialog = false
// 清空当前操作记录
currentHistory = undefined
selected = []
// 刷新
fetchData({
page: currentPage,
itemsPerPage,
})
}"
@done="
() => {
redoDialog = false
// 清空当前操作记录
currentHistory = undefined
selected = []
// 刷新
fetchData({
page: currentPage,
itemsPerPage,
})
}
"
@close="redoDialog = false"
/>
</template>

View File

@@ -84,7 +84,7 @@ onMounted(() => {
<div>
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
<dt class="block text-sm font-bold">
当前版本
软件版本
</dt>
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
<span class="flex-grow flex flex-row items-center truncate">
@@ -98,6 +98,30 @@ onMounted(() => {
</dd>
</div>
</div>
<div>
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
<dt class="block text-sm font-bold">
认证资源版本
</dt>
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
<span class="flex-grow flex flex-row items-center truncate">
<code class="truncate">{{ systemEnv.AUTH_VERSION }}</code>
</span>
</dd>
</div>
</div>
<div>
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
<dt class="block text-sm font-bold">
站点资源版本
</dt>
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
<span class="flex-grow flex flex-row items-center truncate">
<code class="truncate">{{ systemEnv.INDEXER_VERSION }}</code>
</span>
</dd>
</div>
</div>
<div>
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
<dt class="block text-sm font-bold">

View File

@@ -147,6 +147,10 @@ async function deactivateUser(user: User) {
// 新增用户
async function addUser() {
if (!userForm.name || !userForm.password || !userForm.email) {
$toast.error('请填写完整信息!')
return
}
try {
const result: { [key: string]: any } = await api.post('user', userForm)
if (result.success) {
@@ -447,6 +451,7 @@ onMounted(() => {
>
<VTextField
v-model="userForm.email"
:rules="[requiredValidator]"
label="邮箱"
/>
</VCol>

View File

@@ -3,6 +3,8 @@ import { useToast } from 'vue-toast-notification'
import api from '@/api'
import FilterRuleCard from '@/components/cards/FilterRuleCard.vue'
import type { Site } from '@/api/types'
import { copyToClipboard } from '@/@core/utils/navigator'
import ImportCodeForm from '@/components/form/ImportCodeForm.vue'
// 规则卡片类型
interface FilterCard {
@@ -30,6 +32,12 @@ const defaultFilterRules = ref({
exclude: '',
})
// 导入代码弹窗
const importCodeDialog = ref(false)
// 导入的代码
const importCodeString = ref('')
// 查询已设置优先级规则
async function queryCustomFilters() {
try {
@@ -227,6 +235,49 @@ async function saveDefaultFilter() {
}
}
// 分享规则
function shareRules() {
// 有值才处理
if (filterCards.value.length === 0)
return
// 将卡片规则接装为字符串
const value = filterCards.value
.filter(card => card.rules.length > 0)
.map(card => card.rules.join('&'))
.join('>')
// 复制到剪贴板
try {
copyToClipboard(value)
$toast.success('优先级规则已复制到剪贴板')
}
catch (error) {
$toast.error('优先级规则复制失败!')
}
}
// 监听导入代码变化
watchEffect(() => {
if (!importCodeString.value)
return
// 导入代码需要以空格开头和结束,没有则拼接
if (!importCodeString.value.startsWith(' '))
importCodeString.value = ` ${importCodeString.value}`
if (!importCodeString.value.endsWith(' '))
importCodeString.value = `${importCodeString.value} `
// 将导入的代码转换为规则卡片
const groups = importCodeString.value.split('>')
filterCards.value = groups.map((group: string, index: number) => {
return {
pri: (index + 1).toString(),
rules: group.split('&'),
}
})
})
onMounted(() => {
queryCustomFilters()
querySites()
@@ -264,6 +315,36 @@ onMounted(() => {
</VCol>
<VCol cols="12">
<VCard title="搜索优先级">
<template #append>
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu
activator="parent"
close-on-content-click
>
<VList>
<VListItem
variant="plain"
@click="shareRules"
>
<template #prepend>
<VIcon icon="mdi-share" />
</template>
<VListItemTitle>分享</VListItemTitle>
</VListItem>
<VListItem
variant="plain"
@click="importCodeDialog = true"
>
<template #prepend>
<VIcon icon="mdi-import" />
</template>
<VListItemTitle>导入</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</template>
<VCardSubtitle> 设置在搜索时默认使用的优先级排序未在优先级中的资源将不在搜索结果中显示 </VCardSubtitle>
<VCardItem>
<div class="grid gap-3 grid-filterrule-card">
@@ -332,6 +413,17 @@ onMounted(() => {
</VCard>
</VCol>
</VRow>
<VDialog
v-model="importCodeDialog"
width="60rem"
scrollable
>
<ImportCodeForm
v-model="importCodeString"
title="导入优先级规则"
@close="importCodeDialog = false"
/>
</VDialog>
</template>
<style lang="scss">

View File

@@ -3,6 +3,8 @@ import { useToast } from 'vue-toast-notification'
import api from '@/api'
import FilterRuleCard from '@/components/cards/FilterRuleCard.vue'
import type { Site } from '@/api/types'
import { copyToClipboard } from '@/@core/utils/navigator'
import ImportCodeForm from '@/components/form/ImportCodeForm.vue'
// 规则卡片类型
interface FilterCard {
@@ -27,12 +29,24 @@ const allSites = ref<Site[]>([])
// 选中订阅站点
const selectedRssSites = ref<number[]>([])
// 当前规则类型
const currentRuleType = ref('SubscribeFilterRules')
// 包含与排除规则
const defaultFilterRules = ref({
include: '',
exclude: '',
movie_size: '',
tv_size: '',
show_edit_dialog: false,
})
// 导入代码弹窗
const importCodeDialog = ref(false)
// 导入的代码
const importCodeString = ref('')
// 查询用户选中的订阅站点
async function querySelectedRssSites() {
try {
@@ -244,6 +258,66 @@ async function saveDefaultFilter() {
}
}
// 分享规则
function shareRules(ruleType: string) {
let filterCards: Ref<FilterCard[]>
if (ruleType === 'SubscribeFilterRules')
filterCards = subscribeFilterCards
else
filterCards = bestVersionFilterCards
// 有值才处理
if (filterCards.value.length === 0)
return
// 将卡片规则接装为字符串
const value = filterCards.value
.filter(card => card.rules.length > 0)
.map(card => card.rules.join('&'))
.join('>')
// 复制到剪贴板
try {
copyToClipboard(value)
$toast.success('优先级规则已复制到剪贴板')
}
catch (error) {
$toast.error('优先级规则复制失败!')
}
}
// 导入规则
async function importRules(ruleType: string) {
currentRuleType.value = ruleType
importCodeString.value = ''
importCodeDialog.value = true
}
// 监听导入代码变化
watchEffect(() => {
if (!importCodeString.value)
return
if (!currentRuleType.value)
return
// 导入代码需要以空格开头和结束,没有则拼接
if (!importCodeString.value.startsWith(' '))
importCodeString.value = ` ${importCodeString.value}`
if (!importCodeString.value.endsWith(' '))
importCodeString.value = `${importCodeString.value} `
let filterCards: Ref<FilterCard[]>
if (currentRuleType.value === 'SubscribeFilterRules')
filterCards = subscribeFilterCards
else
filterCards = bestVersionFilterCards
// 将导入的代码转换为规则卡片
const groups = importCodeString.value.split('>')
filterCards.value = groups.map((group: string, index: number) => {
return {
pri: (index + 1).toString(),
rules: group.split('&'),
}
})
})
onMounted(() => {
querySites()
queryCustomFilters('SubscribeFilterRules')
@@ -282,6 +356,36 @@ onMounted(() => {
</VCol>
<VCol cols="12">
<VCard title="订阅优先级">
<template #append>
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu
activator="parent"
close-on-content-click
>
<VList>
<VListItem
variant="plain"
@click="shareRules('SubscribeFilterRules')"
>
<template #prepend>
<VIcon icon="mdi-share" />
</template>
<VListItemTitle>分享</VListItemTitle>
</VListItem>
<VListItem
variant="plain"
@click="importRules('SubscribeFilterRules')"
>
<template #prepend>
<VIcon icon="mdi-import" />
</template>
<VListItemTitle>导入</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</template>
<VCardSubtitle> 设置在正常订阅时默认使用的优先级未在优先级中的资源将不会自动下载 </VCardSubtitle>
<VCardItem>
<div class="grid gap-3 grid-filterrule-card">
@@ -318,6 +422,36 @@ onMounted(() => {
</VCol>
<VCol cols="12">
<VCard title="洗版优先级">
<template #append>
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu
activator="parent"
close-on-content-click
>
<VList>
<VListItem
variant="plain"
@click="shareRules('BestVersionFilterRules')"
>
<template #prepend>
<VIcon icon="mdi-share" />
</template>
<VListItemTitle>分享</VListItemTitle>
</VListItem>
<VListItem
variant="plain"
@click="importRules('BestVersionFilterRules')"
>
<template #prepend>
<VIcon icon="mdi-import" />
</template>
<VListItemTitle>导入</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</template>
<VCardSubtitle> 设置在订阅洗版时使用的优先级匹配优先级1时洗版完成 </VCardSubtitle>
<VCardItem>
<div class="grid gap-3 grid-filterrule-card">
@@ -372,6 +506,28 @@ onMounted(() => {
label="排除(关键字、正则式)"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="defaultFilterRules.movie_size"
type="text"
label="电影文件大小GB"
placeholder="0-30"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="defaultFilterRules.tv_size"
type="text"
label="剧集单集文件大小GB"
placeholder="0-10"
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="defaultFilterRules.show_edit_dialog"
label="订阅时编辑更多规则"
/>
</VCol>
</VRow>
</VForm>
</VCardText>
@@ -386,6 +542,17 @@ onMounted(() => {
</VCard>
</VCol>
</VRow>
<VDialog
v-model="importCodeDialog"
width="60rem"
scrollable
>
<ImportCodeForm
v-model="importCodeString"
title="导入优先级规则"
@close="importCodeDialog = false"
/>
</VDialog>
</template>
<style lang="scss">

View File

@@ -173,7 +173,7 @@ onMounted(() => {
<VAlert
type="info"
variant="tonal"
title="支持的配置格式:"
title="支持的配置格式(注意空格)"
>
<span
v-html="`
@@ -181,7 +181,7 @@ onMounted(() => {
被替换词 => 替换词<br>
前定位词 <> 后定位词 >> 集偏移量EP<br>
被替换词 => 替换词 && 前定位词 <> 后定位词 >> 集偏移量EP<br>
其中替换词支持格式:{[tmdbid=xxx;type=movie/tv;s=xxx;e=xxx]} 直接指定TMDBID识别其中s、e为季数和集数可选<br>
其中替换词支持格式:{[tmdbid/doubanid=xxx;type=movie/tv;s=xxx;e=xxx]} 直接指定TMDBID/豆瓣ID识别其中s、e为季数和集数可选<br>
`"
/>
</VAlert>

Some files were not shown because too many files have changed in this diff Show More