mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-05-08 09:12:43 +08:00
Compare commits
156 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fdbc8104c | ||
|
|
433c14679c | ||
|
|
fcaa4476f0 | ||
|
|
85c5c3058c | ||
|
|
035122a08e | ||
|
|
0a76875f8e | ||
|
|
218eac54ce | ||
|
|
84deeff4f5 | ||
|
|
0c72d026f6 | ||
|
|
aec9ea83c5 | ||
|
|
effd13aedd | ||
|
|
42b43d65d7 | ||
|
|
c501d824dd | ||
|
|
384ac2faf1 | ||
|
|
dd2c4dd24b | ||
|
|
356ffddb1c | ||
|
|
de69be7c4e | ||
|
|
e962f555ae | ||
|
|
1987246585 | ||
|
|
393264f66b | ||
|
|
9b50020b3b | ||
|
|
5e5545fe01 | ||
|
|
0e8da35b0a | ||
|
|
4d2cf73330 | ||
|
|
5df89f2ce4 | ||
|
|
045c0b4c0c | ||
|
|
8b4ffa0795 | ||
|
|
14359a37ae | ||
|
|
a8e4a1c2e0 | ||
|
|
9048d181af | ||
|
|
1cb02994bf | ||
|
|
6fad85e957 | ||
|
|
db9b2ee6b3 | ||
|
|
8efeb77102 | ||
|
|
0215a800e2 | ||
|
|
87d282f98b | ||
|
|
60c392d3d0 | ||
|
|
34c3aa25da | ||
|
|
80690d4cc8 | ||
|
|
18f3dc2d44 | ||
|
|
e8256b4e1a | ||
|
|
4f67bb0250 | ||
|
|
5dd071adf4 | ||
|
|
aaf5e7f49d | ||
|
|
6a5958409a | ||
|
|
e0ff98b1d7 | ||
|
|
a815e07cdd | ||
|
|
aa2fe9740c | ||
|
|
75a358a4d2 | ||
|
|
d5646be6f8 | ||
|
|
cb04ebcd95 | ||
|
|
9889ccfc74 | ||
|
|
f528bd861a | ||
|
|
f793654bd8 | ||
|
|
8d064a2165 | ||
|
|
1240899b08 | ||
|
|
558752b890 | ||
|
|
997548b7d6 | ||
|
|
865d597fe8 | ||
|
|
b0a043b464 | ||
|
|
e003b6f9a7 | ||
|
|
9e9e940dfd | ||
|
|
d6dac704eb | ||
|
|
9aa8dff650 | ||
|
|
14c2503b0d | ||
|
|
cb282c6f9a | ||
|
|
66a5a40482 | ||
|
|
8d211ed20b | ||
|
|
bbf2814285 | ||
|
|
a15e479a3e | ||
|
|
505d6ec010 | ||
|
|
314ac65e23 | ||
|
|
118a9a2c5d | ||
|
|
347f47bbef | ||
|
|
a73c35468d | ||
|
|
f9a1446ed5 | ||
|
|
874ba45034 | ||
|
|
febe08eb9d | ||
|
|
9123b34c82 | ||
|
|
c66d7cafa6 | ||
|
|
73c54992e2 | ||
|
|
be1a44ad61 | ||
|
|
28b307fb98 | ||
|
|
a1dc723445 | ||
|
|
23f4a70693 | ||
|
|
be5b4b39e5 | ||
|
|
cf706e0e30 | ||
|
|
8bc80d2088 | ||
|
|
b94f8c92f0 | ||
|
|
c3be75bed1 | ||
|
|
91c8d8077f | ||
|
|
f598eed149 | ||
|
|
971bae3be0 | ||
|
|
9a6abf4d5a | ||
|
|
d756077a48 | ||
|
|
a1fc87bb1e | ||
|
|
07186d2ae1 | ||
|
|
d2164d9ada | ||
|
|
7eacaf8fc5 | ||
|
|
9aa2de526e | ||
|
|
12dfc5b407 | ||
|
|
1fc964ec16 | ||
|
|
7f2f7b100b | ||
|
|
8292140f1f | ||
|
|
c26e610a23 | ||
|
|
c96cfe81ab | ||
|
|
bb1cc0b60e | ||
|
|
1e74073344 | ||
|
|
d83d1dd888 | ||
|
|
e34573e72f | ||
|
|
9d3f4879ef | ||
|
|
6317277a70 | ||
|
|
a1130ec60b | ||
|
|
a1a3ccf6fb | ||
|
|
aedb8bee9c | ||
|
|
6620d1c8fe | ||
|
|
0ecc7dfead | ||
|
|
9f5859ee93 | ||
|
|
d559e1717c | ||
|
|
e649be58a2 | ||
|
|
157c37c862 | ||
|
|
da910ac670 | ||
|
|
3831363815 | ||
|
|
94a6ea13bd | ||
|
|
06c1ad0f69 | ||
|
|
d6873781e8 | ||
|
|
ab6c9647a7 | ||
|
|
59b0350993 | ||
|
|
df0be4c070 | ||
|
|
87f3ef4353 | ||
|
|
2611bbaea4 | ||
|
|
7c0d8cf792 | ||
|
|
2d17baccd2 | ||
|
|
fe31723726 | ||
|
|
bb10b22421 | ||
|
|
6445f3a634 | ||
|
|
d1f28d9c94 | ||
|
|
1e5366123c | ||
|
|
7feff7c90b | ||
|
|
429b3bc045 | ||
|
|
e76f1b89da | ||
|
|
f25e8595c3 | ||
|
|
6977ce55a3 | ||
|
|
222e0e5ff2 | ||
|
|
6996d9bbe2 | ||
|
|
f70e08adac | ||
|
|
223ecc0e6b | ||
|
|
43f36f556c | ||
|
|
4579e00283 | ||
|
|
b5e9b14048 | ||
|
|
2288e72c5f | ||
|
|
4882cc0417 | ||
|
|
499d3d0424 | ||
|
|
d6b17debb4 | ||
|
|
8f970e0008 | ||
|
|
18d778a1cc |
@@ -1 +1,2 @@
|
||||
VITE_API_BASE_URL=http://localhost:3001/api/v1/
|
||||
VITE_PUBLIC_VAPID_KEY=BH3w49sZA6jXUnE-yt4jO6VKh73lsdsvwoJ6Hx7fmPIDKoqGiUl2GEoZzy-iJfn4SfQQcx7yQdHf9RknwrL_lSM
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
VITE_API_BASE_URL=api/v1/
|
||||
VITE_PUBLIC_VAPID_KEY=BH3w49sZA6jXUnE-yt4jO6VKh73lsdsvwoJ6Hx7fmPIDKoqGiUl2GEoZzy-iJfn4SfQQcx7yQdHf9RknwrL_lSM
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -11,6 +11,7 @@ node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
dev-dist
|
||||
*.local
|
||||
|
||||
/cypress/videos/
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="apple-touch-startup-image" href="/splash/apple-splash.jpg" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
|
||||
<link rel="manifest" href="manifest.json" crossorigin="use-credentials" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
@@ -37,7 +36,7 @@
|
||||
<div id="loading-bg">
|
||||
<div class="loading-logo">
|
||||
<!-- Logo -->
|
||||
<svg width="100px" height="100px" viewBox="0 0 192 192" version="1.1" xmlns="http://www.w3.org/2000/svg"
|
||||
<svg width="10rem" height="10rem" viewBox="0 0 192 192" version="1.1" xmlns="http://www.w3.org/2000/svg"
|
||||
style="fill-rule: evenodd; clip-rule: evenodd; stroke-linejoin: round; stroke-miterlimit: 2">
|
||||
<g transform="matrix(1,0,0,1,-2606,-236)">
|
||||
<g id="a2-c" transform="matrix(1,0,0,1,2606,236)">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "moviepilot",
|
||||
"version": "1.9.3",
|
||||
"version": "1.9.17",
|
||||
"private": true,
|
||||
"bin": "dist/service.js",
|
||||
"scripts": {
|
||||
@@ -42,7 +42,6 @@
|
||||
"sass": "^1.59.3",
|
||||
"tailwindcss": "^3.3.2",
|
||||
"unplugin-vue-define-options": "^1.3.5",
|
||||
"vite-plugin-pwa": "^0.19.8",
|
||||
"vue": "^3.3.2",
|
||||
"vue-router": "^4.2.0",
|
||||
"vue-toast-notification": "^3",
|
||||
@@ -92,6 +91,7 @@
|
||||
"unplugin-vue-components": "^0.26.0",
|
||||
"vite": "^5.2.8",
|
||||
"vite-plugin-pages": "^0.32.1",
|
||||
"vite-plugin-pwa": "^0.20.0",
|
||||
"vite-plugin-vue-layouts": "^0.11.0",
|
||||
"vite-plugin-vuetify": "2.0.3",
|
||||
"vue-shepherd": "^3.0.0",
|
||||
|
||||
@@ -3,10 +3,9 @@ body {
|
||||
}
|
||||
|
||||
html {
|
||||
overflow: hidden auto;
|
||||
background: var(--initial-loader-bg, #fff);
|
||||
min-block-size: calc(100% + env(safe-area-inset-top));
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#loading-bg {
|
||||
@@ -20,8 +19,8 @@ html {
|
||||
|
||||
.loading-logo {
|
||||
position: absolute;
|
||||
inset-block-start: 40%;
|
||||
inset-inline-start: calc(50% - 50px);
|
||||
inset-block-start: 35%;
|
||||
inset-inline-start: calc(50% - 5rem);
|
||||
}
|
||||
|
||||
.loading {
|
||||
@@ -83,4 +82,4 @@ html {
|
||||
opacity: 1;
|
||||
transform: rotate(1turn);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
{
|
||||
"name": "MoviePilot",
|
||||
"short_name": "MoviePilot",
|
||||
"start_url": "./",
|
||||
"display": "standalone",
|
||||
"icons": [
|
||||
{
|
||||
"src": "./android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "./android-chrome-192x192_maskable.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "./android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "./android-chrome-512x512_maskable.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"theme_color": "#28243D",
|
||||
"background_color": "#28243D",
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "推荐",
|
||||
"url": "./ranking",
|
||||
"icons": [
|
||||
{
|
||||
"src": "./sparkles-icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "电影订阅",
|
||||
"url": "./subscribe-movie",
|
||||
"icons": [
|
||||
{
|
||||
"src": "./clock-icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "电视剧订阅",
|
||||
"url": "./subscribe-tv",
|
||||
"icons": [
|
||||
{
|
||||
"src": "./clock-icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "设置",
|
||||
"url": "./setting",
|
||||
"icons": [
|
||||
{
|
||||
"src": "./cog-icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
2
public/robots.txt
Normal file
2
public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
@@ -1,28 +1,15 @@
|
||||
<script lang="ts" setup>
|
||||
// 定义输入参数
|
||||
const props = defineProps({
|
||||
progress: Number,
|
||||
text: String
|
||||
})
|
||||
// 定义输入参数
|
||||
const props = defineProps({
|
||||
progress: Number,
|
||||
text: String,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-full text-center text-gray-500 text-sm flex flex-col items-center"
|
||||
>
|
||||
<VProgressCircular
|
||||
v-if="!props.text"
|
||||
size="48"
|
||||
indeterminate
|
||||
color="primary"
|
||||
/>
|
||||
<VProgressCircular
|
||||
v-if="props.progress"
|
||||
class="mb-3"
|
||||
color="primary"
|
||||
:model-value="props.progress"
|
||||
size="64"
|
||||
/>
|
||||
<div class="w-full text-center text-gray-500 text-sm flex flex-col items-center">
|
||||
<VProgressCircular v-if="!props.text || !props.progress" class="mb-3" size="64" indeterminate color="primary" />
|
||||
<VProgressCircular v-if="props.progress" class="mb-3" color="primary" :model-value="props.progress" size="64" />
|
||||
<span>{{ props.text }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
backdrop-filter: blur(6px);
|
||||
/* stylelint-enable */
|
||||
background-color: rgb(var(--v-theme-surface), 0.9);
|
||||
background-color: rgb(var(--v-theme-surface), 0.8);
|
||||
}
|
||||
|
||||
@@ -5,14 +5,14 @@
|
||||
/**
|
||||
* 修复低版本Safari等浏览器数组不支持at函数的问题
|
||||
*/
|
||||
export function fixArrayAt() {
|
||||
if (!Array.prototype.at) {
|
||||
Array.prototype.at = function(index: number) {
|
||||
if (index >= 0) {
|
||||
return this[index]
|
||||
} else {
|
||||
return this[this.length + index]
|
||||
}
|
||||
}
|
||||
;(function fixArrayAt() {
|
||||
if (!Array.prototype.at) {
|
||||
Array.prototype.at = function (index: number) {
|
||||
if (index >= 0) {
|
||||
return this[index]
|
||||
} else {
|
||||
return this[this.length + index]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
@@ -8,8 +8,7 @@ dayjs.extend(relativeTime)
|
||||
dayjs.locale(ZH_CN)
|
||||
|
||||
export function avatarText(value: string) {
|
||||
if (!value)
|
||||
return ''
|
||||
if (!value) return ''
|
||||
const nameArray = value.split(' ')
|
||||
|
||||
return nameArray.map(word => word.charAt(0).toUpperCase()).join('')
|
||||
@@ -19,7 +18,9 @@ export function avatarText(value: string) {
|
||||
export function kFormatter(num: number) {
|
||||
const regex = /\B(?=(\d{3})+(?!\d))/g
|
||||
|
||||
return Math.abs(num) > 9999 ? `${Math.sign(num) * +((Math.abs(num) / 1000).toFixed(1))}k` : Math.abs(num).toFixed(0).replace(regex, ',')
|
||||
return Math.abs(num) > 9999
|
||||
? `${Math.sign(num) * +(Math.abs(num) / 1000).toFixed(1)}k`
|
||||
: Math.abs(num).toFixed(0).replace(regex, ',')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,9 +30,11 @@ export function kFormatter(num: number) {
|
||||
* @param {string} value date to format
|
||||
* @param {Intl.DateTimeFormatOptions} formatting Intl object to format with
|
||||
*/
|
||||
export function formatDate(value: string, formatting: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', year: 'numeric' }) {
|
||||
if (!value)
|
||||
return value
|
||||
export function formatDate(
|
||||
value: string,
|
||||
formatting: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', year: 'numeric' },
|
||||
) {
|
||||
if (!value) return value
|
||||
|
||||
return new Intl.DateTimeFormat('en-US', formatting).format(new Date(value))
|
||||
}
|
||||
@@ -46,21 +49,19 @@ export function formatDateToMonthShort(value: string, toTimeForCurrentDay = true
|
||||
const date = new Date(value)
|
||||
let formatting: Record<string, string> = { month: 'short', day: 'numeric' }
|
||||
|
||||
if (toTimeForCurrentDay && isToday(date))
|
||||
formatting = { hour: 'numeric', minute: 'numeric' }
|
||||
if (toTimeForCurrentDay && isToday(date)) formatting = { hour: 'numeric', minute: 'numeric' }
|
||||
|
||||
return new Intl.DateTimeFormat('en-US', formatting).format(new Date(value))
|
||||
}
|
||||
|
||||
export const prefixWithPlus = (value: number) => value > 0 ? `+${value}` : value
|
||||
export const prefixWithPlus = (value: number) => (value > 0 ? `+${value}` : value)
|
||||
|
||||
// 格式化为Sxx
|
||||
export const formatSeason = (value: string) => value ? `S${value.padStart(2, '0')}` : ''
|
||||
export const formatSeason = (value: string) => (value ? `S${value.padStart(2, '0')}` : '')
|
||||
|
||||
// 格式化为xx[TGMK]B
|
||||
export function formatFileSize(bytes: number) {
|
||||
if (bytes < 0)
|
||||
throw new Error('字节数不能为负数。')
|
||||
if (bytes < 0) throw new Error('字节数不能为负数。')
|
||||
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let size = bytes
|
||||
@@ -82,22 +83,18 @@ export function formatSeconds(seconds: number) {
|
||||
|
||||
let formattedTime = ''
|
||||
|
||||
if (hours > 0)
|
||||
formattedTime += `${hours}小时`
|
||||
if (hours > 0) formattedTime += `${hours}小时`
|
||||
|
||||
if (minutes > 0)
|
||||
formattedTime += `${minutes}分`
|
||||
if (minutes > 0) formattedTime += `${minutes}分`
|
||||
|
||||
if ((remainingSeconds > 0 || formattedTime === '') && hours <= 0)
|
||||
formattedTime += `${remainingSeconds}秒`
|
||||
if ((remainingSeconds > 0 || formattedTime === '') && hours <= 0) formattedTime += `${remainingSeconds}秒`
|
||||
|
||||
return formattedTime
|
||||
}
|
||||
|
||||
// YYYY-MM-DD 转化为Date
|
||||
export function parseDate(dateString: string): Date | null {
|
||||
if (!dateString)
|
||||
return null
|
||||
if (!dateString) return null
|
||||
const [year, month, day] = dateString.split('-').map(Number)
|
||||
|
||||
return new Date(year, month - 1, day)
|
||||
@@ -105,8 +102,7 @@ export function parseDate(dateString: string): Date | null {
|
||||
|
||||
// 文件大小格式化
|
||||
export function formatBytes(bytes: number, decimals = 2) {
|
||||
if (bytes === 0)
|
||||
return '0 bytes'
|
||||
if (bytes === 0) return '0 bytes'
|
||||
|
||||
const k = 1024
|
||||
const dm = decimals < 0 ? 0 : decimals
|
||||
@@ -119,11 +115,9 @@ export function formatBytes(bytes: number, decimals = 2) {
|
||||
|
||||
// 格式化剧集列表
|
||||
export function formatEp(nums: number[]): string {
|
||||
if (!nums.length)
|
||||
return ''
|
||||
if (!nums.length) return ''
|
||||
|
||||
if (nums.length === 1)
|
||||
return nums[0].toString()
|
||||
if (nums.length === 1) return nums[0].toString()
|
||||
|
||||
// 将数组升序排序
|
||||
nums.sort((a, b) => a - b)
|
||||
@@ -134,44 +128,22 @@ export function formatEp(nums: number[]): string {
|
||||
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()}`)
|
||||
} 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()}`)
|
||||
if (start === end) formattedRanges.push(start.toString())
|
||||
else formattedRanges.push(`${start.toString()}-${end.toString()}`)
|
||||
|
||||
return formattedRanges.join('、')
|
||||
}
|
||||
|
||||
// 将yyyy-mm-dd hh:mm:ss转换为时间差,如:1小时前,1天前
|
||||
export function formatDateDifference(dateString: string): string {
|
||||
// const timeDifference = dayjs().millisecond() - dayjs(dateString).millisecond()
|
||||
// const secondsDifference = Math.floor(timeDifference / 1000)
|
||||
// const minutesDifference = Math.floor(secondsDifference / 60)
|
||||
// const hoursDifference = Math.floor(minutesDifference / 60)
|
||||
// const daysDifference = Math.floor(hoursDifference / 24)
|
||||
|
||||
// if (daysDifference > 0)
|
||||
// return `${daysDifference}天前`
|
||||
// else if (hoursDifference > 0)
|
||||
// return `${hoursDifference}小时前`
|
||||
// else if (minutesDifference > 0)
|
||||
// return `${minutesDifference}分钟前`
|
||||
// else
|
||||
// return '刚刚'
|
||||
if (!dateString)
|
||||
return ''
|
||||
if (!dateString) return ''
|
||||
return dayjs(dateString).fromNow()
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// 👉 IsEmpty
|
||||
export function isEmpty(value: unknown): boolean {
|
||||
if (value === null || value === undefined || value === '')
|
||||
return true
|
||||
if (value === null || value === undefined || value === '') return true
|
||||
|
||||
return !!(Array.isArray(value) && value.length === 0)
|
||||
}
|
||||
@@ -33,73 +32,6 @@ export function isToday(date: Date) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算时间差,返回xx天/xx小时/xx分钟/xx秒
|
||||
*
|
||||
* @deprecated 建议使用:@core/utils/formatters.ts formatDateDifference
|
||||
*/
|
||||
export function calculateTimeDifference(inputTime: string): string {
|
||||
if (!inputTime)
|
||||
return ''
|
||||
|
||||
const inputDate = new Date(inputTime.replaceAll(/-/g, '/'))
|
||||
const currentDate = new Date()
|
||||
|
||||
const timeDifference = currentDate.getTime() - inputDate.getTime()
|
||||
const secondsDifference = Math.floor(timeDifference / 1000)
|
||||
|
||||
if (secondsDifference < 60) {
|
||||
return `${secondsDifference}秒`
|
||||
}
|
||||
else if (secondsDifference < 3600) {
|
||||
const minutes = Math.floor(secondsDifference / 60)
|
||||
|
||||
return `${minutes}分钟`
|
||||
}
|
||||
else if (secondsDifference < 86400) {
|
||||
const hours = Math.floor(secondsDifference / 3600)
|
||||
|
||||
return `${hours}小时`
|
||||
}
|
||||
else {
|
||||
const days = Math.floor(secondsDifference / 86400)
|
||||
|
||||
return `${days}天`
|
||||
}
|
||||
}
|
||||
|
||||
// 计算时间差,返回xx天xx小时xx分钟
|
||||
export function calculateTimeDiff(inputTime: string): string {
|
||||
if (!inputTime)
|
||||
return ''
|
||||
|
||||
// 使用当前时区
|
||||
const inputDate = new Date(inputTime.replaceAll(/-/g, '/'))
|
||||
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))
|
||||
@@ -112,8 +44,7 @@ export function isIntersected(array1: any[], array2: any[]): boolean {
|
||||
|
||||
export function isNullOrEmptyObject(obj: any): boolean {
|
||||
// 首先判断是否为 null 或 undefined
|
||||
if (obj === null || obj === undefined)
|
||||
return true
|
||||
if (obj === null || obj === undefined) return true
|
||||
|
||||
// 然后判断是否为空对象
|
||||
return !!(typeof obj === 'object' && Object.keys(obj).length === 0)
|
||||
@@ -127,3 +58,10 @@ export function checkPrefersColorSchemeIsDark(): boolean {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 从URL中获取参数值
|
||||
export function getQueryValue(key: string, url = window.location.href): string {
|
||||
const reg = new RegExp(`[?&]${key}=([^&#]*)`, 'i')
|
||||
const res = reg.exec(url)
|
||||
return res ? res[1] : ''
|
||||
}
|
||||
|
||||
@@ -28,3 +28,17 @@ export async function copyToClipboard(content: string) {
|
||||
document.body.removeChild(input)
|
||||
}
|
||||
}
|
||||
|
||||
// VAPID公钥转Uint8Array
|
||||
export function urlBase64ToUint8Array(base64String: string) {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
|
||||
|
||||
const rawData = window.atob(base64)
|
||||
const outputArray = new Uint8Array(rawData.length)
|
||||
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i)
|
||||
}
|
||||
return outputArray
|
||||
}
|
||||
|
||||
@@ -33,7 +33,10 @@ defineProps<{
|
||||
.nav-link a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 0 3.125rem 3.125rem 0 !important;
|
||||
cursor: pointer;
|
||||
margin-inline-end: 1.125em;
|
||||
padding-inline: 1.375rem 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -18,3 +18,12 @@ defineProps<{
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.layout-vertical-nav {
|
||||
.nav-section-title {
|
||||
padding-left: 1.375rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,6 +10,8 @@ import modeYamlUrl from 'ace-builds/src-noconflict/mode-yaml?url'
|
||||
|
||||
import modeCssUrl from 'ace-builds/src-noconflict/mode-css?url'
|
||||
|
||||
import modePythonUrl from 'ace-builds/src-noconflict/mode-python?url'
|
||||
|
||||
import themeGithubUrl from 'ace-builds/src-noconflict/theme-github?url'
|
||||
|
||||
import themeChromeUrl from 'ace-builds/src-noconflict/theme-chrome?url'
|
||||
@@ -38,6 +40,8 @@ import snippetsJsonUrl from 'ace-builds/src-noconflict/snippets/json?url'
|
||||
|
||||
import snippertsCssUrl from 'ace-builds/src-noconflict/snippets/css?url'
|
||||
|
||||
import snippetsPythonUrl from 'ace-builds/src-noconflict/snippets/python?url'
|
||||
|
||||
import 'ace-builds/src-noconflict/ext-language_tools'
|
||||
|
||||
ace.config.setModuleUrl('ace/mode/json', modeJsonUrl)
|
||||
@@ -45,6 +49,7 @@ ace.config.setModuleUrl('ace/mode/javascript', modeJavascriptUrl)
|
||||
ace.config.setModuleUrl('ace/mode/html', modeHtmlUrl)
|
||||
ace.config.setModuleUrl('ace/mode/yaml', modeYamlUrl)
|
||||
ace.config.setModuleUrl('ace/mode/css', modeCssUrl)
|
||||
ace.config.setModuleUrl('ace/mode/python', modePythonUrl)
|
||||
ace.config.setModuleUrl('ace/theme/github', themeGithubUrl)
|
||||
ace.config.setModuleUrl('ace/theme/chrome', themeChromeUrl)
|
||||
ace.config.setModuleUrl('ace/theme/monokai', themeMonokaiUrl)
|
||||
@@ -59,5 +64,6 @@ ace.config.setModuleUrl('ace/snippets/javascript', snippetsJsUrl)
|
||||
ace.config.setModuleUrl('ace/snippets/javascript', snippetsYamlUrl)
|
||||
ace.config.setModuleUrl('ace/snippets/json', snippetsJsonUrl)
|
||||
ace.config.setModuleUrl('ace/snippets/css', snippertsCssUrl)
|
||||
ace.config.setModuleUrl('ace/snippets/python', snippetsPythonUrl)
|
||||
|
||||
ace.require('ace/ext/language_tools')
|
||||
|
||||
@@ -27,13 +27,12 @@ api.interceptors.response.use(
|
||||
return Promise.reject(new Error(error))
|
||||
} else if (error.response.status === 403) {
|
||||
// 清除登录状态信息
|
||||
store.dispatch('auth/clearToken')
|
||||
|
||||
store.dispatch('auth/logout')
|
||||
// token验证失败,跳转到登录页面
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(error))
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -58,11 +58,13 @@ export interface Subscribe {
|
||||
// 当前优先级
|
||||
current_priority: number
|
||||
// 保存目录
|
||||
save_path: string
|
||||
save_path?: string
|
||||
// 时间
|
||||
date: string
|
||||
// 编辑框设置项
|
||||
show_edit_dialog: boolean
|
||||
// 编辑框打开状态
|
||||
page_open?: boolean
|
||||
}
|
||||
|
||||
// 历史记录
|
||||
@@ -718,6 +720,7 @@ export interface NotificationSwitch {
|
||||
slack: boolean
|
||||
synologychat: boolean
|
||||
vocechat: boolean
|
||||
webpush: boolean
|
||||
}
|
||||
|
||||
// 文件浏览接口
|
||||
@@ -743,17 +746,27 @@ export interface FileItem {
|
||||
// 文件名
|
||||
name: string
|
||||
// 文件名不含扩展名
|
||||
basename: string
|
||||
basename?: string
|
||||
// 文件路径
|
||||
path: string
|
||||
// 文件扩展名
|
||||
extension: string
|
||||
extension?: string
|
||||
// 文件大小
|
||||
size: number
|
||||
size?: number
|
||||
// 文件子元素
|
||||
children: FileItem[]
|
||||
children?: FileItem[]
|
||||
// 文件创建时间
|
||||
modify_time: number
|
||||
modify_time?: number
|
||||
// 文件ID
|
||||
fileid?: string
|
||||
// 上级文件ID
|
||||
parent_fileid?: string
|
||||
// 缩略图
|
||||
thumbnail?: string
|
||||
// pickcode
|
||||
pickcode?: string
|
||||
// drive_id
|
||||
drive_id?: string
|
||||
}
|
||||
|
||||
// 媒体服务器播放条目
|
||||
|
||||
@@ -1,19 +1,28 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Axios } from 'axios'
|
||||
import axios from 'axios'
|
||||
import FileList from './filebrowser/FileList.vue'
|
||||
import FileToolbar from './filebrowser/FileToolbar.vue'
|
||||
import type { EndPoints } from '@/api/types'
|
||||
import type { EndPoints, FileItem } from '@/api/types'
|
||||
import api from '@/api'
|
||||
import AliyunAuthDialog from './dialog/AliyunAuthDialog.vue'
|
||||
import U115AuthDialog from './dialog/U115AuthDialog.vue'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
storages: String,
|
||||
storage: String,
|
||||
path: String,
|
||||
tree: Boolean,
|
||||
endpoints: Object as PropType<EndPoints>,
|
||||
axios: Object as PropType<Axios>,
|
||||
axios: {
|
||||
type: Object as PropType<Axios>,
|
||||
required: true,
|
||||
},
|
||||
axiosconfig: Object,
|
||||
item: {
|
||||
type: Object as PropType<FileItem>,
|
||||
required: true,
|
||||
},
|
||||
itemstack: Array as PropType<FileItem[]>,
|
||||
})
|
||||
|
||||
// 对外事件
|
||||
@@ -25,6 +34,16 @@ const availableStorages = [
|
||||
code: 'local',
|
||||
icon: 'mdi-folder-multiple-outline',
|
||||
},
|
||||
{
|
||||
name: '阿里云盘',
|
||||
code: 'aliyun',
|
||||
icon: 'mdi-cloud-outline',
|
||||
},
|
||||
{
|
||||
name: '115网盘',
|
||||
code: 'u115',
|
||||
icon: 'mdi-cloud-outline',
|
||||
},
|
||||
]
|
||||
|
||||
const fileIcons = {
|
||||
@@ -57,8 +76,14 @@ const activeStorage = ref('local')
|
||||
const refreshPending = ref(false)
|
||||
// 排序
|
||||
const sort = ref('name')
|
||||
// axios实例
|
||||
const axiosInstance = ref<Axios>()
|
||||
// 阿里云盘认证对话框
|
||||
const aliyunAuthDialog = ref(false)
|
||||
// 阿里云盘用户信息
|
||||
const aliyunUserInfo = ref<{ [key: string]: any }>({})
|
||||
// 115网盘认证对话框
|
||||
const u115AuthDialog = ref(false)
|
||||
// 115网盘用户信息
|
||||
const u115UserInfo = ref<{ [key: string]: any }>({})
|
||||
|
||||
// 计算属性
|
||||
const storagesArray = computed(() => {
|
||||
@@ -68,19 +93,56 @@ const storagesArray = computed(() => {
|
||||
|
||||
// 方法
|
||||
function loadingChanged(loading: number) {
|
||||
if (loading)
|
||||
loading++
|
||||
else if (loading > 0)
|
||||
loading--
|
||||
if (loading) loading++
|
||||
else if (loading > 0) loading--
|
||||
}
|
||||
|
||||
function storageChanged(storage: string) {
|
||||
// 查询阿里云
|
||||
async function loadAliyunUserInfo() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('aliyun/userinfo')
|
||||
if (result.success) {
|
||||
aliyunUserInfo.value = result
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 查询115
|
||||
async function loadU115UserInfo() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('u115/storage')
|
||||
if (result.success) {
|
||||
u115UserInfo.value = result
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 存储切换
|
||||
async function storageChanged(storage: string) {
|
||||
if (storage == 'aliyun') {
|
||||
await loadAliyunUserInfo()
|
||||
if (isNullOrEmptyObject(aliyunUserInfo.value)) {
|
||||
aliyunAuthDialog.value = true
|
||||
return
|
||||
}
|
||||
} else if (storage == 'u115') {
|
||||
await loadU115UserInfo()
|
||||
if (isNullOrEmptyObject(u115UserInfo.value)) {
|
||||
u115AuthDialog.value = true
|
||||
return
|
||||
}
|
||||
}
|
||||
activeStorage.value = storage
|
||||
emit('pathchanged', { path: '/', fileid: 'root' })
|
||||
}
|
||||
|
||||
// 路径变化
|
||||
function pathChanged(_path: string) {
|
||||
emit('pathchanged', _path)
|
||||
function pathChanged(item: FileItem) {
|
||||
emit('pathchanged', item)
|
||||
}
|
||||
|
||||
// 排序变化
|
||||
@@ -89,33 +151,40 @@ function sortChanged(s: string) {
|
||||
refreshPending.value = true
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
activeStorage.value = props.storage ?? 'local'
|
||||
axiosInstance.value = props.axios ?? axios.create(props.axiosconfig)
|
||||
})
|
||||
// aliyun认证完成
|
||||
function aliyunAuthDone() {
|
||||
aliyunAuthDialog.value = false
|
||||
activeStorage.value = 'aliyun'
|
||||
}
|
||||
|
||||
// u115认证完成
|
||||
function u115AuthDone() {
|
||||
u115AuthDialog.value = false
|
||||
activeStorage.value = 'u115'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard class="mx-auto" :loading="loading > 0 || !path">
|
||||
<div v-if="path">
|
||||
<VCard class="mx-auto" :loading="loading > 0">
|
||||
<div v-if="activeStorage && item">
|
||||
<FileToolbar
|
||||
:path="path"
|
||||
:item="item"
|
||||
:itemstack="itemstack"
|
||||
:storages="storagesArray"
|
||||
:storage="activeStorage"
|
||||
:endpoints="endpoints"
|
||||
:axios="axiosInstance"
|
||||
:axios="axios"
|
||||
@storagechanged="storageChanged"
|
||||
@pathchanged="pathChanged"
|
||||
@foldercreated="refreshPending = true"
|
||||
@sortchanged="sortChanged"
|
||||
/>
|
||||
<FileList
|
||||
:path="path"
|
||||
:item="item"
|
||||
:storage="activeStorage"
|
||||
:icons="fileIcons"
|
||||
:endpoints="endpoints"
|
||||
:axios="axiosInstance"
|
||||
:axios="axios"
|
||||
:refreshpending="refreshPending"
|
||||
:sort="sort"
|
||||
@pathchanged="pathChanged"
|
||||
@@ -126,4 +195,11 @@ onMounted(() => {
|
||||
/>
|
||||
</div>
|
||||
</VCard>
|
||||
<AliyunAuthDialog
|
||||
v-if="aliyunAuthDialog"
|
||||
v-model="aliyunAuthDialog"
|
||||
@close="aliyunAuthDialog = false"
|
||||
@done="aliyunAuthDone"
|
||||
/>
|
||||
<U115AuthDialog v-if="u115AuthDialog" v-model="u115AuthDialog" @close="u115AuthDialog = false" @done="u115AuthDone" />
|
||||
</template>
|
||||
|
||||
@@ -21,6 +21,14 @@ function filtersChanged(value: string[]) {
|
||||
emit('changed', props.pri, value)
|
||||
}
|
||||
|
||||
// 清洗规则中的换行符和多余空格,并在前后添加空格
|
||||
const cleanedRules = computed(() => {
|
||||
return props.rules.map(rule => {
|
||||
rule = rule ?? ''
|
||||
return ` ${rule.replace(/[\r\n]/g, '').replace(/\s+/g, '')} `
|
||||
})
|
||||
})
|
||||
|
||||
// 过滤规则下拉框
|
||||
const selectFilterOptions = ref<{ [key: string]: string }[]>([
|
||||
{ title: '特效字幕', value: ' SPECSUB ' },
|
||||
@@ -77,7 +85,7 @@ const selectFilterOptions = ref<{ [key: string]: string }[]>([
|
||||
<VRow>
|
||||
<VCol>
|
||||
<VSelect
|
||||
v-model="props.rules"
|
||||
v-model="cleanedRules"
|
||||
variant="underlined"
|
||||
:items="selectFilterOptions"
|
||||
chips
|
||||
|
||||
@@ -35,36 +35,28 @@ function imageErrorHandler() {
|
||||
|
||||
// 默认图片
|
||||
function getDefaultImage() {
|
||||
if (props.media?.server === 'plex')
|
||||
return plex
|
||||
else if (props.media?.server === 'emby')
|
||||
return emby
|
||||
else if (props.media?.server === 'jellyfin')
|
||||
return jellyfin
|
||||
else
|
||||
return plex
|
||||
if (props.media?.server === 'plex') return plex
|
||||
else if (props.media?.server === 'emby') return emby
|
||||
else if (props.media?.server === 'jellyfin') return jellyfin
|
||||
else return plex
|
||||
}
|
||||
|
||||
// 跳转播放
|
||||
function goPlay() {
|
||||
if (props.media?.link)
|
||||
window.open(props.media?.link, '_blank')
|
||||
if (props.media?.link) window.open(props.media?.link, '_blank')
|
||||
}
|
||||
|
||||
// 生成图片代理路径
|
||||
function getImgUrl(url: string) {
|
||||
if (!url)
|
||||
return getDefaultImage()
|
||||
else
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`
|
||||
if (!url) return getDefaultImage()
|
||||
else return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`
|
||||
}
|
||||
|
||||
// 根据多张图片生成媒体库封面
|
||||
async function drawImages(imageList: string[]) {
|
||||
// 图片
|
||||
const IMAGES = imageList
|
||||
if (IMAGES.length === 0)
|
||||
return getDefaultImage()
|
||||
if (IMAGES.length === 0) return getDefaultImage()
|
||||
|
||||
// 为所有图片添加system/img前缀
|
||||
for (let i = 0; i < IMAGES.length; i++)
|
||||
@@ -72,8 +64,7 @@ async function drawImages(imageList: string[]) {
|
||||
|
||||
// canvas
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas)
|
||||
return getDefaultImage()
|
||||
if (!canvas) return getDefaultImage()
|
||||
|
||||
// 画布参数
|
||||
const POSTER_WIDTH = (canvas.width - 32) / 4
|
||||
@@ -85,8 +76,7 @@ async function drawImages(imageList: string[]) {
|
||||
|
||||
// 获取画布上下文
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx)
|
||||
return getDefaultImage()
|
||||
if (!ctx) return getDefaultImage()
|
||||
|
||||
// 设置背景色为黑色
|
||||
ctx.fillStyle = '#000000'
|
||||
@@ -94,16 +84,14 @@ async function drawImages(imageList: string[]) {
|
||||
|
||||
// 绘制图片
|
||||
async function drawImageWithReflection(imgSrc: string, index: number) {
|
||||
if (!canvas)
|
||||
return
|
||||
if (!canvas) return
|
||||
|
||||
if (!ctx)
|
||||
return
|
||||
if (!ctx) return
|
||||
|
||||
const img = new Image()
|
||||
img.setAttribute('crossorigin', 'anonymous')
|
||||
img.src = imgSrc
|
||||
await new Promise(resolve => img.onload = resolve)
|
||||
await new Promise(resolve => (img.onload = resolve))
|
||||
|
||||
const x = MARGIN_WIDTH * index + POSTER_WIDTH * (index - 1)
|
||||
const y = MARGIN_HEIGHT
|
||||
@@ -125,12 +113,7 @@ async function drawImages(imageList: string[]) {
|
||||
REFLECTION_HEIGHT,
|
||||
)
|
||||
|
||||
const gradient = ctx.createLinearGradient(
|
||||
0,
|
||||
REFLECTION_SHOW_HEIGHT - REFLECTION_HEIGHT,
|
||||
0,
|
||||
REFLECTION_HEIGHT,
|
||||
)
|
||||
const gradient = ctx.createLinearGradient(0, REFLECTION_SHOW_HEIGHT - REFLECTION_HEIGHT, 0, REFLECTION_HEIGHT)
|
||||
|
||||
gradient.addColorStop(0, 'rgba(0, 0, 0, 1)')
|
||||
gradient.addColorStop(1, 'rgba(0, 0, 0, 0.3)')
|
||||
@@ -142,8 +125,7 @@ async function drawImages(imageList: string[]) {
|
||||
|
||||
// 绘制多张图片
|
||||
const loopCount = Math.min(4, IMAGES.length)
|
||||
for (let i = 0; i < loopCount; i++)
|
||||
await drawImageWithReflection(IMAGES[i], i + 1)
|
||||
for (let i = 0; i < loopCount; i++) await drawImageWithReflection(IMAGES[i], i + 1)
|
||||
|
||||
// 转换为图片地址
|
||||
return canvas.toDataURL('image/png')
|
||||
@@ -152,17 +134,12 @@ async function drawImages(imageList: string[]) {
|
||||
onMounted(async () => {
|
||||
if (props.media?.image_list && props.media?.image_list.length > 0)
|
||||
imgUrl.value = await drawImages(props.media?.image_list || [])
|
||||
else
|
||||
imgUrl.value = getImgUrl(props.media?.image || '')
|
||||
else imgUrl.value = getImgUrl(props.media?.image || '')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VHover
|
||||
v-bind="props"
|
||||
:height="props.height"
|
||||
:width="props.width"
|
||||
>
|
||||
<VHover v-bind="props" :height="props.height" :width="props.width">
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
@@ -175,13 +152,7 @@ onMounted(async () => {
|
||||
>
|
||||
<template #image>
|
||||
<canvas ref="canvasRef" class="w-full h-full hidden" />
|
||||
<VImg
|
||||
:src="imgUrl"
|
||||
aspect-ratio="2/3"
|
||||
cover
|
||||
@load="imageLoadHandler"
|
||||
@error="imageErrorHandler"
|
||||
>
|
||||
<VImg :src="imgUrl" aspect-ratio="2/3" cover @load="imageLoadHandler" @error="imageErrorHandler">
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
|
||||
@@ -190,7 +161,7 @@ onMounted(async () => {
|
||||
<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 ...">
|
||||
<h1 class="mb-1 text-white text-shadow font-bold line-clamp-2 overflow-hidden text-ellipsis ...">
|
||||
{{ props.media?.name }}
|
||||
</h1>
|
||||
</VCardText>
|
||||
@@ -200,3 +171,9 @@ onMounted(async () => {
|
||||
</template>
|
||||
</VHover>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.text-shadow {
|
||||
text-shadow: 1px 1px #777;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -150,55 +150,61 @@ const dropdownItems = ref([
|
||||
|
||||
<template>
|
||||
<VCard :width="props.width" :height="props.height" @click="installPlugin" class="flex flex-col">
|
||||
<div class="relative pa-3 text-center card-cover-blurred" :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"
|
||||
v-show="item.show"
|
||||
: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>
|
||||
<VAvatar size="6rem">
|
||||
<VImg
|
||||
ref="imageRef"
|
||||
:src="iconPath"
|
||||
aspect-ratio="4/3"
|
||||
cover
|
||||
:class="{ shadow: isImageLoaded }"
|
||||
@load="imageLoaded"
|
||||
@error="imageLoadError = true"
|
||||
/>
|
||||
</VAvatar>
|
||||
<div class="me-n3 absolute bottom-0 right-3">
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem
|
||||
v-for="(item, i) in dropdownItems"
|
||||
v-show="item.show"
|
||||
: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>
|
||||
<VCardTitle>
|
||||
{{ props.plugin?.plugin_name }}
|
||||
<span class="text-sm text-gray-500">v{{ props.plugin?.plugin_version }}</span>
|
||||
</VCardTitle>
|
||||
<VCardText class="pb-2">
|
||||
<div>{{ props.plugin?.plugin_desc }}</div>
|
||||
<div>
|
||||
<VChip v-for="label in pluginLabels" variant="tonal" size="small" class="me-1 my-1" color="info" label>
|
||||
{{ label }}
|
||||
</VChip>
|
||||
<div
|
||||
class="relative flex flex-row items-start pa-3 justify-between grow"
|
||||
:style="{ background: `${backgroundColor}` }"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-cover bg-center"
|
||||
:style="{ background: `${backgroundColor}`, filter: 'brightness(0.7)' }"
|
||||
></div>
|
||||
<div class="relative flex-1 min-w-0">
|
||||
<VCardTitle class="text-white px-2 text-shadow whitespace-nowrap overflow-hidden text-ellipsis">
|
||||
{{ props.plugin?.plugin_name }}
|
||||
<span class="text-sm text-gray-200">v{{ props.plugin?.plugin_version }}</span>
|
||||
</VCardTitle>
|
||||
<VCardText class="text-white px-2 py-1 text-shadow line-clamp-3">
|
||||
{{ props.plugin?.plugin_desc }}
|
||||
</VCardText>
|
||||
</div>
|
||||
</VCardText>
|
||||
<VCardText class="flex align-self-baseline pb-2 w-full align-end">
|
||||
<div class="relative flex-shrink-0 self-center">
|
||||
<VAvatar size="64">
|
||||
<VImg
|
||||
ref="imageRef"
|
||||
:src="iconPath"
|
||||
aspect-ratio="4/3"
|
||||
cover
|
||||
:class="{ shadow: isImageLoaded }"
|
||||
@load="imageLoaded"
|
||||
@error="imageLoadError = true"
|
||||
/>
|
||||
</VAvatar>
|
||||
</div>
|
||||
</div>
|
||||
<VCardText class="flex flex-none align-self-baseline py-3 w-full align-end">
|
||||
<span>
|
||||
<VIcon icon="mdi-account" class="me-1" />
|
||||
<VIcon icon="mdi-github" class="me-1" />
|
||||
<a :href="props.plugin?.author_url" target="_blank" @click.stop>
|
||||
{{ props.plugin?.plugin_author }}
|
||||
</a>
|
||||
@@ -220,15 +226,3 @@ const dropdownItems = ref([
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.card-cover-blurred::before {
|
||||
position: absolute;
|
||||
/* stylelint-disable-next-line property-no-vendor-prefix */
|
||||
-webkit-backdrop-filter: blur(2px);
|
||||
backdrop-filter: blur(2px);
|
||||
background: rgba(29, 39, 59, 48%);
|
||||
content: '';
|
||||
inset: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -383,60 +383,75 @@ watch(
|
||||
<template>
|
||||
<!-- 插件卡片 -->
|
||||
<VCard v-if="isVisible" :width="props.width" :height="props.height" @click="openPluginDetail" class="flex flex-col">
|
||||
<div class="relative pa-3 text-center card-cover-blurred" :style="{ background: `${backgroundColor}` }">
|
||||
<div v-if="props.plugin?.has_update" class="me-n3 absolute top-0 left-1">
|
||||
<VIcon icon="mdi-new-box" class="text-white" />
|
||||
</div>
|
||||
<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"
|
||||
v-show="item.show"
|
||||
:key="i"
|
||||
variant="plain"
|
||||
:base-color="item.props.color"
|
||||
@click="item.props.click"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="item.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="item.title" />
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
<VAvatar size="6rem">
|
||||
<VImg
|
||||
ref="imageRef"
|
||||
:src="iconPath"
|
||||
aspect-ratio="4/3"
|
||||
cover
|
||||
:class="{ shadow: isImageLoaded }"
|
||||
@load="imageLoaded"
|
||||
@error="imageLoadError = true"
|
||||
/>
|
||||
</VAvatar>
|
||||
<div class="me-n3 absolute bottom-0 right-3">
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem
|
||||
v-for="(item, i) in dropdownItems"
|
||||
v-show="item.show"
|
||||
:key="i"
|
||||
variant="plain"
|
||||
:base-color="item.props.color"
|
||||
@click="item.props.click"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="item.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="item.title" />
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</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 }}
|
||||
<span class="text-sm ms-2 mt-1 text-gray-500">v{{ props.plugin?.plugin_version }}</span>
|
||||
</VCardTitle>
|
||||
</VCardItem>
|
||||
<VCardText class="pb-1">
|
||||
{{ props.plugin?.plugin_desc }}
|
||||
</VCardText>
|
||||
<VCardText class="flex justify-end align-self-baseline p-1 w-full align-end">
|
||||
<div
|
||||
class="relative flex flex-row items-start pa-3 justify-between grow"
|
||||
:style="{ background: `${backgroundColor}` }"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-cover bg-center"
|
||||
:style="{ background: `${backgroundColor}`, filter: 'brightness(0.7)' }"
|
||||
/>
|
||||
<div class="relative flex-1 min-w-0">
|
||||
<VCardTitle class="text-white px-2 text-shadow whitespace-nowrap overflow-hidden text-ellipsis">
|
||||
<VBadge v-if="props.plugin?.state" dot inline color="success" />
|
||||
{{ props.plugin?.plugin_name }}
|
||||
<span class="text-sm mt-1 text-gray-200">v{{ props.plugin?.plugin_version }}</span>
|
||||
</VCardTitle>
|
||||
<VCardText class="px-2 py-1 text-white text-shadow line-clamp-3">
|
||||
{{ props.plugin?.plugin_desc }}
|
||||
</VCardText>
|
||||
</div>
|
||||
<div class="relative flex-shrink-0 self-center">
|
||||
<VAvatar size="64">
|
||||
<VImg
|
||||
ref="imageRef"
|
||||
:src="iconPath"
|
||||
aspect-ratio="4/3"
|
||||
cover
|
||||
:class="{ shadow: isImageLoaded }"
|
||||
@load="imageLoaded"
|
||||
@error="imageLoadError = true"
|
||||
/>
|
||||
</VAvatar>
|
||||
</div>
|
||||
</div>
|
||||
<VCardText class="flex flex-none align-self-baseline py-3 w-full align-end">
|
||||
<span>
|
||||
<VIcon icon="mdi-github" class="me-1" />
|
||||
<a :href="props.plugin?.author_url" target="_blank" @click.stop>
|
||||
{{ props.plugin?.plugin_author }}
|
||||
</a>
|
||||
</span>
|
||||
<span v-if="props.count" class="ms-3">
|
||||
<VIcon icon="mdi-fire" />
|
||||
<span class="text-sm ms-1">{{ props.count?.toLocaleString() }}</span>
|
||||
<VIcon icon="mdi-download" />
|
||||
<span class="text-sm ms-1 mt-1">{{ props.count?.toLocaleString() }}</span>
|
||||
</span>
|
||||
</VCardText>
|
||||
<div v-if="props.plugin?.has_update" class="me-n3 absolute top-0 right-5">
|
||||
<VIcon icon="mdi-new-box" class="text-white" />
|
||||
</div>
|
||||
</VCard>
|
||||
|
||||
<!-- 插件配置页面 -->
|
||||
|
||||
@@ -194,7 +194,7 @@ onMounted(() => {
|
||||
<VImg :src="siteIcon" />
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VCardItem>
|
||||
<VCardItem style="padding-block-end: 0;">
|
||||
<VCardTitle class="font-bold">
|
||||
<span @click.stop="openSitePage">{{ cardProps.site?.name }}</span>
|
||||
</VCardTitle>
|
||||
@@ -202,10 +202,10 @@ onMounted(() => {
|
||||
<span @click.stop="openSitePage">{{ cardProps.site?.url }}</span>
|
||||
</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VCardText class="py-2">
|
||||
<VTooltip v-if="cardProps.site?.render === 1" text="浏览器仿真">
|
||||
<VCardText class="py-2" style="block-size: 36px;">
|
||||
<VTooltip v-if="cardProps.site?.limit_interval" text="流控">
|
||||
<template #activator="{ props }">
|
||||
<VIcon color="primary" class="me-2" v-bind="props" icon="mdi-apple-safari" />
|
||||
<VIcon color="primary" class="me-2" v-bind="props" icon="mdi-speedometer" />
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VTooltip v-if="cardProps.site?.proxy === 1" text="代理">
|
||||
@@ -213,9 +213,9 @@ onMounted(() => {
|
||||
<VIcon color="primary" class="me-2" v-bind="props" icon="mdi-network-outline" />
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VTooltip v-if="cardProps.site?.limit_interval" text="流控">
|
||||
<VTooltip v-if="cardProps.site?.render === 1" text="浏览器仿真">
|
||||
<template #activator="{ props }">
|
||||
<VIcon color="primary" class="me-2" v-bind="props" icon="mdi-speedometer" />
|
||||
<VIcon color="primary" class="me-2" v-bind="props" icon="mdi-apple-safari" />
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VTooltip v-if="cardProps.site?.filter" text="过滤">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { useConfirm } from 'vuetify-use-dialog'
|
||||
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
|
||||
import { formatDateDifference } from '@/@core/utils/formatters'
|
||||
import { formatSeason } from '@/@core/utils/formatters'
|
||||
@@ -15,6 +16,9 @@ const props = defineProps({
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['remove', 'save'])
|
||||
|
||||
// 确认框
|
||||
const createConfirm = useConfirm()
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
@@ -34,8 +38,8 @@ function imageLoadHandler() {
|
||||
|
||||
// 根据 type 返回不同的图标
|
||||
function getIcon() {
|
||||
if (props.media?.type === '电影') return 'mdi-movie'
|
||||
else if (props.media?.type === '电视剧') return 'mdi-television-classic'
|
||||
if (props.media?.type === '电影') return 'mdi-movie-open'
|
||||
else if (props.media?.type === '电视剧') return 'mdi-television-play'
|
||||
else return 'mdi-help-circle'
|
||||
}
|
||||
|
||||
@@ -48,16 +52,6 @@ function getPercentage() {
|
||||
)
|
||||
}
|
||||
|
||||
// 计算文本颜色
|
||||
function getTextColor() {
|
||||
return imageLoaded.value ? 'white' : ''
|
||||
}
|
||||
|
||||
// 计算文本类
|
||||
function getTextClass() {
|
||||
return imageLoaded.value ? 'text-white' : ''
|
||||
}
|
||||
|
||||
// 删除订阅
|
||||
async function removeSubscribe() {
|
||||
try {
|
||||
@@ -84,11 +78,43 @@ async function searchSubscribe() {
|
||||
}
|
||||
}
|
||||
|
||||
// 重置订阅
|
||||
async function resetSubscribe() {
|
||||
// 确认
|
||||
try {
|
||||
const isConfirmed = await createConfirm({
|
||||
title: '确认',
|
||||
content: `重置后 ${props.media?.name} 已下载记录将被清除,未入库的剧集将会重新下载,是否确认?`,
|
||||
})
|
||||
if (!isConfirmed) return
|
||||
// 重置
|
||||
const result: { [key: string]: any } = await api.get(`subscribe/reset/${props.media?.id}`)
|
||||
// 提示
|
||||
if (result.success) {
|
||||
$toast.success(`${props.media?.name} 重置成功!`)
|
||||
emit('save')
|
||||
} else $toast.error(`${props.media?.name} 重置失败:${result.message}`)
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑订阅响应
|
||||
async function editSubscribeDialog() {
|
||||
subscribeEditDialog.value = true
|
||||
}
|
||||
|
||||
// 查看详情
|
||||
async function viewMediaDetail() {
|
||||
router.push({
|
||||
path: '/media',
|
||||
query: {
|
||||
mediaid: `${props.media?.tmdbid ? `tmdb:${props.media?.tmdbid}` : `douban:${props.media?.doubanid}`}`,
|
||||
type: props.media?.type,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 弹出菜单
|
||||
const dropdownItems = ref([
|
||||
{
|
||||
@@ -112,20 +138,22 @@ const dropdownItems = ref([
|
||||
value: 3,
|
||||
props: {
|
||||
prependIcon: 'mdi-open-in-new',
|
||||
click: () => {
|
||||
router.push({
|
||||
path: '/media',
|
||||
query: {
|
||||
mediaid: `${props.media?.tmdbid ? `tmdb:${props.media?.tmdbid}` : `douban:${props.media?.doubanid}`}`,
|
||||
type: props.media?.type,
|
||||
},
|
||||
})
|
||||
},
|
||||
click: viewMediaDetail,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '取消订阅',
|
||||
title: '重置',
|
||||
value: 4,
|
||||
props: {
|
||||
prependIcon: 'mdi-restore-alert',
|
||||
click: resetSubscribe,
|
||||
color: 'warning',
|
||||
},
|
||||
show: props.media?.type === '电视剧',
|
||||
},
|
||||
{
|
||||
title: '取消订阅',
|
||||
value: 5,
|
||||
props: {
|
||||
prependIcon: 'mdi-trash-can-outline',
|
||||
color: 'error',
|
||||
@@ -133,6 +161,14 @@ const dropdownItems = ref([
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
// 监听插件窗口状态变化
|
||||
watch(
|
||||
() => props.media?.page_open,
|
||||
(newOpenState, _) => {
|
||||
if (newOpenState) editSubscribeDialog()
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -141,83 +177,103 @@ const dropdownItems = ref([
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:key="props.media?.id"
|
||||
class="flex flex-col"
|
||||
class="flex flex-col rounded-lg"
|
||||
:class="{
|
||||
'outline-dashed outline-1': props.media?.best_version,
|
||||
'outline-dashed outline-1': props.media?.best_version && imageLoaded,
|
||||
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
|
||||
}"
|
||||
min-height="170"
|
||||
@click="editSubscribeDialog"
|
||||
>
|
||||
<div class="me-n3 absolute top-1 right-2">
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" color="white" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<template v-for="(item, i) in dropdownItems" :key="i">
|
||||
<VListItem
|
||||
v-if="item.show !== false"
|
||||
variant="plain"
|
||||
:base-color="item.props.color"
|
||||
@click="item.props.click"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="item.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="item.title" />
|
||||
</VListItem>
|
||||
</template>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
<template #image>
|
||||
<VImg
|
||||
:src="props.media?.backdrop || props.media?.poster"
|
||||
aspect-ratio="2/3"
|
||||
aspect-ratio="3/2"
|
||||
cover
|
||||
class="brightness-50"
|
||||
@load="imageLoadHandler"
|
||||
/>
|
||||
position="top"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
|
||||
</div>
|
||||
</template>
|
||||
<div class="absolute inset-0 subscribe-card-background"></div>
|
||||
</VImg>
|
||||
</template>
|
||||
<VCardItem>
|
||||
<template #prepend>
|
||||
<VIcon size="1.9rem" :color="getTextColor()" :icon="getIcon()" />
|
||||
</template>
|
||||
<VCardTitle :class="getTextClass()">
|
||||
{{ props.media?.name }}
|
||||
{{ formatSeason(props.media?.season ? props.media?.season.toString() : '') }}
|
||||
</VCardTitle>
|
||||
<template #append>
|
||||
<div class="me-n3">
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" :color="getTextColor()" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem
|
||||
v-for="(item, i) in dropdownItems"
|
||||
:key="i"
|
||||
variant="plain"
|
||||
:base-color="item.props.color"
|
||||
@click="item.props.click"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="item.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="item.title" />
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
<div>
|
||||
<VCardText class="flex items-center">
|
||||
<div class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md shadow-lg" v-if="imageLoaded">
|
||||
<VImg :src="props.media?.poster" aspect-ratio="2/3" cover @click.stop="viewMediaDetail">
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</div>
|
||||
</template>
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<p class="clamp-text mb-0" :class="getTextClass()">
|
||||
{{ props.media?.description }}
|
||||
</p>
|
||||
</VCardText>
|
||||
<VCardText class="d-flex justify-space-between align-center flex-wrap">
|
||||
<div class="d-flex align-center">
|
||||
<IconBtn
|
||||
v-if="props.media?.total_episode"
|
||||
v-bind="props"
|
||||
icon="mdi-progress-clock"
|
||||
:color="getTextColor()"
|
||||
class="me-1"
|
||||
<div class="flex flex-col justify-center overflow-hidden pl-2 xl:pl-4">
|
||||
<div class="text-sm font-medium text-white sm:pt-1">{{ props.media?.year }}</div>
|
||||
<div class="mr-2 min-w-0 text-lg font-bold text-white">
|
||||
{{ props.media?.name }}
|
||||
{{ formatSeason(props.media?.season ? props.media?.season.toString() : '') }}
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
<VCardText class="flex justify-space-between align-center flex-wrap">
|
||||
<div class="flex align-center">
|
||||
<IconBtn
|
||||
v-if="props.media?.total_episode"
|
||||
v-bind="props"
|
||||
icon="mdi-progress-download"
|
||||
color="white"
|
||||
class="me-1"
|
||||
/>
|
||||
<div v-if="props.media?.season" class="text-subtitle-2 me-4 text-white">
|
||||
{{ (props.media?.total_episode || 0) - (props.media?.lack_episode || 0) }} /
|
||||
{{ props.media?.total_episode }}
|
||||
</div>
|
||||
<IconBtn v-if="props.media?.username" icon="mdi-account" color="white" class="me-1" />
|
||||
<span v-if="props.media?.username" class="text-subtitle-2 me-4 text-white">
|
||||
{{ props.media?.username }}
|
||||
</span>
|
||||
</div>
|
||||
</VCardText>
|
||||
<VCardText v-if="lastUpdateText" class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300">
|
||||
<VIcon icon="mdi-download" class="me-1" />
|
||||
{{ lastUpdateText }}
|
||||
</VCardText>
|
||||
<div class="w-full absolute bottom-0">
|
||||
<VProgressLinear
|
||||
v-if="getPercentage() > 0"
|
||||
:model-value="getPercentage()"
|
||||
bg-color="success"
|
||||
color="success"
|
||||
/>
|
||||
<span v-if="props.media?.season" class="text-subtitle-2 me-4" :class="getTextClass()"
|
||||
>{{ (props.media?.total_episode || 0) - (props.media?.lack_episode || 0) }} /
|
||||
{{ props.media?.total_episode }}</span
|
||||
>
|
||||
<IconBtn v-if="props.media?.username" icon="mdi-account" :color="getTextColor()" class="me-1" />
|
||||
<span v-if="props.media?.username" class="text-subtitle-2 me-4" :class="getTextClass()">
|
||||
{{ props.media?.username }}
|
||||
</span>
|
||||
</div>
|
||||
</VCardText>
|
||||
<VCardText v-if="lastUpdateText" class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300">
|
||||
<VIcon icon="mdi-download" class="me-1" />
|
||||
{{ lastUpdateText }}
|
||||
</VCardText>
|
||||
<VProgressLinear v-if="getPercentage() > 0" :model-value="getPercentage()" bg-color="success" color="success" />
|
||||
</div>
|
||||
</VCard>
|
||||
</template>
|
||||
</VHover>
|
||||
@@ -241,3 +297,8 @@ const dropdownItems = ref([
|
||||
@close="subscribeEditDialog = false"
|
||||
/>
|
||||
</template>
|
||||
<style lang="scss">
|
||||
.subscribe-card-background {
|
||||
background-image: linear-gradient(90deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -70,18 +70,24 @@ async function handleAddDownload(_site: any = undefined, _media: any = undefined
|
||||
async function addDownload(_media: MediaInfo, _torrent: TorrentInfo) {
|
||||
startNProgress()
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post('download/', {
|
||||
media_in: _media,
|
||||
torrent_in: _torrent,
|
||||
})
|
||||
let result: { [key: string]: any }
|
||||
|
||||
if (result.success) {
|
||||
if (_media) {
|
||||
result = await api.post('download/', {
|
||||
media_in: _media,
|
||||
torrent_in: _torrent,
|
||||
})
|
||||
} else {
|
||||
result = await api.post('download/add', _torrent)
|
||||
}
|
||||
|
||||
if (result && result.success) {
|
||||
// 添加下载成功
|
||||
$toast.success(`${_torrent?.site_name} ${_torrent?.title} 添加下载成功!`)
|
||||
$toast.success(`${_torrent?.site_name} ${_torrent?.title} 下载成功!`)
|
||||
downloaded.value.push(_torrent?.enclosure || '')
|
||||
} else {
|
||||
// 添加下载失败
|
||||
$toast.error(`${_torrent?.site_name} ${_torrent?.title} 添加下载失败!`)
|
||||
$toast.error(`${_torrent?.site_name} ${_torrent?.title} 下载失败:${result?.message}!`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
@@ -127,7 +133,7 @@ onMounted(() => {
|
||||
</template>
|
||||
<VCardItem class="py-1">
|
||||
<VCardTitle class="break-words overflow-visible whitespace-break-spaces">
|
||||
{{ media?.title }} {{ meta?.season_episode }}
|
||||
{{ media?.title ?? meta?.name }} {{ meta?.season_episode }}
|
||||
<span class="text-green-700 ms-2 text-sm">↑{{ torrent?.seeders }}</span>
|
||||
<span class="text-orange-700 ms-2 text-sm">↓{{ torrent?.peers }}</span>
|
||||
</VCardTitle>
|
||||
|
||||
@@ -67,18 +67,24 @@ async function handleAddDownload(_site: any = undefined, _media: any = undefined
|
||||
async function addDownload(_media: MediaInfo, _torrent: TorrentInfo) {
|
||||
startNProgress()
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post('download/', {
|
||||
media_in: _media,
|
||||
torrent_in: _torrent,
|
||||
})
|
||||
let result: { [key: string]: any }
|
||||
|
||||
if (result.success) {
|
||||
if (_media) {
|
||||
result = await api.post('download/', {
|
||||
media_in: _media,
|
||||
torrent_in: _torrent,
|
||||
})
|
||||
} else {
|
||||
result = await api.post('download/add', _torrent)
|
||||
}
|
||||
|
||||
if (result && result.success) {
|
||||
// 添加下载成功
|
||||
$toast.success(`${_torrent?.site_name} ${_torrent?.title} 添加下载成功!`)
|
||||
$toast.success(`${_torrent?.site_name} ${_torrent?.title} 下载成功!`)
|
||||
downloaded.value.push(_torrent?.enclosure || '')
|
||||
} else {
|
||||
// 添加下载失败
|
||||
$toast.error(`${_torrent?.site_name} ${_torrent?.title} 添加下载失败!`)
|
||||
$toast.error(`${_torrent?.site_name} ${_torrent?.title} 下载失败:${result?.message}!`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
|
||||
109
src/components/dialog/AliyunAuthDialog.vue
Normal file
109
src/components/dialog/AliyunAuthDialog.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<script lang="ts" setup>
|
||||
import QrcodeVue from 'qrcode.vue'
|
||||
import api from '@/api'
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['done', 'close'])
|
||||
|
||||
// 二维码内容
|
||||
const qrCodeContent = ref('')
|
||||
|
||||
// ck参数
|
||||
const ck = ref('')
|
||||
|
||||
// t参数
|
||||
const t = ref('')
|
||||
|
||||
// 下方的提示信息
|
||||
const text = ref('请用阿里云盘 App 扫码')
|
||||
|
||||
// 提醒类型
|
||||
const alertType = ref<'success' | 'info' | 'error' | 'warning' | undefined>('info')
|
||||
|
||||
// timeout定时器
|
||||
let timeoutTimer: NodeJS.Timeout | undefined = undefined
|
||||
|
||||
// 完成
|
||||
async function handleDone() {
|
||||
emit('done')
|
||||
}
|
||||
|
||||
// 调用/aliyun/qrcode api生成二维码
|
||||
async function getQrcode() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('/aliyun/qrcode')
|
||||
if (result.success && result.data) {
|
||||
qrCodeContent.value = result.data.codeContent
|
||||
ck.value = result.data.ck
|
||||
t.value = result.data.t
|
||||
} else {
|
||||
text.value = result.message
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
// 调用/aliyun/check api验证二维码
|
||||
async function checkQrcode() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('/aliyun/check', {
|
||||
params: {
|
||||
ck: ck.value,
|
||||
t: t.value,
|
||||
},
|
||||
})
|
||||
if (result.success && result.data) {
|
||||
const qrCodeStatus = result.data.qrCodeStatus
|
||||
text.value = result.data.tip
|
||||
if (qrCodeStatus == 'CONFIRMED') {
|
||||
// 已确认完成
|
||||
alertType.value = 'success'
|
||||
handleDone()
|
||||
} else if (qrCodeStatus == 'NEW' || qrCodeStatus == 'SCANED') {
|
||||
alertType.value = 'info'
|
||||
// 新建、待扫码
|
||||
clearTimeout(timeoutTimer)
|
||||
timeoutTimer = setTimeout(checkQrcode, 3000)
|
||||
} else {
|
||||
// 过期或者已取消
|
||||
alertType.value = 'error'
|
||||
}
|
||||
} else {
|
||||
alertType.value = 'error'
|
||||
text.value = result.message
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await getQrcode()
|
||||
timeoutTimer = setTimeout(checkQrcode, 3000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timeoutTimer) clearTimeout(timeoutTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog width="40rem" scrollable max-height="85vh">
|
||||
<VCard title="阿里云盘登录" class="rounded-t">
|
||||
<DialogCloseBtn @click="emit('close')" />
|
||||
<VCardText class="pt-2 flex flex-col items-center">
|
||||
<div class="my-6 shadow-lg rounded text-center p-3 border">
|
||||
<QrcodeVue class="mx-auto" :value="qrCodeContent" :size="200" />
|
||||
</div>
|
||||
<VAlert variant="tonal" :type="alertType" class="my-4 text-center" :text="text">
|
||||
<template #prepend />
|
||||
</VAlert>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn variant="elevated" @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3"> 完成 </VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
@@ -6,16 +6,20 @@ import api from '@/api'
|
||||
import { numberValidator } from '@/@validators'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import ProgressDialog from './ProgressDialog.vue'
|
||||
import { MediaDirectory } from '@/api/types'
|
||||
import { FileItem, MediaDirectory } from '@/api/types'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
path: String,
|
||||
target: String,
|
||||
storage: {
|
||||
type: String,
|
||||
default: () => 'local',
|
||||
},
|
||||
logids: Array<number>,
|
||||
items: Array<FileItem>,
|
||||
target: String,
|
||||
})
|
||||
|
||||
// 定义事件
|
||||
@@ -50,10 +54,25 @@ const progressText = ref('请稍候 ...')
|
||||
// 整理进度
|
||||
const progressValue = ref(0)
|
||||
|
||||
// 文件转移表单
|
||||
// 标题
|
||||
const dialogTitle = computed(() => {
|
||||
if (props.items) {
|
||||
if (props.items.length > 1) return `整理 - 共 ${props.items.length} 项`
|
||||
return `整理 - ${props.items[0].path}`
|
||||
} else if (props.logids) {
|
||||
return `整理 - 共 ${props.logids.length} 项`
|
||||
}
|
||||
return '手动整理'
|
||||
})
|
||||
|
||||
// 表单
|
||||
const transferForm = reactive({
|
||||
storage: props.storage,
|
||||
logid: 0,
|
||||
path: '',
|
||||
drive_id: '',
|
||||
fileid: '',
|
||||
filetype: '',
|
||||
target: props.target ?? null,
|
||||
tmdbid: null,
|
||||
doubanid: null,
|
||||
@@ -77,12 +96,6 @@ const targetDirectories = computed(() => {
|
||||
return [...new Set(directories)]
|
||||
})
|
||||
|
||||
// 监听输入变化
|
||||
watchEffect(() => {
|
||||
transferForm.path = props.path ?? ''
|
||||
transferForm.target = props.target ?? null
|
||||
})
|
||||
|
||||
// 监听目的路径变化,自动查询目录的刮削配置
|
||||
watch(transferForm, async () => {
|
||||
if (transferForm.target) {
|
||||
@@ -117,47 +130,25 @@ function stopLoadingProgress() {
|
||||
}
|
||||
|
||||
// 整理文件
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
async function transfer() {
|
||||
if (!props.logids && !props.path) return
|
||||
if (!props.logids && !props.items) return
|
||||
|
||||
// 显示进度条
|
||||
progressDialog.value = true
|
||||
// 开始监听进度
|
||||
startLoadingProgress()
|
||||
|
||||
if (props.path) {
|
||||
// 文件整理
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post(
|
||||
'transfer/manual',
|
||||
{},
|
||||
{
|
||||
params: transferForm,
|
||||
},
|
||||
)
|
||||
// 显示结果
|
||||
if (result.success) $toast.success(`${props.path} 整理完成!`)
|
||||
else $toast.error(`${props.path} 整理失败:${result.message}!`)
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
// 文件整理
|
||||
if (props.items) {
|
||||
for (const item of props.items) {
|
||||
await handleTransfer(item)
|
||||
}
|
||||
} else if (props.logids) {
|
||||
// 日志整理
|
||||
}
|
||||
|
||||
// 日志整理
|
||||
if (props.logids) {
|
||||
for (const logid of props.logids) {
|
||||
transferForm.logid = logid
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post(
|
||||
'transfer/manual',
|
||||
{},
|
||||
{
|
||||
params: transferForm,
|
||||
},
|
||||
)
|
||||
if (!result.success) $toast.error(`历史记录 ${logid} 重新整理失败:${result.message}!`)
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
await handleTransferLog(logid)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,6 +160,32 @@ async function transfer() {
|
||||
emit('done')
|
||||
}
|
||||
|
||||
// 整理文件
|
||||
async function handleTransfer(item: FileItem) {
|
||||
transferForm.path = item.path
|
||||
transferForm.fileid = item.fileid || ''
|
||||
transferForm.drive_id = item.drive_id || ''
|
||||
transferForm.filetype = item.type || 'dir'
|
||||
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post('transfer/manual', {}, { params: transferForm })
|
||||
if (!result.success) $toast.error(`文件 ${item.path} 整理失败:${result.message}!`)
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
|
||||
// 整理日志
|
||||
async function handleTransferLog(logid: number) {
|
||||
transferForm.logid = logid
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post('transfer/manual', {}, { params: transferForm })
|
||||
if (!result.success) $toast.error(`历史记录 ${logid} 重新整理失败:${result.message}!`)
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
|
||||
// 调用API,加载当前系统环境设置
|
||||
async function loadSystemSettings() {
|
||||
try {
|
||||
@@ -199,25 +216,23 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<VDialog scrollable max-width="50rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard
|
||||
:title="`${props.path ? `整理 - ${props.path}` : `整理 - 共 ${props.logids?.length} 条记录`}`"
|
||||
class="rounded-t"
|
||||
>
|
||||
<VCard :title="dialogTitle" class="rounded-t">
|
||||
<DialogCloseBtn @click="emit('close')" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VForm @submit.prevent="() => {}">
|
||||
<VRow>
|
||||
<VCol cols="12" md="8">
|
||||
<VCol v-if="props.storage == 'local'" cols="12" md="8">
|
||||
<VCombobox
|
||||
v-model="transferForm.target"
|
||||
:items="targetDirectories"
|
||||
label="目的路径"
|
||||
placeholder="留空自动"
|
||||
hint="留空将自动匹配目标路径"
|
||||
hint="整理目的路径,留空将自动匹配"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VCol v-if="props.storage == 'local'" cols="12" md="4">
|
||||
<VSelect
|
||||
v-model="transferForm.transfer_type"
|
||||
label="整理方式"
|
||||
@@ -230,6 +245,8 @@ onMounted(() => {
|
||||
{ title: 'Rclone复制', value: 'rclone_copy' },
|
||||
{ title: 'Rclone移动', value: 'rclone_move' },
|
||||
]"
|
||||
hint="文件操作整理方式"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -243,6 +260,8 @@ onMounted(() => {
|
||||
{ title: '电影', value: '电影' },
|
||||
{ title: '电视剧', value: '电视剧' },
|
||||
]"
|
||||
hint="文件的媒体类型"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -254,7 +273,8 @@ onMounted(() => {
|
||||
placeholder="留空自动识别"
|
||||
:rules="[numberValidator]"
|
||||
append-inner-icon="mdi-magnify"
|
||||
hint="点击图标按名称搜索,留空将自动重新识别"
|
||||
hint="按名称查询媒体编号,留空自动识别"
|
||||
persistent-hint
|
||||
@click:append-inner="mediaSelectorDialog = true"
|
||||
/>
|
||||
<VTextField
|
||||
@@ -265,7 +285,8 @@ onMounted(() => {
|
||||
placeholder="留空自动识别"
|
||||
:rules="[numberValidator]"
|
||||
append-inner-icon="mdi-magnify"
|
||||
hint="点击图标按名称搜索,留空将自动重新识别"
|
||||
hint="按名称查询媒体编号,留空自动识别"
|
||||
persistent-hint
|
||||
@click:append-inner="mediaSelectorDialog = true"
|
||||
/>
|
||||
</VCol>
|
||||
@@ -275,6 +296,8 @@ onMounted(() => {
|
||||
v-model.number="transferForm.season"
|
||||
label="季"
|
||||
:items="seasonItems"
|
||||
hint="指定季数"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -284,7 +307,8 @@ onMounted(() => {
|
||||
v-model="transferForm.episode_format"
|
||||
label="集数定位"
|
||||
placeholder="使用{ep}定位集数"
|
||||
hint="使用{ep}定位文件名中的集数部分,其余相同部分直接填写,不同部分使用{a}进行忽略,例如:{a}葬送的芙莉莲_Sousou no Frieren 第{ep}话{b}"
|
||||
hint="使用{ep}定位文件名中的集数部分以辅助识别"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -292,7 +316,8 @@ onMounted(() => {
|
||||
v-model="transferForm.episode_detail"
|
||||
label="指定集数"
|
||||
placeholder="起始集,终止集,如1或1,2"
|
||||
hint="直接指定集数或者范围,格式:起始集,终止集,如1或1,2"
|
||||
hint="指定集数或范围,如1或1,2"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -300,7 +325,8 @@ onMounted(() => {
|
||||
v-model="transferForm.episode_part"
|
||||
label="指定Part"
|
||||
placeholder="如part1"
|
||||
hint="指定集数的Part,如part1"
|
||||
hint="指定Part,如part1"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -308,7 +334,8 @@ onMounted(() => {
|
||||
v-model.number="transferForm.episode_offset"
|
||||
label="集数偏移"
|
||||
placeholder="如-10"
|
||||
hint="对集数进行偏移运算,如-10表示文件名中的集数减10为整理后集数"
|
||||
hint="集数偏移运算,如-10或EP*2"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -317,13 +344,19 @@ onMounted(() => {
|
||||
label="最小文件大小(MB)"
|
||||
:rules="[numberValidator]"
|
||||
placeholder="0"
|
||||
hint="最小文件大小,小于此大小的文件将被忽略不进行整理"
|
||||
hint="只整理大于最小文件大小的文件"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch v-model="transferForm.scrape" label="刮削元数据" hint="整理完成后自动刮削元数据" />
|
||||
<VSwitch
|
||||
v-model="transferForm.scrape"
|
||||
label="刮削元数据"
|
||||
hint="整理完成后自动刮削元数据"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
|
||||
@@ -143,6 +143,7 @@ async function updateSiteInfo() {
|
||||
label="站点地址"
|
||||
:rules="[requiredValidator]"
|
||||
hint="格式:http://www.example.com/"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6" md="3">
|
||||
@@ -151,11 +152,18 @@ async function updateSiteInfo() {
|
||||
label="优先级"
|
||||
:items="priorityItems"
|
||||
:rules="[requiredValidator]"
|
||||
hint="站点资源下载优先级,优先级数字越小越优先下载"
|
||||
hint="优先级越小越优先"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6" md="3">
|
||||
<VSelect v-model="siteForm.is_active" :items="statusItems" label="状态" />
|
||||
<VSelect
|
||||
v-model="siteForm.is_active"
|
||||
:items="statusItems"
|
||||
label="状态"
|
||||
hint="站点启用/停用"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
@@ -163,34 +171,38 @@ async function updateSiteInfo() {
|
||||
<VTextField
|
||||
v-model="siteForm.rss"
|
||||
label="RSS地址"
|
||||
hint="订阅模式为站点RSS时,将会使用此地址获取站点种子资源,该地址一般会自动获取,也可手动补充"
|
||||
hint="订阅模式为`站点RSS`时使用的订阅链接,如未自动获取需手动补充"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="3">
|
||||
<VTextField v-model="siteForm.timeout" label="超时时间(秒)" hint="站点请求超时时间,为空将使用默认值" />
|
||||
<VTextField v-model="siteForm.timeout" label="超时时间(秒)" hint="站点请求超时时间" persistent-hint />
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextarea
|
||||
v-model="siteForm.cookie"
|
||||
label="站点Cookie"
|
||||
hint="浏览器打开站点首页,打开开发人员工具,刷新页面后在网络选项中找到首页地址,在请求头中获取Cookie信息"
|
||||
/>
|
||||
<VTextarea v-model="siteForm.cookie" label="站点Cookie" hint="站点请求头中的Cookie信息" persistent-hint />
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="siteForm.token"
|
||||
label="请求头(Authorization)"
|
||||
hint="在开发人员工具,网络请求头中获取Authorization,仅个别站点需要"
|
||||
hint="站点请求头中的Authorization信息,特殊站点需要"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField v-model="siteForm.apikey" label="令牌(API Key)" hint="站点的访问API Key,仅个别站点需要" />
|
||||
<VTextField
|
||||
v-model="siteForm.apikey"
|
||||
label="令牌(API Key)"
|
||||
hint="站点的访问API Key,特殊站点需要"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="siteForm.ua"
|
||||
label="站点User-Agent"
|
||||
hint="在开发人员工具,网络请求头中获取User-Agent信息,需与站点Cookie配套使用"
|
||||
hint="获取Cookie的浏览器对应的User-Agent"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -200,7 +212,8 @@ async function updateSiteInfo() {
|
||||
v-model="siteForm.limit_interval"
|
||||
label="单位周期(秒)"
|
||||
:rules="[numberValidator]"
|
||||
hint="设定站点限流的单位周期,单位为秒,0为不限流"
|
||||
hint="限流控制的单位周期时长"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -208,7 +221,8 @@ async function updateSiteInfo() {
|
||||
v-model="siteForm.limit_count"
|
||||
label="周期内访问次数"
|
||||
:rules="[numberValidator]"
|
||||
hint="设定单位周期内站点允许的访问次数,0为不限制"
|
||||
hint="单位周期内允许的访问次数"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -216,20 +230,17 @@ async function updateSiteInfo() {
|
||||
v-model="siteForm.limit_seconds"
|
||||
label="访问间隔(秒)"
|
||||
:rules="[numberValidator]"
|
||||
hint="设定单位周期内每次站点访问需间隔时间,单位为秒,0为不限制"
|
||||
hint="每次访问需要间隔的最小时间"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch v-model="siteForm.proxy" label="代理" hint="站点是否需要代理访问,需要设置好代理服务器信息" />
|
||||
<VSwitch v-model="siteForm.proxy" label="代理" hint="使用代理服务器访问该站点" persistent-hint />
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="siteForm.render"
|
||||
label="仿真"
|
||||
hint="站点是否需要使用浏览器模拟访问,开启可以一定程度上提升连通性,但会大大增加站点请求时间"
|
||||
/>
|
||||
<VSwitch v-model="siteForm.render" label="仿真" hint="使用浏览器模拟真实访问该站点" persistent-hint />
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
|
||||
@@ -53,7 +53,7 @@ const subscribeForm = ref<Subscribe>({
|
||||
last_update: '',
|
||||
username: '',
|
||||
current_priority: 0,
|
||||
save_path: '',
|
||||
save_path: undefined,
|
||||
date: '',
|
||||
show_edit_dialog: false,
|
||||
})
|
||||
@@ -301,7 +301,8 @@ onMounted(() => {
|
||||
v-if="!props.default"
|
||||
v-model="subscribeForm.keyword"
|
||||
label="搜索关键词"
|
||||
hint="设定搜索关键词后,将使用此关键词搜索站点资源,否则自动使用themoviedb中的名称搜索"
|
||||
hint="指定搜索站点时使用的关键词"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="subscribeForm.type === '电视剧'" cols="12" md="2">
|
||||
@@ -309,7 +310,8 @@ onMounted(() => {
|
||||
v-model="subscribeForm.total_episode"
|
||||
label="总集数"
|
||||
:rules="[numberValidator]"
|
||||
hint="手动设定总集数"
|
||||
hint="剧集总集数"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="subscribeForm.type === '电视剧'" cols="12" md="2">
|
||||
@@ -317,19 +319,38 @@ onMounted(() => {
|
||||
v-model="subscribeForm.start_episode"
|
||||
label="开始集数"
|
||||
:rules="[numberValidator]"
|
||||
hint="只下载此集数及之后的集"
|
||||
hint="开始订阅集数"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol cols="12" md="4">
|
||||
<VSelect v-model="subscribeForm.quality" label="质量" :items="qualityOptions" />
|
||||
<VSelect
|
||||
v-model="subscribeForm.quality"
|
||||
label="质量"
|
||||
:items="qualityOptions"
|
||||
hint="订阅资源质量"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VSelect v-model="subscribeForm.resolution" label="分辨率" :items="resolutionOptions" />
|
||||
<VSelect
|
||||
v-model="subscribeForm.resolution"
|
||||
label="分辨率"
|
||||
:items="resolutionOptions"
|
||||
hint="订阅资源分辨率"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VSelect v-model="subscribeForm.effect" label="特效" :items="effectOptions" />
|
||||
<VSelect
|
||||
v-model="subscribeForm.effect"
|
||||
label="特效"
|
||||
:items="effectOptions"
|
||||
hint="订阅资源特效"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
@@ -337,14 +358,16 @@ onMounted(() => {
|
||||
<VTextField
|
||||
v-model="subscribeForm.include"
|
||||
label="包含(关键字、正则式)"
|
||||
hint="支持正则表达式,多个关键字用 | 分隔表示或"
|
||||
hint="包含规则,支持正则表达式"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField
|
||||
v-model="subscribeForm.exclude"
|
||||
label="排除(关键字、正则式)"
|
||||
hint="支持正则表达式,多个关键字用 | 分隔表示或"
|
||||
hint="排除规则,支持正则表达式"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -354,7 +377,8 @@ onMounted(() => {
|
||||
chips
|
||||
label="订阅站点"
|
||||
multiple
|
||||
hint="只订阅选中的订阅站点,不选则订阅所有可订阅站点"
|
||||
hint="订阅的站点范围,不选使用系统设置"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -365,6 +389,7 @@ onMounted(() => {
|
||||
:items="targetDirectories"
|
||||
label="保存路径"
|
||||
hint="指定该订阅的下载保存路径,留空自动使用设定的下载目录"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -373,21 +398,24 @@ onMounted(() => {
|
||||
<VSwitch
|
||||
v-model="subscribeForm.best_version"
|
||||
label="洗版"
|
||||
hint="开启后不管媒体库是否存在,均会根据洗版优先级进行过滤下载,直到下载到了最高优先级的资源为止"
|
||||
hint="根据洗版优先级进行洗版订阅"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VSwitch
|
||||
v-model="subscribeForm.search_imdbid"
|
||||
label="使用 ImdbID 搜索"
|
||||
hint="开启后将使用 ImdbID 搜索资源,搜索结果更精确,但不是所有站点都支持"
|
||||
hint="开使用 ImdbID 精确搜索资源"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="props.default" cols="12" md="4">
|
||||
<VSwitch
|
||||
v-model="subscribeForm.show_edit_dialog"
|
||||
label="订阅时编辑更多规则"
|
||||
hint="开启后将在添加订阅后弹出编辑订阅的对话框,方便用户编辑订阅规则"
|
||||
hint="添加订阅时显示此编辑订阅对话框"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
98
src/components/dialog/U115AuthDialog.vue
Normal file
98
src/components/dialog/U115AuthDialog.vue
Normal file
@@ -0,0 +1,98 @@
|
||||
<script lang="ts" setup>
|
||||
import QrcodeVue from 'qrcode.vue'
|
||||
import api from '@/api'
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['done', 'close'])
|
||||
|
||||
// 二维码内容
|
||||
const qrCodeContent = ref('')
|
||||
|
||||
// 下方的提示信息
|
||||
const text = ref('请使用微信或115客户端扫码')
|
||||
|
||||
// 提醒类型
|
||||
const alertType = ref<'success' | 'info' | 'error' | 'warning' | undefined>('info')
|
||||
|
||||
// timeout定时器
|
||||
let timeoutTimer: NodeJS.Timeout | undefined = undefined
|
||||
|
||||
// 完成
|
||||
async function handleDone() {
|
||||
emit('done')
|
||||
}
|
||||
|
||||
// 调用/aliyun/qrcode api生成二维码
|
||||
async function getQrcode() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('/u115/qrcode')
|
||||
if (result.success && result.data) {
|
||||
qrCodeContent.value = result.data.codeContent
|
||||
} else {
|
||||
text.value = result.message
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
// 调用/aliyun/check api验证二维码
|
||||
async function checkQrcode() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('/u115/check')
|
||||
if (result.success && result.data) {
|
||||
const status = result.data.status
|
||||
text.value = result.data.tip
|
||||
if (status == 1) {
|
||||
// 已确认完成
|
||||
alertType.value = 'success'
|
||||
handleDone()
|
||||
} else if (status == 0) {
|
||||
alertType.value = 'info'
|
||||
// 新建、待扫码
|
||||
clearTimeout(timeoutTimer)
|
||||
timeoutTimer = setTimeout(checkQrcode, 3000)
|
||||
} else {
|
||||
// 过期或者已取消
|
||||
alertType.value = 'error'
|
||||
}
|
||||
} else {
|
||||
alertType.value = 'error'
|
||||
text.value = result.message
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await getQrcode()
|
||||
timeoutTimer = setTimeout(checkQrcode, 3000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timeoutTimer) clearTimeout(timeoutTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog width="40rem" scrollable max-height="85vh">
|
||||
<VCard title="115网盘登录" class="rounded-t">
|
||||
<DialogCloseBtn @click="emit('close')" />
|
||||
<VCardText class="pt-2 flex flex-col items-center">
|
||||
<div class="my-6 shadow-lg rounded border">
|
||||
<VImg class="mx-auto" :src="qrCodeContent" style="block-size: 200px; inline-size: 200px">
|
||||
<VSkeletonLoader v-if="!qrCodeContent" class="w-full h-full" />
|
||||
</VImg>
|
||||
</div>
|
||||
<VAlert variant="tonal" :type="alertType" class="my-4 text-center" :text="text">
|
||||
<template #prepend />
|
||||
</VAlert>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn variant="elevated" @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3"> 完成 </VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
@@ -1,8 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Axios } from 'axios'
|
||||
import type { Axios, AxiosRequestConfig } from 'axios'
|
||||
import type { PropType } from 'vue'
|
||||
import { useConfirm } from 'vuetify-use-dialog'
|
||||
import axios from 'axios'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import ReorganizeDialog from '../dialog/ReorganizeDialog.vue'
|
||||
import { formatBytes } from '@core/utils/formatters'
|
||||
@@ -11,27 +10,51 @@ import store from '@/store'
|
||||
import api from '@/api'
|
||||
import MediaInfoCard from '@/components/cards/MediaInfoCard.vue'
|
||||
import ProgressDialog from '../dialog/ProgressDialog.vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// APP
|
||||
const appMode = computed(() => {
|
||||
return localStorage.getItem('MP_APPMODE') != '0' && display.mdAndDown.value
|
||||
})
|
||||
|
||||
// 输入参数
|
||||
const inProps = defineProps({
|
||||
icons: Object,
|
||||
storage: String,
|
||||
path: String,
|
||||
endpoints: Object as PropType<EndPoints>,
|
||||
axios: Object as PropType<Axios>,
|
||||
axios: {
|
||||
type: Object as PropType<Axios>,
|
||||
required: true,
|
||||
},
|
||||
refreshpending: Boolean,
|
||||
item: {
|
||||
type: Object as PropType<FileItem>,
|
||||
required: true,
|
||||
},
|
||||
sort: String,
|
||||
})
|
||||
|
||||
// 对外事件
|
||||
const emit = defineEmits(['loading', 'pathchanged', 'refreshed', 'filedeleted', 'renamed'])
|
||||
|
||||
// 确认框
|
||||
const createConfirm = useConfirm()
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 是否选择模式
|
||||
const selectMode = ref(false)
|
||||
|
||||
// 是否正在加载
|
||||
const loading = ref(true)
|
||||
|
||||
// 重命名loading
|
||||
const renameLoading = ref(false)
|
||||
|
||||
// 识别进度条
|
||||
const progressDialog = ref(false)
|
||||
|
||||
@@ -41,15 +64,6 @@ const progressText = ref('请稍候 ...')
|
||||
// 识别进度
|
||||
const progressValue = ref(0)
|
||||
|
||||
// 确认框
|
||||
const createConfirm = useConfirm()
|
||||
|
||||
// 存储空间类型
|
||||
const storage = ref(inProps.storage ?? '')
|
||||
|
||||
// axios实例
|
||||
const axiosInstance = ref<Axios>(inProps.axios ?? axios)
|
||||
|
||||
// 内容列表
|
||||
const items = ref<FileItem[]>([])
|
||||
|
||||
@@ -65,170 +79,358 @@ const transferPopper = ref(false)
|
||||
// 新名称
|
||||
const newName = ref('')
|
||||
|
||||
// 当前名称
|
||||
// 处理目录内所有文件
|
||||
const renameAll = ref(false)
|
||||
|
||||
// 当前操作项
|
||||
const currentItem = ref<FileItem>()
|
||||
|
||||
// 选中的项目
|
||||
const selected = ref<FileItem[]>([])
|
||||
|
||||
// 识别结果
|
||||
const nameTestResult = ref<Context>()
|
||||
|
||||
// 识别结果对话框
|
||||
const nameTestDialog = ref(false)
|
||||
|
||||
// 弹出菜单
|
||||
const dropdownItems = ref<{ [key: string]: any }[]>([])
|
||||
|
||||
// 加载进度SSE
|
||||
const progressEventSource = ref<EventSource>()
|
||||
|
||||
// 目录过滤
|
||||
const dirs = computed(() => items.value.filter(item => item.type === 'dir' && item.basename.includes(filter.value)))
|
||||
const dirs = computed(() => items.value.filter(item => item.type === 'dir' && item.name.includes(filter.value)))
|
||||
|
||||
// 文件过滤
|
||||
const files = computed(() => items.value.filter(item => item.type === 'file' && item.basename.includes(filter.value)))
|
||||
|
||||
const files = computed(() => items.value.filter(item => item.type === 'file' && item.name.includes(filter.value)))
|
||||
// 是否目录
|
||||
const isDir = computed(() => inProps.path?.endsWith('/'))
|
||||
const isDir = computed(() => inProps.item.path?.endsWith('/'))
|
||||
|
||||
// 是否文件
|
||||
const isFile = computed(() => !isDir.value)
|
||||
|
||||
// 需要整理的文件项
|
||||
const transferItems = ref<FileItem[]>([])
|
||||
|
||||
// 大小控制
|
||||
const scrollStyle = computed(() => {
|
||||
return appMode.value
|
||||
? 'height: calc(100vh - 15.5rem - env(safe-area-inset-bottom) - 3.5rem)'
|
||||
: 'height: calc(100vh - 14.5rem - env(safe-area-inset-bottom)'
|
||||
})
|
||||
|
||||
// 是否为图片文件
|
||||
const isImage = computed(() => {
|
||||
const ext = inProps.path?.split('.').pop()?.toLowerCase()
|
||||
const ext = inProps.item.path?.split('.').pop()?.toLowerCase()
|
||||
return ['png', 'jpg', 'jpeg', 'gif', 'bmp'].includes(ext ?? '')
|
||||
})
|
||||
|
||||
// 调API加载内容
|
||||
async function load() {
|
||||
// 调整选择模式
|
||||
function changeSelectMode() {
|
||||
selectMode.value = !selectMode.value
|
||||
if (!selectMode.value) selected.value = []
|
||||
}
|
||||
|
||||
// 调API加载文件夹内的内容
|
||||
async function list_files() {
|
||||
loading.value = true
|
||||
emit('loading', true)
|
||||
|
||||
// 参数
|
||||
const url = inProps.endpoints?.list.url
|
||||
.replace(/{storage}/g, storage.value)
|
||||
.replace(/{path}/g, encodeURIComponent(inProps.path || ''))
|
||||
.replace(/{storage}/g, inProps.storage)
|
||||
.replace(/{sort}/g, inProps.sort || 'name')
|
||||
|
||||
const config = {
|
||||
const config: AxiosRequestConfig<FileItem> = {
|
||||
url,
|
||||
method: inProps.endpoints?.list.method || 'get',
|
||||
data: inProps.item,
|
||||
}
|
||||
|
||||
// 加载数据
|
||||
items.value = (await axiosInstance.value.request(config)) ?? []
|
||||
items.value = (await inProps.axios.request(config)) ?? []
|
||||
emit('loading', false)
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
// 删除项目
|
||||
async function deleteItem(item: FileItem) {
|
||||
async function deleteItem(item: FileItem, confirm: boolean = true) {
|
||||
if (confirm) {
|
||||
const confirmed = await createConfirm({
|
||||
title: '确认',
|
||||
content: `是否确认删除${item.type === 'dir' ? '目录' : '文件'} ${item.name}?`,
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
|
||||
// 加载中
|
||||
emit('loading', true)
|
||||
|
||||
// 请求API
|
||||
const url = inProps.endpoints?.delete.url.replace(/{storage}/g, inProps.storage)
|
||||
const config: AxiosRequestConfig<FileItem> = {
|
||||
url,
|
||||
method: inProps.endpoints?.delete.method || 'post',
|
||||
data: item,
|
||||
}
|
||||
await inProps.axios.request(config)
|
||||
|
||||
// 删除完成
|
||||
emit('loading', false)
|
||||
emit('filedeleted')
|
||||
|
||||
// 重新加载
|
||||
list_files()
|
||||
}
|
||||
|
||||
// 批量删除
|
||||
async function batchDelete() {
|
||||
const confirmed = await createConfirm({
|
||||
title: '确认',
|
||||
content: `是否确认删除${item.type === 'dir' ? '目录' : '文件'} ${item.basename}?`,
|
||||
content: `是否确认删除选中的 ${selected.value.length} 个项目?`,
|
||||
})
|
||||
|
||||
if (confirmed) {
|
||||
emit('loading', true)
|
||||
const url = inProps.endpoints?.delete.url
|
||||
.replace(/{storage}/g, storage.value)
|
||||
.replace(/{path}/g, encodeURIComponent(item.path))
|
||||
if (!confirmed) return
|
||||
|
||||
const config = {
|
||||
url,
|
||||
method: inProps.endpoints?.delete.method || 'post',
|
||||
}
|
||||
// 显示进度条
|
||||
progressDialog.value = true
|
||||
progressValue.value = 0
|
||||
|
||||
await axiosInstance.value.request(config)
|
||||
emit('filedeleted')
|
||||
emit('loading', false)
|
||||
// 重新加载
|
||||
load()
|
||||
}
|
||||
// 删除选中的项目
|
||||
selected.value.every(async item => {
|
||||
progressText.value = `正在删除 ${item.name} ...`
|
||||
await deleteItem(item, false)
|
||||
})
|
||||
|
||||
// 关闭进度条
|
||||
progressDialog.value = false
|
||||
|
||||
// 重新加载
|
||||
list_files()
|
||||
}
|
||||
|
||||
// 切换路径
|
||||
function changePath(_path: string) {
|
||||
emit('pathchanged', _path)
|
||||
function changePath(item: FileItem) {
|
||||
item.path = inProps.item.path + item.name + (item.type === 'dir' ? '/' : '')
|
||||
emit('pathchanged', item)
|
||||
}
|
||||
|
||||
// 点击列表项
|
||||
function listItemClick(item: FileItem) {
|
||||
if (selectMode.value) {
|
||||
if (selected.value.includes(item)) {
|
||||
selected.value = selected.value.filter(i => i !== item)
|
||||
} else {
|
||||
selected.value.push(item)
|
||||
}
|
||||
// 去重
|
||||
selected.value = Array.from(new Set(selected.value))
|
||||
return false
|
||||
}
|
||||
changePath(item)
|
||||
}
|
||||
|
||||
// 新窗口中下载文件
|
||||
function download(path: string) {
|
||||
if (!path) return
|
||||
const token = store.state.auth.token
|
||||
const url_path = inProps.endpoints?.download.url
|
||||
.replace(/{storage}/g, storage.value)
|
||||
.replace(/{path}/g, encodeURIComponent(path))
|
||||
const url = `${import.meta.env.VITE_API_BASE_URL}${url_path.slice(1)}&token=${token}`
|
||||
// 下载文件
|
||||
window.open(url, '_blank')
|
||||
async function download(item: FileItem) {
|
||||
const url = inProps.endpoints?.download.url.replace(/{storage}/g, inProps.storage)
|
||||
const filterEntries = Object.entries(item).filter(([key, value]) => !['children', 'thumbnail'].includes(key) && value)
|
||||
const queryParams = filterEntries.map(([key, value]) => `${key}=${encodeURIComponent(value)}`).join('&')
|
||||
window.open(
|
||||
`${import.meta.env.VITE_API_BASE_URL}${url.slice(1)}?${queryParams}&token=${store.state.auth.token}`,
|
||||
'_blank',
|
||||
)
|
||||
}
|
||||
|
||||
// 显示图片
|
||||
function getImgLink(path: string) {
|
||||
if (!path) return ''
|
||||
const token = store.state.auth.token
|
||||
const url_path = inProps.endpoints?.image.url
|
||||
.replace(/{storage}/g, storage.value)
|
||||
.replace(/{path}/g, encodeURIComponent(path))
|
||||
return `${import.meta.env.VITE_API_BASE_URL}${url_path.slice(1)}&token=${token}`
|
||||
// 获取图片地址
|
||||
function getImgLink(item: FileItem) {
|
||||
let url = inProps.endpoints?.image.url.replace(/{storage}/g, inProps.storage)
|
||||
const filterEntries = Object.entries(item).filter(([key, value]) => !['children', 'thumbnail'].includes(key) && value)
|
||||
const queryParams = filterEntries.map(([key, value]) => `${key}=${encodeURIComponent(value)}`).join('&')
|
||||
return `${import.meta.env.VITE_API_BASE_URL}${url.slice(1)}?${queryParams}&token=${store.state.auth.token}`
|
||||
}
|
||||
|
||||
// 显示重命名弹窗
|
||||
function showRenmae(item: FileItem) {
|
||||
currentItem.value = item
|
||||
newName.value = item.name
|
||||
renameAll.value = false
|
||||
renamePopper.value = true
|
||||
}
|
||||
|
||||
// 调用API获取新名称
|
||||
async function get_recommend_name() {
|
||||
renameLoading.value = true
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('transfer/name', {
|
||||
params: {
|
||||
path: `${inProps.item.path}${currentItem.value?.name}`,
|
||||
filetype: currentItem.value?.type ?? 'file',
|
||||
},
|
||||
})
|
||||
if (result.success && result.data) {
|
||||
newName.value = result.data.name
|
||||
} else {
|
||||
$toast.error(result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
renameLoading.value = false
|
||||
}
|
||||
|
||||
// 重命名
|
||||
async function rename() {
|
||||
emit('loading', true)
|
||||
const url = inProps.endpoints?.rename.url
|
||||
.replace(/{storage}/g, inProps.storage)
|
||||
.replace(/{path}/g, encodeURIComponent(currentItem.value?.path || ''))
|
||||
.replace(/{newname}/g, encodeURIComponent(newName.value))
|
||||
|
||||
const config = {
|
||||
url,
|
||||
method: inProps.endpoints?.mkdir.method || 'post',
|
||||
// 关闭弹窗
|
||||
renamePopper.value = false
|
||||
|
||||
// 显示进度条
|
||||
progressDialog.value = true
|
||||
progressValue.value = 0
|
||||
if (renameAll.value) {
|
||||
progressText.value = `正在重命名 ${currentItem.value?.path} 及目录内所有文件 ...`
|
||||
} else {
|
||||
progressText.value = `正在重命名 ${currentItem.value?.name} ...`
|
||||
}
|
||||
if (renameAll.value) {
|
||||
startLoadingProgress()
|
||||
}
|
||||
|
||||
// 调API
|
||||
await inProps.axios?.request(config)
|
||||
let url = inProps.endpoints?.rename.url
|
||||
.replace(/{storage}/g, inProps.storage)
|
||||
.replace(/{newname}/g, encodeURIComponent(newName.value))
|
||||
if (renameAll.value) {
|
||||
url += '&recursive=true'
|
||||
}
|
||||
|
||||
renamePopper.value = false
|
||||
newName.value = ''
|
||||
emit('loading', false)
|
||||
const config: AxiosRequestConfig<FileItem> = {
|
||||
url,
|
||||
method: inProps.endpoints?.rename.method || 'post',
|
||||
data: currentItem.value,
|
||||
}
|
||||
const result: { [key: string]: any } = await inProps.axios?.request(config)
|
||||
if (!result.success) {
|
||||
$toast.error(result.message)
|
||||
}
|
||||
|
||||
// 关闭进度条
|
||||
if (renameAll.value) {
|
||||
stopLoadingProgress()
|
||||
}
|
||||
progressDialog.value = false
|
||||
|
||||
// 通知重新加载
|
||||
newName.value = ''
|
||||
renameAll.value = false
|
||||
emit('loading', false)
|
||||
emit('renamed')
|
||||
}
|
||||
|
||||
// 显示整理对话框
|
||||
function showTransfer(item: FileItem) {
|
||||
currentItem.value = item
|
||||
transferItems.value = [item]
|
||||
transferPopper.value = true
|
||||
}
|
||||
|
||||
// 显示批量整理对话框
|
||||
function showBatchTransfer() {
|
||||
transferItems.value = selected.value
|
||||
transferPopper.value = true
|
||||
}
|
||||
|
||||
// 整理完成
|
||||
function transferDone() {
|
||||
transferPopper.value = false
|
||||
list_files()
|
||||
}
|
||||
|
||||
// 将文件修改时间(timestape)转换为本地时间
|
||||
function formatTime(timestape: number) {
|
||||
return new Date(timestape * 1000).toLocaleString()
|
||||
}
|
||||
|
||||
// 监听path变化
|
||||
watch(
|
||||
() => inProps.path,
|
||||
async () => {
|
||||
items.value = []
|
||||
nameTestResult.value = undefined
|
||||
nameTestDialog.value = false
|
||||
await load()
|
||||
},
|
||||
)
|
||||
|
||||
// 监听refreshPending变化
|
||||
watch(
|
||||
() => inProps.refreshpending,
|
||||
async () => {
|
||||
if (inProps.refreshpending) {
|
||||
await load()
|
||||
await list_files()
|
||||
emit('refreshed')
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// 监听item变化或者storage变化
|
||||
watch(
|
||||
[() => inProps.item, () => inProps.storage],
|
||||
async () => {
|
||||
// 清空列表
|
||||
items.value = []
|
||||
// 关闭弹窗
|
||||
nameTestResult.value = undefined
|
||||
nameTestDialog.value = false
|
||||
// 重置菜单
|
||||
dropdownItems.value = [
|
||||
{
|
||||
title: '识别',
|
||||
value: 1,
|
||||
show: true,
|
||||
props: {
|
||||
prependIcon: 'mdi-text-recognition',
|
||||
click: (_item: FileItem) => {
|
||||
recognize(_item.path || '')
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '刮削',
|
||||
value: 2,
|
||||
show: true,
|
||||
props: {
|
||||
prependIcon: 'mdi-auto-fix',
|
||||
click: (_item: FileItem) => {
|
||||
scrape(_item)
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '重命名',
|
||||
value: 3,
|
||||
show: true,
|
||||
props: {
|
||||
prependIcon: 'mdi-rename',
|
||||
click: showRenmae,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '整理',
|
||||
value: 4,
|
||||
show: true,
|
||||
props: {
|
||||
prependIcon: 'mdi-folder-arrow-right',
|
||||
click: showTransfer,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '删除',
|
||||
value: 5,
|
||||
show: true,
|
||||
props: {
|
||||
prependIcon: 'mdi-delete-outline',
|
||||
color: 'error',
|
||||
click: deleteItem,
|
||||
},
|
||||
},
|
||||
]
|
||||
await list_files()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 调用API识别
|
||||
async function recognize(path: string) {
|
||||
try {
|
||||
@@ -251,75 +453,71 @@ async function recognize(path: string) {
|
||||
}
|
||||
|
||||
// 调用API刮削
|
||||
async function scrape(path: string) {
|
||||
async function scrape(item: FileItem, confirm: boolean = true) {
|
||||
try {
|
||||
if (confirm) {
|
||||
// 确认
|
||||
const confirmed = await createConfirm({
|
||||
title: '确认',
|
||||
content: `是否确认刮削 ${item.path}?`,
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
|
||||
// 显示进度条
|
||||
progressDialog.value = true
|
||||
progressText.value = `正在刮削 ${path} ...`
|
||||
const result: { [key: string]: any } = await api.get('media/scrape', {
|
||||
params: {
|
||||
path,
|
||||
},
|
||||
})
|
||||
progressText.value = `正在刮削 ${item.path} ...`
|
||||
|
||||
const result: { [key: string]: any } = await api.post(`media/scrape/${inProps.storage}`, item)
|
||||
|
||||
// 关闭进度条
|
||||
progressDialog.value = false
|
||||
if (!result.success) $toast.error(result.message)
|
||||
else $toast.success(`${path}削刮完成!`)
|
||||
else $toast.success(`${item.path} 削刮完成!`)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
// 弹出菜单
|
||||
const dropdownItems = ref([
|
||||
{
|
||||
title: '识别',
|
||||
value: 1,
|
||||
props: {
|
||||
prependIcon: 'mdi-text-recognition',
|
||||
click: (_item: FileItem) => {
|
||||
recognize(_item.path || '')
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '刮削',
|
||||
value: 2,
|
||||
props: {
|
||||
prependIcon: 'mdi-auto-fix',
|
||||
click: (_item: FileItem) => {
|
||||
scrape(_item.path || '')
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '重命名',
|
||||
value: 3,
|
||||
props: {
|
||||
prependIcon: 'mdi-rename',
|
||||
click: showRenmae,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '整理',
|
||||
value: 4,
|
||||
props: {
|
||||
prependIcon: 'mdi-folder-arrow-right',
|
||||
click: showTransfer,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '删除',
|
||||
value: 5,
|
||||
props: {
|
||||
prependIcon: 'mdi-delete-outline',
|
||||
color: 'error',
|
||||
click: deleteItem,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
// 批量刮削
|
||||
async function batchScrape() {
|
||||
// 确认
|
||||
const confirmed = await createConfirm({
|
||||
title: '确认',
|
||||
content: `是否确认刮削选中的 ${selected.value.length} 项?`,
|
||||
})
|
||||
if (!confirmed) return
|
||||
|
||||
selected.value.map(item => {
|
||||
scrape(item, false)
|
||||
})
|
||||
}
|
||||
|
||||
// 使用SSE监听加载进度
|
||||
function startLoadingProgress() {
|
||||
progressText.value = '请稍候 ...'
|
||||
|
||||
const token = store.state.auth.token
|
||||
|
||||
progressEventSource.value = new EventSource(
|
||||
`${import.meta.env.VITE_API_BASE_URL}system/progress/batchrename?token=${token}`,
|
||||
)
|
||||
progressEventSource.value.onmessage = event => {
|
||||
const progress = JSON.parse(event.data)
|
||||
if (progress) {
|
||||
progressText.value = progress.text
|
||||
progressValue.value = progress.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 停止监听加载进度
|
||||
function stopLoadingProgress() {
|
||||
progressEventSource.value?.close()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
list_files()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -339,99 +537,131 @@ onMounted(() => {
|
||||
rounded="0"
|
||||
/>
|
||||
<VSpacer v-if="isFile" />
|
||||
<IconBtn v-if="isFile" @click="recognize(inProps.path || '')">
|
||||
<IconBtn v-if="!isFile" @click="changeSelectMode">
|
||||
<VIcon color="primary" v-if="selectMode"> mdi-selection-remove </VIcon>
|
||||
<VIcon color="primary" v-else>mdi-select</VIcon>
|
||||
</IconBtn>
|
||||
<IconBtn v-if="isFile" @click="recognize(inProps.item.path || '')">
|
||||
<VIcon color="primary"> mdi-text-recognition </VIcon>
|
||||
</IconBtn>
|
||||
<IconBtn v-if="isFile" @click="download(inProps.path || '')">
|
||||
<IconBtn v-if="isFile && items.length > 0" @click="download(items[0])">
|
||||
<VIcon color="primary"> mdi-download </VIcon>
|
||||
</IconBtn>
|
||||
<IconBtn v-if="!isFile" @click="load">
|
||||
<IconBtn v-if="!isFile" @click="list_files">
|
||||
<VIcon color="primary"> mdi-refresh </VIcon>
|
||||
</IconBtn>
|
||||
<!-- 批量操作按钮 -->
|
||||
<span v-if="selected.length > 0">
|
||||
<IconBtn @click.stop="batchScrape">
|
||||
<VIcon color="primary" icon="mdi-auto-fix" />
|
||||
</IconBtn>
|
||||
<IconBtn @click.stop="showBatchTransfer">
|
||||
<VIcon color="primary" icon="mdi-folder-arrow-right" />
|
||||
</IconBtn>
|
||||
<IconBtn @click.stop="batchDelete">
|
||||
<VIcon icon="mdi-delete-outline" color="error" />
|
||||
</IconBtn>
|
||||
</span>
|
||||
</VToolbar>
|
||||
<VCardText v-if="loading" class="text-center flex flex-col items-center">
|
||||
<VProgressCircular size="48" indeterminate color="primary" />
|
||||
</VCardText>
|
||||
<VCardText v-if="!path" class="grow d-flex justify-center align-center grey--text"> 选择目录或文件 </VCardText>
|
||||
<VCardText v-else-if="isFile && !isImage" class="text-center break-all">
|
||||
<strong>{{ items[0]?.name }}</strong
|
||||
><br />
|
||||
大小:{{ formatBytes(items[0]?.size || 0) }}<br />
|
||||
修改时间:{{ formatTime(items[0]?.modify_time || 0) }}
|
||||
<!-- 文件详情 -->
|
||||
<VCardText v-else-if="isFile && !isImage && items.length > 0" class="text-center break-all">
|
||||
<div v-if="items[0]?.thumbnail" class="flex justify-center">
|
||||
<VImg max-width="15rem" cover :src="items[0]?.thumbnail" class="rounded border shadow-lg">
|
||||
<template #placeholder>
|
||||
<VSkeletonLoader class="object-cover w-full h-full" />
|
||||
</template>
|
||||
</VImg>
|
||||
</div>
|
||||
<div class="text-xl text-high-emphasis mt-3">{{ items[0]?.name }}</div>
|
||||
<p class="mt-2" v-if="items[0]?.size && items[0].modify_time">
|
||||
大小:{{ formatBytes(items[0]?.size || 0) }}<br />
|
||||
修改时间:{{ formatTime(items[0]?.modify_time || 0) }}
|
||||
</p>
|
||||
</VCardText>
|
||||
<VCardText v-else-if="isFile && isImage" class="grow d-flex justify-center align-center">
|
||||
<VImg :src="getImgLink(path)" max-width="100%" max-height="100%" />
|
||||
<!-- 图片 -->
|
||||
<VCardText v-else-if="isFile && isImage && items.length > 0" class="grow d-flex justify-center align-center">
|
||||
<VImg :src="getImgLink(items[0])" max-width="100%" max-height="100%" />
|
||||
</VCardText>
|
||||
<!-- 目录和文件列表 -->
|
||||
<VCardText v-else-if="dirs.length || files.length" class="p-0">
|
||||
<VList subheader>
|
||||
<VVirtualScroll class="virtual-scroll-div" :items="[...dirs, ...files]">
|
||||
<VVirtualScroll :items="[...dirs, ...files]" :style="scrollStyle">
|
||||
<template #default="{ item }">
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VListItem v-bind="hover.props" class="px-3 pe-1" @click="changePath(item.path)">
|
||||
<VListItem v-bind="hover.props" class="px-3 pe-1" @click="listItemClick(item)">
|
||||
<template #prepend>
|
||||
<VIcon
|
||||
v-if="inProps.icons && item.extension"
|
||||
:icon="inProps.icons[item.extension.toLowerCase()] || inProps.icons?.other"
|
||||
/>
|
||||
<VIcon v-else icon="mdi-folder-outline" />
|
||||
<VListItemAction v-if="selectMode">
|
||||
<VCheckbox v-model="selected" :value="item" />
|
||||
</VListItemAction>
|
||||
<template v-else>
|
||||
<VIcon
|
||||
v-if="inProps.icons && item.extension"
|
||||
:icon="inProps.icons[item.extension.toLowerCase()] || inProps.icons?.other"
|
||||
/>
|
||||
<VIcon v-else icon="mdi-folder-outline" />
|
||||
</template>
|
||||
</template>
|
||||
<VListItemTitle v-text="item.name" />
|
||||
<VListItemSubtitle v-if="item.size">
|
||||
{{ formatBytes(item.size) }}
|
||||
</VListItemSubtitle>
|
||||
<template #append>
|
||||
<IconBtn class="d-sm-none">
|
||||
<IconBtn v-if="display.smAndDown.value && !selectMode">
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<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)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="menu.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="menu.title" />
|
||||
</VListItem>
|
||||
<template v-for="(menu, i) in dropdownItems" :key="i">
|
||||
<VListItem
|
||||
v-if="menu.show"
|
||||
variant="plain"
|
||||
:base-color="menu.props.color"
|
||||
@click="menu.props.click(item)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="menu.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="menu.title" />
|
||||
</VListItem>
|
||||
</template>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
<span v-if="hover.isHovering" class="flex">
|
||||
<span v-if="hover.isHovering && display.mdAndUp.value && !selectMode" class="flex">
|
||||
<VTooltip text="识别">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="recognize(item.path)">
|
||||
<IconBtn v-bind="props" @click.stop="recognize(item.path)">
|
||||
<VIcon icon="mdi-text-recognition" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VTooltip text="刮削">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="scrape(item.path)">
|
||||
<IconBtn v-bind="props" @click.stop="scrape(item)">
|
||||
<VIcon icon="mdi-auto-fix" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VTooltip text="重命名">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="showRenmae(item)">
|
||||
<IconBtn v-bind="props" @click.stop="showRenmae(item)">
|
||||
<VIcon icon="mdi-rename" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VTooltip text="整理">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="showTransfer(item)">
|
||||
<IconBtn v-bind="props" @click.stop="showTransfer(item)">
|
||||
<VIcon icon="mdi-folder-arrow-right" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VTooltip text="删除">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="deleteItem(item)">
|
||||
<IconBtn v-bind="props" @click.stop="deleteItem(item)">
|
||||
<VIcon icon="mdi-delete-outline" color="error" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
@@ -453,13 +683,25 @@ onMounted(() => {
|
||||
<!-- 重命名弹窗 -->
|
||||
<VDialog v-if="renamePopper" v-model="renamePopper" max-width="50rem">
|
||||
<VCard title="重命名">
|
||||
<DialogCloseBtn @click="renamePopper = false" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VTextField v-model="newName" label="名称" />
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VTextField v-model="newName" label="新名称" :loading="renameLoading" />
|
||||
</VCol>
|
||||
<VCol cols="12" md="6" v-if="currentItem && currentItem.type == 'dir'">
|
||||
<VSwitch v-model="renameAll" label="自动重命名目录内所有媒体文件" />
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VBtn depressed @click="renamePopper = false"> 取消 </VBtn>
|
||||
<VSpacer />
|
||||
<VBtn :disabled="!newName" depressed variant="tonal" @click="rename"> 重命名 </VBtn>
|
||||
<VBtn color="success" variant="elevated" @click="get_recommend_name" prepend-icon="mdi-magic" class="px-5 me-3">
|
||||
自动识别名称
|
||||
</VBtn>
|
||||
<VBtn :disabled="!newName" variant="elevated" @click="rename" prepend-icon="mdi-check" class="px-5 me-3">
|
||||
确定
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
@@ -467,13 +709,9 @@ onMounted(() => {
|
||||
<ReorganizeDialog
|
||||
v-if="transferPopper"
|
||||
v-model="transferPopper"
|
||||
:path="currentItem?.path"
|
||||
@done="
|
||||
() => {
|
||||
transferPopper = false
|
||||
load()
|
||||
}
|
||||
"
|
||||
:storage="inProps.storage"
|
||||
:items="transferItems"
|
||||
@done="transferDone"
|
||||
@close="transferPopper = false"
|
||||
/>
|
||||
<!-- 进度框 -->
|
||||
@@ -497,14 +735,4 @@ onMounted(() => {
|
||||
.v-toolbar {
|
||||
background: rgb(var(--v-table-header-background));
|
||||
}
|
||||
|
||||
.virtual-scroll-div {
|
||||
block-size: calc(100vh - 14rem);
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.virtual-scroll-div {
|
||||
block-size: calc(100vh - 17rem);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Axios } from 'axios'
|
||||
import type { EndPoints } from '@/api/types'
|
||||
import type { Axios, AxiosRequestConfig } from 'axios'
|
||||
import type { EndPoints, FileItem } from '@/api/types'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 输入参数
|
||||
const inProps = defineProps({
|
||||
storages: Array as PropType<any[]>,
|
||||
storage: String,
|
||||
path: String,
|
||||
item: {
|
||||
type: Object as PropType<FileItem>,
|
||||
required: true,
|
||||
},
|
||||
itemstack: {
|
||||
type: Array as PropType<FileItem[]>,
|
||||
required: true,
|
||||
},
|
||||
endpoints: Object as PropType<EndPoints>,
|
||||
axios: Object as PropType<Axios>,
|
||||
axios: {
|
||||
type: Object as PropType<Axios>,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 对外事件
|
||||
@@ -25,10 +39,8 @@ const sort = ref('name')
|
||||
|
||||
// 调整排序方式
|
||||
function changeSort() {
|
||||
if (sort.value === 'name')
|
||||
sort.value = 'time'
|
||||
else
|
||||
sort.value = 'name'
|
||||
if (sort.value === 'name') sort.value = 'time'
|
||||
else sort.value = 'name'
|
||||
|
||||
emit('sortchanged', sort.value)
|
||||
}
|
||||
@@ -36,18 +48,20 @@ function changeSort() {
|
||||
// 计算PATH面包屑
|
||||
const pathSegments = computed(() => {
|
||||
let path_str = ''
|
||||
const isFolder = inProps.path?.endsWith('/')
|
||||
const segments = inProps.path?.split('/').filter(item => item)
|
||||
|
||||
return segments?.map((item, index) => {
|
||||
path_str += item + ((index < segments.length - 1 || isFolder) ? '/' : '')
|
||||
return {
|
||||
name: item,
|
||||
path: path_str,
|
||||
}
|
||||
}) ?? []
|
||||
const isFolder = inProps.item.path?.endsWith('/')
|
||||
const segments = inProps.item.path?.split('/').filter(item => item)
|
||||
return (
|
||||
segments?.map((item, index) => {
|
||||
path_str += item + (index < segments.length - 1 || isFolder ? '/' : '')
|
||||
return {
|
||||
name: item,
|
||||
path: path_str,
|
||||
}
|
||||
}) ?? []
|
||||
)
|
||||
})
|
||||
|
||||
// 当前存储
|
||||
const storageObject = computed(() => {
|
||||
return inProps.storages?.find(item => item.code === inProps.storage)
|
||||
})
|
||||
@@ -56,20 +70,19 @@ const storageObject = computed(() => {
|
||||
function changeStorage(code: string) {
|
||||
if (inProps.storage !== code) {
|
||||
emit('storagechanged', code)
|
||||
emit('pathchanged', '')
|
||||
}
|
||||
}
|
||||
|
||||
// 路径变化
|
||||
function changePath(_path: string) {
|
||||
emit('pathchanged', _path)
|
||||
function changePath(item: FileItem) {
|
||||
emit('pathchanged', item)
|
||||
}
|
||||
|
||||
// 返回上一级
|
||||
function goUp() {
|
||||
const segments = pathSegments.value ?? []
|
||||
const path = segments?.length === 1 ? '/' : segments[segments.length - 2].path
|
||||
changePath(path)
|
||||
const fileitem = inProps.itemstack[segments.length - 1]
|
||||
changePath(fileitem)
|
||||
}
|
||||
|
||||
// 创建目录
|
||||
@@ -77,15 +90,16 @@ async function mkdir() {
|
||||
emit('loading', true)
|
||||
const url = inProps.endpoints?.mkdir.url
|
||||
.replace(/{storage}/g, inProps.storage)
|
||||
.replace(/{path}/g, encodeURIComponent(inProps.path + newFolderName.value))
|
||||
.replace(/{name}/g, newFolderName.value)
|
||||
|
||||
const config = {
|
||||
const config: AxiosRequestConfig<FileItem> = {
|
||||
url,
|
||||
method: inProps.endpoints?.mkdir.method || 'post',
|
||||
data: inProps.item,
|
||||
}
|
||||
|
||||
// 调API
|
||||
await inProps.axios?.request(config)
|
||||
await inProps.axios.request(config)
|
||||
|
||||
newFolderPopper.value = false
|
||||
newFolderName.value = ''
|
||||
@@ -97,10 +111,8 @@ async function mkdir() {
|
||||
|
||||
// 计算排序图标
|
||||
const sortIcon = computed(() => {
|
||||
if (sort.value === 'time')
|
||||
return 'mdi-sort-clock-ascending-outline'
|
||||
else
|
||||
return 'mdi-sort-alphabetical-ascending'
|
||||
if (sort.value === 'time') return 'mdi-sort-clock-ascending-outline'
|
||||
else return 'mdi-sort-alphabetical-ascending'
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -127,16 +139,17 @@ const sortIcon = computed(() => {
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
<VBtn variant="text" :input-value="path === '/'" class="px-1" @click="changePath('/')">
|
||||
<VBtn variant="text" :input-value="item.path === '/'" class="px-1" @click="changePath(inProps.itemstack[0])">
|
||||
<VIcon :icon="storageObject?.icon" class="mr-2" />
|
||||
{{ storageObject?.name }}
|
||||
</VBtn>
|
||||
<template v-for="(segment, index) in pathSegments" :key="index">
|
||||
<VBtn
|
||||
v-if="display.mdAndUp.value"
|
||||
variant="text"
|
||||
:input-value="index === pathSegments.length - 1"
|
||||
class="px-1 d-none d-md-block"
|
||||
@click="changePath(segment.path)"
|
||||
class="px-1"
|
||||
@click="changePath(inProps.itemstack[index + 1])"
|
||||
>
|
||||
<VIcon icon=" mdi-chevron-right" />
|
||||
{{ segment.name }}
|
||||
@@ -158,10 +171,7 @@ const sortIcon = computed(() => {
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VDialog
|
||||
v-model="newFolderPopper"
|
||||
max-width="50rem"
|
||||
>
|
||||
<VDialog v-model="newFolderPopper" max-width="50rem">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn v-bind="props">
|
||||
<VTooltip text="新建文件夹">
|
||||
@@ -172,20 +182,14 @@ const sortIcon = computed(() => {
|
||||
</IconBtn>
|
||||
</template>
|
||||
<VCard title="新建文件夹">
|
||||
<DialogCloseBtn @click="newFolderPopper = false" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VTextField v-model="newFolderName" label="名称" />
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<div class="flex-grow-1" />
|
||||
<VBtn depressed @click="newFolderPopper = false">
|
||||
取消
|
||||
</VBtn>
|
||||
<VBtn
|
||||
:disabled="!newFolderName"
|
||||
depressed
|
||||
variant="tonal"
|
||||
@click="mkdir"
|
||||
>
|
||||
<VBtn :disabled="!newFolderName" variant="elevated" @click="mkdir" prepend-icon="mdi-check" class="px-5 me-3">
|
||||
新建
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -8,7 +8,6 @@ const props = defineProps({
|
||||
root: {
|
||||
type: String,
|
||||
default: '/',
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -32,13 +31,15 @@ const treeItems = ref<FileItem[]>([
|
||||
extension: '',
|
||||
size: 0,
|
||||
modify_time: 0,
|
||||
fileid: '',
|
||||
parent_fileid: '',
|
||||
},
|
||||
])
|
||||
|
||||
// 拉取子目录
|
||||
async function fetchDirs(item: any) {
|
||||
return api
|
||||
.get('/filebrowser/listdir?path=' + item.path)
|
||||
.get('/local/listdir?path=' + item.path)
|
||||
.then((data: any) => {
|
||||
item.children.push(...data)
|
||||
})
|
||||
|
||||
@@ -12,12 +12,18 @@ import MediaServerLibrary from '@/views/dashboard/MediaServerLibrary.vue'
|
||||
import MediaServerPlaying from '@/views/dashboard/MediaServerPlaying.vue'
|
||||
import DashboardRender from '@/components/render/DashboardRender.vue'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
// 仪表板配置
|
||||
config: Object as PropType<DashboardItem>,
|
||||
// 刷新状态
|
||||
refreshStatus: Boolean,
|
||||
// 是否允许刷新数据
|
||||
allowRefresh: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:refreshStatus'])
|
||||
@@ -32,10 +38,10 @@ onUnmounted(() => {
|
||||
<AnalyticsStorage v-if="config?.id === 'storage'" />
|
||||
<AnalyticsMediaStatistic v-else-if="config?.id === 'mediaStatistic'" />
|
||||
<AnalyticsWeeklyOverview v-else-if="config?.id === 'weeklyOverview'" />
|
||||
<AnalyticsSpeed v-else-if="config?.id === 'speed'" />
|
||||
<AnalyticsScheduler v-else-if="config?.id === 'scheduler'" />
|
||||
<AnalyticsCpu v-else-if="config?.id === 'cpu'" />
|
||||
<AnalyticsMemory v-else-if="config?.id === 'memory'" />
|
||||
<AnalyticsSpeed v-else-if="config?.id === 'speed'" :allowRefresh="props.allowRefresh" />
|
||||
<AnalyticsScheduler v-else-if="config?.id === 'scheduler'" :allowRefresh="props.allowRefresh" />
|
||||
<AnalyticsCpu v-else-if="config?.id === 'cpu'" :allowRefresh="props.allowRefresh" />
|
||||
<AnalyticsMemory v-else-if="config?.id === 'memory'" :allowRefresh="props.allowRefresh" />
|
||||
<MediaServerLibrary v-else-if="config?.id === 'library'" />
|
||||
<MediaServerPlaying v-else-if="config?.id === 'playing'" />
|
||||
<MediaServerLatest v-else-if="config?.id === 'latest'" />
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import SlideViewTitle from '@/components/slide/SlideViewTitle.vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 元素
|
||||
const slideview_content = ref()
|
||||
@@ -27,12 +31,10 @@ function slideNext(next: boolean) {
|
||||
if (run_to_left_px >= slideview_content.value.scrollWidth - slideview_content.value.clientWidth)
|
||||
run_to_left_px = slideview_content.value.scrollWidth - slideview_content.value.clientWidth
|
||||
// console.log(`最多显示: ${card_max} 当前起点: ${card_current} 目标起点: ${card_index} 卡片宽度: ${card_width}`)
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
const card_index = card_current - card_max
|
||||
run_to_left_px = card_index * card_width
|
||||
if (run_to_left_px <= 0)
|
||||
run_to_left_px = 0
|
||||
if (run_to_left_px <= 0) run_to_left_px = 0
|
||||
// console.log(`最多显示: ${card_max} 当前起点: ${card_current} 目标起点: ${card_index} 卡片宽度: ${card_width}`)
|
||||
}
|
||||
slideview_content.value.scrollTo({
|
||||
@@ -46,7 +48,7 @@ function slideNext(next: boolean) {
|
||||
function countMaxNumber() {
|
||||
slide_card_length = slideview_content.value.children.length
|
||||
card_width = slideview_content.value.firstElementChild.getBoundingClientRect().width
|
||||
slide_gap_px = (slideview_content.value.scrollWidth / slide_card_length) - card_width
|
||||
slide_gap_px = slideview_content.value.scrollWidth / slide_card_length - card_width
|
||||
card_width += slide_gap_px
|
||||
card_max = Math.trunc(slideview_content.value.clientWidth / card_width)
|
||||
countDisabled()
|
||||
@@ -55,16 +57,18 @@ function countMaxNumber() {
|
||||
// 修改分页切换按钮状态
|
||||
function countDisabled() {
|
||||
slideview_scrollLeft.value = slideview_content.value.scrollLeft
|
||||
card_current = slideview_content.value.scrollLeft === 0 ? 0 : Math.trunc((slideview_content.value.scrollLeft + card_width / 2) / card_width)
|
||||
if (slide_card_length * card_width <= slideview_content.value.clientWidth)
|
||||
disabled.value = 3
|
||||
else if (slideview_content.value.scrollLeft === 0)
|
||||
disabled.value = 0
|
||||
else if (slideview_content.value.scrollLeft >= slideview_content.value.scrollWidth - slideview_content.value.clientWidth - 2)
|
||||
card_current =
|
||||
slideview_content.value.scrollLeft === 0
|
||||
? 0
|
||||
: Math.trunc((slideview_content.value.scrollLeft + card_width / 2) / card_width)
|
||||
if (slide_card_length * card_width <= slideview_content.value.clientWidth) disabled.value = 3
|
||||
else if (slideview_content.value.scrollLeft === 0) disabled.value = 0
|
||||
else if (
|
||||
slideview_content.value.scrollLeft >=
|
||||
slideview_content.value.scrollWidth - slideview_content.value.clientWidth - 2
|
||||
)
|
||||
disabled.value = 2
|
||||
|
||||
else
|
||||
disabled.value = 1
|
||||
else disabled.value = 1
|
||||
}
|
||||
|
||||
// 组件加载完成
|
||||
@@ -91,7 +95,7 @@ onActivated(() => {
|
||||
<slot name="title">
|
||||
<SlideViewTitle />
|
||||
</slot>
|
||||
<div v-if="disabled !== 3" class="me-1 d-none d-md-flex">
|
||||
<div v-if="disabled !== 3 && display.mdAndUp.value" class="me-1 d-flex">
|
||||
<VBtn
|
||||
class="rounded-circle"
|
||||
variant="text"
|
||||
@@ -122,9 +126,8 @@ onActivated(() => {
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.slideview_content {
|
||||
overflow: scroll hidden !important;
|
||||
-ms-overflow-style: none !important;
|
||||
overflow-x: scroll !important;
|
||||
overflow-y: hidden !important;
|
||||
overscroll-behavior-x: contain !important;
|
||||
scrollbar-width: none !important;
|
||||
}
|
||||
|
||||
@@ -1,22 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
// 输入参数
|
||||
const props = inject('rankingPropsKey')
|
||||
|
||||
const props: any = inject('rankingPropsKey')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="ms-1"
|
||||
>
|
||||
<RouterLink
|
||||
:to="props.linkurl ? props.linkurl : ''"
|
||||
class="slider-title"
|
||||
>
|
||||
<span>{{ props.title }}</span>
|
||||
<VIcon
|
||||
icon="mdi-arrow-right-circle-outline"
|
||||
class="ms-1"
|
||||
/>
|
||||
<div class="ms-1">
|
||||
<RouterLink :to="props?.linkurl ? props?.linkurl : ''" class="slider-title">
|
||||
<span>{{ props?.title }}</span>
|
||||
<VIcon icon="mdi-arrow-right-circle-outline" class="ms-1" />
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -11,6 +11,12 @@ import UserProfile from '@/layouts/components/UserProfile.vue'
|
||||
import store from '@/store'
|
||||
import { SystemNavMenus } from '@/router/menu'
|
||||
import { NavMenu } from '@/@layouts/types'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
const display = useDisplay()
|
||||
const appMode = computed(() => {
|
||||
return localStorage.getItem('MP_APPMODE') != '0' && display.mdAndDown.value
|
||||
})
|
||||
|
||||
// 从Vuex Store中获取superuser信息
|
||||
const superUser = store.state.auth.superUser
|
||||
@@ -19,6 +25,11 @@ const superUser = store.state.auth.superUser
|
||||
const getMenuList = (header: string) => {
|
||||
return SystemNavMenus.filter((item: NavMenu) => item.header === header && (!item.admin || superUser))
|
||||
}
|
||||
|
||||
// 返回上一页
|
||||
function goBack() {
|
||||
history.back()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -27,9 +38,13 @@ const getMenuList = (header: string) => {
|
||||
<template #navbar="{ toggleVerticalOverlayNavActive }">
|
||||
<div class="d-flex h-100 align-center mx-1">
|
||||
<!-- 👉 Vertical Nav Toggle -->
|
||||
<IconBtn class="ms-n2 d-lg-none" @click="toggleVerticalOverlayNavActive(true)">
|
||||
<IconBtn v-if="!appMode && display.mdAndDown.value" class="ms-n2" @click="toggleVerticalOverlayNavActive(true)">
|
||||
<VIcon icon="mdi-menu" />
|
||||
</IconBtn>
|
||||
<!-- 👉 Back Button -->
|
||||
<IconBtn v-if="appMode" class="ms-n2" @click="goBack">
|
||||
<VIcon icon="mdi-arrow-left" size="32" />
|
||||
</IconBtn>
|
||||
<!-- 👉 Search Bar -->
|
||||
<SearchBar />
|
||||
<!-- 👉 Spacer -->
|
||||
|
||||
@@ -1,3 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
const display = useDisplay()
|
||||
const appMode = computed(() => {
|
||||
return localStorage.getItem('MP_APPMODE') != '0' && display.mdAndDown.value
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
// 各按钮活动状态
|
||||
const activeState = computed(() => {
|
||||
return {
|
||||
home: route.path === '/dashboard',
|
||||
ranking: route.path === '/ranking',
|
||||
movie: route.path === '/subscribe/movie',
|
||||
tv: route.path === '/subscribe/tv',
|
||||
apps: route.path === '/apps',
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-100 d-flex align-center justify-space-between" />
|
||||
<div v-if="appMode" class="w-100" style="block-size: calc(3.5rem + env(safe-area-inset-bottom))">
|
||||
<VBottomNavigation
|
||||
grow
|
||||
horizontal
|
||||
color="primary"
|
||||
class="footer-nav border-t"
|
||||
style="block-size: calc(3.5rem + env(safe-area-inset-bottom))"
|
||||
>
|
||||
<VBtn to="/dashboard" :ripple="false">
|
||||
<VIcon v-if="activeState.home" size="28">mdi-home</VIcon>
|
||||
<VIcon v-else size="28">mdi-home-outline</VIcon>
|
||||
</VBtn>
|
||||
<VBtn to="/ranking" :ripple="false">
|
||||
<VIcon v-if="activeState.ranking" size="28">mdi-star</VIcon>
|
||||
<VIcon v-else size="28">mdi-star-outline</VIcon>
|
||||
</VBtn>
|
||||
<VBtn to="/subscribe/movie" :ripple="false">
|
||||
<VIcon v-if="activeState.movie" size="28">mdi-movie-open</VIcon>
|
||||
<VIcon v-else size="28">mdi-movie-open-outline</VIcon>
|
||||
</VBtn>
|
||||
<VBtn to="/subscribe/tv" :ripple="false">
|
||||
<VIcon v-if="activeState.tv" size="28">mdi-television-play</VIcon>
|
||||
<VIcon v-else size="28">mdi-television</VIcon>
|
||||
</VBtn>
|
||||
<VBtn to="/apps" :ripple="false">
|
||||
<VIcon v-if="activeState.apps" size="28">mdi-dots-horizontal-circle</VIcon>
|
||||
<VIcon v-else size="28">mdi-dots-horizontal</VIcon>
|
||||
</VBtn>
|
||||
</VBottomNavigation>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.footer-nav {
|
||||
/* stylelint-disable-next-line property-no-vendor-prefix */
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
backdrop-filter: blur(6px);
|
||||
background-color: rgb(var(--v-theme-surface), 0.8);
|
||||
padding-block-end: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.footer-nav .v-btn--variant-text .v-btn__overlay {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
</style>
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<script lang="ts" setup>
|
||||
import * as Mousetrap from 'mousetrap'
|
||||
import SearchBarView from '@/views/system/SearchBarView.vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const display = useDisplay()
|
||||
|
||||
const searchDialog = ref(false)
|
||||
|
||||
@@ -12,6 +16,14 @@ function openSearchDialog() {
|
||||
searchDialog.value = true
|
||||
return false
|
||||
}
|
||||
|
||||
// 检测操作系统是否是Mac
|
||||
function isMac() {
|
||||
return navigator.platform.toUpperCase().indexOf('MAC') >= 0
|
||||
}
|
||||
|
||||
// 计算属性:根据操作系统显示不同的按键提示
|
||||
const metaKey = computed(() => (isMac() ? '⌘+K' : 'Ctrl+K'))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -20,14 +32,15 @@ function openSearchDialog() {
|
||||
<IconBtn @click="openSearchDialog">
|
||||
<VIcon icon="ri-search-line" />
|
||||
</IconBtn>
|
||||
<span class="d-none d-md-flex align-center text-disabled ms-2" @click="openSearchDialog">
|
||||
<span v-if="display.lgAndUp.value" class="flex align-center text-disabled ms-2" @click="openSearchDialog">
|
||||
<span class="me-3">搜索</span>
|
||||
<span class="meta-key">⌘K</span>
|
||||
<span class="meta-key">{{ metaKey }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<!-- 搜索弹窗 -->
|
||||
<SearchBarView v-model="searchDialog" v-if="searchDialog" @close="searchDialog = false" />
|
||||
</template>
|
||||
|
||||
<style type="scss" scoped>
|
||||
.meta-key {
|
||||
border: thin solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
@@ -36,4 +49,4 @@ function openSearchDialog() {
|
||||
padding-block: 0.1rem;
|
||||
padding-inline: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -8,6 +8,7 @@ import MessageView from '@/views/system/MessageView.vue'
|
||||
import store from '@/store'
|
||||
import api from '@/api'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { getQueryValue } from '@/@core/utils'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
@@ -75,6 +76,29 @@ async function sendMessage() {
|
||||
|
||||
onMounted(() => {
|
||||
scrollMessageToEnd()
|
||||
const shortcut = getQueryValue('shortcut')
|
||||
if (shortcut) {
|
||||
switch (shortcut) {
|
||||
case 'nameTest':
|
||||
nameTestDialog.value = true
|
||||
break
|
||||
case 'netTest':
|
||||
netTestDialog.value = true
|
||||
break
|
||||
case 'logging':
|
||||
loggingDialog.value = true
|
||||
break
|
||||
case 'ruleTest':
|
||||
ruleTestDialog.value = true
|
||||
break
|
||||
case 'systemTest':
|
||||
systemTestDialog.value = true
|
||||
break
|
||||
case 'message':
|
||||
messageDialog.value = true
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@ import router from '@/router'
|
||||
import avatar1 from '@images/avatars/avatar-1.png'
|
||||
import api from '@/api'
|
||||
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
const display = useDisplay()
|
||||
|
||||
// Vuex Store
|
||||
const store = useStore()
|
||||
@@ -22,9 +25,7 @@ const progressDialog = ref(false)
|
||||
// 执行注销操作
|
||||
function logout() {
|
||||
// 清除登录状态信息
|
||||
store.dispatch('auth/clearToken')
|
||||
// 主动登出时清除路由标记
|
||||
store.state.auth.originalPath = null
|
||||
store.dispatch('auth/logout')
|
||||
// 重定向到登录页面或其他适当的页面
|
||||
router.push('/login')
|
||||
}
|
||||
@@ -58,10 +59,20 @@ async function restart() {
|
||||
}
|
||||
}
|
||||
|
||||
// 是否精简模式
|
||||
const isCompactMode = ref(localStorage.getItem('MP_APPMODE') != '0')
|
||||
|
||||
// 从Vuex Store中获取信息
|
||||
const superUser = store.state.auth.superUser
|
||||
const userName = store.state.auth.userName
|
||||
const avatar = store.state.auth.avatar
|
||||
|
||||
// 监听精简模式切换
|
||||
watch(isCompactMode, value => {
|
||||
localStorage.setItem('MP_APPMODE', value ? '1' : '0')
|
||||
//刷新页面
|
||||
location.reload()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -86,6 +97,17 @@ const avatar = store.state.auth.avatar
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle>{{ userName }}</VListItemSubtitle>
|
||||
</VListItem>
|
||||
|
||||
<!-- Divider -->
|
||||
<VDivider v-if="display.mdAndDown.value" class="my-2" />
|
||||
|
||||
<!-- 👉 AppMode -->
|
||||
<VListItem v-if="display.mdAndDown.value">
|
||||
<template #prepend>
|
||||
<VSwitch class="me-2" v-model="isCompactMode"></VSwitch>
|
||||
</template>
|
||||
<VListItemTitle>App模式</VListItemTitle>
|
||||
</VListItem>
|
||||
<VDivider class="my-2" />
|
||||
|
||||
<!-- 👉 Profile -->
|
||||
@@ -97,7 +119,7 @@ const avatar = store.state.auth.avatar
|
||||
</VListItem>
|
||||
|
||||
<!-- 👉 FAQ -->
|
||||
<VListItem href="https://github.com/jxxghp/MoviePilot/blob/main/README.md" target="_blank">
|
||||
<VListItem href="https://wiki.movie-pilot.org" target="_blank">
|
||||
<template #prepend>
|
||||
<VIcon class="me-2" icon="mdi-help-circle-outline" size="22" />
|
||||
</template>
|
||||
@@ -105,7 +127,7 @@ const avatar = store.state.auth.avatar
|
||||
</VListItem>
|
||||
|
||||
<!-- Divider -->
|
||||
<VDivider class="my-2" />
|
||||
<VDivider v-if="superUser" class="my-2" />
|
||||
|
||||
<!-- 👉 restart -->
|
||||
<VListItem v-if="superUser" @click="restart">
|
||||
@@ -115,9 +137,6 @@ const avatar = store.state.auth.avatar
|
||||
<VListItemTitle>重启</VListItemTitle>
|
||||
</VListItem>
|
||||
|
||||
<!-- Divider -->
|
||||
<VDivider class="my-2" />
|
||||
|
||||
<!-- 👉 Logout -->
|
||||
<VListItem @click="logout">
|
||||
<VBtn color="error" block>
|
||||
|
||||
34
src/main.ts
34
src/main.ts
@@ -1,23 +1,19 @@
|
||||
import { VAceEditor } from 'vue3-ace-editor'
|
||||
import { createApp } from 'vue'
|
||||
import '@/@iconify/icons-bundle'
|
||||
import ToastPlugin from 'vue-toast-notification'
|
||||
import VuetifyUseDialog from 'vuetify-use-dialog'
|
||||
import '@/@core/utils/compatibility'
|
||||
import './ace-config'
|
||||
import VueApexCharts from 'vue3-apexcharts'
|
||||
import { removeEl } from './@core/utils/dom'
|
||||
import '@/@iconify/icons-bundle'
|
||||
import '@/plugins/webfontloader'
|
||||
import App from '@/App.vue'
|
||||
import vuetify from '@/plugins/vuetify'
|
||||
import { loadFonts } from '@/plugins/webfontloader'
|
||||
import router from '@/router'
|
||||
import store from '@/store'
|
||||
import '@core/scss/template/index.scss'
|
||||
import '@layouts/styles/index.scss'
|
||||
import '@styles/styles.scss'
|
||||
import 'vue-toast-notification/dist/theme-bootstrap.css'
|
||||
import { VAceEditor } from 'vue3-ace-editor'
|
||||
import { createApp } from 'vue'
|
||||
import { removeEl } from './@core/utils/dom'
|
||||
import { PerfectScrollbarPlugin } from 'vue3-perfect-scrollbar'
|
||||
import 'vue3-perfect-scrollbar/style.css'
|
||||
import { VTreeview } from 'vuetify/labs/VTreeview'
|
||||
import ToastPlugin from 'vue-toast-notification'
|
||||
import VuetifyUseDialog from 'vuetify-use-dialog'
|
||||
import VueApexCharts from 'vue3-apexcharts'
|
||||
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
|
||||
import MediaCard from './components/cards/MediaCard.vue'
|
||||
import PosterCard from './components/cards/PosterCard.vue'
|
||||
@@ -27,13 +23,11 @@ import MediaInfoCard from './components/cards/MediaInfoCard.vue'
|
||||
import TorrentCard from './components/cards/TorrentCard.vue'
|
||||
import MediaIdSelector from './components/misc/MediaIdSelector.vue'
|
||||
import PathField from './components/input/PathField.vue'
|
||||
import { fixArrayAt } from '@/@core/utils/compatibility'
|
||||
|
||||
// 修复低版本Safari等浏览器数组不支持at函数的问题
|
||||
fixArrayAt()
|
||||
|
||||
// 加载字体
|
||||
loadFonts()
|
||||
import '@core/scss/template/index.scss'
|
||||
import '@layouts/styles/index.scss'
|
||||
import '@styles/styles.scss'
|
||||
import 'vue-toast-notification/dist/theme-bootstrap.css'
|
||||
import 'vue3-perfect-scrollbar/style.css'
|
||||
|
||||
// 创建Vue实例
|
||||
const app = createApp(App)
|
||||
|
||||
71
src/pages/appcenter.vue
Normal file
71
src/pages/appcenter.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import { NavMenu } from '@/@layouts/types'
|
||||
import { SystemNavMenus } from '@/router/menu'
|
||||
import store from '@/store'
|
||||
import draggable from 'vuedraggable'
|
||||
|
||||
// 从Vuex Store中获取superuser信息
|
||||
const superUser = store.state.auth.superUser
|
||||
|
||||
// APP图标顺序
|
||||
const appOrder = ref<string[]>([])
|
||||
|
||||
// 根据分类获取菜单列表
|
||||
const getMenuList = () => {
|
||||
return SystemNavMenus.filter((item: NavMenu) => !item.admin || superUser)
|
||||
}
|
||||
|
||||
// APP列表
|
||||
const appList = ref<NavMenu[]>(getMenuList())
|
||||
|
||||
// 保存APP图标顺序到localStorage
|
||||
function saveAppsOrder() {
|
||||
appOrder.value = appList.value.map(app => app.title)
|
||||
localStorage.setItem('MP_APPS_ORDER', JSON.stringify(appOrder.value))
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const localOrder = localStorage.getItem('MP_APPS_ORDER')
|
||||
if (localOrder) {
|
||||
appOrder.value = JSON.parse(localOrder)
|
||||
// 对appList进行排序
|
||||
appList.value.sort((a, b) => {
|
||||
const aIndex = appOrder.value.findIndex(item => item === a.title)
|
||||
const bIndex = appOrder.value.findIndex(item => item === b.title)
|
||||
return (aIndex === -1 ? 999 : aIndex) - (bIndex === -1 ? 999 : bIndex)
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div class="ps ps--active-y mx-3 appcenter-grid" tabindex="0">
|
||||
<draggable
|
||||
v-model="appList"
|
||||
item-key="title"
|
||||
tag="VRow"
|
||||
delay="300"
|
||||
@end="saveAppsOrder"
|
||||
:component-data="{ 'class': 'ma-0 mt-n1' }"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<VCol cols="6" md="4" lg="3" class="text-center cursor-pointer shortcut-icon select-none">
|
||||
<VCard class="pa-4" :to="element.to" variant="flat">
|
||||
<VAvatar size="64" variant="text">
|
||||
<VIcon size="48" :icon="element.icon" color="primary" />
|
||||
</VAvatar>
|
||||
<h6 class="text-base font-weight-medium mt-2 mb-0">{{ element.full_title || element.title }}</h6>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style type="scss">
|
||||
.appcenter-grid .v-card {
|
||||
/* stylelint-disable-next-line property-no-vendor-prefix */
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
backdrop-filter: blur(6px);
|
||||
background-color: rgb(var(--v-theme-surface), 0.8);
|
||||
}
|
||||
</style>
|
||||
@@ -31,7 +31,7 @@ function getApiPath(paths: string[] | string) {
|
||||
<div v-if="title" class="mt-3 md:flex md:items-center md:justify-between">
|
||||
<div class="min-w-0 flex-1 mx-0">
|
||||
<h2
|
||||
class="mb-4 truncate text-2xl font-bold leading-7 text-gray-100 sm:overflow-visible sm:text-4xl sm:leading-9 md:mb-0"
|
||||
class="mb-4 ms-3 truncate text-2xl font-bold leading-7 text-gray-100 sm:overflow-visible sm:text-4xl sm:leading-9 md:mb-0"
|
||||
data-testid="page-header"
|
||||
>
|
||||
<span class="text-moviepilot">{{ title }}</span>
|
||||
|
||||
@@ -5,6 +5,13 @@ import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import { DashboardItem } from '@/api/types'
|
||||
import store from '@/store'
|
||||
import DashboardElement from '@/components/misc/DashboardElement.vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// APP
|
||||
const display = useDisplay()
|
||||
const appMode = computed(() => {
|
||||
return localStorage.getItem('MP_APPMODE') != '0' && display.mdAndDown.value
|
||||
})
|
||||
|
||||
// 从Vuex Store中获取superuser信息
|
||||
const superUser = store.state.auth.superUser
|
||||
@@ -12,6 +19,9 @@ const superUser = store.state.auth.superUser
|
||||
// 是否拉升高度
|
||||
const isElevated = ref(true)
|
||||
|
||||
// 是否发送请求的总开关
|
||||
const isRequest = ref(true)
|
||||
|
||||
// 计算属性,控制是否拉升高度
|
||||
const elevatedConf = controlledComputed(
|
||||
() => isElevated.value,
|
||||
@@ -262,7 +272,8 @@ async function getPluginDashboard(id: string, key: string) {
|
||||
if (
|
||||
res.attrs?.refresh &&
|
||||
pluginDashboardRefreshStatus.value[pluginDashboardId] &&
|
||||
enableConfig.value[pluginDashboardId]
|
||||
enableConfig.value[pluginDashboardId] &&
|
||||
isRequest.value
|
||||
) {
|
||||
// 清除之前的定时器
|
||||
if (refreshTimers.value[pluginDashboardId]) {
|
||||
@@ -291,6 +302,14 @@ onBeforeMount(async () => {
|
||||
await loadDashboardConfig()
|
||||
getPluginDashboardMeta()
|
||||
})
|
||||
|
||||
onActivated(async () => {
|
||||
isRequest.value = true
|
||||
})
|
||||
|
||||
onDeactivated(() => {
|
||||
isRequest.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -307,6 +326,7 @@ onBeforeMount(async () => {
|
||||
<VCol v-if="enableConfig[buildPluginDashboardId(element.id, element.key)] && element.cols" v-bind:="element.cols">
|
||||
<DashboardElement
|
||||
:config="element"
|
||||
:allow-refresh="isRequest"
|
||||
v-model:refreshStatus="pluginDashboardRefreshStatus[buildPluginDashboardId(element.id, element.key)]"
|
||||
/>
|
||||
</VCol>
|
||||
@@ -314,7 +334,16 @@ onBeforeMount(async () => {
|
||||
</draggable>
|
||||
|
||||
<!-- 底部操作按钮 -->
|
||||
<VFab icon="mdi-view-dashboard-edit" location="bottom" size="x-large" fixed app appear @click="dialog = true" />
|
||||
<VFab
|
||||
icon="mdi-view-dashboard-edit"
|
||||
location="bottom"
|
||||
size="x-large"
|
||||
fixed
|
||||
app
|
||||
appear
|
||||
@click="dialog = true"
|
||||
:class="{ 'mb-12': appMode }"
|
||||
/>
|
||||
|
||||
<!-- 弹窗,根据配置生成选项 -->
|
||||
<VDialog v-model="dialog" max-width="35rem" scrollable>
|
||||
|
||||
@@ -8,6 +8,7 @@ import router from '@/router'
|
||||
import logo from '@images/logo.png'
|
||||
import { useTheme } from 'vuetify'
|
||||
import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
|
||||
import { urlBase64ToUint8Array } from '@/@core/utils/navigator'
|
||||
|
||||
const { global: globalTheme } = useTheme()
|
||||
|
||||
@@ -33,6 +34,9 @@ const errorMessage = ref('')
|
||||
// 背景图片
|
||||
const backgroundImageUrl = ref('')
|
||||
|
||||
// 所有的背景图片
|
||||
const backgroundImages = ref<string[]>([])
|
||||
|
||||
// 背景图片加载状态
|
||||
const isImageLoaded = ref(false)
|
||||
|
||||
@@ -42,17 +46,21 @@ const isOTP = ref(false)
|
||||
// 用户名称输入框
|
||||
const usernameInput = ref()
|
||||
|
||||
// Interval定时器
|
||||
let intervalTimer: NodeJS.Timeout | null = null
|
||||
|
||||
// 获取背景图片
|
||||
async function fetchBackgroundImage() {
|
||||
api
|
||||
.get('/login/wallpaper')
|
||||
.then((response: any) => {
|
||||
backgroundImageUrl.value = response.message
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.log(error)
|
||||
})
|
||||
try {
|
||||
backgroundImages.value = await api.get('/login/wallpapers')
|
||||
if (backgroundImages.value && backgroundImages.value.length > 0) {
|
||||
backgroundImageUrl.value = backgroundImages.value[0]
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
|
||||
// 查询是否开启双重验证
|
||||
const fetchOTP = debounce(async () => {
|
||||
const userid = usernameInput.value?.value
|
||||
@@ -89,11 +97,39 @@ async function setTheme() {
|
||||
localStorage.setItem('materio-initial-loader-bg', globalTheme.current.value.colors.background)
|
||||
}
|
||||
|
||||
async function afterLogin() {
|
||||
// 订阅推送通知
|
||||
async function subscribeForPushNotifications() {
|
||||
if ('serviceWorker' in navigator && 'PushManager' in window) {
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
// 获取订阅信息
|
||||
const subscription = await registration.pushManager.getSubscription().then(function (subscription) {
|
||||
if (subscription === null) {
|
||||
const convertedVapidKey = urlBase64ToUint8Array(import.meta.env.VITE_PUBLIC_VAPID_KEY)
|
||||
return registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: convertedVapidKey,
|
||||
})
|
||||
} else {
|
||||
return subscription
|
||||
}
|
||||
})
|
||||
// 发送订阅请求
|
||||
try {
|
||||
await api.post('/message/webpush/subscribe', subscription)
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 登录后处理
|
||||
async function afterLogin(superuser: boolean) {
|
||||
// 生效主题配置
|
||||
await setTheme()
|
||||
// 跳转到首页或回原始页面
|
||||
router.push(store.state.auth.originalPath ?? '/')
|
||||
// 订阅推送通知
|
||||
if (superuser) await subscribeForPushNotifications()
|
||||
}
|
||||
|
||||
// 登录获取token事件
|
||||
@@ -122,19 +158,17 @@ function login() {
|
||||
.then((response: any) => {
|
||||
// 获取token
|
||||
const token = response.access_token
|
||||
const superuser = response.super_user
|
||||
const username = response.user_name
|
||||
const superUser = response.super_user
|
||||
const userName = response.user_name
|
||||
const avatar = response.avatar
|
||||
const level = response.level
|
||||
const remember = form.value.remember
|
||||
|
||||
// 更新token和remember状态到Vuex Store
|
||||
store.dispatch('auth/updateToken', token)
|
||||
store.dispatch('auth/updateRemember', form.value.remember)
|
||||
store.dispatch('auth/updateSuperUser', superuser)
|
||||
store.dispatch('auth/updateUserName', username)
|
||||
store.dispatch('auth/updateAvatar', avatar)
|
||||
store.dispatch('auth/login', { token, remember, superUser, userName, avatar, level })
|
||||
|
||||
// 登录后处理
|
||||
afterLogin()
|
||||
afterLogin(superUser)
|
||||
})
|
||||
.catch((error: any) => {
|
||||
// 登录失败,显示错误提示
|
||||
@@ -147,7 +181,7 @@ function login() {
|
||||
}
|
||||
|
||||
// 自动登录
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
// 从Vuex Store中获取token和remember状态
|
||||
const token = store.state.auth.token
|
||||
const remember = store.state.auth.remember
|
||||
@@ -157,81 +191,97 @@ onMounted(() => {
|
||||
router.push('/')
|
||||
} else {
|
||||
// 获取背景图片
|
||||
fetchBackgroundImage()
|
||||
await fetchBackgroundImage()
|
||||
|
||||
// 每隔5秒更换一次背景图片
|
||||
intervalTimer = setInterval(() => {
|
||||
if (backgroundImages.value.length > 0) {
|
||||
const index = Math.floor(Math.random() * backgroundImages.value.length)
|
||||
backgroundImageUrl.value = backgroundImages.value[index]
|
||||
}
|
||||
}, 5000)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (intervalTimer) clearInterval(intervalTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VImg
|
||||
aspect-ratio="4/3"
|
||||
:src="backgroundImageUrl"
|
||||
class="w-full h-full overflow-hidden"
|
||||
cover
|
||||
@load="isImageLoaded = true"
|
||||
>
|
||||
<div class="auth-wrapper d-flex align-center justify-center pa-4">
|
||||
<VCard
|
||||
class="auth-card pa-7 w-full h-full"
|
||||
:class="isImageLoaded ? 'backdrop-blur-xl bg-white/50' : ''"
|
||||
max-width="25rem"
|
||||
:theme="isImageLoaded ? 'light' : ''"
|
||||
>
|
||||
<VCardItem class="justify-center mb-7">
|
||||
<template #prepend>
|
||||
<div class="d-flex pe-0">
|
||||
<VImg :src="logo" width="64" height="64" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<VCardTitle class="font-weight-semibold text-2xl text-uppercase"> MoviePilot </VCardTitle>
|
||||
</VCardItem>
|
||||
|
||||
<VCardText>
|
||||
<VForm ref="refForm" @submit.prevent="() => {}">
|
||||
<VRow>
|
||||
<!-- username -->
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
ref="usernameInput"
|
||||
v-model="form.username"
|
||||
label="用户名"
|
||||
type="text"
|
||||
:rules="[requiredValidator]"
|
||||
@input="fetchOTP"
|
||||
/>
|
||||
</VCol>
|
||||
<!-- password -->
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="form.password"
|
||||
label="密码"
|
||||
:type="isPasswordVisible ? 'text' : 'password'"
|
||||
:append-inner-icon="isPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
|
||||
:rules="[requiredValidator]"
|
||||
@click:append-inner="isPasswordVisible = !isPasswordVisible"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField v-if="isOTP" v-model="form.otp_password" label="双重验证码" type="input" />
|
||||
<!-- remember me checkbox -->
|
||||
<div class="d-flex align-center justify-space-between flex-wrap">
|
||||
<VCheckbox v-model="form.remember" label="保持登录" required />
|
||||
</div>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<!-- login button -->
|
||||
<VBtn block type="submit" @click="login"> 登录 </VBtn>
|
||||
<div v-if="errorMessage" class="text-error mt-2 text-shadow">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<template v-for="image in backgroundImages">
|
||||
<div v-if="backgroundImageUrl == image" class="absolute inset-0">
|
||||
<VImg :src="image" class="w-full h-full" cover position="center top" @load="isImageLoaded = true">
|
||||
<template #placeholder>
|
||||
<VSkeletonLoader v-if="!isImageLoaded" class="object-cover" />
|
||||
</template>
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
style="background-image: linear-gradient(rgba(45, 55, 72, 33%) 0%, rgb(26, 32, 46) 100%)"
|
||||
/>
|
||||
</VImg>
|
||||
</div>
|
||||
</VImg>
|
||||
</template>
|
||||
<div class="auth-wrapper d-flex align-center justify-center pa-4">
|
||||
<VCard
|
||||
class="auth-card px-7 py-3 w-full h-full rounded-lg"
|
||||
:class="{ 'opacity-85': isImageLoaded }"
|
||||
max-width="24rem"
|
||||
>
|
||||
<VCardItem class="justify-center">
|
||||
<template #prepend>
|
||||
<div class="d-flex pe-0">
|
||||
<VImg :src="logo" width="64" height="64" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<VCardTitle class="font-weight-semibold text-2xl text-uppercase"> MoviePilot </VCardTitle>
|
||||
</VCardItem>
|
||||
|
||||
<VCardText>
|
||||
<VForm ref="refForm" @submit.prevent="() => {}">
|
||||
<VRow>
|
||||
<!-- username -->
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
ref="usernameInput"
|
||||
v-model="form.username"
|
||||
label="用户名"
|
||||
type="text"
|
||||
:rules="[requiredValidator]"
|
||||
@input="fetchOTP"
|
||||
/>
|
||||
</VCol>
|
||||
<!-- password -->
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="form.password"
|
||||
label="密码"
|
||||
:type="isPasswordVisible ? 'text' : 'password'"
|
||||
:append-inner-icon="isPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
|
||||
:rules="[requiredValidator]"
|
||||
@click:append-inner="isPasswordVisible = !isPasswordVisible"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField v-if="isOTP" v-model="form.otp_password" label="双重验证码" type="input" />
|
||||
<!-- remember me checkbox -->
|
||||
<div class="d-flex align-center justify-space-between flex-wrap">
|
||||
<VCheckbox v-model="form.remember" label="保持登录" required />
|
||||
</div>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<!-- login button -->
|
||||
<VBtn block type="submit" @click="login"> 登录 </VBtn>
|
||||
<div v-if="errorMessage" class="text-error mt-2 text-shadow">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
@@ -1,82 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
import MediaCardSlideView from '@/views/discover/MediaCardSlideView.vue'
|
||||
|
||||
const viewList = reactive<{apipath: string, linkurl: string, title: string}[]>([
|
||||
const viewList = reactive<{ apipath: string; linkurl: string; title: string }[]>([
|
||||
{
|
||||
apipath: 'tmdb/trending',
|
||||
linkurl: "/browse/tmdb/trending?title=流行趋势",
|
||||
title: "流行趋势",
|
||||
linkurl: '/browse/tmdb/trending?title=流行趋势',
|
||||
title: '流行趋势',
|
||||
},
|
||||
{
|
||||
apipath: "douban/showing",
|
||||
linkurl: "/browse/douban/showing?title=正在热映",
|
||||
title: "正在热映"
|
||||
apipath: 'douban/showing',
|
||||
linkurl: '/browse/douban/showing?title=正在热映',
|
||||
title: '正在热映',
|
||||
},
|
||||
{
|
||||
apipath: "bangumi/calendar",
|
||||
linkurl: "/browse/bangumi/calendar?title=Bangumi每日放送",
|
||||
title: "Bangumi每日放送"
|
||||
apipath: 'bangumi/calendar',
|
||||
linkurl: '/browse/bangumi/calendar?title=Bangumi每日放送',
|
||||
title: 'Bangumi每日放送',
|
||||
},
|
||||
{
|
||||
apipath: "tmdb/movies",
|
||||
linkurl: "/browse/tmdb/movies?title=热门电影",
|
||||
title: "热门电影"
|
||||
apipath: 'tmdb/movies',
|
||||
linkurl: '/browse/tmdb/movies?title=TMDB热门电影',
|
||||
title: 'TMDB热门电影',
|
||||
},
|
||||
{
|
||||
apipath: "tmdb/tvs?with_original_language=zh|en|ja|ko",
|
||||
linkurl: "/browse/tmdb/tvs??with_original_language=zh|en|ja|ko&title=热门电视剧",
|
||||
title: "热门电视剧"
|
||||
apipath: 'tmdb/tvs?with_original_language=zh|en|ja|ko',
|
||||
linkurl: '/browse/tmdb/tvs??with_original_language=zh|en|ja|ko&title=TMDB热门电视剧',
|
||||
title: 'TMDB热门电视剧',
|
||||
},
|
||||
{
|
||||
apipath: "douban/movie_hot",
|
||||
linkurl: "/browse/douban/movie_hot?title=热门电影",
|
||||
title: "热门电影"
|
||||
apipath: 'douban/movie_hot',
|
||||
linkurl: '/browse/douban/movie_hot?title=豆瓣热门电影',
|
||||
title: '豆瓣热门电影',
|
||||
},
|
||||
{
|
||||
apipath: "douban/tv_hot",
|
||||
linkurl: "/browse/douban/tv_hot?title=热门电视剧",
|
||||
title: "热门电视剧"
|
||||
apipath: 'douban/tv_hot',
|
||||
linkurl: '/browse/douban/tv_hot?title=豆瓣热门电视剧',
|
||||
title: '豆瓣热门电视剧',
|
||||
},
|
||||
{
|
||||
apipath: "douban/tv_animation",
|
||||
linkurl: "/browse/douban/tv_animation?title=热门动漫",
|
||||
title: "热门动漫"
|
||||
apipath: 'douban/tv_animation',
|
||||
linkurl: '/browse/douban/tv_animation?title=豆瓣热门动漫',
|
||||
title: '豆瓣热门动漫',
|
||||
},
|
||||
{
|
||||
apipath: "douban/movies",
|
||||
linkurl: "/browse/douban/movies?title=最新电影",
|
||||
title: "最新电影"
|
||||
apipath: 'douban/movies',
|
||||
linkurl: '/browse/douban/movies?title=豆瓣最新电影',
|
||||
title: '豆瓣最新电影',
|
||||
},
|
||||
{
|
||||
apipath: "douban/tvs",
|
||||
linkurl: "/browse/douban/tvs?title=最新电视剧",
|
||||
title: "最新电视剧"
|
||||
apipath: 'douban/tvs',
|
||||
linkurl: '/browse/douban/tvs?title=豆瓣最新电视剧',
|
||||
title: '豆瓣最新电视剧',
|
||||
},
|
||||
{
|
||||
apipath: "douban/movie_top250",
|
||||
linkurl: "/browse/douban/movie_top250?title=电影TOP250",
|
||||
title: "电影TOP250"
|
||||
apipath: 'douban/movie_top250',
|
||||
linkurl: '/browse/douban/movie_top250?title=电影TOP250',
|
||||
title: '豆瓣电影TOP250',
|
||||
},
|
||||
{
|
||||
apipath: "douban/tv_weekly_chinese",
|
||||
linkurl: "/browse/douban/tv_weekly_chinese?title=国产剧集榜",
|
||||
title: "国产剧集榜"
|
||||
apipath: 'douban/tv_weekly_chinese',
|
||||
linkurl: '/browse/douban/tv_weekly_chinese?title=豆瓣国产剧集榜',
|
||||
title: '豆瓣国产剧集榜',
|
||||
},
|
||||
{
|
||||
apipath: "douban/tv_weekly_global",
|
||||
linkurl: "/browse/douban/tv_weekly_global?title=全球剧集榜",
|
||||
title: "全球剧集榜"
|
||||
}
|
||||
apipath: 'douban/tv_weekly_global',
|
||||
linkurl: '/browse/douban/tv_weekly_global?title=豆瓣全球剧集榜',
|
||||
title: '豆瓣全球剧集榜',
|
||||
},
|
||||
])
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<MediaCardSlideView
|
||||
v-for="item in viewList"
|
||||
:key="item.apipath"
|
||||
v-bind="item"
|
||||
/>
|
||||
<MediaCardSlideView v-for="(item, index) in viewList" :key="index" v-bind="item" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -5,6 +5,13 @@ import type { Context } from '@/api/types'
|
||||
import store from '@/store'
|
||||
import TorrentCardListView from '@/views/discover/TorrentCardListView.vue'
|
||||
import TorrentRowListView from '@/views/discover/TorrentRowListView.vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// APP
|
||||
const display = useDisplay()
|
||||
const appMode = computed(() => {
|
||||
return localStorage.getItem('MP_APPMODE') != '0' && display.mdAndDown.value
|
||||
})
|
||||
|
||||
// 路由参数
|
||||
const route = useRoute()
|
||||
@@ -65,7 +72,7 @@ function startLoadingProgress() {
|
||||
|
||||
// 停止监听加载进度
|
||||
function stopLoadingProgress() {
|
||||
progressEventSource.value?.close()
|
||||
if (progressEventSource.value) progressEventSource.value?.close()
|
||||
}
|
||||
|
||||
// 设置视图类型
|
||||
@@ -82,23 +89,28 @@ async function fetchData() {
|
||||
dataList.value = await api.get('search/last')
|
||||
} else {
|
||||
startLoadingProgress()
|
||||
let result: { [key: string]: any }
|
||||
// 优先按TMDBID精确查询
|
||||
if (keyword?.startsWith('tmdb:') || keyword?.startsWith('douban:') || keyword?.startsWith('bangumi:')) {
|
||||
const result: { [key: string]: any } = await api.get(`search/media/${keyword}`, {
|
||||
result = await api.get(`search/media/${keyword}`, {
|
||||
params: {
|
||||
mtype: type,
|
||||
area,
|
||||
season,
|
||||
},
|
||||
})
|
||||
if (result.success) {
|
||||
dataList.value = result.data
|
||||
} else {
|
||||
errorDescription.value = result.message
|
||||
}
|
||||
} else {
|
||||
// 按标题模糊查询
|
||||
dataList.value = await api.get(`search/title/${keyword}`)
|
||||
result = await api.get(`search/title`, {
|
||||
params: {
|
||||
keyword,
|
||||
},
|
||||
})
|
||||
}
|
||||
if (result && result.success) {
|
||||
dataList.value = result.data
|
||||
} else if (result && result.message) {
|
||||
errorDescription.value = result.message
|
||||
}
|
||||
stopLoadingProgress()
|
||||
// 从浏览器历史中删除当前搜索
|
||||
@@ -116,6 +128,11 @@ async function fetchData() {
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
|
||||
// 卸载时停止加载进度
|
||||
onUnmounted(() => {
|
||||
stopLoadingProgress()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -132,13 +149,25 @@ onMounted(() => {
|
||||
<!-- 视图切换 -->
|
||||
<VFab
|
||||
v-if="viewType === 'list'"
|
||||
class="mb-12"
|
||||
icon="mdi-view-grid"
|
||||
location="bottom"
|
||||
size="x-large"
|
||||
absolute
|
||||
app
|
||||
appear
|
||||
@click="setViewType('card')"
|
||||
:class="{ 'mb-12': appMode }"
|
||||
/>
|
||||
<VFab
|
||||
v-else
|
||||
icon="mdi-view-list"
|
||||
location="bottom"
|
||||
size="x-large"
|
||||
fixed
|
||||
app
|
||||
appear
|
||||
@click="setViewType('card')"
|
||||
@click="setViewType('list')"
|
||||
:class="{ 'mb-12': appMode }"
|
||||
/>
|
||||
<VFab v-else icon="mdi-view-list" location="bottom" size="x-large" fixed app appear @click="setViewType('list')" />
|
||||
</template>
|
||||
|
||||
@@ -32,8 +32,10 @@ function jumpTab(tab: string) {
|
||||
@click="jumpTab(item.tab)"
|
||||
selected-class="v-slide-group-item--active v-tab--selected"
|
||||
>
|
||||
<VIcon size="20" start :icon="item.icon" />
|
||||
{{ item.title }}
|
||||
<div>
|
||||
<VIcon size="20" start :icon="item.icon" />
|
||||
{{ item.title }}
|
||||
</div>
|
||||
</VTab>
|
||||
</VTabs>
|
||||
|
||||
@@ -41,70 +43,90 @@ function jumpTab(tab: string) {
|
||||
<!-- 用户 -->
|
||||
<VWindowItem value="account">
|
||||
<transition name="fade-slide" appear>
|
||||
<AccountSettingAccount />
|
||||
<div>
|
||||
<AccountSettingAccount />
|
||||
</div>
|
||||
</transition>
|
||||
</VWindowItem>
|
||||
|
||||
<!-- 连接 -->
|
||||
<VWindowItem value="system">
|
||||
<transition name="fade-slide" appear>
|
||||
<AccountSettingSystem />
|
||||
<div>
|
||||
<AccountSettingSystem />
|
||||
</div>
|
||||
</transition>
|
||||
</VWindowItem>
|
||||
|
||||
<!-- 目录 -->
|
||||
<VWindowItem value="directory">
|
||||
<transition name="fade-slide" appear>
|
||||
<AccountSettingDirectory />
|
||||
<div>
|
||||
<AccountSettingDirectory />
|
||||
</div>
|
||||
</transition>
|
||||
</VWindowItem>
|
||||
|
||||
<!-- 站点 -->
|
||||
<VWindowItem value="site">
|
||||
<transition name="fade-slide" appear>
|
||||
<AccountSettingSite />
|
||||
<div>
|
||||
<AccountSettingSite />
|
||||
</div>
|
||||
</transition>
|
||||
</VWindowItem>
|
||||
|
||||
<!-- 搜索 -->
|
||||
<VWindowItem value="search">
|
||||
<transition name="fade-slide" appear>
|
||||
<AccountSettingSearch />
|
||||
<div>
|
||||
<AccountSettingSearch />
|
||||
</div>
|
||||
</transition>
|
||||
</VWindowItem>
|
||||
|
||||
<!-- 订阅 -->
|
||||
<VWindowItem value="subscribe">
|
||||
<transition name="fade-slide" appear>
|
||||
<AccountSettingSubscribe />
|
||||
<div>
|
||||
<AccountSettingSubscribe />
|
||||
</div>
|
||||
</transition>
|
||||
</VWindowItem>
|
||||
|
||||
<!-- 服务 -->
|
||||
<VWindowItem value="service">
|
||||
<transition name="fade-slide" appear>
|
||||
<AccountSettingService />
|
||||
<div>
|
||||
<AccountSettingService />
|
||||
</div>
|
||||
</transition>
|
||||
</VWindowItem>
|
||||
|
||||
<!-- 通知 -->
|
||||
<VWindowItem value="notification">
|
||||
<transition name="fade-slide" appear>
|
||||
<AccountSettingNotification />
|
||||
<div>
|
||||
<AccountSettingNotification />
|
||||
</div>
|
||||
</transition>
|
||||
</VWindowItem>
|
||||
|
||||
<!-- 词表 -->
|
||||
<VWindowItem value="words">
|
||||
<transition name="fade-slide" appear>
|
||||
<AccountSettingWords />
|
||||
<div>
|
||||
<AccountSettingWords />
|
||||
</div>
|
||||
</transition>
|
||||
</VWindowItem>
|
||||
|
||||
<!-- 关于 -->
|
||||
<VWindowItem value="about">
|
||||
<transition name="fade-slide" appear>
|
||||
<AccountSettingAbout />
|
||||
<div>
|
||||
<AccountSettingAbout />
|
||||
</div>
|
||||
</transition>
|
||||
</VWindowItem>
|
||||
</VWindow>
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import SubscribeListView from '@/views/subscribe/SubscribeListView.vue'
|
||||
import SubscribePopularView from '@/views/subscribe/SubscribePopularView.vue'
|
||||
import router from '@/router'
|
||||
import { SubscribeTvTabs } from '@/router/menu'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const activeTab = ref(route.query.tab)
|
||||
|
||||
// 跳转tab
|
||||
function jumpTab(tab: string) {
|
||||
router.push('/subscribe-tv?tab=' + tab)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VTabs v-model="activeTab">
|
||||
<VTab v-for="item in SubscribeTvTabs" :value="item.tab" @click="jumpTab(item.tab)">
|
||||
<span class="mx-5">{{ item.title }}</span>
|
||||
</VTab>
|
||||
</VTabs>
|
||||
|
||||
<VWindow v-model="activeTab" class="mt-5 disable-tab-transition" :touch="false">
|
||||
<VWindowItem value="mysub">
|
||||
<transition name="fade-slide" appear>
|
||||
<SubscribeListView type="电视剧" />
|
||||
</transition>
|
||||
</VWindowItem>
|
||||
<VWindowItem value="popular">
|
||||
<transition name="fade-slide" appear>
|
||||
<SubscribePopularView type="电视剧" />
|
||||
</transition>
|
||||
</VWindowItem>
|
||||
</VWindow>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,24 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import SubscribeListView from '@/views/subscribe/SubscribeListView.vue'
|
||||
import SubscribePopularView from '@/views/subscribe/SubscribePopularView.vue'
|
||||
import router from '@/router'
|
||||
import { SubscribeMovieTabs } from '@/router/menu'
|
||||
import router from '@/router'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
// 当前标签
|
||||
const subType = route.meta.subType?.toString()
|
||||
const subId = ref(route.query.id as string)
|
||||
const activeTab = ref(route.query.tab)
|
||||
|
||||
// 跳转tab
|
||||
function jumpTab(tab: string) {
|
||||
router.push('/subscribe-movie?tab=' + tab)
|
||||
router.push('/subscribe/movie?tab=' + tab)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VTabs v-model="activeTab">
|
||||
<VTab v-for="item in SubscribeMovieTabs" :value="item.tab" @click="jumpTab(item.tab)">
|
||||
<VTab v-for="item in SubscribeMovieTabs" :value="item.tab" @to="jumpTab(item.tab)">
|
||||
<span class="mx-5">{{ item.title }}</span>
|
||||
</VTab>
|
||||
</VTabs>
|
||||
@@ -26,12 +26,12 @@ function jumpTab(tab: string) {
|
||||
<VWindow v-model="activeTab" class="mt-5 disable-tab-transition" :touch="false">
|
||||
<VWindowItem value="mysub">
|
||||
<transition name="fade-slide" appear>
|
||||
<SubscribeListView type="电影" />
|
||||
<SubscribeListView :type="subType" :subid="subId" />
|
||||
</transition>
|
||||
</VWindowItem>
|
||||
<VWindowItem value="popular">
|
||||
<transition name="fade-slide" appear>
|
||||
<SubscribePopularView type="电影" />
|
||||
<SubscribePopularView :type="subType" />
|
||||
</transition>
|
||||
</VWindowItem>
|
||||
</VWindow>
|
||||
@@ -4,12 +4,12 @@
|
||||
* webfontloader documentation: https://github.com/typekit/webfontloader
|
||||
*/
|
||||
|
||||
export async function loadFonts() {
|
||||
const webFontLoader = await import(/* webpackChunkName: "webfontloader" */'webfontloader')
|
||||
;(async function loadFonts() {
|
||||
const webFontLoader = await import(/* webpackChunkName: "webfontloader" */ 'webfontloader')
|
||||
|
||||
webFontLoader.load({
|
||||
google: {
|
||||
families: ['Inter:100,200,300,400,500,600,700&display=swap'],
|
||||
},
|
||||
})
|
||||
}
|
||||
})()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import { configureNProgress, doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import { configureNProgress } from '@/api/nprogress'
|
||||
import store from '@/store'
|
||||
|
||||
// Nprogress
|
||||
@@ -23,6 +23,7 @@ const router = createRouter({
|
||||
path: '/dashboard',
|
||||
component: () => import('../pages/dashboard.vue'),
|
||||
meta: {
|
||||
keepAlive: true,
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
@@ -42,23 +43,28 @@ const router = createRouter({
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/subscribe-movie',
|
||||
component: () => import('../pages/subscribe-movie.vue'),
|
||||
path: '/subscribe/movie',
|
||||
component: () => import('../pages/subscribe.vue'),
|
||||
meta: {
|
||||
keepAlive: true,
|
||||
requiresAuth: true,
|
||||
subType: '电影',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/subscribe-tv',
|
||||
component: () => import('../pages/subscribe-tv.vue'),
|
||||
path: '/subscribe/tv',
|
||||
component: () => import('../pages/subscribe.vue'),
|
||||
meta: {
|
||||
keepAlive: true,
|
||||
requiresAuth: true,
|
||||
subType: '电视剧',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/calendar',
|
||||
component: () => import('../pages/calendar.vue'),
|
||||
meta: {
|
||||
keepAlive: true,
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
@@ -80,6 +86,7 @@ const router = createRouter({
|
||||
path: '/site',
|
||||
component: () => import('../pages/site.vue'),
|
||||
meta: {
|
||||
keepAlive: true,
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
@@ -87,6 +94,7 @@ const router = createRouter({
|
||||
path: '/plugins',
|
||||
component: () => import('../pages/plugin.vue'),
|
||||
meta: {
|
||||
keepAlive: true,
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
@@ -120,6 +128,7 @@ const router = createRouter({
|
||||
component: () => import('../pages/person.vue'),
|
||||
props: true,
|
||||
meta: {
|
||||
keepAlive: true,
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
@@ -127,12 +136,21 @@ const router = createRouter({
|
||||
path: '/media',
|
||||
component: () => import('../pages/media.vue'),
|
||||
meta: {
|
||||
keepAlive: true,
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/filemanager',
|
||||
component: () => import('../pages/filemanager.vue'),
|
||||
meta: {
|
||||
keepAlive: true,
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/apps',
|
||||
component: () => import('../pages/appcenter.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
},
|
||||
@@ -161,17 +179,11 @@ router.beforeEach((to, from, next) => {
|
||||
// 总是记录非login路由
|
||||
if (to.fullPath != '/login') store.state.auth.originalPath = to.fullPath
|
||||
const isAuthenticated = store.state.auth.token !== null
|
||||
|
||||
if (to.meta.requiresAuth && !isAuthenticated) {
|
||||
next('/login')
|
||||
} else {
|
||||
startNProgress()
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
router.afterEach(() => {
|
||||
doneNProgress()
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -9,7 +9,7 @@ export const SystemNavMenus = [
|
||||
},
|
||||
{
|
||||
title: '推荐',
|
||||
icon: 'mdi-table-star',
|
||||
icon: 'mdi-star-outline',
|
||||
to: '/ranking',
|
||||
header: '发现',
|
||||
admin: false,
|
||||
@@ -23,20 +23,23 @@ export const SystemNavMenus = [
|
||||
},
|
||||
{
|
||||
title: '电影',
|
||||
icon: 'mdi-movie-roll',
|
||||
to: '/subscribe-movie?tab=mysub',
|
||||
full_title: '电影订阅',
|
||||
icon: 'mdi-movie-open-outline',
|
||||
to: '/subscribe/movie',
|
||||
header: '订阅',
|
||||
admin: false,
|
||||
},
|
||||
{
|
||||
title: '电视剧',
|
||||
icon: 'mdi-television-classic',
|
||||
to: '/subscribe-tv?tab=mysub',
|
||||
full_title: '电视剧订阅',
|
||||
icon: 'mdi-television',
|
||||
to: '/subscribe/tv',
|
||||
header: '订阅',
|
||||
admin: false,
|
||||
},
|
||||
{
|
||||
title: '日历',
|
||||
full_title: '订阅日历',
|
||||
icon: 'mdi-calendar',
|
||||
to: '/calendar',
|
||||
header: '订阅',
|
||||
@@ -66,7 +69,7 @@ export const SystemNavMenus = [
|
||||
{
|
||||
title: '插件',
|
||||
icon: 'mdi-apps',
|
||||
to: '/plugins?tab=installed',
|
||||
to: '/plugins',
|
||||
header: '系统',
|
||||
admin: true,
|
||||
},
|
||||
@@ -183,12 +186,12 @@ export const SubscribeMovieTabs = [
|
||||
{
|
||||
title: '我的订阅',
|
||||
tab: 'mysub',
|
||||
icon: 'mdi-movie-roll',
|
||||
icon: 'mdi-movie-open-outline',
|
||||
},
|
||||
{
|
||||
title: '热门订阅',
|
||||
tab: 'popular',
|
||||
icon: 'mdi-movie-roll',
|
||||
icon: 'mdi-movie-open-outline',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -197,12 +200,12 @@ export const SubscribeTvTabs = [
|
||||
{
|
||||
title: '我的订阅',
|
||||
tab: 'mysub',
|
||||
icon: 'mdi-television-classic',
|
||||
icon: 'mdi-television',
|
||||
},
|
||||
{
|
||||
title: '热门订阅',
|
||||
tab: 'popular',
|
||||
icon: 'mdi-television-classic',
|
||||
icon: 'mdi-television',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
74
src/service-worker.ts
Normal file
74
src/service-worker.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { createHandlerBoundToURL, cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching'
|
||||
import { NavigationRoute, registerRoute } from 'workbox-routing'
|
||||
import { clientsClaim } from 'workbox-core'
|
||||
|
||||
declare let self: ServiceWorkerGlobalScope
|
||||
|
||||
cleanupOutdatedCaches()
|
||||
|
||||
// self.__WB_MANIFEST is default injection point
|
||||
precacheAndRoute(self.__WB_MANIFEST)
|
||||
|
||||
// to allow work offline
|
||||
registerRoute(new NavigationRoute(createHandlerBoundToURL('index.html'), { denylist: [/^(\/[\w-]+)*\/api/] }))
|
||||
|
||||
// 通知选项
|
||||
const options = {
|
||||
icon: '/logo.png',
|
||||
vibrate: [100, 50, 100],
|
||||
actions: [{ action: 'close', title: '关闭' }],
|
||||
}
|
||||
|
||||
// 监听 push 事件,显示通知
|
||||
self.addEventListener('push', function (event) {
|
||||
console.log('notification push')
|
||||
if (!event.data) {
|
||||
return
|
||||
}
|
||||
// 解析获取推送消息
|
||||
let payload
|
||||
try {
|
||||
payload = event.data?.json()
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
payload = {
|
||||
title: event.data?.text(),
|
||||
}
|
||||
}
|
||||
// 根据推送消息生成桌面通知并展现出来
|
||||
try {
|
||||
const content = {
|
||||
body: payload.body || '',
|
||||
icon: payload.icon || options.icon,
|
||||
vibrate: [100, 50, 100],
|
||||
data: { url: payload.url },
|
||||
actions: options.actions,
|
||||
}
|
||||
event.waitUntil(self.registration.showNotification(payload.title, content))
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
})
|
||||
|
||||
// 安装
|
||||
self.addEventListener('install', function (e) {
|
||||
console.log('worker install')
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
// 激活
|
||||
self.addEventListener('activate', function (e) {
|
||||
console.log('worker activate')
|
||||
e.waitUntil(self.clients.claim())
|
||||
})
|
||||
|
||||
// 监听通知点击事件
|
||||
self.addEventListener('notificationclick', function (event) {
|
||||
console.log('notification click')
|
||||
const info = event.notification
|
||||
if (event.action === 'close') {
|
||||
info.close()
|
||||
} else if (info.data?.url) {
|
||||
event.waitUntil(self.clients.openWindow(info.data?.url))
|
||||
}
|
||||
})
|
||||
@@ -8,6 +8,7 @@ interface AuthState {
|
||||
userName: string
|
||||
avatar: string
|
||||
originalPath: string | null
|
||||
level: number
|
||||
}
|
||||
|
||||
// 定义根状态类型
|
||||
@@ -25,6 +26,7 @@ const authModule: Module<AuthState, RootState> = {
|
||||
userName: '',
|
||||
avatar: '',
|
||||
originalPath: null,
|
||||
level: 1,
|
||||
},
|
||||
mutations: {
|
||||
setToken(state, token: string) {
|
||||
@@ -45,25 +47,25 @@ const authModule: Module<AuthState, RootState> = {
|
||||
setAvatar(state, avatar: string) {
|
||||
state.avatar = avatar
|
||||
},
|
||||
setOriginalPath(state, originalPath: string) {
|
||||
state.originalPath = originalPath
|
||||
},
|
||||
setLevel(state, level: number) {
|
||||
state.level = level
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
updateToken({ commit }, token: string) {
|
||||
login({ commit }, { token, remember, superUser, userName, avatar, level }) {
|
||||
commit('setToken', token)
|
||||
},
|
||||
clearToken({ commit }) {
|
||||
commit('clearToken')
|
||||
},
|
||||
updateRemember({ commit }, remember: boolean) {
|
||||
commit('setRemember', remember)
|
||||
},
|
||||
updateSuperUser({ commit }, superUser: boolean) {
|
||||
commit('setSuperUser', superUser)
|
||||
},
|
||||
updateUserName({ commit }, userName: string) {
|
||||
commit('setUserName', userName)
|
||||
},
|
||||
updateAvatar({ commit }, avatar: string) {
|
||||
commit('setAvatar', avatar)
|
||||
commit('setLevel', level)
|
||||
},
|
||||
logout({ commit }) {
|
||||
commit('clearToken')
|
||||
commit('setOriginalPath', null)
|
||||
},
|
||||
},
|
||||
getters: {
|
||||
@@ -72,6 +74,8 @@ const authModule: Module<AuthState, RootState> = {
|
||||
getSuperUser: state => state.superUser,
|
||||
getUserName: state => state.userName,
|
||||
getAvatar: state => state.avatar,
|
||||
getOriginalPath: state => state.originalPath,
|
||||
getLevel: state => state.level,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -24,14 +24,11 @@
|
||||
}
|
||||
|
||||
.v-dialog > .v-overlay__content {
|
||||
inline-size: calc(100% - 1rem);
|
||||
margin-block-start: calc(env(safe-area-inset-top) + 1rem);
|
||||
max-block-size: calc(100% - env(safe-area-inset-top) - 1rem);
|
||||
}
|
||||
|
||||
.v-dialog > .v-overlay__content{
|
||||
inline-size: calc(100% - 1rem);
|
||||
}
|
||||
|
||||
.v-dialog--fullscreen > .v-overlay__content{
|
||||
inline-size: 100%;
|
||||
margin-block-start: env(safe-area-inset-top);
|
||||
@@ -65,7 +62,6 @@
|
||||
color: transparent;
|
||||
|
||||
--tw-gradient-from: #818cf8;
|
||||
--tw-gradient-to: rgba(129,140,248,0%);
|
||||
--tw-gradient-stops: var(--tw-gradient-from),var(--tw-gradient-to);
|
||||
--tw-gradient-to: #c084fc;
|
||||
}
|
||||
@@ -160,7 +156,7 @@
|
||||
}
|
||||
|
||||
.grid-plugin-card {
|
||||
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
|
||||
padding-block-end: 1rem;
|
||||
}
|
||||
|
||||
@@ -188,3 +184,28 @@
|
||||
.v-tabs:not(.v-tabs-pill).v-tabs--horizontal {
|
||||
border-block-end: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
}
|
||||
|
||||
.v-fab__container {
|
||||
padding-block-end: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.v-overlay__content .v-list{
|
||||
/* stylelint-disable-next-line property-no-vendor-prefix */
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
backdrop-filter: blur(6px);
|
||||
background-color: rgb(var(--v-theme-surface), 0.9) !important;
|
||||
}
|
||||
|
||||
.v-overlay__content .v-card:not(.bg-primary){
|
||||
/* stylelint-disable-next-line property-no-vendor-prefix */
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
backdrop-filter: blur(8px);
|
||||
background-color: rgb(var(--v-theme-surface), 0.95) !important;
|
||||
|
||||
.v-list, .v-table {
|
||||
/* stylelint-disable-next-line property-no-vendor-prefix */
|
||||
-webkit-backdrop-filter: none;
|
||||
backdrop-filter: none;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,15 @@ import { useTheme } from 'vuetify'
|
||||
import { hexToRgb } from '@layouts/utils'
|
||||
import api from '@/api'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
// 是否允许刷新数据
|
||||
allowRefresh: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
const vuetifyTheme = useTheme()
|
||||
|
||||
const currentTheme = controlledComputed(
|
||||
@@ -94,6 +103,7 @@ const chartOptions = controlledComputed(
|
||||
|
||||
// 调用API接口获取最新CPU使用率
|
||||
async function getCpuUsage() {
|
||||
if (!props.allowRefresh) return
|
||||
try {
|
||||
// 请求数据
|
||||
current.value = (await api.get('dashboard/cpu')) ?? 0
|
||||
|
||||
@@ -2,14 +2,7 @@
|
||||
import api from '@/api'
|
||||
import type { MediaStatistic } from '@/api/types'
|
||||
|
||||
const statistics = ref([
|
||||
{
|
||||
title: '',
|
||||
stats: '',
|
||||
icon: '',
|
||||
color: '',
|
||||
},
|
||||
])
|
||||
const statistics = ref<{ [key: string]: string }[]>([])
|
||||
|
||||
// 调用API加载媒体统计数据
|
||||
async function loadMediaStatistic() {
|
||||
|
||||
@@ -5,6 +5,15 @@ import { hexToRgb } from '@layouts/utils'
|
||||
import api from '@/api'
|
||||
import { formatBytes } from '@/@core/utils/formatters'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
// 是否允许刷新数据
|
||||
allowRefresh: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
const vuetifyTheme = useTheme()
|
||||
|
||||
const currentTheme = controlledComputed(
|
||||
@@ -100,6 +109,7 @@ const chartOptions = controlledComputed(
|
||||
|
||||
// 调用API接口获取最新内存使用量
|
||||
async function getMemorgUsage() {
|
||||
if (!props.allowRefresh) return
|
||||
try {
|
||||
// 请求数据
|
||||
;[usedMemory.value, memoryUsage.value] = await api.get('dashboard/memory')
|
||||
|
||||
@@ -2,6 +2,15 @@
|
||||
import api from '@/api'
|
||||
import type { ScheduleInfo } from '@/api/types'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
// 是否允许刷新数据
|
||||
allowRefresh: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 定时服务列表
|
||||
const schedulerList = ref<ScheduleInfo[]>([])
|
||||
|
||||
@@ -10,6 +19,9 @@ let refreshTimer: NodeJS.Timeout | null = null
|
||||
|
||||
// 调用API加载定时服务列表
|
||||
async function loadSchedulerList() {
|
||||
if (!props.allowRefresh) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res: ScheduleInfo[] = await api.get('dashboard/schedule')
|
||||
|
||||
|
||||
@@ -3,6 +3,15 @@ import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
import type { DownloaderInfo } from '@/api/types'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
// 是否允许刷新数据
|
||||
allowRefresh: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 定时器
|
||||
let refreshTimer: NodeJS.Timeout | null = null
|
||||
|
||||
@@ -35,6 +44,10 @@ const infoItems = ref([
|
||||
|
||||
// 调用API查询下载器数据
|
||||
async function loadDownloaderInfo() {
|
||||
if (!props.allowRefresh) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res: DownloaderInfo = await api.get('dashboard/downloader')
|
||||
|
||||
|
||||
@@ -80,7 +80,13 @@ const options = controlledComputed(
|
||||
fontSize: '12px',
|
||||
},
|
||||
|
||||
formatter: (value: number) => (value > 999 ? (value / 1000).toFixed(0) : value),
|
||||
formatter: (value: number) => {
|
||||
if (value > 999) {
|
||||
return (value / 1000).toFixed(1) + 'k'
|
||||
} else {
|
||||
return value.toString()
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -425,7 +425,7 @@ onBeforeMount(() => {
|
||||
<div v-if="mediaDetail.tmdb_id || mediaDetail.douban_id || mediaDetail.bangumi_id" class="max-w-8xl mx-auto px-4">
|
||||
<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 || mediaDetail.poster_path" cover />
|
||||
<VImg class="h-96" position="top" :src="mediaDetail.backdrop_path || mediaDetail.poster_path" cover />
|
||||
</div>
|
||||
<div class="vue-media-back absolute left-0 top-0 w-full h-96" />
|
||||
</template>
|
||||
@@ -861,6 +861,7 @@ onBeforeMount(() => {
|
||||
/>
|
||||
<!-- 订阅编辑弹窗 -->
|
||||
<SubscribeEditDialog
|
||||
v-if="subscribeEditDialog"
|
||||
v-model="subscribeEditDialog"
|
||||
:subid="subscribeId"
|
||||
@close="subscribeEditDialog = false"
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
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>
|
||||
@@ -47,8 +46,10 @@ const editionFilterOptions = ref<Array<string>>([])
|
||||
// 获取分辨率过滤选项
|
||||
const resolutionFilterOptions = ref<Array<string>>([])
|
||||
|
||||
// 数据列表
|
||||
const dataList = ref<Array<SearchTorrent>>([])
|
||||
// 完整的数据列表
|
||||
let dataList: SearchTorrent[]
|
||||
// 显示用的数据列表
|
||||
const displayDataList = ref<Array<SearchTorrent>>([])
|
||||
|
||||
// 分组后的数据列表
|
||||
const groupedDataList = ref<Map<string, Context[]>>()
|
||||
@@ -71,7 +72,34 @@ function initOptions(data: Context) {
|
||||
// 对季过滤选项进行排序
|
||||
const sortSeasonFilterOptions = computed(() => {
|
||||
return seasonFilterOptions.value.sort((a, b) => {
|
||||
// 按字符串降序排序
|
||||
// 按季,集降序排序
|
||||
const parseSeasonEpisode = (str: string) => {
|
||||
const seasonRangeMatch = str.match(/S(\d+)(?:-S(\d+))?/)
|
||||
const episodeRangeMatch = str.match(/E(\d+)(?:-E(\d+))?/)
|
||||
return {
|
||||
seasonStart: seasonRangeMatch?.[1] ? parseInt(seasonRangeMatch[1]) : 0,
|
||||
seasonEnd: seasonRangeMatch?.[2] ? parseInt(seasonRangeMatch[2]) : 0,
|
||||
episodeStart: episodeRangeMatch?.[1] ? parseInt(episodeRangeMatch[1]) : 0,
|
||||
episodeEnd: episodeRangeMatch?.[2] ? parseInt(episodeRangeMatch[2]) : 0,
|
||||
}
|
||||
}
|
||||
const parsedA = parseSeasonEpisode(a)
|
||||
const parsedB = parseSeasonEpisode(b)
|
||||
// 先按季降序排序
|
||||
if (parsedB.seasonStart !== parsedA.seasonStart) {
|
||||
return parsedB.seasonStart - parsedA.seasonStart
|
||||
}
|
||||
if (parsedB.seasonEnd !== parsedA.seasonEnd) {
|
||||
return parsedB.seasonEnd - parsedA.seasonEnd
|
||||
}
|
||||
// 按集降序排序
|
||||
if (parsedB.episodeStart !== parsedA.episodeStart) {
|
||||
return parsedB.episodeStart - parsedA.episodeStart
|
||||
}
|
||||
if (parsedB.episodeEnd !== parsedA.episodeEnd) {
|
||||
return parsedB.episodeEnd - parsedA.episodeEnd
|
||||
}
|
||||
// 兜底
|
||||
return b.localeCompare(a)
|
||||
})
|
||||
})
|
||||
@@ -97,17 +125,18 @@ onMounted(() => {
|
||||
}
|
||||
})
|
||||
groupedDataList.value = groupMap
|
||||
|
||||
})
|
||||
|
||||
let defer = (_: number) => true
|
||||
|
||||
// 计算过滤后的列表
|
||||
watchEffect(() => {
|
||||
// 只监听filterForm和groupedDataList的变化。因为displayDataList的变化不需要清空列表
|
||||
watch([filterForm, groupedDataList], filterData)
|
||||
function filterData() {
|
||||
// 清空列表
|
||||
dataList.value = []
|
||||
dataList = []
|
||||
displayDataList.value = []
|
||||
// 匹配过滤函数,filter中有任一值包含value则返回true
|
||||
const match = (filter: Array<string>, value: string | undefined): boolean =>
|
||||
filter.length === 0 || filter.includes(value ?? '') || filter.some(v => value?.includes(v) ?? false)
|
||||
const match = (filter: Array<string>, value: string | undefined) =>
|
||||
filter.length === 0 || (value && filter.includes(value))
|
||||
|
||||
groupedDataList.value?.forEach(value => {
|
||||
if (value.length > 0) {
|
||||
@@ -135,12 +164,23 @@ watchEffect(() => {
|
||||
const firstData = _.cloneDeepWith(matchData[0]) as SearchTorrent
|
||||
if (matchData.length > 1) firstData.more = matchData.slice(1)
|
||||
|
||||
dataList.value.push(firstData)
|
||||
// 显示前20个,4行左右。
|
||||
if (displayDataList.value.length < 20) {
|
||||
displayDataList.value.push(firstData)
|
||||
} else {
|
||||
// 后续内容不显示,存在list里。loadMore的时候再加载。
|
||||
dataList.push(firstData)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
defer = useDefer(dataList.value.length)
|
||||
})
|
||||
}
|
||||
|
||||
function loadMore({ done }: { done: any }) {
|
||||
const itemsToMove = dataList.splice(0, 20) // 从 dataList 中获取最前面的 20 个元素
|
||||
displayDataList.value.push(...itemsToMove)
|
||||
done('ok')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -225,9 +265,12 @@ watchEffect(() => {
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCard>
|
||||
<div class="grid gap-3 grid-torrent-card items-start">
|
||||
<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>
|
||||
<VInfiniteScroll mode="intersect" side="end" :items="displayDataList" class="overflow-hidden"
|
||||
@load="loadMore">
|
||||
<template #loading />
|
||||
<template #empty />
|
||||
<div class="grid gap-3 grid-torrent-card items-start">
|
||||
<TorrentCard v-for="item in displayDataList" :key="`${item.torrent_info.page_url}`" :torrent="item" :more="item.more" />
|
||||
</div>
|
||||
</VInfiniteScroll>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Context } from '@/api/types'
|
||||
import TorrentItem from '@/components/cards/TorrentItem.vue'
|
||||
import { useDefer } from '@/@core/utils/dom'
|
||||
import { list } from 'postcss'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// APP
|
||||
const appMode = computed(() => {
|
||||
return localStorage.getItem('MP_APPMODE') != '0' && display.mdAndDown.value
|
||||
})
|
||||
|
||||
// 定义输入参数
|
||||
const props = defineProps({
|
||||
@@ -27,6 +36,13 @@ const filterForm = reactive({
|
||||
resolution: [] as string[],
|
||||
})
|
||||
|
||||
// 列表样式
|
||||
const listStyle = computed(() => {
|
||||
return appMode.value
|
||||
? 'height: calc(100vh - 7.5rem - env(safe-area-inset-bottom) - 3.5rem)'
|
||||
: 'height: calc(100vh - 6.5rem - env(safe-area-inset-bottom)'
|
||||
})
|
||||
|
||||
// 排序字段
|
||||
const sortField = ref('default')
|
||||
|
||||
@@ -63,6 +79,41 @@ function initOptions(data: Context) {
|
||||
optionValue(resolutionFilterOptions.value, meta_info?.resource_pix)
|
||||
}
|
||||
|
||||
// 对季过滤选项进行排序
|
||||
const sortSeasonFilterOptions = computed(() => {
|
||||
return seasonFilterOptions.value.sort((a, b) => {
|
||||
// 按季,集降序排序
|
||||
const parseSeasonEpisode = (str: string) => {
|
||||
const seasonRangeMatch = str.match(/S(\d+)(?:-S(\d+))?/)
|
||||
const episodeRangeMatch = str.match(/E(\d+)(?:-E(\d+))?/)
|
||||
return {
|
||||
seasonStart: seasonRangeMatch?.[1] ? parseInt(seasonRangeMatch[1]) : 0,
|
||||
seasonEnd: seasonRangeMatch?.[2] ? parseInt(seasonRangeMatch[2]) : 0,
|
||||
episodeStart: episodeRangeMatch?.[1] ? parseInt(episodeRangeMatch[1]) : 0,
|
||||
episodeEnd: episodeRangeMatch?.[2] ? parseInt(episodeRangeMatch[2]) : 0,
|
||||
}
|
||||
}
|
||||
const parsedA = parseSeasonEpisode(a)
|
||||
const parsedB = parseSeasonEpisode(b)
|
||||
// 先按季降序排序
|
||||
if (parsedB.seasonStart !== parsedA.seasonStart) {
|
||||
return parsedB.seasonStart - parsedA.seasonStart
|
||||
}
|
||||
if (parsedB.seasonEnd !== parsedA.seasonEnd) {
|
||||
return parsedB.seasonEnd - parsedA.seasonEnd
|
||||
}
|
||||
// 按集降序排序
|
||||
if (parsedB.episodeStart !== parsedA.episodeStart) {
|
||||
return parsedB.episodeStart - parsedA.episodeStart
|
||||
}
|
||||
if (parsedB.episodeEnd !== parsedA.episodeEnd) {
|
||||
return parsedB.episodeEnd - parsedA.episodeEnd
|
||||
}
|
||||
// 兜底
|
||||
return b.localeCompare(a)
|
||||
})
|
||||
})
|
||||
|
||||
// 排序
|
||||
watchEffect(() => {
|
||||
const list = dataList.value
|
||||
@@ -124,15 +175,15 @@ onMounted(() => {
|
||||
</VListItem>
|
||||
</VList>
|
||||
<VList v-if="dataList.length !== 0" lines="three" class="rounded p-0 torrent-list-vscroll shadow-lg">
|
||||
<VVirtualScroll :items="dataList">
|
||||
<VVirtualScroll :items="dataList" :style="listStyle">
|
||||
<template #default="{ item }">
|
||||
<TorrentItem :torrent="item" :key="`${item.torrent_info.title}_${item.torrent_info.site}`" />
|
||||
<TorrentItem :torrent="item" :key="`${item.torrent_info.page_url}`" />
|
||||
</template>
|
||||
</VVirtualScroll>
|
||||
</VList>
|
||||
</VCol>
|
||||
<VCol xl="2" md="3" class="d-none d-md-block">
|
||||
<VList lines="one" class="rounded torrent-list-vscroll shadow-lg">
|
||||
<VCol xl="2" md="3" v-if="display.mdAndUp.value">
|
||||
<VList lines="one" class="rounded shadow-lg" :style="listStyle">
|
||||
<VListSubheader> 排序 </VListSubheader>
|
||||
<VListItem>
|
||||
<VChipGroup column v-model="sortField">
|
||||
@@ -242,7 +293,7 @@ onMounted(() => {
|
||||
<VListItem>
|
||||
<VChipGroup v-model="filterForm.season" column multiple>
|
||||
<VChip
|
||||
v-for="season in seasonFilterOptions"
|
||||
v-for="season in sortSeasonFilterOptions"
|
||||
:key="season"
|
||||
:color="filterForm.season.includes(season) ? 'primary' : ''"
|
||||
filter
|
||||
@@ -257,15 +308,3 @@ onMounted(() => {
|
||||
</VCol>
|
||||
</VRow>
|
||||
</template>
|
||||
<style lang="scss">
|
||||
.torrent-list-vscroll {
|
||||
block-size: calc(100vh - 6rem);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.orrent-list-vscroll {
|
||||
block-size: calc(100vh - 10rem);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,8 +8,6 @@ import PluginCard from '@/components/cards/PluginCard.vue'
|
||||
import noImage from '@images/logos/plugin.png'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import { useDefer } from '@/@core/utils/dom'
|
||||
import router from '@/router'
|
||||
import { PluginTabs } from '@/router/menu'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -17,8 +15,10 @@ const route = useRoute()
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 延迟加载
|
||||
let deferApp = (_: number) => true
|
||||
// APP
|
||||
const appMode = computed(() => {
|
||||
return localStorage.getItem('MP_APPMODE') != '0' && display.mdAndDown.value
|
||||
})
|
||||
|
||||
// 当前标签
|
||||
const activeTab = ref(route.query.tab)
|
||||
@@ -38,6 +38,9 @@ const sortOptions = [
|
||||
{ title: '最新发布', value: 'add_time' },
|
||||
]
|
||||
|
||||
// 加载中
|
||||
const loading = ref(false)
|
||||
|
||||
// 已安装插件列表
|
||||
const dataList = ref<Plugin[]>([])
|
||||
|
||||
@@ -197,11 +200,13 @@ const filterPlugins = computed(() => {
|
||||
// 获取插件列表数据
|
||||
async function fetchInstalledPlugins() {
|
||||
try {
|
||||
loading.value = true
|
||||
dataList.value = await api.get('plugin/', {
|
||||
params: {
|
||||
state: 'installed',
|
||||
},
|
||||
})
|
||||
loading.value = false
|
||||
isRefreshed.value = true
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
@@ -211,6 +216,7 @@ async function fetchInstalledPlugins() {
|
||||
// 获取未安装插件列表数据
|
||||
async function fetchUninstalledPlugins() {
|
||||
try {
|
||||
loading.value = true
|
||||
uninstalledList.value = await api.get('plugin/', {
|
||||
params: {
|
||||
state: 'market',
|
||||
@@ -226,6 +232,8 @@ async function fetchUninstalledPlugins() {
|
||||
}
|
||||
}
|
||||
}
|
||||
loading.value = false
|
||||
isRefreshed.value = true
|
||||
// 更新插件市场列表
|
||||
// 排除已安装且有更新的,上面的问题在于“本地存在未安装的旧版本插件且云端有更新时”不会在插件市场展示
|
||||
marketList.value = uninstalledList.value.filter(item => !(item.has_update && item.installed))
|
||||
@@ -280,8 +288,6 @@ const sortedUninstalledList = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
deferApp = useDefer(ret_list.length)
|
||||
|
||||
if (isNullOrEmptyObject(PluginStatistics.value)) return ret_list
|
||||
// 数据排序
|
||||
if (!activeSort.value || activeSort.value === 'count') {
|
||||
@@ -313,11 +319,6 @@ function handleRepoUrl(url: string | undefined) {
|
||||
return url.replace('https://github.com/', '').replace('https://raw.githubusercontent.com/', '')
|
||||
}
|
||||
|
||||
// 跳转tab
|
||||
function jumpTab(tab: string) {
|
||||
router.push('/plugins?tab=' + tab)
|
||||
}
|
||||
|
||||
// 加载时获取数据
|
||||
onBeforeMount(async () => {
|
||||
await refreshData()
|
||||
@@ -335,7 +336,7 @@ onBeforeMount(async () => {
|
||||
<template>
|
||||
<div>
|
||||
<VTabs v-model="activeTab">
|
||||
<VTab v-for="item in PluginTabs" :value="item.tab" @click="jumpTab(item.tab)">
|
||||
<VTab v-for="item in PluginTabs" :value="item.tab">
|
||||
<span class="mx-5">{{ item.title }}</span>
|
||||
</VTab>
|
||||
</VTabs>
|
||||
@@ -346,7 +347,7 @@ onBeforeMount(async () => {
|
||||
<transition name="fade-slide" appear>
|
||||
<div>
|
||||
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
|
||||
<div v-if="dataList.length > 0" class="grid gap-4 grid-plugin-card items-start">
|
||||
<div v-if="dataList.length > 0" class="grid gap-4 grid-plugin-card">
|
||||
<template v-for="(data, index) in dataList" :key="`${data.id}_v${data.plugin_version}`">
|
||||
<PluginCard
|
||||
:count="PluginStatistics[data.id || '0']"
|
||||
@@ -416,14 +417,9 @@ onBeforeMount(async () => {
|
||||
</VCol>
|
||||
</VRow>
|
||||
</div>
|
||||
<div v-if="isAppMarketLoaded" class="grid gap-4 grid-plugin-card items-start">
|
||||
<div v-if="isAppMarketLoaded" class="grid gap-4 grid-plugin-card">
|
||||
<template v-for="(data, index) in sortedUninstalledList" :key="`${data.id}_v${data.plugin_version}`">
|
||||
<PluginAppCard
|
||||
v-if="deferApp(index)"
|
||||
:plugin="data"
|
||||
:count="PluginStatistics[data.id || '0']"
|
||||
@install="pluginInstalled"
|
||||
/>
|
||||
<PluginAppCard :plugin="data" :count="PluginStatistics[data.id || '0']" @install="pluginInstalled" />
|
||||
</template>
|
||||
</div>
|
||||
<NoDataFound
|
||||
@@ -449,6 +445,7 @@ onBeforeMount(async () => {
|
||||
app
|
||||
appear
|
||||
@click="SearchDialog = true"
|
||||
:class="{ 'mb-12': appMode }"
|
||||
/>
|
||||
<VDialog
|
||||
v-if="SearchDialog"
|
||||
|
||||
@@ -1,44 +1,65 @@
|
||||
<script lang="ts" setup>
|
||||
import api from '@/api'
|
||||
import { MediaDirectory } from '@/api/types'
|
||||
import { FileItem, MediaDirectory } from '@/api/types'
|
||||
import FileBrowser from '@/components/FileBrowser.vue'
|
||||
import store from '@/store'
|
||||
|
||||
const endpoints = {
|
||||
list: {
|
||||
url: '/filebrowser/list?path={path}&sort={sort}',
|
||||
method: 'get',
|
||||
url: '/{storage}/list?sort={sort}',
|
||||
method: 'post',
|
||||
},
|
||||
mkdir: {
|
||||
url: '/filebrowser/mkdir?path={path}',
|
||||
method: 'get',
|
||||
url: '/{storage}/mkdir?name={name}',
|
||||
method: 'post',
|
||||
},
|
||||
delete: {
|
||||
url: '/filebrowser/delete?path={path}',
|
||||
method: 'get',
|
||||
url: '/{storage}/delete',
|
||||
method: 'post',
|
||||
},
|
||||
download: {
|
||||
url: '/filebrowser/download?path={path}',
|
||||
url: '/{storage}/download',
|
||||
method: 'get',
|
||||
},
|
||||
image: {
|
||||
url: '/filebrowser/image?path={path}',
|
||||
url: '/{storage}/image',
|
||||
method: 'get',
|
||||
},
|
||||
rename: {
|
||||
url: '/filebrowser/rename?path={path}&new_name={newname}',
|
||||
method: 'get',
|
||||
url: '/{storage}/rename?new_name={newname}',
|
||||
method: 'post',
|
||||
},
|
||||
}
|
||||
|
||||
// 当前目录
|
||||
const path: Ref<string | undefined> = ref()
|
||||
const user_level = store.state.auth.level
|
||||
|
||||
// 用户存储
|
||||
const userStorage = user_level > 1 ? 'local,aliyun,u115' : 'local'
|
||||
|
||||
// 当前文件项
|
||||
const operItem = ref<FileItem>({
|
||||
type: 'dir',
|
||||
name: '/',
|
||||
path: '/',
|
||||
fileid: 'root',
|
||||
})
|
||||
|
||||
// fileid的堆栈
|
||||
const itemstack = ref<FileItem[]>([
|
||||
{
|
||||
type: 'dir',
|
||||
name: '/',
|
||||
path: '/',
|
||||
fileid: 'root',
|
||||
},
|
||||
])
|
||||
|
||||
// 下载目录列表
|
||||
const downloadDirectories = ref<MediaDirectory[]>([])
|
||||
|
||||
// 计算公共路径
|
||||
function findCommonPath(paths: string[]): string {
|
||||
let commonPath = '/'
|
||||
let commonPath
|
||||
if (!paths || paths.length === 0) {
|
||||
commonPath = '/'
|
||||
} else if (paths.length === 1) {
|
||||
@@ -64,7 +85,7 @@ function findCommonPath(paths: string[]): string {
|
||||
}
|
||||
|
||||
if (commonPath.includes(':')) {
|
||||
commonPath = commonPath.replace('/', '\\')
|
||||
commonPath = commonPath.replace('\\', '/')
|
||||
}
|
||||
|
||||
return commonPath
|
||||
@@ -76,7 +97,23 @@ async function loadDownloadDirectories() {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/DownloadDirectories')
|
||||
if (result.success && result.data?.value) {
|
||||
downloadDirectories.value = result.data.value
|
||||
path.value = findCommonPath(downloadDirectories.value.map(item => item.path) as string[])
|
||||
const path = findCommonPath(downloadDirectories.value.map(item => item.path) as string[])
|
||||
const name = path.split('/').filter(Boolean).pop() ?? ''
|
||||
operItem.value = {
|
||||
type: 'dir',
|
||||
name: name,
|
||||
path: path,
|
||||
}
|
||||
// 将初始数据拆分到堆栈中
|
||||
const paths = path.split('/').filter(Boolean)
|
||||
paths.map((name, index) => {
|
||||
const path = '/' + paths.slice(0, index + 1).join('/') + '/'
|
||||
itemstack.value.push({
|
||||
type: 'dir',
|
||||
name: name,
|
||||
path: path,
|
||||
})
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
@@ -84,21 +121,29 @@ async function loadDownloadDirectories() {
|
||||
}
|
||||
|
||||
// 目录变化
|
||||
function pathChanged(_path: string) {
|
||||
path.value = _path
|
||||
function pathChanged(item: FileItem) {
|
||||
operItem.value = item
|
||||
const index = itemstack.value.findIndex(i => i.path === item.path)
|
||||
if (index >= 0) {
|
||||
itemstack.value = itemstack.value.slice(0, index + 1)
|
||||
} else {
|
||||
itemstack.value.push(item)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载初始目录
|
||||
onBeforeMount(loadDownloadDirectories)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<FileBrowser
|
||||
storages="local"
|
||||
:storages="userStorage"
|
||||
:tree="false"
|
||||
:path="path"
|
||||
:itemstack="itemstack"
|
||||
:endpoints="endpoints"
|
||||
:axios="api"
|
||||
:item="operItem"
|
||||
@pathchanged="pathChanged"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { debounce } from 'lodash'
|
||||
import { ref, unref } from 'vue'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import api from '@/api'
|
||||
import type { TransferHistory } from '@/api/types'
|
||||
import ReorganizeDialog from '@/components/dialog/ReorganizeDialog.vue'
|
||||
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import router from '@/router'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// APP
|
||||
const appMode = computed(() => {
|
||||
return localStorage.getItem('MP_APPMODE') != '0' && display.mdAndDown.value
|
||||
})
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 路由
|
||||
const route = useRoute()
|
||||
|
||||
// 重新整理对话框
|
||||
const redoDialog = ref(false)
|
||||
|
||||
@@ -69,7 +82,7 @@ const pageRange = [
|
||||
const dataList = ref<TransferHistory[]>([])
|
||||
|
||||
// 搜索
|
||||
const search = ref()
|
||||
const search = ref(route.query.search as string)
|
||||
|
||||
// 搜索提示词列表
|
||||
const searchHintList = ref<string[]>([])
|
||||
@@ -81,10 +94,10 @@ const loading = ref(false)
|
||||
const totalItems = ref(0)
|
||||
|
||||
// 每页条数
|
||||
const itemsPerPage = ref(50)
|
||||
const itemsPerPage = ref<number>(ensureNumber(route.query.itemsPerPage, 50))
|
||||
|
||||
// 当前页码
|
||||
const currentPage = ref(1)
|
||||
const currentPage = ref<number>(ensureNumber(route.query.currentPage, 1))
|
||||
|
||||
// 进度条
|
||||
const progressDialog = ref(false)
|
||||
@@ -111,10 +124,16 @@ const TransferDict: { [key: string]: string } = {
|
||||
rclone_move: 'Rclone移动',
|
||||
}
|
||||
|
||||
const tableStyle = computed(() => {
|
||||
return appMode.value
|
||||
? 'height: calc(100vh - 15.5rem - env(safe-area-inset-bottom) - 3.5rem)'
|
||||
: 'height: calc(100vh - 14.5rem - env(safe-area-inset-bottom)'
|
||||
})
|
||||
|
||||
// 分页提示
|
||||
const pageTip = computed(() => {
|
||||
const begin = unref(itemsPerPage) * (unref(currentPage) - 1) + 1
|
||||
const end = unref(itemsPerPage) * unref(currentPage) === -1 ? 'ALL' : unref(itemsPerPage) * unref(currentPage)
|
||||
const begin = itemsPerPage.value * (currentPage.value - 1) + 1
|
||||
const end = itemsPerPage.value * currentPage.value === -1 ? 'ALL' : itemsPerPage.value * currentPage.value
|
||||
return {
|
||||
begin,
|
||||
end,
|
||||
@@ -123,15 +142,22 @@ const pageTip = computed(() => {
|
||||
|
||||
// 分页总数
|
||||
const totalPage = computed(() => {
|
||||
const total = Math.ceil(unref(totalItems) / unref(itemsPerPage))
|
||||
const total = Math.ceil(totalItems.value / itemsPerPage.value)
|
||||
return total
|
||||
})
|
||||
|
||||
// 切换页签和搜索词
|
||||
watch(
|
||||
[() => currentPage.value, () => itemsPerPage.value, () => search.value],
|
||||
[() => currentPage.value, () => itemsPerPage.value],
|
||||
debounce(async () => {
|
||||
await fetchData()
|
||||
reloadPage()
|
||||
}, 1000),
|
||||
)
|
||||
|
||||
watch(
|
||||
[() => search.value],
|
||||
debounce(async () => {
|
||||
reloadPage(true)
|
||||
}, 1000),
|
||||
)
|
||||
|
||||
@@ -272,6 +298,16 @@ async function retransferBatch() {
|
||||
redoDialog.value = true
|
||||
}
|
||||
|
||||
// 整理完成
|
||||
function transferDone() {
|
||||
redoDialog.value = false
|
||||
// 清空当前操作记录
|
||||
currentHistory.value = undefined
|
||||
selected.value = []
|
||||
// 刷新
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 弹出菜单
|
||||
const dropdownItems = ref([
|
||||
{
|
||||
@@ -298,6 +334,38 @@ const dropdownItems = ref([
|
||||
},
|
||||
])
|
||||
|
||||
// 添加url参数
|
||||
function addUrlQuery(url: string, name: string, value: any) {
|
||||
if (!url || !name || !value) return url
|
||||
const separator = url.includes('?') ? '&' : '?'
|
||||
return url + separator + name + '=' + encodeURIComponent(value)
|
||||
}
|
||||
|
||||
// 重载页面
|
||||
function reloadPage(resetPage = false) {
|
||||
let url = '/history'
|
||||
if (search.value) {
|
||||
url = addUrlQuery(url, 'search', search.value)
|
||||
}
|
||||
if (itemsPerPage.value) {
|
||||
url = addUrlQuery(url, 'itemsPerPage', itemsPerPage.value)
|
||||
}
|
||||
if (currentPage.value) {
|
||||
url = addUrlQuery(url, 'currentPage', resetPage ? 1 : currentPage.value)
|
||||
}
|
||||
router.push(url)
|
||||
}
|
||||
|
||||
// 确保值为number类型
|
||||
function ensureNumber(value: any, defaultValue: number = 0) {
|
||||
value = Number(value)
|
||||
// 如果不是数字
|
||||
if (Number.isNaN(value)) {
|
||||
value = defaultValue
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// 初始加载数据
|
||||
onMounted(fetchData)
|
||||
</script>
|
||||
@@ -338,8 +406,8 @@ onMounted(fetchData)
|
||||
fixed-header
|
||||
show-select
|
||||
loading-text="加载中..."
|
||||
class="data-table-div"
|
||||
hover
|
||||
:style="tableStyle"
|
||||
>
|
||||
<template #item.title="{ item }">
|
||||
<div class="d-flex align-center">
|
||||
@@ -427,10 +495,11 @@ onMounted(fetchData)
|
||||
app
|
||||
appear
|
||||
@click="removeHistoryBatch"
|
||||
:class="{ 'mb-12': appMode }"
|
||||
/>
|
||||
<VFab
|
||||
v-if="selected.length > 0"
|
||||
class="mb-16"
|
||||
:class="appMode ? 'mb-28' : 'mb-16'"
|
||||
icon="mdi-redo-variant"
|
||||
location="bottom"
|
||||
size="x-large"
|
||||
@@ -466,16 +535,7 @@ onMounted(fetchData)
|
||||
v-if="redoDialog"
|
||||
v-model="redoDialog"
|
||||
:logids="redoIds"
|
||||
@done="
|
||||
() => {
|
||||
redoDialog = false
|
||||
// 清空当前操作记录
|
||||
currentHistory = undefined
|
||||
selected = []
|
||||
// 刷新
|
||||
fetchData()
|
||||
}
|
||||
"
|
||||
@done="transferDone"
|
||||
@close="redoDialog = false"
|
||||
/>
|
||||
</template>
|
||||
@@ -484,14 +544,4 @@ onMounted(fetchData)
|
||||
.v-table th {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.data-table-div {
|
||||
block-size: calc(100vh - 14rem);
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.data-table-div {
|
||||
block-size: calc(100vh - 17rem);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -170,7 +170,7 @@ onMounted(() => {
|
||||
rel="noreferrer"
|
||||
class="text-indigo-500 transition duration-300 hover:underline"
|
||||
>
|
||||
https://github.com/jxxghp/MoviePilot/blob/main/README.md
|
||||
https://wiki.movie-pilot.org
|
||||
</a>
|
||||
</span>
|
||||
</dd>
|
||||
|
||||
@@ -6,6 +6,10 @@ import { requiredValidator } from '@/@validators'
|
||||
import api from '@/api'
|
||||
import type { User } from '@/api/types'
|
||||
import avatar1 from '@images/avatars/avatar-1.png'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
const isNewPasswordVisible = ref(false)
|
||||
const isConfirmPasswordVisible = ref(false)
|
||||
@@ -160,7 +164,7 @@ async function addUser() {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post('user', userForm)
|
||||
const result: { [key: string]: any } = await api.post('user/', userForm)
|
||||
if (result.success) {
|
||||
$toast.success('用户新增成功!')
|
||||
loadAllUsers()
|
||||
@@ -250,7 +254,7 @@ onMounted(() => {
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<VBtn color="primary" @click="refInputEl?.click()">
|
||||
<VIcon icon="mdi-cloud-upload-outline" />
|
||||
<span class="d-none d-sm-block ms-2">上传头像</span>
|
||||
<span v-if="display.mdAndUp.value" class="ms-2">上传头像</span>
|
||||
</VBtn>
|
||||
|
||||
<input
|
||||
@@ -264,7 +268,7 @@ onMounted(() => {
|
||||
|
||||
<VBtn type="reset" color="error" variant="tonal" @click="resetAvatar">
|
||||
<VIcon icon="mdi-refresh" />
|
||||
<span class="d-none d-sm-block ms-2">重置</span>
|
||||
<span v-if="display.mdAndUp.value" class="ms-2">重置</span>
|
||||
</VBtn>
|
||||
|
||||
<VBtn
|
||||
@@ -273,7 +277,9 @@ onMounted(() => {
|
||||
@click.stop="accountInfo.is_otp ? disableOtp() : getOtpUri()"
|
||||
>
|
||||
<VIcon icon="mdi-account-key" />
|
||||
<span class="d-none d-sm-block ms-2">{{ accountInfo.is_otp ? '关闭验证' : '双重验证' }}</span>
|
||||
<span v-if="display.mdAndUp.value" class="ms-2">{{
|
||||
accountInfo.is_otp ? '关闭验证' : '双重验证'
|
||||
}}</span>
|
||||
</VBtn>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -137,7 +137,7 @@ function addDownloadDirectory() {
|
||||
downloadDirectories.value.push({
|
||||
name: `下载目录${downloadDirectories.value.length + 1}`,
|
||||
path: '',
|
||||
media_type: '全部',
|
||||
media_type: '',
|
||||
category: '',
|
||||
})
|
||||
}
|
||||
@@ -181,7 +181,7 @@ function addLibraryDirectory() {
|
||||
libraryDirectories.value.push({
|
||||
name: `媒体库目录${libraryDirectories.value.length + 1}`,
|
||||
path: '',
|
||||
media_type: '全部',
|
||||
media_type: '',
|
||||
category: '',
|
||||
scrape: true,
|
||||
})
|
||||
@@ -261,6 +261,7 @@ onMounted(() => {
|
||||
type="library"
|
||||
:directory="element"
|
||||
:categories="mediaCategories"
|
||||
@update:modelValue="(value: string) => (element.path = value)"
|
||||
@close="libraryCardClose(element.name)"
|
||||
/>
|
||||
</template>
|
||||
@@ -288,7 +289,8 @@ onMounted(() => {
|
||||
v-model="transferSettings.TRANSFER_TYPE"
|
||||
:items="transferTypeItems"
|
||||
label="整理方式"
|
||||
hint="硬链接需要确保下载目录和媒体库目录不跨盘、不跨共享目录、不分别映射;rclone需要手动在容器中完成配置,且配置名为:`MP`"
|
||||
hint="文件从下载目录整理到媒体库目录的操作方式"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -296,14 +298,16 @@ onMounted(() => {
|
||||
v-model="transferSettings.OVERWRITE_MODE"
|
||||
:items="overwriteModeItems"
|
||||
label="覆盖模式"
|
||||
hint="从不覆盖:不覆盖已存在的文件;按大小覆盖:大文件将覆盖小文件;总是覆盖:总是覆盖已存在的文件;仅保留最新版本:保留最新版本的文件,删除其它版本的文件"
|
||||
hint="媒体库中同名文件已存在时的覆盖方式"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="transferSettings.TRANSFER_SAME_DISK"
|
||||
label="同盘/同根目录优先"
|
||||
hint="开启后优先整理到与下载目录同一磁盘/同一根路径的媒体库目录中"
|
||||
hint="优先整理到与下载目录同一磁盘/同一根路径的媒体库目录中"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
@@ -56,6 +56,10 @@ const NotificationChannels = [
|
||||
title: 'VoceChat',
|
||||
value: 'vocechat',
|
||||
},
|
||||
{
|
||||
title: 'WebPush',
|
||||
value: 'webpush',
|
||||
},
|
||||
]
|
||||
|
||||
// 提示框
|
||||
@@ -195,7 +199,8 @@ onMounted(() => {
|
||||
chips
|
||||
:items="NotificationChannels"
|
||||
label="当前使用通知渠道"
|
||||
hint="选中的渠道才会按消息类型的设定发送消息"
|
||||
hint="消息通知渠道总开关"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -216,42 +221,48 @@ onMounted(() => {
|
||||
<VTextField
|
||||
v-model="notificationSettings.WECHAT_CORPID"
|
||||
label="企业ID"
|
||||
hint="登录企业微信后台,在 https://work.weixin.qq.com/wework_admin/frame#profile 中查看"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField
|
||||
v-model="notificationSettings.WECHAT_APP_SECRET"
|
||||
label="应用Secret"
|
||||
hint="在企业微信中创建应用,查看应用的Secret"
|
||||
hint="企业微信后台企业信息中的企业ID"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField
|
||||
v-model="notificationSettings.WECHAT_APP_ID"
|
||||
label="应用 AgentId"
|
||||
hint="在企业微信中创建应用,查看应用的AgentId"
|
||||
hint="企业微信自建应用的AgentId"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField
|
||||
v-model="notificationSettings.WECHAT_APP_SECRET"
|
||||
label="应用 Secret"
|
||||
hint="企业微信自建应用的Secret"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField
|
||||
v-model="notificationSettings.WECHAT_PROXY"
|
||||
label="代理地址"
|
||||
hint="由于微信官方限制,2022年6月20日后创建的企业微信应用需要有固定的公网IP地址并加入IP白名单后才能接收消息,使用有固定公网IP的代理服务器转发可解决该问题;代理服务器需自行搭建,搭建方法参考项目主页说明,不使用代理需保留默认值"
|
||||
hint="微信消息的转发代理地址,2022年6月20日后创建的自建应用才需要,不使用代理时需要保留默认值"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField
|
||||
v-model="notificationSettings.WECHAT_TOKEN"
|
||||
label="Token"
|
||||
hint="在微信企业应用管理后台-接收消息设置页面生成"
|
||||
hint="微信企业自建应用->API接收消息配置中的Token"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField
|
||||
v-model="notificationSettings.WECHAT_ENCODING_AESKEY"
|
||||
label="EncodingAESKey"
|
||||
hint="在微信企业应用管理后台-接收消息设置页面生成,所有信息填入完成后保存,然后再在企业微信应用消息接收服务中输入回调地址:http(s)://domain:port/api/v1/message/"
|
||||
hint="微信企业自建应用->API接收消息配置中的EncodingAESKey"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -259,7 +270,8 @@ onMounted(() => {
|
||||
v-model="notificationSettings.WECHAT_ADMINS"
|
||||
label="管理员白名单"
|
||||
placeholder="多个用,分隔"
|
||||
hint="只有在白名单中的用户才能使用菜单管理功能,不填写则所有用户都能使用,菜单会自动生成,不需要手动创建"
|
||||
hint="可使用管理菜单及命令的用户ID列表,多个ID使用,分隔"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -272,14 +284,16 @@ onMounted(() => {
|
||||
<VTextField
|
||||
v-model="notificationSettings.TELEGRAM_TOKEN"
|
||||
label="Bot Token"
|
||||
hint="Telegram机器人的token,关注BotFather创建机器人并获取token,格式为:123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
|
||||
hint="Telegram机器人token,格式:123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationSettings.TELEGRAM_CHAT_ID"
|
||||
label="Chat ID"
|
||||
hint="接受消息通知的用户、群组或频道Chat ID,关注@getidsbot获取"
|
||||
hint="接受消息通知的用户、群组或频道Chat ID"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -287,7 +301,8 @@ onMounted(() => {
|
||||
v-model="notificationSettings.TELEGRAM_USERS"
|
||||
label="用户白名单"
|
||||
placeholder="多个用,分隔"
|
||||
hint="只有在白名单中的用户才能使用Telegram机器人,不填写则所有用户都能使用,多个用户用英文,分隔"
|
||||
hint="可使用Telegram机器人的用户ID清单,多个用户用,分隔,不填写则所有用户都能使用"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -295,7 +310,8 @@ onMounted(() => {
|
||||
v-model="notificationSettings.TELEGRAM_ADMINS"
|
||||
label="管理员白名单"
|
||||
placeholder="多个用,分隔"
|
||||
hint="只有在白名单中的用户才能使用管理功能,不填写则所有用户都能使用,多个用户用英文,分隔。菜单会自动生成,不需要手动创建"
|
||||
hint="可使用管理菜单及命令的用户ID列表,多个ID使用,分隔"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -309,7 +325,8 @@ onMounted(() => {
|
||||
v-model="notificationSettings.SLACK_OAUTH_TOKEN"
|
||||
label="Slack Bot User OAuth Token"
|
||||
placeholder="xoxb-xxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
hint="在 https://api.slack.com/apps 中创建应用,查看OAuth & Permissions页面中的Bot User OAuth Token"
|
||||
hint="Slack应用`OAuth & Permissions`页面中的`Bot User OAuth Token`"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="5">
|
||||
@@ -317,7 +334,8 @@ onMounted(() => {
|
||||
v-model="notificationSettings.SLACK_APP_TOKEN"
|
||||
label="Slack App-Level Token"
|
||||
placeholder="xapp-xxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
hint="在 https://api.slack.com/apps 中创建应用,查看OAuth & Permissions页面中的App-Level Token"
|
||||
hint="Slack应用`OAuth & Permissions`页面中的`App-Level Token`"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="2">
|
||||
@@ -325,7 +343,8 @@ onMounted(() => {
|
||||
v-model="notificationSettings.SLACK_CHANNEL"
|
||||
label="频道名称"
|
||||
placeholder="全体"
|
||||
hint="消息发送到的频道名称,不填写则发送到全体频道"
|
||||
hint="消息发送频道,默认`全体`"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -338,14 +357,16 @@ onMounted(() => {
|
||||
<VTextField
|
||||
v-model="notificationSettings.SYNOLOGYCHAT_WEBHOOK"
|
||||
label="机器人传入URL"
|
||||
hint="在Synology Chat中创建机器人,获取机器人传入URL"
|
||||
hint="Synology Chat机器人传入URL"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationSettings.SYNOLOGYCHAT_TOKEN"
|
||||
label="令牌"
|
||||
hint="在Synology Chat中创建机器人,获取机器人令牌"
|
||||
hint="Synology Chat机器人令牌"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -355,13 +376,19 @@ onMounted(() => {
|
||||
<VForm>
|
||||
<VRow>
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField v-model="notificationSettings.VOCECHAT_HOST" label="地址" />
|
||||
<VTextField
|
||||
v-model="notificationSettings.VOCECHAT_HOST"
|
||||
label="地址"
|
||||
hint="VoceChat服务端地址,格式:http(s)://ip:port"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField
|
||||
v-model="notificationSettings.VOCECHAT_API_KEY"
|
||||
label="机器人密钥"
|
||||
hint="在VoceChat中创建机器人,获取机器人密钥"
|
||||
hint="VoceChat机器人密钥"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -369,7 +396,8 @@ onMounted(() => {
|
||||
v-model="notificationSettings.VOCECHAT_CHANNEL_ID"
|
||||
label="频道ID"
|
||||
placeholder="不包含#号"
|
||||
hint="在VoceChat中创建频道,获取频道ID,不包含#号"
|
||||
hint="VoceChat的频道ID,不包含#号"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -406,6 +434,7 @@ onMounted(() => {
|
||||
<th scope="col">Slack</th>
|
||||
<th scope="col">SynologyChat</th>
|
||||
<th scope="col">VoceChat</th>
|
||||
<th scope="col">WebPush</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -428,6 +457,9 @@ onMounted(() => {
|
||||
<td>
|
||||
<VCheckbox v-model="message.vocechat" />
|
||||
</td>
|
||||
<td>
|
||||
<VCheckbox v-model="message.webpush" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="messagemTypes.length === 0">
|
||||
<td colspan="6" class="text-center">没有设置任何通知渠道</td>
|
||||
|
||||
@@ -294,7 +294,8 @@ onMounted(() => {
|
||||
chips
|
||||
:items="mediaSourcesDict"
|
||||
label="当前使用数据源"
|
||||
hint="选中多项时会同时展示来自不同数据源的搜索结果,选择的数据源顺序将会影响搜索结果的排序"
|
||||
hint="搜索媒体信息时使用的数据源以及排序"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -398,7 +399,8 @@ onMounted(() => {
|
||||
v-model="defaultFilterRules.include"
|
||||
type="text"
|
||||
label="包含(关键字、正则式)"
|
||||
hint="支持正式表达式,多个关键字用 | 分隔表示或"
|
||||
hint="包含规则,支持正式表达式,多个关键字用 | 分隔表示或"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -406,7 +408,8 @@ onMounted(() => {
|
||||
v-model="defaultFilterRules.exclude"
|
||||
type="text"
|
||||
label="排除(关键字、正则式)"
|
||||
hint="支持正式表达式,多个关键字用 | 分隔表示或"
|
||||
hint="排除规则,支持正式表达式,多个关键字用 | 分隔表示或"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -416,15 +419,17 @@ onMounted(() => {
|
||||
label="最小做种数"
|
||||
placeholder="0"
|
||||
hint="小于该值的资源将被过滤掉,0表示不过滤"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="defaultFilterRules.min_seeders_time"
|
||||
type="text"
|
||||
label="最少做种人数生效发布时间(分钟)"
|
||||
label="最少做种数生效发布时间(分钟)"
|
||||
placeholder="0"
|
||||
hint="发布时间距现在大于该值的资源将生效最小做种数规则,0表示不生效"
|
||||
hint="发布时间距当前时间大于该值的资源将生效最小做种数规则,0表示不生效"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
@@ -25,6 +25,7 @@ const cookieCloudSetting = ref({
|
||||
COOKIECLOUD_INTERVAL: 0,
|
||||
USER_AGENT: '',
|
||||
COOKIECLOUD_ENABLE_LOCAL: '',
|
||||
COOKIECLOUD_BLACKLIST: '',
|
||||
})
|
||||
|
||||
// 种子优先规则下拉框
|
||||
@@ -100,6 +101,7 @@ async function loadCookieCloudSettings() {
|
||||
COOKIECLOUD_INTERVAL,
|
||||
USER_AGENT,
|
||||
COOKIECLOUD_ENABLE_LOCAL,
|
||||
COOKIECLOUD_BLACKLIST,
|
||||
} = result.data
|
||||
cookieCloudSetting.value = {
|
||||
COOKIECLOUD_HOST,
|
||||
@@ -108,6 +110,7 @@ async function loadCookieCloudSettings() {
|
||||
COOKIECLOUD_INTERVAL,
|
||||
USER_AGENT,
|
||||
COOKIECLOUD_ENABLE_LOCAL,
|
||||
COOKIECLOUD_BLACKLIST,
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -149,7 +152,8 @@ onMounted(() => {
|
||||
<VCheckbox
|
||||
v-model="cookieCloudSetting.COOKIECLOUD_ENABLE_LOCAL"
|
||||
label="启用本地CookieCloud服务器"
|
||||
hint="启用后,将使用内建CookieCloud服务同步站点数据,服务地址为:http://localhost:3000/cookiecloud"
|
||||
hint="使用内建CookieCloud服务同步站点数据,服务地址为:http://localhost:3000/cookiecloud"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -157,17 +161,19 @@ onMounted(() => {
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="cookieCloudSetting.COOKIECLOUD_HOST"
|
||||
label="远程CookieCloud服务器地址"
|
||||
label="服务地址"
|
||||
placeholder="https://movie-pilot.org/cookiecloud"
|
||||
:disabled="!!cookieCloudSetting.COOKIECLOUD_ENABLE_LOCAL"
|
||||
hint="格式:https://movie-pilot.org/cookiecloud"
|
||||
hint="远端CookieCloud服务地址,格式:https://movie-pilot.org/cookiecloud"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="cookieCloudSetting.COOKIECLOUD_KEY"
|
||||
label="用户KEY"
|
||||
hint="在CookieCloud浏览器插件中生成"
|
||||
hint="CookieCloud浏览器插件生成的用户KEY"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -175,7 +181,8 @@ onMounted(() => {
|
||||
v-model="cookieCloudSetting.COOKIECLOUD_PASSWORD"
|
||||
type="password"
|
||||
label="端对端加密密码"
|
||||
hint="在CookieCloud浏览器插件中生成"
|
||||
hint="CookieCloud浏览器插件生成的端对端加密密码"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -183,14 +190,25 @@ onMounted(() => {
|
||||
v-model="cookieCloudSetting.COOKIECLOUD_INTERVAL"
|
||||
label="自动同步间隔"
|
||||
:items="CookieCloudIntervalItems"
|
||||
hint="设置定时从CookieCloud服务器同步站点Cookie到MoviePilot的时间周期"
|
||||
hint="从CookieCloud服务器自动同步站点Cookie到MoviePilot的时间间隔"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="cookieCloudSetting.COOKIECLOUD_BLACKLIST"
|
||||
label="同步域名黑名单"
|
||||
placeholder="多个域名,分割"
|
||||
hint="CookieCloud同步域名黑名单,多个域名,分割"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="cookieCloudSetting.USER_AGENT"
|
||||
label="浏览器User-Agent"
|
||||
hint="设置为CookieCloud插件所在的浏览器的User-Agent,用于模拟浏览器请求,正确填写后有助于提升站点访问成功率"
|
||||
hint="CookieCloud插件所在的浏览器的User-Agent"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -215,7 +233,8 @@ onMounted(() => {
|
||||
v-model="selectedTorrentPriority"
|
||||
:items="TorrentPriorityItems"
|
||||
label="当前使用下载优先规则"
|
||||
hint="站点优先:优先下载站点优先级最高的站点的种子;做种数优先:优先下载做种数量最多的种子。注意下载优先级仍然低于搜索和订阅中设定的优先级规则"
|
||||
hint="同时命中多个站点的多个资源时下载的优先规则"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -233,7 +252,8 @@ onMounted(() => {
|
||||
<VCheckbox
|
||||
v-model="isConfirmResetSites"
|
||||
label="确认删除所有站点数据并重新同步。"
|
||||
hint="删除所有站点数据并重新同步,站点图标短时间内会因数缓存而混乱,重启或者等待2两时自动恢复。"
|
||||
hint="删除所有站点数据并重新从CookieCloud同步,操作请先清空涉及站点的相关设置。"
|
||||
persistent-hint
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -338,7 +338,8 @@ onMounted(() => {
|
||||
v-model="selectedSubscribeMode"
|
||||
:items="subscribeModeItems"
|
||||
label="订阅模式"
|
||||
hint="自动:系统自动爬取站点首页资源;站点RSS:使用站点RSS订阅资源,站点RSS会自动获取,也可手动在站点管理中补全"
|
||||
hint="自动:自动爬取站点首页,站点RSS:通过站点RSS链接订阅"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -346,7 +347,8 @@ onMounted(() => {
|
||||
v-model="selectedRssInterval"
|
||||
:items="rssIntervalItems"
|
||||
label="站点RSS周期"
|
||||
hint="设置站点RSS运行周期,在订阅模式为站点RSS时生效"
|
||||
hint="设置站点RSS运行周期,在订阅模式为`站点RSS`时生效"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -355,7 +357,8 @@ onMounted(() => {
|
||||
<VSwitch
|
||||
v-model="enableIntervalSearch"
|
||||
label="开启订阅定时搜索"
|
||||
hint="开启后,系统每隔24小时将按名称搜索全站,补全订阅可能漏掉的资源"
|
||||
hint="每隔24小时全站搜索,以补全订阅可能漏掉的资源"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -490,7 +493,8 @@ onMounted(() => {
|
||||
v-model="defaultFilterRules.include"
|
||||
type="text"
|
||||
label="包含(关键字、正则式)"
|
||||
hint="支持正式表达式,多个关键字用 | 分隔表示或"
|
||||
hint="包含规则,支持正式表达式,多个关键字用 | 分隔表示或"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -498,7 +502,8 @@ onMounted(() => {
|
||||
v-model="defaultFilterRules.exclude"
|
||||
type="text"
|
||||
label="排除(关键字、正则式)"
|
||||
hint="支持正式表达式,多个关键字用 | 分隔表示或"
|
||||
hint="排除规则,支持正式表达式,多个关键字用 | 分隔表示或"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -507,7 +512,8 @@ onMounted(() => {
|
||||
type="text"
|
||||
label="电影文件大小(GB)"
|
||||
placeholder="0-30"
|
||||
hint="格式:0-30,表示0到30GB之间的资源"
|
||||
hint="文件大小范围,格式:0-30,表示0-30GB之间的资源"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -516,7 +522,8 @@ onMounted(() => {
|
||||
type="text"
|
||||
label="剧集单集文件大小(GB)"
|
||||
placeholder="0-10"
|
||||
hint="格式:0-10,表示0到10GB之间的资源"
|
||||
hint="单集文件大小范围,格式:0-10,表示0-10GB之间的资源"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -526,15 +533,17 @@ onMounted(() => {
|
||||
label="最小做种数"
|
||||
placeholder="0"
|
||||
hint="小于该值的资源将被过滤掉,0表示不过滤"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="defaultFilterRules.min_seeders_time"
|
||||
type="text"
|
||||
label="最少做种人数生效发布时间(分钟)"
|
||||
label="最少做种数生效发布时间(分钟)"
|
||||
placeholder="0"
|
||||
hint="发布时间距现在大于该值的资源将生效最小做种数规则,0表示不生效"
|
||||
hint="发布时间距当前时间大于该值的资源将生效最小做种数规则,0表示不生效"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
@@ -16,6 +16,11 @@ const downloaderTab = ref('qbittorrent')
|
||||
// 媒体服务器选中标签页
|
||||
const mediaserverTab = ref('emby')
|
||||
|
||||
// 系统设置项
|
||||
const SystemSettings = ref({
|
||||
APP_DOMAIN: '',
|
||||
})
|
||||
|
||||
// 下载器设置项
|
||||
const downloaderSettings = ref({
|
||||
DOWNLOADER_MONITOR: true,
|
||||
@@ -208,6 +213,33 @@ async function saveMediaServerSetting() {
|
||||
}
|
||||
}
|
||||
|
||||
// 加载系统设置
|
||||
async function loadSystemSettings() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/env')
|
||||
if (result.success) {
|
||||
const { APP_DOMAIN } = result.data
|
||||
SystemSettings.value = {
|
||||
APP_DOMAIN,
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 调用API保存系统设置
|
||||
async function saveSystemSetting() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post('system/env', SystemSettings.value)
|
||||
|
||||
if (result.success) $toast.success('保存设置成功')
|
||||
else $toast.error('保存设置失败!')
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 调用API接口重新加载模块
|
||||
async function reloadModule() {
|
||||
try {
|
||||
@@ -223,11 +255,41 @@ async function reloadModule() {
|
||||
onMounted(() => {
|
||||
loadDownloaderSetting()
|
||||
loadMediaServerSetting()
|
||||
loadSystemSettings()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle>系统</VCardTitle>
|
||||
<VCardSubtitle>设置服务使用的域名等信息。</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<VForm>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="SystemSettings.APP_DOMAIN"
|
||||
label="访问域名"
|
||||
hint="用于通知跳转,格式:http(s)://domain:port"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardText>
|
||||
<VForm @submit.prevent="() => {}">
|
||||
<div class="d-flex flex-wrap gap-4 mt-4">
|
||||
<VBtn mtype="submit" @click="saveSystemSetting"> 保存 </VBtn>
|
||||
</div>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
@@ -244,14 +306,16 @@ onMounted(() => {
|
||||
chips
|
||||
:items="Downloaders"
|
||||
label="当前使用下载器"
|
||||
hint="MoviePilot自动添加的下载任务将使用选中的第1个下载器"
|
||||
hint="启用下载器,只有第1个会被默认下载使用"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderSettings.TORRENT_TAG"
|
||||
label="下载器种子标签"
|
||||
hint="设置种子标签用于区分MoviePilot添加的下载任务,默认标签为`MOVIEPILOT`"
|
||||
hint="MoviePilot添加的下载任务标签"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -259,8 +323,9 @@ onMounted(() => {
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="downloaderSettings.DOWNLOADER_MONITOR"
|
||||
label="监控默认下载器"
|
||||
hint="监控选中的第1个下载器,当任务下载完成时自动整理文件到媒体库"
|
||||
label="下载文件自动整理"
|
||||
hint="任务下载完成时自动整理文件到媒体库"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -278,8 +343,9 @@ onMounted(() => {
|
||||
<VTextField
|
||||
v-model="downloaderSettings.QB_HOST"
|
||||
label="地址"
|
||||
placeholder="IP:PORT"
|
||||
hint="格式:IP:PORT,如启用了HTTPS,请使用https://IP:PORT"
|
||||
placeholder="http(s)://ip:port"
|
||||
hint="服务端地址,格式:http(s)://ip:port"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -287,7 +353,8 @@ onMounted(() => {
|
||||
v-model="downloaderSettings.QB_USER"
|
||||
label="用户名"
|
||||
placeholder="admin"
|
||||
hint="QB的登录用户名"
|
||||
hint="登录使用的用户名"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -295,28 +362,32 @@ onMounted(() => {
|
||||
v-model="downloaderSettings.QB_PASSWORD"
|
||||
type="password"
|
||||
label="密码"
|
||||
hint="QB的登录密码"
|
||||
hint="登录使用的密码"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VSwitch
|
||||
v-model="downloaderSettings.QB_CATEGORY"
|
||||
label="自动分类管理"
|
||||
hint="开启后,下载目录将由QB控制自动下载到分类到目录,此时MoviePilot的下载目录设定无效,需在QB中提前创建分类"
|
||||
hint="由下载器自动管理分类和下载目录"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VSwitch
|
||||
v-model="downloaderSettings.QB_SEQUENTIAL"
|
||||
label="顺序下载"
|
||||
hint="开启后QB将按照文件顺序依次下载"
|
||||
hint="按顺序依次下载文件"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VSwitch
|
||||
v-model="downloaderSettings.QB_FORCE_RESUME"
|
||||
label="强制继续"
|
||||
hint="开启后,QB将设置为强制继续、强制上传模式(带[F]标识)"
|
||||
hint="强制继续、强制上传模式"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -329,8 +400,9 @@ onMounted(() => {
|
||||
<VTextField
|
||||
v-model="downloaderSettings.TR_HOST"
|
||||
label="地址"
|
||||
placeholder="IP:PORT"
|
||||
hint="格式:IP:PORT,如启用了HTTPS,请使用https://IP:PORT"
|
||||
placeholder="http(s)://ip:port"
|
||||
hint="服务端地址,格式:http(s)://ip:port"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -338,7 +410,8 @@ onMounted(() => {
|
||||
v-model="downloaderSettings.TR_USER"
|
||||
label="用户名"
|
||||
placeholder="admin"
|
||||
hint="TR的登录用户名"
|
||||
hint="登录使用的用户名"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -346,7 +419,8 @@ onMounted(() => {
|
||||
v-model="downloaderSettings.TR_PASSWORD"
|
||||
type="password"
|
||||
label="密码"
|
||||
hint="TR的登录密码"
|
||||
hint="登录使用的密码"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -384,7 +458,8 @@ onMounted(() => {
|
||||
chips
|
||||
:items="MediaServers"
|
||||
label="当前使用媒体服务器"
|
||||
hint="媒体服务器用于搜索下载等判断库中是否已存在,以避免重复下载"
|
||||
hint="启用媒体服务器,入库展示、下载控重等将使用"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -392,7 +467,8 @@ onMounted(() => {
|
||||
v-model="mediaServerSettings.MEDIASERVER_SYNC_INTERVAL"
|
||||
:items="syncIntervalItems"
|
||||
label="同步周期"
|
||||
hint="设置后数据将定时同步到MoviePilot数据库,以便展示媒体库是否存在标识"
|
||||
hint="同步媒体库数据到MoviePilot的时间间隔"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -400,7 +476,8 @@ onMounted(() => {
|
||||
v-model="mediaServerSettings.MEDIASERVER_SYNC_BLACKLIST"
|
||||
label="媒体库同步黑名单"
|
||||
placeholder="使用,分隔"
|
||||
hint="设置不同步数据的媒体库名称,使用,分隔,如:电影,电视剧"
|
||||
hint="不同步数据的媒体库名称,多个使用,分隔"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -419,8 +496,9 @@ onMounted(() => {
|
||||
<VTextField
|
||||
v-model="mediaServerSettings.EMBY_HOST"
|
||||
label="地址"
|
||||
placeholder="IP:PORT"
|
||||
hint="格式:IP:PORT 或 http(s)://IP:PORT/"
|
||||
placeholder="http(s)://ip:port"
|
||||
hint="服务端地址,格式:http(s)://ip:port"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -428,14 +506,16 @@ onMounted(() => {
|
||||
v-model="mediaServerSettings.EMBY_PLAY_HOST"
|
||||
label="外网播放地址"
|
||||
placeholder="http(s)://domain:port"
|
||||
hint="格式:http(s)://domain:port,设置后跳转Emby时将优先使用此地址"
|
||||
hint="跳转播放页面使用的地址,格式:http(s)://domain:port"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField
|
||||
v-model="mediaServerSettings.EMBY_API_KEY"
|
||||
label="API密钥"
|
||||
hint="Emby的API密钥,在 Emby设置->高级->API 密钥 中生成"
|
||||
hint="Emby设置->高级->API密钥中生成的密钥"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -448,8 +528,9 @@ onMounted(() => {
|
||||
<VTextField
|
||||
v-model="mediaServerSettings.JELLYFIN_HOST"
|
||||
label="地址"
|
||||
placeholder="IP:PORT"
|
||||
hint="格式:IP:PORT 或 http(s)://IP:PORT/"
|
||||
placeholder="http(s)://ip:port"
|
||||
hint="服务端地址,格式:http(s)://ip:port"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -457,14 +538,16 @@ onMounted(() => {
|
||||
v-model="mediaServerSettings.JELLYFIN_PLAY_HOST"
|
||||
label="外网播放地址"
|
||||
placeholder="http(s)://domain:port"
|
||||
hint="格式:http(s)://domain:port,设置后跳转Jellyfin时将优先使用此地址"
|
||||
hint="跳转播放页面使用的地址,格式:http(s)://domain:port"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField
|
||||
v-model="mediaServerSettings.JELLYFIN_API_KEY"
|
||||
label="API密钥"
|
||||
hint="Jellyfin的API密钥,在 Jellyfin设置->高级->API 密钥 中生成"
|
||||
hint="Jellyfin设置->高级->API密钥中生成的密钥"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -477,8 +560,9 @@ onMounted(() => {
|
||||
<VTextField
|
||||
v-model="mediaServerSettings.PLEX_HOST"
|
||||
label="地址"
|
||||
placeholder="IP:PORT"
|
||||
hint="格式:IP:PORT 或 http(s)://IP:PORT/"
|
||||
placeholder="http(s)://ip:port"
|
||||
hint="服务端地址,格式:http(s)://ip:port"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -486,14 +570,16 @@ onMounted(() => {
|
||||
v-model="mediaServerSettings.PLEX_PLAY_HOST"
|
||||
label="外网播放地址"
|
||||
placeholder="http(s)://domain:port"
|
||||
hint="格式:http(s)://domain:port,设置后跳转Plex时将优先使用此地址"
|
||||
hint="跳转播放页面使用的地址,格式:http(s)://domain:port"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField
|
||||
v-model="mediaServerSettings.PLEX_TOKEN"
|
||||
label="API密钥"
|
||||
hint="Plex网页Url中的X-Plex-Token,通过浏览器F12->网络从请求URL中获取"
|
||||
label="X-Plex-Token"
|
||||
hint="浏览器F12->网络,从Plex请求URL中获取的X-Plex-Token"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
@@ -143,6 +143,7 @@ onMounted(() => {
|
||||
auto-grow
|
||||
placeholder="支持正则表达式,特殊字符需要\转义,一行为一组"
|
||||
hint="支持正则表达式,特殊字符需要\转义,一行为一组"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCardText>
|
||||
<VCardText>
|
||||
@@ -177,6 +178,7 @@ onMounted(() => {
|
||||
auto-grow
|
||||
placeholder="支持正则表达式,特殊字符需要\转义,一行代表一个制作组/字幕组"
|
||||
hint="支持正则表达式,特殊字符需要\转义,一行代表一个制作组/字幕组"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCardText>
|
||||
<VCardText>
|
||||
@@ -194,8 +196,9 @@ onMounted(() => {
|
||||
<VTextarea
|
||||
v-model="customization"
|
||||
auto-grow
|
||||
placeholder="多个匹配对象请换行分隔,支持正则表达式,特殊字符注意转义"
|
||||
hint="多个匹配对象请换行分隔,支持正则表达式,特殊字符注意转义"
|
||||
placeholder="支持正则表达式,特殊字符需要\转义,多个匹配对象请换行分隔"
|
||||
hint="支持正则表达式,特殊字符需要\转义,多个匹配对象请换行分隔"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCardText>
|
||||
<VCardText>
|
||||
@@ -215,6 +218,7 @@ onMounted(() => {
|
||||
auto-grow
|
||||
placeholder="支持正则表达式,特殊字符需要\转义,一行代表一个屏蔽词"
|
||||
hint="支持正则表达式,特殊字符需要\转义,一行代表一个屏蔽词"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCardText>
|
||||
<VCardText>
|
||||
|
||||
@@ -5,6 +5,16 @@ import type { Site } from '@/api/types'
|
||||
import SiteCard from '@/components/cards/SiteCard.vue'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import SiteAddEditDialog from '@/components/dialog/SiteAddEditDialog.vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { isLength } from 'lodash'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// APP
|
||||
const appMode = computed(() => {
|
||||
return localStorage.getItem('MP_APPMODE') != '0' && display.mdAndDown.value
|
||||
})
|
||||
|
||||
// 数据列表
|
||||
const dataList = ref<Site[]>([])
|
||||
@@ -12,13 +22,18 @@ const dataList = ref<Site[]>([])
|
||||
// 是否刷新过
|
||||
const isRefreshed = ref(false)
|
||||
|
||||
// 是否加载中
|
||||
const loading = ref(false)
|
||||
|
||||
// 新增站点对话框
|
||||
const siteAddDialog = ref(false)
|
||||
|
||||
// 获取站点列表数据
|
||||
async function fetchData() {
|
||||
try {
|
||||
loading.value = true
|
||||
dataList.value = await api.get('site/')
|
||||
loading.value = false
|
||||
isRefreshed.value = true
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
@@ -41,6 +56,12 @@ async function savaSitesPriority() {
|
||||
|
||||
// 加载时获取数据
|
||||
onBeforeMount(fetchData)
|
||||
|
||||
onActivated(() => {
|
||||
if (!loading.value) {
|
||||
fetchData()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -67,7 +88,16 @@ onBeforeMount(fetchData)
|
||||
error-description="已添加并支持的站点将会在这里显示。"
|
||||
/>
|
||||
<!-- 新增站点按钮 -->
|
||||
<VFab icon="mdi-plus" location="bottom" size="x-large" fixed app appear @click="siteAddDialog = true" />
|
||||
<VFab
|
||||
icon="mdi-plus"
|
||||
location="bottom"
|
||||
size="x-large"
|
||||
fixed
|
||||
app
|
||||
appear
|
||||
@click="siteAddDialog = true"
|
||||
:class="{ 'mb-12': appMode }"
|
||||
/>
|
||||
<!-- 新增站点弹窗 -->
|
||||
<SiteAddEditDialog
|
||||
v-if="siteAddDialog"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<script lang='ts' setup>
|
||||
<script lang="ts" setup>
|
||||
import type { CalendarOptions, EventSourceInput } from '@fullcalendar/core'
|
||||
import dayGridPlugin from '@fullcalendar/daygrid'
|
||||
import interactionPlugin from '@fullcalendar/interaction'
|
||||
@@ -8,6 +8,16 @@ import type { Ref } from 'vue'
|
||||
import type { MediaInfo, Subscribe, TmdbEpisode } from '@/api/types'
|
||||
import api from '@/api'
|
||||
import { formatEp, parseDate } from '@/@core/utils/formatters'
|
||||
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
|
||||
|
||||
// 进度框
|
||||
const progressDialog = ref(false)
|
||||
|
||||
// 加载中
|
||||
const loading = ref(false)
|
||||
|
||||
// 已加载过
|
||||
const isLoaded = ref(false)
|
||||
|
||||
// 日历属性
|
||||
const calendarOptions: Ref<CalendarOptions> = ref({
|
||||
@@ -51,12 +61,9 @@ async function eventsHander(subscribe: Subscribe) {
|
||||
mediaType: subscribe.type,
|
||||
len: 1,
|
||||
}
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
// 调用API查询集信息
|
||||
const episodes: TmdbEpisode[] = await api.get(
|
||||
`tmdb/${subscribe.tmdbid}/${subscribe.season}`,
|
||||
)
|
||||
const episodes: TmdbEpisode[] = await api.get(`tmdb/${subscribe.tmdbid}/${subscribe.season}`)
|
||||
|
||||
interface EpisodeInfo {
|
||||
title: string
|
||||
@@ -78,8 +85,7 @@ async function eventsHander(subscribe: Subscribe) {
|
||||
if (dictEpisode[air_date]) {
|
||||
dictEpisode[air_date].subtitle += `,${episode.episode_number}`
|
||||
dictEpisode[air_date].len++
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
dictEpisode[air_date] = {
|
||||
title: subscribe.name,
|
||||
subtitle: `${episode.episode_number}`,
|
||||
@@ -100,25 +106,31 @@ async function eventsHander(subscribe: Subscribe) {
|
||||
|
||||
// 调用API查询所有订阅
|
||||
async function getSubscribes() {
|
||||
if (!isLoaded.value) progressDialog.value = true
|
||||
try {
|
||||
// 订阅
|
||||
loading.value = true
|
||||
const subscribes: Subscribe[] = await api.get('subscribe/')
|
||||
|
||||
const subEvents = await Promise.all(
|
||||
subscribes.map(async sub => eventsHander(sub)),
|
||||
)
|
||||
|
||||
loading.value = false
|
||||
const subEvents = await Promise.all(subscribes.map(async sub => eventsHander(sub)))
|
||||
calendarOptions.value.events = subEvents.flat().filter(event => event.start) as EventSourceInput
|
||||
}
|
||||
catch (error) {
|
||||
isLoaded.value = true
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
progressDialog.value = false
|
||||
}
|
||||
|
||||
// 页面加载时调用API查询所有订阅
|
||||
onMounted(() => {
|
||||
getSubscribes()
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
if (!loading.value) {
|
||||
getSubscribes()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -186,9 +198,10 @@ onMounted(() => {
|
||||
</div>
|
||||
</template>
|
||||
</FullCalendar>
|
||||
<ProgressDialog v-if="progressDialog" v-model="progressDialog" text="正在加载 ..." />
|
||||
</template>
|
||||
|
||||
<style lang='scss'>
|
||||
<style lang="scss">
|
||||
.v-application .fc {
|
||||
--fc-today-bg-color: rgba(var(--v-theme-on-surface), 0.04);
|
||||
--fc-border-color: rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
@@ -200,7 +213,7 @@ onMounted(() => {
|
||||
|
||||
// 当天背景渐变
|
||||
.fc-day-today {
|
||||
background-image: linear-gradient(to bottom, #AF85FD ,rgba(var(--v-theme-on-surface), 0.04));
|
||||
background-image: linear-gradient(to bottom, #af85fd, rgba(var(--v-theme-on-surface), 0.04));
|
||||
}
|
||||
|
||||
.v-application .fc a {
|
||||
@@ -295,11 +308,7 @@ onMounted(() => {
|
||||
|
||||
.v-application .fc .fc-toolbar-chunk .fc-button-group .fc-button-primary,
|
||||
.v-application .fc .fc-toolbar-chunk .fc-button-group .fc-button-primary:hover,
|
||||
.v-application
|
||||
.fc
|
||||
.fc-toolbar-chunk
|
||||
.fc-button-group
|
||||
.fc-button-primary:not(.disabled):active {
|
||||
.v-application .fc .fc-toolbar-chunk .fc-button-group .fc-button-primary:not(.disabled):active {
|
||||
border-color: transparent;
|
||||
background-color: transparent;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||
@@ -323,19 +332,11 @@ onMounted(() => {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.v-application
|
||||
.fc
|
||||
.fc-toolbar-chunk:last-child
|
||||
.fc-button-group
|
||||
.fc-button:not(:last-child) {
|
||||
.v-application .fc .fc-toolbar-chunk:last-child .fc-button-group .fc-button:not(:last-child) {
|
||||
border-inline-end: 0.0625rem solid rgba(var(--v-theme-primary), var(--v-overlay-scrim-opacity));
|
||||
}
|
||||
|
||||
.v-application
|
||||
.fc
|
||||
.fc-toolbar-chunk:last-child
|
||||
.fc-button-group
|
||||
.fc-button.fc-button-active {
|
||||
.v-application .fc .fc-toolbar-chunk:last-child .fc-button-group .fc-button.fc-button-active {
|
||||
background-color: rgba(var(--v-theme-primary), var(--v-activated-opacity));
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
@@ -371,7 +372,7 @@ onMounted(() => {
|
||||
padding-inline: 0.25rem;
|
||||
}
|
||||
|
||||
.v-application .fc tbody[role="rowgroup"] > tr > td[role="presentation"] {
|
||||
.v-application .fc tbody[role='rowgroup'] > tr > td[role='presentation'] {
|
||||
border: none;
|
||||
}
|
||||
|
||||
@@ -400,9 +401,8 @@ onMounted(() => {
|
||||
|
||||
.v-application .fc .fc-popover {
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 14px -4px var(--v-shadow-key-umbra-opacity),
|
||||
0 4px 8px -4px var(--v-shadow-key-penumbra-opacity),
|
||||
0 4px 8px -4px var(--v-shadow-key-ambient-opacity);
|
||||
box-shadow: 0 4px 14px -4px var(--v-shadow-key-umbra-opacity), 0 4px 8px -4px var(--v-shadow-key-penumbra-opacity),
|
||||
0 4px 8px -4px var(--v-shadow-key-ambient-opacity);
|
||||
}
|
||||
|
||||
.v-application .fc .fc-popover .fc-popover-header,
|
||||
@@ -441,12 +441,7 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.v-theme--dark
|
||||
.v-application
|
||||
.fc
|
||||
.fc-toolbar-chunk
|
||||
.fc-button-group
|
||||
.fc-drawerToggler-button {
|
||||
.v-theme--dark .v-application .fc .fc-toolbar-chunk .fc-button-group .fc-drawerToggler-button {
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='rgba(232,232,241,0.68)' stroke-width='2' fill='none' stroke-linecap='round' stroke-linejoin='round' class='css-i6dzq1'%3E%3Cpath d='M3 12h18M3 6h18M3 18h18'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
|
||||
@@ -7,14 +7,24 @@ import SubscribeCard from '@/components/cards/SubscribeCard.vue'
|
||||
import SubscribeEditDialog from '@/components/dialog/SubscribeEditDialog.vue'
|
||||
import SubscribeHistoryDialog from '@/components/dialog/SubscribeHistoryDialog.vue'
|
||||
import store from '@/store'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// APP
|
||||
const appMode = computed(() => {
|
||||
return localStorage.getItem('MP_APPMODE') != '0' && display.mdAndDown.value
|
||||
})
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
type: String,
|
||||
subid: String,
|
||||
})
|
||||
|
||||
// 是否刷新过
|
||||
const isRefreshed = ref(false)
|
||||
let isRefreshed = ref(false)
|
||||
|
||||
// 数据列表
|
||||
const dataList = ref<Subscribe[]>([])
|
||||
@@ -28,24 +38,22 @@ const historyDialog = ref(false)
|
||||
// 获取订阅列表数据
|
||||
async function fetchData() {
|
||||
try {
|
||||
loading.value = true
|
||||
dataList.value = await api.get('subscribe/')
|
||||
loading.value = false
|
||||
isRefreshed.value = true
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载时获取数据
|
||||
onBeforeMount(fetchData)
|
||||
|
||||
// 刷新状态
|
||||
const loading = ref(false)
|
||||
|
||||
// 下拉刷新
|
||||
function onRefresh() {
|
||||
loading.value = true
|
||||
fetchData()
|
||||
loading.value = false
|
||||
async function onRefresh({ done }: { done: any }) {
|
||||
await fetchData()
|
||||
done('ok')
|
||||
}
|
||||
|
||||
// 过滤数据,管理员用户显示全部,非管理员只显示自己的订阅
|
||||
@@ -56,6 +64,24 @@ const filteredDataList = computed(() => {
|
||||
if (superUser) return dataList.value.filter(data => data.type === props.type)
|
||||
else return dataList.value.filter(data => data.type === props.type && data.username === userName)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchData()
|
||||
if (props.subid) {
|
||||
// 找到这个订阅
|
||||
const sub = dataList.value.find(sub => sub.id.toString() == props.subid?.toString())
|
||||
if (sub) {
|
||||
// 打开编辑弹窗
|
||||
sub.page_open = true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onActivated(async () => {
|
||||
if (!loading.value) {
|
||||
fetchData()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -87,13 +113,14 @@ const filteredDataList = computed(() => {
|
||||
app
|
||||
appear
|
||||
@click="subscribeEditDialog = true"
|
||||
:class="{ 'mb-12': appMode }"
|
||||
/>
|
||||
<VFab
|
||||
v-if="store.state.auth.superUser"
|
||||
icon="mdi-history"
|
||||
color="info"
|
||||
location="bottom"
|
||||
class="mb-16"
|
||||
:class="appMode ? 'mb-28' : 'mb-16'"
|
||||
size="x-large"
|
||||
fixed
|
||||
app
|
||||
|
||||
@@ -76,13 +76,13 @@ async function fetchData({ done }: { done: any }) {
|
||||
done('ok')
|
||||
}
|
||||
} else {
|
||||
// 加载一次
|
||||
// 设置加载中
|
||||
loading.value = true
|
||||
// 请求API
|
||||
currData.value = await api.get(apipath, {
|
||||
params: getParams(),
|
||||
})
|
||||
loading.value = false
|
||||
// 标计为已请求完成
|
||||
isRefreshed.value = true
|
||||
if (currData.value.length === 0) {
|
||||
@@ -97,8 +97,6 @@ async function fetchData({ done }: { done: any }) {
|
||||
done('ok')
|
||||
}
|
||||
}
|
||||
// 取消加载中
|
||||
loading.value = false
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
// 返回加载失败
|
||||
|
||||
@@ -1,82 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
import api from '@/api'
|
||||
import type { Plugin } from '@/api/types'
|
||||
import {
|
||||
SystemNavMenus,
|
||||
UserfulMenus,
|
||||
SubscribeMovieTabs,
|
||||
SubscribeTvTabs,
|
||||
PluginTabs,
|
||||
SettingTabs,
|
||||
} from '@/router/menu'
|
||||
import type { Plugin, Subscribe } from '@/api/types'
|
||||
import { SystemNavMenus, UserfulMenus, SettingTabs } from '@/router/menu'
|
||||
import { NavMenu } from '@/@layouts/types'
|
||||
import store from '@/store'
|
||||
|
||||
// 路由
|
||||
const router = useRouter()
|
||||
|
||||
// 超级用户
|
||||
const superUser = store.state.auth.superUser
|
||||
|
||||
// 当前用户名
|
||||
const userName = store.state.auth.userName
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
// 搜索词
|
||||
const searchWord = ref(null)
|
||||
const searchWord = ref<string | null>(null)
|
||||
|
||||
// ref
|
||||
const searchWordInput = ref<HTMLElement | null>(null)
|
||||
|
||||
// 搜索提示词列表
|
||||
const searchHintList = ref<string[]>([])
|
||||
// 近期搜索词条
|
||||
const recentSearches = ref<string[]>([])
|
||||
|
||||
// 保存近期搜索到本地
|
||||
function saveRecentSearches(keyword: string) {
|
||||
if (!keyword) return
|
||||
if (recentSearches.value.includes(keyword)) return
|
||||
recentSearches.value.unshift(keyword)
|
||||
localStorage.setItem('MP_RecentSearches', JSON.stringify(recentSearches.value))
|
||||
}
|
||||
|
||||
// 从本地加载近期搜索
|
||||
function loadRecentSearches() {
|
||||
const recentSearchesStr = localStorage.getItem('MP_RecentSearches')
|
||||
if (recentSearchesStr) {
|
||||
recentSearches.value = JSON.parse(recentSearchesStr)
|
||||
// 只保留最近的 5 条
|
||||
if (recentSearches.value.length > 5) {
|
||||
recentSearches.value = recentSearches.value.slice(0, 5)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 所有菜单功能
|
||||
function getMenus(): NavMenu[] {
|
||||
let menus: NavMenu[] = []
|
||||
// 导航菜单
|
||||
for (const key in SystemNavMenus) {
|
||||
menus.push({
|
||||
title: SystemNavMenus[key].title,
|
||||
icon: SystemNavMenus[key].icon,
|
||||
to: SystemNavMenus[key].to,
|
||||
header: SystemNavMenus[key].header,
|
||||
admin: SystemNavMenus[key].admin,
|
||||
})
|
||||
}
|
||||
// 各类标签页
|
||||
for (const key in SettingTabs) {
|
||||
menus.push({
|
||||
title: '设定 -> ' + SettingTabs[key].title,
|
||||
icon: SettingTabs[key].icon,
|
||||
to: `/setting?tab=${SettingTabs[key].tab}`,
|
||||
header: '',
|
||||
admin: true,
|
||||
description: SettingTabs[key].description,
|
||||
})
|
||||
}
|
||||
for (const key in SubscribeMovieTabs) {
|
||||
menus.push({
|
||||
title: '电影 -> ' + SubscribeMovieTabs[key].title,
|
||||
icon: SubscribeMovieTabs[key].icon,
|
||||
to: `/subscribe-movie?tab=${SubscribeMovieTabs[key].tab}`,
|
||||
header: '',
|
||||
admin: false,
|
||||
})
|
||||
}
|
||||
for (const key in SubscribeTvTabs) {
|
||||
menus.push({
|
||||
title: '电视剧 -> ' + SubscribeTvTabs[key].title,
|
||||
icon: SubscribeTvTabs[key].icon,
|
||||
to: `/subscribe-tv?tab=${SubscribeTvTabs[key].tab}`,
|
||||
header: '',
|
||||
admin: false,
|
||||
})
|
||||
}
|
||||
for (const key in PluginTabs) {
|
||||
menus.push({
|
||||
title: '插件 -> ' + PluginTabs[key].title,
|
||||
icon: PluginTabs[key].icon,
|
||||
to: `/plugins?tab=${PluginTabs[key].tab}`,
|
||||
header: '',
|
||||
admin: true,
|
||||
})
|
||||
}
|
||||
SystemNavMenus.forEach(
|
||||
item =>
|
||||
item &&
|
||||
menus.push({
|
||||
title: item.full_title ?? item.title,
|
||||
icon: item.icon,
|
||||
to: item.to,
|
||||
header: item.header,
|
||||
admin: item.admin,
|
||||
}),
|
||||
)
|
||||
// 设置标签页
|
||||
SettingTabs.forEach(
|
||||
item =>
|
||||
item &&
|
||||
menus.push({
|
||||
title: '设定 -> ' + item.title,
|
||||
icon: item.icon,
|
||||
to: `/setting?tab=${item.tab}`,
|
||||
header: '',
|
||||
admin: true,
|
||||
description: item.description,
|
||||
}),
|
||||
)
|
||||
|
||||
return menus
|
||||
}
|
||||
@@ -84,7 +81,8 @@ function getMenus(): NavMenu[] {
|
||||
// 匹配的菜单列表
|
||||
const matchedMenuItems = computed(() => {
|
||||
if (!searchWord.value) return []
|
||||
const lowerWord = searchWord.value?.toLowerCase()
|
||||
if (!superUser) return []
|
||||
const lowerWord = (searchWord.value as string).toLowerCase()
|
||||
const menuItems = getMenus()
|
||||
if (menuItems)
|
||||
return menuItems.filter(
|
||||
@@ -114,18 +112,40 @@ async function fetchInstalledPlugins() {
|
||||
// 区配的插件列表
|
||||
const matchedPluginItems = computed(() => {
|
||||
if (!searchWord.value) return []
|
||||
const lowerWord = searchWord.value?.toLowerCase()
|
||||
if (!superUser) return []
|
||||
const lowerWord = (searchWord.value as string).toLowerCase()
|
||||
return pluginItems.value.filter((item: Plugin) => {
|
||||
if (!item.plugin_name && !item.plugin_desc) return false
|
||||
return item.plugin_name?.toLowerCase().includes(lowerWord) || item.plugin_desc?.toLowerCase().includes(lowerWord)
|
||||
})
|
||||
})
|
||||
|
||||
// 所有订阅数据
|
||||
const SubscribeItems = ref<Subscribe[]>([])
|
||||
|
||||
// 获取订阅列表数据
|
||||
async function fetchSubscribes() {
|
||||
try {
|
||||
SubscribeItems.value = await api.get('subscribe/')
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 匹配的订阅列表
|
||||
const matchedSubscribeItems = computed(() => {
|
||||
if (!searchWord.value) return []
|
||||
const lowerWord = (searchWord.value as string).toLowerCase()
|
||||
return SubscribeItems.value.filter((item: Subscribe) => {
|
||||
return (item.name.toLowerCase().includes(lowerWord) && (superUser || userName === item.username)) || false
|
||||
})
|
||||
})
|
||||
|
||||
// 跳转媒体搜索页面
|
||||
function searchMedia(searchType: string) {
|
||||
// 搜索类型 media/person
|
||||
if (!searchWord.value) return
|
||||
if (!searchHintList.value.includes(searchWord.value)) searchHintList.value.push(searchWord.value)
|
||||
saveRecentSearches(searchWord.value)
|
||||
router.push({
|
||||
path: '/browse/media/search',
|
||||
query: {
|
||||
@@ -136,6 +156,33 @@ function searchMedia(searchType: string) {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// 跳转到种子搜索页面
|
||||
function searchTorrent() {
|
||||
if (!searchWord.value) return
|
||||
saveRecentSearches(searchWord.value)
|
||||
router.push({
|
||||
path: '/resource',
|
||||
query: {
|
||||
keyword: searchWord.value,
|
||||
area: 'title',
|
||||
},
|
||||
})
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// 跳转到历史记录页面
|
||||
function searchHistory() {
|
||||
if (!searchWord.value) return
|
||||
saveRecentSearches(searchWord.value)
|
||||
router.push({
|
||||
path: '/history',
|
||||
query: {
|
||||
search: searchWord.value,
|
||||
},
|
||||
})
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// 跳转插件页面
|
||||
function showPlugin(pluginId: string) {
|
||||
router.push({
|
||||
@@ -154,17 +201,39 @@ function goPage(to: string) {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// 跳转订阅页面
|
||||
function goSubscribe(subscribe: Subscribe) {
|
||||
if (subscribe.type === '电影') {
|
||||
router.push({
|
||||
path: '/subscribe/movie',
|
||||
query: {
|
||||
id: subscribe.id,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
router.push({
|
||||
path: '/subscribe/tv',
|
||||
query: {
|
||||
id: subscribe.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
emit('close')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
searchWordInput.value?.focus()
|
||||
}, 500)
|
||||
fetchInstalledPlugins()
|
||||
fetchSubscribes()
|
||||
loadRecentSearches()
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<VDialog max-width="40rem">
|
||||
<VDialog max-width="40rem" scrollable>
|
||||
<VCard>
|
||||
<VCardText class="pe-12">
|
||||
<VCardItem class="pe-12">
|
||||
<VCombobox
|
||||
ref="searchWordInput"
|
||||
v-model="searchWord"
|
||||
@@ -172,20 +241,19 @@ onMounted(() => {
|
||||
variant="plain"
|
||||
class="text-high-emphasis"
|
||||
placeholder="搜索 ..."
|
||||
:items="searchHintList"
|
||||
@keydown.enter="searchMedia('media')"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="ri-search-line" style="opacity: 1" />
|
||||
</template>
|
||||
</VCombobox>
|
||||
</VCardText>
|
||||
</VCardItem>
|
||||
<DialogCloseBtn inner-class="absolute right-3 top-5 text-high-emphasis" @click="emit('close')" />
|
||||
<VDivider />
|
||||
<div class="ps h-100">
|
||||
<VList lines="one" v-if="searchWord">
|
||||
<VCardText class="p-0">
|
||||
<VList lines="two" v-if="searchWord">
|
||||
<!-- 搜索结果 -->
|
||||
<VListSubheader v-if="searchWord"> 媒体 </VListSubheader>
|
||||
<VListSubheader v-if="searchWord"> 媒体 & 资源 </VListSubheader>
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VListItem
|
||||
@@ -195,8 +263,8 @@ onMounted(() => {
|
||||
v-bind="hover.props"
|
||||
@click="searchMedia('media')"
|
||||
>
|
||||
<VListItemTitle>
|
||||
搜索 <span class="font-bold">{{ searchWord }} </span> 相关的电影、电视剧 ...
|
||||
<VListItemTitle class="break-words whitespace-break-spaces">
|
||||
搜索 <span class="font-bold">{{ searchWord }} </span> 相关的【电影、电视剧】 ...
|
||||
</VListItemTitle>
|
||||
<template #append>
|
||||
<VIcon v-if="hover.isHovering" icon="ri-corner-down-left-line" />
|
||||
@@ -205,17 +273,59 @@ onMounted(() => {
|
||||
</template>
|
||||
</VHover>
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VListItem prepend-icon="mdi-account-search" link v-bind="hover.props" @click="searchMedia('person')">
|
||||
<VListItemTitle class="break-words whitespace-break-spaces">
|
||||
搜索 <span class="font-bold">{{ searchWord }}</span> 相关的【演职人员】 ...
|
||||
</VListItemTitle>
|
||||
<template #append>
|
||||
<VIcon v-if="hover.isHovering" icon="ri-corner-down-left-line" />
|
||||
</template>
|
||||
</VListItem>
|
||||
</template>
|
||||
</VHover>
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VListItem prepend-icon="mdi-search-web" link v-bind="hover.props" @click="searchTorrent">
|
||||
<VListItemTitle class="break-words whitespace-break-spaces">
|
||||
搜索 <span class="font-bold">{{ searchWord }}</span> 相关的【站点资源】 ...
|
||||
</VListItemTitle>
|
||||
<template #append>
|
||||
<VIcon v-if="hover.isHovering" icon="ri-corner-down-left-line" />
|
||||
</template>
|
||||
</VListItem>
|
||||
</template>
|
||||
</VHover>
|
||||
<VHover v-if="superUser">
|
||||
<template #default="hover">
|
||||
<VListItem prepend-icon="mdi-history" link v-bind="hover.props" @click="searchHistory">
|
||||
<VListItemTitle class="break-words whitespace-break-spaces">
|
||||
搜索 <span class="font-bold">{{ searchWord }}</span> 相关的【历史记录】 ...
|
||||
</VListItemTitle>
|
||||
<template #append>
|
||||
<VIcon v-if="hover.isHovering" icon="ri-corner-down-left-line" />
|
||||
</template>
|
||||
</VListItem>
|
||||
</template>
|
||||
</VHover>
|
||||
<VListSubheader v-if="matchedSubscribeItems.length > 0"> 订阅 </VListSubheader>
|
||||
<VHover
|
||||
v-if="matchedSubscribeItems.length > 0"
|
||||
v-for="subscribe in matchedSubscribeItems"
|
||||
:key="subscribe.id"
|
||||
>
|
||||
<template #default="hover">
|
||||
<VListItem
|
||||
prepend-icon="mdi-account-search"
|
||||
:prepend-icon="`${subscribe.type === '电影' ? 'mdi-movie-roll' : 'mdi-television-classic'}`"
|
||||
density="compact"
|
||||
link
|
||||
v-bind="hover.props"
|
||||
@click="searchMedia('person')"
|
||||
@click="goSubscribe(subscribe)"
|
||||
>
|
||||
<VListItemTitle>
|
||||
搜索 <span class="font-bold">{{ searchWord }}</span> 相关的人物 ...
|
||||
{{ subscribe.name }}<span v-if="subscribe.season"> 第 {{ subscribe.season }} 季</span>
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle> {{ subscribe.type }}</VListItemSubtitle>
|
||||
<template #append>
|
||||
<VIcon v-if="hover.isHovering" icon="ri-corner-down-left-line" />
|
||||
</template>
|
||||
@@ -264,7 +374,24 @@ onMounted(() => {
|
||||
<div v-else>
|
||||
<!-- 默认 -->
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VRow v-if="recentSearches.length > 0">
|
||||
<VCol cols="12">
|
||||
<p class="custom-letter-spacing text-sm text-disabled text-uppercase py-2 px-4 mb-0">最近搜索</p>
|
||||
<div class="px-3">
|
||||
<VChip
|
||||
v-for="(word, index) in recentSearches"
|
||||
:key="index"
|
||||
class="me-2 mb-1"
|
||||
variant="tonal"
|
||||
@click="searchWord = word"
|
||||
label
|
||||
>
|
||||
{{ word }}
|
||||
</VChip>
|
||||
</div>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-if="superUser">
|
||||
<VCol cols="12" md="6">
|
||||
<p class="custom-letter-spacing text-sm text-disabled text-uppercase py-2 px-4 mb-0">常用功能</p>
|
||||
<VList lines="one">
|
||||
@@ -314,7 +441,7 @@ onMounted(() => {
|
||||
</VRow>
|
||||
</VCardText>
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
@@ -46,7 +46,8 @@
|
||||
"esnext",
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"scripthost"
|
||||
"scripthost",
|
||||
"WebWorker"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"types": [
|
||||
@@ -63,11 +64,13 @@
|
||||
"src/**/*.vue",
|
||||
"themeConfig.ts",
|
||||
"auto-imports.d.ts",
|
||||
"components.d.ts"
|
||||
"components.d.ts",
|
||||
"src/service-worker.ts",
|
||||
"public/service.js"
|
||||
],
|
||||
"exclude": [
|
||||
"dist",
|
||||
"node_modules",
|
||||
"src/@iconify/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
104
vite.config.ts
104
vite.config.ts
@@ -4,8 +4,8 @@ import vueJsx from '@vitejs/plugin-vue-jsx'
|
||||
import AutoImport from 'unplugin-auto-import/vite'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import { defineConfig } from 'vite'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
import vuetify from 'vite-plugin-vuetify'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
@@ -13,8 +13,6 @@ export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
vueJsx(),
|
||||
|
||||
// https://github.com/vuetifyjs/vuetify-loader/tree/next/packages/vite-plugin
|
||||
vuetify({
|
||||
styles: {
|
||||
configFile: 'src/styles/variables/_vuetify.scss',
|
||||
@@ -29,12 +27,100 @@ export default defineConfig({
|
||||
vueTemplate: true,
|
||||
}),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
injectRegister: 'script',
|
||||
manifest: false,
|
||||
registerType: 'autoUpdate',
|
||||
strategies: 'injectManifest',
|
||||
srcDir: 'src',
|
||||
filename: 'service-worker.ts',
|
||||
workbox: {
|
||||
navigateFallbackDenylist: [
|
||||
/.*\/api\/v\d+\/system\/logging.*/,
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,jpg,jpeg}'],
|
||||
navigateFallbackDenylist: [/.*\/api\/v\d+\/system\/logging.*/],
|
||||
},
|
||||
injectManifest: {
|
||||
rollupFormat: 'iife',
|
||||
},
|
||||
devOptions: {
|
||||
enabled: true,
|
||||
type: 'module',
|
||||
},
|
||||
manifest: {
|
||||
'name': 'MoviePilot',
|
||||
'short_name': 'MoviePilot',
|
||||
'start_url': './',
|
||||
'display': 'standalone',
|
||||
'icons': [
|
||||
{
|
||||
'src': './android-chrome-192x192.png',
|
||||
'sizes': '192x192',
|
||||
'type': 'image/png',
|
||||
'purpose': 'any',
|
||||
},
|
||||
{
|
||||
'src': './android-chrome-192x192_maskable.png',
|
||||
'sizes': '192x192',
|
||||
'type': 'image/png',
|
||||
'purpose': 'maskable',
|
||||
},
|
||||
{
|
||||
'src': './android-chrome-512x512.png',
|
||||
'sizes': '512x512',
|
||||
'type': 'image/png',
|
||||
'purpose': 'any',
|
||||
},
|
||||
{
|
||||
'src': './android-chrome-512x512_maskable.png',
|
||||
'sizes': '512x512',
|
||||
'type': 'image/png',
|
||||
'purpose': 'maskable',
|
||||
},
|
||||
],
|
||||
'theme_color': '#28243D',
|
||||
'background_color': '#28243D',
|
||||
'shortcuts': [
|
||||
{
|
||||
'name': '推荐',
|
||||
'url': './ranking',
|
||||
'icons': [
|
||||
{
|
||||
'src': './sparkles-icon-192x192.png',
|
||||
'sizes': '192x192',
|
||||
'type': 'image/png',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
'name': '电影订阅',
|
||||
'url': './subscribe/movie',
|
||||
'icons': [
|
||||
{
|
||||
'src': './clock-icon-192x192.png',
|
||||
'sizes': '192x192',
|
||||
'type': 'image/png',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
'name': '电视剧订阅',
|
||||
'url': './subscribe/tv',
|
||||
'icons': [
|
||||
{
|
||||
'src': './clock-icon-192x192.png',
|
||||
'sizes': '192x192',
|
||||
'type': 'image/png',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
'name': '设置',
|
||||
'url': './setting',
|
||||
'icons': [
|
||||
{
|
||||
'src': './cog-icon-192x192.png',
|
||||
'sizes': '192x192',
|
||||
'type': 'image/png',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
@@ -63,8 +149,6 @@ export default defineConfig({
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: ['vuetify'],
|
||||
entries: [
|
||||
'./src/**/*.vue',
|
||||
],
|
||||
entries: ['./src/**/*.vue'],
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user