mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-07-10 23:13:20 +08:00
Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -1 +1,2 @@
|
|||||||
VITE_API_BASE_URL=http://localhost:3001/api/v1/
|
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_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
|
.DS_Store
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
|
dev-dist
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
/cypress/videos/
|
/cypress/videos/
|
||||||
|
|||||||
@@ -15,7 +15,6 @@
|
|||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
<link rel="apple-touch-startup-image" href="/splash/apple-splash.jpg" />
|
<link rel="apple-touch-startup-image" href="/splash/apple-splash.jpg" />
|
||||||
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
|
<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="mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-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" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
@@ -37,7 +36,7 @@
|
|||||||
<div id="loading-bg">
|
<div id="loading-bg">
|
||||||
<div class="loading-logo">
|
<div class="loading-logo">
|
||||||
<!-- 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">
|
style="fill-rule: evenodd; clip-rule: evenodd; stroke-linejoin: round; stroke-miterlimit: 2">
|
||||||
<g transform="matrix(1,0,0,1,-2606,-236)">
|
<g transform="matrix(1,0,0,1,-2606,-236)">
|
||||||
<g id="a2-c" 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",
|
"name": "moviepilot",
|
||||||
"version": "1.9.3-3",
|
"version": "1.9.5-1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"bin": "dist/service.js",
|
"bin": "dist/service.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -42,7 +42,6 @@
|
|||||||
"sass": "^1.59.3",
|
"sass": "^1.59.3",
|
||||||
"tailwindcss": "^3.3.2",
|
"tailwindcss": "^3.3.2",
|
||||||
"unplugin-vue-define-options": "^1.3.5",
|
"unplugin-vue-define-options": "^1.3.5",
|
||||||
"vite-plugin-pwa": "^0.19.8",
|
|
||||||
"vue": "^3.3.2",
|
"vue": "^3.3.2",
|
||||||
"vue-router": "^4.2.0",
|
"vue-router": "^4.2.0",
|
||||||
"vue-toast-notification": "^3",
|
"vue-toast-notification": "^3",
|
||||||
@@ -92,6 +91,7 @@
|
|||||||
"unplugin-vue-components": "^0.26.0",
|
"unplugin-vue-components": "^0.26.0",
|
||||||
"vite": "^5.2.8",
|
"vite": "^5.2.8",
|
||||||
"vite-plugin-pages": "^0.32.1",
|
"vite-plugin-pages": "^0.32.1",
|
||||||
|
"vite-plugin-pwa": "^0.20.0",
|
||||||
"vite-plugin-vue-layouts": "^0.11.0",
|
"vite-plugin-vue-layouts": "^0.11.0",
|
||||||
"vite-plugin-vuetify": "2.0.3",
|
"vite-plugin-vuetify": "2.0.3",
|
||||||
"vue-shepherd": "^3.0.0",
|
"vue-shepherd": "^3.0.0",
|
||||||
|
|||||||
@@ -3,10 +3,9 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
|
overflow: hidden auto;
|
||||||
background: var(--initial-loader-bg, #fff);
|
background: var(--initial-loader-bg, #fff);
|
||||||
min-block-size: calc(100% + env(safe-area-inset-top));
|
min-block-size: calc(100% + env(safe-area-inset-top));
|
||||||
overflow-x: hidden;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#loading-bg {
|
#loading-bg {
|
||||||
@@ -20,8 +19,8 @@ html {
|
|||||||
|
|
||||||
.loading-logo {
|
.loading-logo {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset-block-start: 40%;
|
inset-block-start: 35%;
|
||||||
inset-inline-start: calc(50% - 50px);
|
inset-inline-start: calc(50% - 5rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading {
|
.loading {
|
||||||
@@ -83,4 +82,4 @@ html {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: rotate(1turn);
|
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: /
|
||||||
@@ -8,7 +8,7 @@ const props = defineProps({
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="w-full text-center text-gray-500 text-sm flex flex-col items-center">
|
<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="48" indeterminate color="primary" />
|
<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" />
|
<VProgressCircular v-if="props.progress" class="mb-3" color="primary" :model-value="props.progress" size="64" />
|
||||||
<span>{{ props.text }}</span>
|
<span>{{ props.text }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,5 +3,5 @@
|
|||||||
-webkit-backdrop-filter: blur(6px);
|
-webkit-backdrop-filter: blur(6px);
|
||||||
backdrop-filter: blur(6px);
|
backdrop-filter: blur(6px);
|
||||||
/* stylelint-enable */
|
/* 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函数的问题
|
* 修复低版本Safari等浏览器数组不支持at函数的问题
|
||||||
*/
|
*/
|
||||||
export function fixArrayAt() {
|
;(function fixArrayAt() {
|
||||||
if (!Array.prototype.at) {
|
if (!Array.prototype.at) {
|
||||||
Array.prototype.at = function(index: number) {
|
Array.prototype.at = function (index: number) {
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
return this[index]
|
return this[index]
|
||||||
} else {
|
} else {
|
||||||
return this[this.length + index]
|
return this[this.length + index]
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
})()
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ dayjs.extend(relativeTime)
|
|||||||
dayjs.locale(ZH_CN)
|
dayjs.locale(ZH_CN)
|
||||||
|
|
||||||
export function avatarText(value: string) {
|
export function avatarText(value: string) {
|
||||||
if (!value)
|
if (!value) return ''
|
||||||
return ''
|
|
||||||
const nameArray = value.split(' ')
|
const nameArray = value.split(' ')
|
||||||
|
|
||||||
return nameArray.map(word => word.charAt(0).toUpperCase()).join('')
|
return nameArray.map(word => word.charAt(0).toUpperCase()).join('')
|
||||||
@@ -19,7 +18,9 @@ export function avatarText(value: string) {
|
|||||||
export function kFormatter(num: number) {
|
export function kFormatter(num: number) {
|
||||||
const regex = /\B(?=(\d{3})+(?!\d))/g
|
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 {string} value date to format
|
||||||
* @param {Intl.DateTimeFormatOptions} formatting Intl object to format with
|
* @param {Intl.DateTimeFormatOptions} formatting Intl object to format with
|
||||||
*/
|
*/
|
||||||
export function formatDate(value: string, formatting: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', year: 'numeric' }) {
|
export function formatDate(
|
||||||
if (!value)
|
value: string,
|
||||||
return value
|
formatting: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', year: 'numeric' },
|
||||||
|
) {
|
||||||
|
if (!value) return value
|
||||||
|
|
||||||
return new Intl.DateTimeFormat('en-US', formatting).format(new Date(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)
|
const date = new Date(value)
|
||||||
let formatting: Record<string, string> = { month: 'short', day: 'numeric' }
|
let formatting: Record<string, string> = { month: 'short', day: 'numeric' }
|
||||||
|
|
||||||
if (toTimeForCurrentDay && isToday(date))
|
if (toTimeForCurrentDay && isToday(date)) formatting = { hour: 'numeric', minute: 'numeric' }
|
||||||
formatting = { hour: 'numeric', minute: 'numeric' }
|
|
||||||
|
|
||||||
return new Intl.DateTimeFormat('en-US', formatting).format(new Date(value))
|
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
|
// 格式化为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
|
// 格式化为xx[TGMK]B
|
||||||
export function formatFileSize(bytes: number) {
|
export function formatFileSize(bytes: number) {
|
||||||
if (bytes < 0)
|
if (bytes < 0) throw new Error('字节数不能为负数。')
|
||||||
throw new Error('字节数不能为负数。')
|
|
||||||
|
|
||||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||||
let size = bytes
|
let size = bytes
|
||||||
@@ -82,22 +83,18 @@ export function formatSeconds(seconds: number) {
|
|||||||
|
|
||||||
let formattedTime = ''
|
let formattedTime = ''
|
||||||
|
|
||||||
if (hours > 0)
|
if (hours > 0) formattedTime += `${hours}小时`
|
||||||
formattedTime += `${hours}小时`
|
|
||||||
|
|
||||||
if (minutes > 0)
|
if (minutes > 0) formattedTime += `${minutes}分`
|
||||||
formattedTime += `${minutes}分`
|
|
||||||
|
|
||||||
if ((remainingSeconds > 0 || formattedTime === '') && hours <= 0)
|
if ((remainingSeconds > 0 || formattedTime === '') && hours <= 0) formattedTime += `${remainingSeconds}秒`
|
||||||
formattedTime += `${remainingSeconds}秒`
|
|
||||||
|
|
||||||
return formattedTime
|
return formattedTime
|
||||||
}
|
}
|
||||||
|
|
||||||
// YYYY-MM-DD 转化为Date
|
// YYYY-MM-DD 转化为Date
|
||||||
export function parseDate(dateString: string): Date | null {
|
export function parseDate(dateString: string): Date | null {
|
||||||
if (!dateString)
|
if (!dateString) return null
|
||||||
return null
|
|
||||||
const [year, month, day] = dateString.split('-').map(Number)
|
const [year, month, day] = dateString.split('-').map(Number)
|
||||||
|
|
||||||
return new Date(year, month - 1, day)
|
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) {
|
export function formatBytes(bytes: number, decimals = 2) {
|
||||||
if (bytes === 0)
|
if (bytes === 0) return '0 bytes'
|
||||||
return '0 bytes'
|
|
||||||
|
|
||||||
const k = 1024
|
const k = 1024
|
||||||
const dm = decimals < 0 ? 0 : decimals
|
const dm = decimals < 0 ? 0 : decimals
|
||||||
@@ -119,11 +115,9 @@ export function formatBytes(bytes: number, decimals = 2) {
|
|||||||
|
|
||||||
// 格式化剧集列表
|
// 格式化剧集列表
|
||||||
export function formatEp(nums: number[]): string {
|
export function formatEp(nums: number[]): string {
|
||||||
if (!nums.length)
|
if (!nums.length) return ''
|
||||||
return ''
|
|
||||||
|
|
||||||
if (nums.length === 1)
|
if (nums.length === 1) return nums[0].toString()
|
||||||
return nums[0].toString()
|
|
||||||
|
|
||||||
// 将数组升序排序
|
// 将数组升序排序
|
||||||
nums.sort((a, b) => a - b)
|
nums.sort((a, b) => a - b)
|
||||||
@@ -134,44 +128,22 @@ export function formatEp(nums: number[]): string {
|
|||||||
for (let i = 1; i < nums.length; i++) {
|
for (let i = 1; i < nums.length; i++) {
|
||||||
if (nums[i] === end + 1) {
|
if (nums[i] === end + 1) {
|
||||||
end = nums[i]
|
end = nums[i]
|
||||||
}
|
} else {
|
||||||
else {
|
if (start === end) formattedRanges.push(start.toString())
|
||||||
if (start === end)
|
else formattedRanges.push(`${start.toString()}-${end.toString()}`)
|
||||||
formattedRanges.push(start.toString())
|
|
||||||
|
|
||||||
else
|
|
||||||
formattedRanges.push(`${start.toString()}-${end.toString()}`)
|
|
||||||
|
|
||||||
start = end = nums[i]
|
start = end = nums[i]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (start === end)
|
if (start === end) formattedRanges.push(start.toString())
|
||||||
formattedRanges.push(start.toString())
|
else formattedRanges.push(`${start.toString()}-${end.toString()}`)
|
||||||
|
|
||||||
else
|
|
||||||
formattedRanges.push(`${start.toString()}-${end.toString()}`)
|
|
||||||
|
|
||||||
return formattedRanges.join('、')
|
return formattedRanges.join('、')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将yyyy-mm-dd hh:mm:ss转换为时间差,如:1小时前,1天前
|
// 将yyyy-mm-dd hh:mm:ss转换为时间差,如:1小时前,1天前
|
||||||
export function formatDateDifference(dateString: string): string {
|
export function formatDateDifference(dateString: string): string {
|
||||||
// const timeDifference = dayjs().millisecond() - dayjs(dateString).millisecond()
|
if (!dateString) return ''
|
||||||
// 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 ''
|
|
||||||
return dayjs(dateString).fromNow()
|
return dayjs(dateString).fromNow()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
// 👉 IsEmpty
|
// 👉 IsEmpty
|
||||||
export function isEmpty(value: unknown): boolean {
|
export function isEmpty(value: unknown): boolean {
|
||||||
if (value === null || value === undefined || value === '')
|
if (value === null || value === undefined || value === '') return true
|
||||||
return true
|
|
||||||
|
|
||||||
return !!(Array.isArray(value) && value.length === 0)
|
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中
|
// 判断一个数组subArray是不是在另一个数组mainArray中
|
||||||
export function isContained(subArray: any[], mainArray: any[]): boolean {
|
export function isContained(subArray: any[], mainArray: any[]): boolean {
|
||||||
return subArray.every(element => mainArray.includes(element))
|
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 {
|
export function isNullOrEmptyObject(obj: any): boolean {
|
||||||
// 首先判断是否为 null 或 undefined
|
// 首先判断是否为 null 或 undefined
|
||||||
if (obj === null || obj === undefined)
|
if (obj === null || obj === undefined) return true
|
||||||
return true
|
|
||||||
|
|
||||||
// 然后判断是否为空对象
|
// 然后判断是否为空对象
|
||||||
return !!(typeof obj === 'object' && Object.keys(obj).length === 0)
|
return !!(typeof obj === 'object' && Object.keys(obj).length === 0)
|
||||||
@@ -127,3 +58,10 @@ export function checkPrefersColorSchemeIsDark(): boolean {
|
|||||||
return false
|
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)
|
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 {
|
.nav-link a {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
border-radius: 0 3.125rem 3.125rem 0 !important;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
margin-inline-end: 1.125em;
|
||||||
|
padding-inline: 1.375rem 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -18,3 +18,12 @@ defineProps<{
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.layout-vertical-nav {
|
||||||
|
.nav-section-title {
|
||||||
|
padding-left: 1.375rem;
|
||||||
|
padding-right: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -720,6 +720,7 @@ export interface NotificationSwitch {
|
|||||||
slack: boolean
|
slack: boolean
|
||||||
synologychat: boolean
|
synologychat: boolean
|
||||||
vocechat: boolean
|
vocechat: boolean
|
||||||
|
webpush: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// 文件浏览接口
|
// 文件浏览接口
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { useToast } from 'vue-toast-notification'
|
import { useToast } from 'vue-toast-notification'
|
||||||
|
import { useConfirm } from 'vuetify-use-dialog'
|
||||||
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
|
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
|
||||||
import { formatDateDifference } from '@/@core/utils/formatters'
|
import { formatDateDifference } from '@/@core/utils/formatters'
|
||||||
import { formatSeason } from '@/@core/utils/formatters'
|
import { formatSeason } from '@/@core/utils/formatters'
|
||||||
@@ -15,6 +16,9 @@ const props = defineProps({
|
|||||||
// 定义触发的自定义事件
|
// 定义触发的自定义事件
|
||||||
const emit = defineEmits(['remove', 'save'])
|
const emit = defineEmits(['remove', 'save'])
|
||||||
|
|
||||||
|
// 确认框
|
||||||
|
const createConfirm = useConfirm()
|
||||||
|
|
||||||
// 提示框
|
// 提示框
|
||||||
const $toast = useToast()
|
const $toast = useToast()
|
||||||
|
|
||||||
@@ -34,8 +38,8 @@ function imageLoadHandler() {
|
|||||||
|
|
||||||
// 根据 type 返回不同的图标
|
// 根据 type 返回不同的图标
|
||||||
function getIcon() {
|
function getIcon() {
|
||||||
if (props.media?.type === '电影') return 'mdi-movie'
|
if (props.media?.type === '电影') return 'mdi-movie-open'
|
||||||
else if (props.media?.type === '电视剧') return 'mdi-television-classic'
|
else if (props.media?.type === '电视剧') return 'mdi-television-play'
|
||||||
else return 'mdi-help-circle'
|
else return 'mdi-help-circle'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,12 +54,12 @@ function getPercentage() {
|
|||||||
|
|
||||||
// 计算文本颜色
|
// 计算文本颜色
|
||||||
function getTextColor() {
|
function getTextColor() {
|
||||||
return imageLoaded.value ? 'white' : ''
|
return 'white'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算文本类
|
// 计算文本类
|
||||||
function getTextClass() {
|
function getTextClass() {
|
||||||
return imageLoaded.value ? 'text-white' : ''
|
return 'text-white'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除订阅
|
// 删除订阅
|
||||||
@@ -84,6 +88,27 @@ 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() {
|
async function editSubscribeDialog() {
|
||||||
subscribeEditDialog.value = true
|
subscribeEditDialog.value = true
|
||||||
@@ -124,8 +149,18 @@ const dropdownItems = ref([
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '取消订阅',
|
title: '重置',
|
||||||
value: 4,
|
value: 4,
|
||||||
|
props: {
|
||||||
|
prependIcon: 'mdi-restore-alert',
|
||||||
|
click: resetSubscribe,
|
||||||
|
color: 'warning',
|
||||||
|
},
|
||||||
|
show: props.media?.type === '电视剧',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '取消订阅',
|
||||||
|
value: 5,
|
||||||
props: {
|
props: {
|
||||||
prependIcon: 'mdi-trash-can-outline',
|
prependIcon: 'mdi-trash-can-outline',
|
||||||
color: 'error',
|
color: 'error',
|
||||||
@@ -151,7 +186,7 @@ watch(
|
|||||||
:key="props.media?.id"
|
:key="props.media?.id"
|
||||||
class="flex flex-col"
|
class="flex flex-col"
|
||||||
:class="{
|
: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,
|
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
|
||||||
}"
|
}"
|
||||||
@click="editSubscribeDialog"
|
@click="editSubscribeDialog"
|
||||||
@@ -159,73 +194,87 @@ watch(
|
|||||||
<template #image>
|
<template #image>
|
||||||
<VImg
|
<VImg
|
||||||
:src="props.media?.backdrop || props.media?.poster"
|
:src="props.media?.backdrop || props.media?.poster"
|
||||||
aspect-ratio="2/3"
|
aspect-ratio="3/2"
|
||||||
cover
|
cover
|
||||||
class="brightness-50"
|
:class="{ 'brightness-50': imageLoaded }"
|
||||||
@load="imageLoadHandler"
|
@load="imageLoadHandler"
|
||||||
/>
|
>
|
||||||
|
<template #placeholder>
|
||||||
|
<div class="w-full h-full">
|
||||||
|
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</VImg>
|
||||||
</template>
|
</template>
|
||||||
<VCardItem>
|
<div v-if="imageLoaded">
|
||||||
<template #prepend>
|
<VCardItem>
|
||||||
<VIcon size="1.9rem" :color="getTextColor()" :icon="getIcon()" />
|
<template #prepend>
|
||||||
</template>
|
<VIcon size="1.9rem" :color="getTextColor()" :icon="getIcon()" />
|
||||||
<VCardTitle :class="getTextClass()">
|
</template>
|
||||||
{{ props.media?.name }}
|
<VCardTitle :class="getTextClass()">
|
||||||
{{ formatSeason(props.media?.season ? props.media?.season.toString() : '') }}
|
{{ props.media?.name }}
|
||||||
</VCardTitle>
|
{{ formatSeason(props.media?.season ? props.media?.season.toString() : '') }}
|
||||||
<template #append>
|
</VCardTitle>
|
||||||
<div class="me-n3">
|
<template #append>
|
||||||
<IconBtn>
|
<div class="me-n3">
|
||||||
<VIcon icon="mdi-dots-vertical" :color="getTextColor()" />
|
<IconBtn>
|
||||||
<VMenu activator="parent" close-on-content-click>
|
<VIcon icon="mdi-dots-vertical" :color="getTextColor()" />
|
||||||
<VList>
|
<VMenu activator="parent" close-on-content-click>
|
||||||
<VListItem
|
<VList>
|
||||||
v-for="(item, i) in dropdownItems"
|
<template v-for="(item, i) in dropdownItems" :key="i">
|
||||||
:key="i"
|
<VListItem
|
||||||
variant="plain"
|
v-if="item.show !== false"
|
||||||
:base-color="item.props.color"
|
variant="plain"
|
||||||
@click="item.props.click"
|
:base-color="item.props.color"
|
||||||
>
|
@click="item.props.click"
|
||||||
<template #prepend>
|
>
|
||||||
<VIcon :icon="item.props.prependIcon" />
|
<template #prepend>
|
||||||
|
<VIcon :icon="item.props.prependIcon" />
|
||||||
|
</template>
|
||||||
|
<VListItemTitle v-text="item.title" />
|
||||||
|
</VListItem>
|
||||||
</template>
|
</template>
|
||||||
<VListItemTitle v-text="item.title" />
|
</VList>
|
||||||
</VListItem>
|
</VMenu>
|
||||||
</VList>
|
</IconBtn>
|
||||||
</VMenu>
|
</div>
|
||||||
</IconBtn>
|
</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"
|
||||||
|
/>
|
||||||
|
<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>
|
</div>
|
||||||
</template>
|
</VCardText>
|
||||||
</VCardItem>
|
<VCardText v-if="lastUpdateText" class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300">
|
||||||
<VCardText>
|
<VIcon icon="mdi-download" class="me-1" />
|
||||||
<p class="clamp-text mb-0" :class="getTextClass()">
|
{{ lastUpdateText }}
|
||||||
{{ props.media?.description }}
|
</VCardText>
|
||||||
</p>
|
<VProgressLinear
|
||||||
</VCardText>
|
v-if="getPercentage() > 0"
|
||||||
<VCardText class="d-flex justify-space-between align-center flex-wrap">
|
:model-value="getPercentage()"
|
||||||
<div class="d-flex align-center">
|
bg-color="success"
|
||||||
<IconBtn
|
color="success"
|
||||||
v-if="props.media?.total_episode"
|
/>
|
||||||
v-bind="props"
|
</div>
|
||||||
icon="mdi-progress-clock"
|
|
||||||
:color="getTextColor()"
|
|
||||||
class="me-1"
|
|
||||||
/>
|
|
||||||
<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" />
|
|
||||||
</VCard>
|
</VCard>
|
||||||
</template>
|
</template>
|
||||||
</VHover>
|
</VHover>
|
||||||
|
|||||||
@@ -11,6 +11,15 @@ import store from '@/store'
|
|||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import MediaInfoCard from '@/components/cards/MediaInfoCard.vue'
|
import MediaInfoCard from '@/components/cards/MediaInfoCard.vue'
|
||||||
import ProgressDialog from '../dialog/ProgressDialog.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({
|
const inProps = defineProps({
|
||||||
@@ -364,7 +373,14 @@ onMounted(() => {
|
|||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardText v-else-if="dirs.length || files.length" class="p-0">
|
<VCardText v-else-if="dirs.length || files.length" class="p-0">
|
||||||
<VList subheader>
|
<VList subheader>
|
||||||
<VVirtualScroll class="virtual-scroll-div" :items="[...dirs, ...files]">
|
<VVirtualScroll
|
||||||
|
:items="[...dirs, ...files]"
|
||||||
|
:style="
|
||||||
|
appMode
|
||||||
|
? 'height: calc(100vh - 15.5rem - env(safe-area-inset-bottom) - 3.5rem)'
|
||||||
|
: 'height: calc(100vh - 14.5rem - env(safe-area-inset-bottom)'
|
||||||
|
"
|
||||||
|
>
|
||||||
<template #default="{ item }">
|
<template #default="{ item }">
|
||||||
<VHover>
|
<VHover>
|
||||||
<template #default="hover">
|
<template #default="hover">
|
||||||
@@ -497,14 +513,4 @@ onMounted(() => {
|
|||||||
.v-toolbar {
|
.v-toolbar {
|
||||||
background: rgb(var(--v-table-header-background));
|
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>
|
</style>
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ import UserProfile from '@/layouts/components/UserProfile.vue'
|
|||||||
import store from '@/store'
|
import store from '@/store'
|
||||||
import { SystemNavMenus } from '@/router/menu'
|
import { SystemNavMenus } from '@/router/menu'
|
||||||
import { NavMenu } from '@/@layouts/types'
|
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信息
|
// 从Vuex Store中获取superuser信息
|
||||||
const superUser = store.state.auth.superUser
|
const superUser = store.state.auth.superUser
|
||||||
@@ -19,6 +25,11 @@ const superUser = store.state.auth.superUser
|
|||||||
const getMenuList = (header: string) => {
|
const getMenuList = (header: string) => {
|
||||||
return SystemNavMenus.filter((item: NavMenu) => item.header === header && (!item.admin || superUser))
|
return SystemNavMenus.filter((item: NavMenu) => item.header === header && (!item.admin || superUser))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 返回上一页
|
||||||
|
function goBack() {
|
||||||
|
history.back()
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -27,9 +38,13 @@ const getMenuList = (header: string) => {
|
|||||||
<template #navbar="{ toggleVerticalOverlayNavActive }">
|
<template #navbar="{ toggleVerticalOverlayNavActive }">
|
||||||
<div class="d-flex h-100 align-center mx-1">
|
<div class="d-flex h-100 align-center mx-1">
|
||||||
<!-- 👉 Vertical Nav Toggle -->
|
<!-- 👉 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" />
|
<VIcon icon="mdi-menu" />
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
|
<!-- 👉 Back Button -->
|
||||||
|
<IconBtn v-if="appMode" class="ms-n2" @click="goBack">
|
||||||
|
<VIcon icon="mdi-arrow-left" size="32" />
|
||||||
|
</IconBtn>
|
||||||
<!-- 👉 Search Bar -->
|
<!-- 👉 Search Bar -->
|
||||||
<SearchBar />
|
<SearchBar />
|
||||||
<!-- 👉 Spacer -->
|
<!-- 👉 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>
|
<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="32">mdi-home</VIcon>
|
||||||
|
<VIcon v-else size="32">mdi-home-outline</VIcon>
|
||||||
|
</VBtn>
|
||||||
|
<VBtn to="/ranking" :ripple="false">
|
||||||
|
<VIcon v-if="activeState.ranking" size="32">mdi-star</VIcon>
|
||||||
|
<VIcon v-else size="32">mdi-star-outline</VIcon>
|
||||||
|
</VBtn>
|
||||||
|
<VBtn to="/subscribe-movie?tab=mysub" :ripple="false">
|
||||||
|
<VIcon v-if="activeState.movie" size="32">mdi-movie-open</VIcon>
|
||||||
|
<VIcon v-else size="32">mdi-movie-open-outline</VIcon>
|
||||||
|
</VBtn>
|
||||||
|
<VBtn to="/subscribe-tv?tab=mysub" :ripple="false">
|
||||||
|
<VIcon v-if="activeState.tv" size="32">mdi-television-play</VIcon>
|
||||||
|
<VIcon v-else size="32">mdi-television</VIcon>
|
||||||
|
</VBtn>
|
||||||
|
<VBtn to="/apps" :ripple="false">
|
||||||
|
<VIcon v-if="activeState.apps" size="32">mdi-dots-horizontal-circle</VIcon>
|
||||||
|
<VIcon v-else size="32">mdi-dots-horizontal</VIcon>
|
||||||
|
</VBtn>
|
||||||
|
</VBottomNavigation>
|
||||||
|
</div>
|
||||||
</template>
|
</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,9 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import * as Mousetrap from 'mousetrap'
|
import * as Mousetrap from 'mousetrap'
|
||||||
import SearchBarView from '@/views/system/SearchBarView.vue'
|
import SearchBarView from '@/views/system/SearchBarView.vue'
|
||||||
|
import { useDisplay } from 'vuetify'
|
||||||
|
|
||||||
|
const display = useDisplay()
|
||||||
|
|
||||||
const searchDialog = ref(false)
|
const searchDialog = ref(false)
|
||||||
|
|
||||||
@@ -20,7 +23,7 @@ function openSearchDialog() {
|
|||||||
<IconBtn @click="openSearchDialog">
|
<IconBtn @click="openSearchDialog">
|
||||||
<VIcon icon="ri-search-line" />
|
<VIcon icon="ri-search-line" />
|
||||||
</IconBtn>
|
</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="me-3">搜索</span>
|
||||||
<span class="meta-key">⌘K</span>
|
<span class="meta-key">⌘K</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import MessageView from '@/views/system/MessageView.vue'
|
|||||||
import store from '@/store'
|
import store from '@/store'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
|
import { getQueryValue } from '@/@core/utils'
|
||||||
|
|
||||||
// 显示器宽度
|
// 显示器宽度
|
||||||
const display = useDisplay()
|
const display = useDisplay()
|
||||||
@@ -75,6 +76,29 @@ async function sendMessage() {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
scrollMessageToEnd()
|
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>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import router from '@/router'
|
|||||||
import avatar1 from '@images/avatars/avatar-1.png'
|
import avatar1 from '@images/avatars/avatar-1.png'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
|
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
|
||||||
|
import { useDisplay } from 'vuetify'
|
||||||
|
|
||||||
|
const display = useDisplay()
|
||||||
|
|
||||||
// Vuex Store
|
// Vuex Store
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
@@ -58,10 +61,20 @@ async function restart() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 是否精简模式
|
||||||
|
const isCompactMode = ref(localStorage.getItem('MP_APPMODE') != '0')
|
||||||
|
|
||||||
// 从Vuex Store中获取信息
|
// 从Vuex Store中获取信息
|
||||||
const superUser = store.state.auth.superUser
|
const superUser = store.state.auth.superUser
|
||||||
const userName = store.state.auth.userName
|
const userName = store.state.auth.userName
|
||||||
const avatar = store.state.auth.avatar
|
const avatar = store.state.auth.avatar
|
||||||
|
|
||||||
|
// 监听精简模式切换
|
||||||
|
watch(isCompactMode, value => {
|
||||||
|
localStorage.setItem('MP_APPMODE', value ? '1' : '0')
|
||||||
|
//刷新页面
|
||||||
|
location.reload()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -86,6 +99,17 @@ const avatar = store.state.auth.avatar
|
|||||||
</VListItemTitle>
|
</VListItemTitle>
|
||||||
<VListItemSubtitle>{{ userName }}</VListItemSubtitle>
|
<VListItemSubtitle>{{ userName }}</VListItemSubtitle>
|
||||||
</VListItem>
|
</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" />
|
<VDivider class="my-2" />
|
||||||
|
|
||||||
<!-- 👉 Profile -->
|
<!-- 👉 Profile -->
|
||||||
@@ -97,7 +121,7 @@ const avatar = store.state.auth.avatar
|
|||||||
</VListItem>
|
</VListItem>
|
||||||
|
|
||||||
<!-- 👉 FAQ -->
|
<!-- 👉 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>
|
<template #prepend>
|
||||||
<VIcon class="me-2" icon="mdi-help-circle-outline" size="22" />
|
<VIcon class="me-2" icon="mdi-help-circle-outline" size="22" />
|
||||||
</template>
|
</template>
|
||||||
@@ -105,7 +129,7 @@ const avatar = store.state.auth.avatar
|
|||||||
</VListItem>
|
</VListItem>
|
||||||
|
|
||||||
<!-- Divider -->
|
<!-- Divider -->
|
||||||
<VDivider class="my-2" />
|
<VDivider v-if="superUser" class="my-2" />
|
||||||
|
|
||||||
<!-- 👉 restart -->
|
<!-- 👉 restart -->
|
||||||
<VListItem v-if="superUser" @click="restart">
|
<VListItem v-if="superUser" @click="restart">
|
||||||
@@ -115,9 +139,6 @@ const avatar = store.state.auth.avatar
|
|||||||
<VListItemTitle>重启</VListItemTitle>
|
<VListItemTitle>重启</VListItemTitle>
|
||||||
</VListItem>
|
</VListItem>
|
||||||
|
|
||||||
<!-- Divider -->
|
|
||||||
<VDivider class="my-2" />
|
|
||||||
|
|
||||||
<!-- 👉 Logout -->
|
<!-- 👉 Logout -->
|
||||||
<VListItem @click="logout">
|
<VListItem @click="logout">
|
||||||
<VBtn color="error" block>
|
<VBtn color="error" block>
|
||||||
|
|||||||
34
src/main.ts
34
src/main.ts
@@ -1,23 +1,19 @@
|
|||||||
import { VAceEditor } from 'vue3-ace-editor'
|
import '@/@core/utils/compatibility'
|
||||||
import { createApp } from 'vue'
|
|
||||||
import '@/@iconify/icons-bundle'
|
|
||||||
import ToastPlugin from 'vue-toast-notification'
|
|
||||||
import VuetifyUseDialog from 'vuetify-use-dialog'
|
|
||||||
import './ace-config'
|
import './ace-config'
|
||||||
import VueApexCharts from 'vue3-apexcharts'
|
import '@/@iconify/icons-bundle'
|
||||||
import { removeEl } from './@core/utils/dom'
|
import '@/plugins/webfontloader'
|
||||||
import App from '@/App.vue'
|
import App from '@/App.vue'
|
||||||
import vuetify from '@/plugins/vuetify'
|
import vuetify from '@/plugins/vuetify'
|
||||||
import { loadFonts } from '@/plugins/webfontloader'
|
|
||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
import store from '@/store'
|
import store from '@/store'
|
||||||
import '@core/scss/template/index.scss'
|
import { VAceEditor } from 'vue3-ace-editor'
|
||||||
import '@layouts/styles/index.scss'
|
import { createApp } from 'vue'
|
||||||
import '@styles/styles.scss'
|
import { removeEl } from './@core/utils/dom'
|
||||||
import 'vue-toast-notification/dist/theme-bootstrap.css'
|
|
||||||
import { PerfectScrollbarPlugin } from 'vue3-perfect-scrollbar'
|
import { PerfectScrollbarPlugin } from 'vue3-perfect-scrollbar'
|
||||||
import 'vue3-perfect-scrollbar/style.css'
|
|
||||||
import { VTreeview } from 'vuetify/labs/VTreeview'
|
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 DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
|
||||||
import MediaCard from './components/cards/MediaCard.vue'
|
import MediaCard from './components/cards/MediaCard.vue'
|
||||||
import PosterCard from './components/cards/PosterCard.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 TorrentCard from './components/cards/TorrentCard.vue'
|
||||||
import MediaIdSelector from './components/misc/MediaIdSelector.vue'
|
import MediaIdSelector from './components/misc/MediaIdSelector.vue'
|
||||||
import PathField from './components/input/PathField.vue'
|
import PathField from './components/input/PathField.vue'
|
||||||
import { fixArrayAt } from '@/@core/utils/compatibility'
|
import '@core/scss/template/index.scss'
|
||||||
|
import '@layouts/styles/index.scss'
|
||||||
// 修复低版本Safari等浏览器数组不支持at函数的问题
|
import '@styles/styles.scss'
|
||||||
fixArrayAt()
|
import 'vue-toast-notification/dist/theme-bootstrap.css'
|
||||||
|
import 'vue3-perfect-scrollbar/style.css'
|
||||||
// 加载字体
|
|
||||||
loadFonts()
|
|
||||||
|
|
||||||
// 创建Vue实例
|
// 创建Vue实例
|
||||||
const app = createApp(App)
|
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="500"
|
||||||
|
@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">
|
||||||
|
<VCard class="pa-4 select-none" :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>
|
||||||
@@ -5,6 +5,13 @@ import { isNullOrEmptyObject } from '@/@core/utils'
|
|||||||
import { DashboardItem } from '@/api/types'
|
import { DashboardItem } from '@/api/types'
|
||||||
import store from '@/store'
|
import store from '@/store'
|
||||||
import DashboardElement from '@/components/misc/DashboardElement.vue'
|
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信息
|
// 从Vuex Store中获取superuser信息
|
||||||
const superUser = store.state.auth.superUser
|
const superUser = store.state.auth.superUser
|
||||||
@@ -314,7 +321,16 @@ onBeforeMount(async () => {
|
|||||||
</draggable>
|
</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>
|
<VDialog v-model="dialog" max-width="35rem" scrollable>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import router from '@/router'
|
|||||||
import logo from '@images/logo.png'
|
import logo from '@images/logo.png'
|
||||||
import { useTheme } from 'vuetify'
|
import { useTheme } from 'vuetify'
|
||||||
import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
|
import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
|
||||||
|
import { urlBase64ToUint8Array } from '@/@core/utils/navigator'
|
||||||
|
|
||||||
const { global: globalTheme } = useTheme()
|
const { global: globalTheme } = useTheme()
|
||||||
|
|
||||||
@@ -89,11 +90,39 @@ async function setTheme() {
|
|||||||
localStorage.setItem('materio-initial-loader-bg', globalTheme.current.value.colors.background)
|
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()
|
await setTheme()
|
||||||
// 跳转到首页或回原始页面
|
// 跳转到首页或回原始页面
|
||||||
router.push(store.state.auth.originalPath ?? '/')
|
router.push(store.state.auth.originalPath ?? '/')
|
||||||
|
// 订阅推送通知
|
||||||
|
if (superuser) await subscribeForPushNotifications()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 登录获取token事件
|
// 登录获取token事件
|
||||||
@@ -134,7 +163,7 @@ function login() {
|
|||||||
store.dispatch('auth/updateAvatar', avatar)
|
store.dispatch('auth/updateAvatar', avatar)
|
||||||
|
|
||||||
// 登录后处理
|
// 登录后处理
|
||||||
afterLogin()
|
afterLogin(superuser)
|
||||||
})
|
})
|
||||||
.catch((error: any) => {
|
.catch((error: any) => {
|
||||||
// 登录失败,显示错误提示
|
// 登录失败,显示错误提示
|
||||||
|
|||||||
@@ -1,82 +1,77 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import MediaCardSlideView from '@/views/discover/MediaCardSlideView.vue'
|
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',
|
apipath: 'tmdb/trending',
|
||||||
linkurl: "/browse/tmdb/trending?title=流行趋势",
|
linkurl: '/browse/tmdb/trending?title=流行趋势',
|
||||||
title: "流行趋势",
|
title: '流行趋势',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
apipath: "douban/showing",
|
apipath: 'douban/showing',
|
||||||
linkurl: "/browse/douban/showing?title=正在热映",
|
linkurl: '/browse/douban/showing?title=正在热映',
|
||||||
title: "正在热映"
|
title: '正在热映',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
apipath: "bangumi/calendar",
|
apipath: 'bangumi/calendar',
|
||||||
linkurl: "/browse/bangumi/calendar?title=Bangumi每日放送",
|
linkurl: '/browse/bangumi/calendar?title=Bangumi每日放送',
|
||||||
title: "Bangumi每日放送"
|
title: 'Bangumi每日放送',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
apipath: "tmdb/movies",
|
apipath: 'tmdb/movies',
|
||||||
linkurl: "/browse/tmdb/movies?title=热门电影",
|
linkurl: '/browse/tmdb/movies?title=TMDB热门电影',
|
||||||
title: "热门电影"
|
title: 'TMDB热门电影',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
apipath: "tmdb/tvs?with_original_language=zh|en|ja|ko",
|
apipath: 'tmdb/tvs?with_original_language=zh|en|ja|ko',
|
||||||
linkurl: "/browse/tmdb/tvs??with_original_language=zh|en|ja|ko&title=热门电视剧",
|
linkurl: '/browse/tmdb/tvs??with_original_language=zh|en|ja|ko&title=TMDB热门电视剧',
|
||||||
title: "热门电视剧"
|
title: 'TMDB热门电视剧',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
apipath: "douban/movie_hot",
|
apipath: 'douban/movie_hot',
|
||||||
linkurl: "/browse/douban/movie_hot?title=热门电影",
|
linkurl: '/browse/douban/movie_hot?title=豆瓣热门电影',
|
||||||
title: "热门电影"
|
title: '豆瓣热门电影',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
apipath: "douban/tv_hot",
|
apipath: 'douban/tv_hot',
|
||||||
linkurl: "/browse/douban/tv_hot?title=热门电视剧",
|
linkurl: '/browse/douban/tv_hot?title=豆瓣热门电视剧',
|
||||||
title: "热门电视剧"
|
title: '豆瓣热门电视剧',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
apipath: "douban/tv_animation",
|
apipath: 'douban/tv_animation',
|
||||||
linkurl: "/browse/douban/tv_animation?title=热门动漫",
|
linkurl: '/browse/douban/tv_animation?title=豆瓣热门动漫',
|
||||||
title: "热门动漫"
|
title: '豆瓣热门动漫',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
apipath: "douban/movies",
|
apipath: 'douban/movies',
|
||||||
linkurl: "/browse/douban/movies?title=最新电影",
|
linkurl: '/browse/douban/movies?title=豆瓣最新电影',
|
||||||
title: "最新电影"
|
title: '豆瓣最新电影',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
apipath: "douban/tvs",
|
apipath: 'douban/tvs',
|
||||||
linkurl: "/browse/douban/tvs?title=最新电视剧",
|
linkurl: '/browse/douban/tvs?title=豆瓣最新电视剧',
|
||||||
title: "最新电视剧"
|
title: '豆瓣最新电视剧',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
apipath: "douban/movie_top250",
|
apipath: 'douban/movie_top250',
|
||||||
linkurl: "/browse/douban/movie_top250?title=电影TOP250",
|
linkurl: '/browse/douban/movie_top250?title=电影TOP250',
|
||||||
title: "电影TOP250"
|
title: '豆瓣电影TOP250',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
apipath: "douban/tv_weekly_chinese",
|
apipath: 'douban/tv_weekly_chinese',
|
||||||
linkurl: "/browse/douban/tv_weekly_chinese?title=国产剧集榜",
|
linkurl: '/browse/douban/tv_weekly_chinese?title=豆瓣国产剧集榜',
|
||||||
title: "国产剧集榜"
|
title: '豆瓣国产剧集榜',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
apipath: "douban/tv_weekly_global",
|
apipath: 'douban/tv_weekly_global',
|
||||||
linkurl: "/browse/douban/tv_weekly_global?title=全球剧集榜",
|
linkurl: '/browse/douban/tv_weekly_global?title=豆瓣全球剧集榜',
|
||||||
title: "全球剧集榜"
|
title: '豆瓣全球剧集榜',
|
||||||
}
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<MediaCardSlideView
|
<MediaCardSlideView v-for="(item, index) in viewList" :key="index" v-bind="item" />
|
||||||
v-for="item in viewList"
|
|
||||||
:key="item.apipath"
|
|
||||||
v-bind="item"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -5,6 +5,13 @@ import type { Context } from '@/api/types'
|
|||||||
import store from '@/store'
|
import store from '@/store'
|
||||||
import TorrentCardListView from '@/views/discover/TorrentCardListView.vue'
|
import TorrentCardListView from '@/views/discover/TorrentCardListView.vue'
|
||||||
import TorrentRowListView from '@/views/discover/TorrentRowListView.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()
|
const route = useRoute()
|
||||||
@@ -142,13 +149,25 @@ onUnmounted(() => {
|
|||||||
<!-- 视图切换 -->
|
<!-- 视图切换 -->
|
||||||
<VFab
|
<VFab
|
||||||
v-if="viewType === 'list'"
|
v-if="viewType === 'list'"
|
||||||
|
class="mb-12"
|
||||||
icon="mdi-view-grid"
|
icon="mdi-view-grid"
|
||||||
location="bottom"
|
location="bottom"
|
||||||
size="x-large"
|
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
|
fixed
|
||||||
app
|
app
|
||||||
appear
|
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>
|
</template>
|
||||||
|
|||||||
@@ -4,12 +4,12 @@
|
|||||||
* webfontloader documentation: https://github.com/typekit/webfontloader
|
* webfontloader documentation: https://github.com/typekit/webfontloader
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export async function loadFonts() {
|
;(async function loadFonts() {
|
||||||
const webFontLoader = await import(/* webpackChunkName: "webfontloader" */'webfontloader')
|
const webFontLoader = await import(/* webpackChunkName: "webfontloader" */ 'webfontloader')
|
||||||
|
|
||||||
webFontLoader.load({
|
webFontLoader.load({
|
||||||
google: {
|
google: {
|
||||||
families: ['Inter:100,200,300,400,500,600,700&display=swap'],
|
families: ['Inter:100,200,300,400,500,600,700&display=swap'],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
})()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||||
import { configureNProgress, doneNProgress, startNProgress } from '@/api/nprogress'
|
import { configureNProgress } from '@/api/nprogress'
|
||||||
import store from '@/store'
|
import store from '@/store'
|
||||||
|
|
||||||
// Nprogress
|
// Nprogress
|
||||||
@@ -137,6 +137,13 @@ const router = createRouter({
|
|||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/apps',
|
||||||
|
component: () => import('../pages/appcenter.vue'),
|
||||||
|
meta: {
|
||||||
|
requiresAuth: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -161,17 +168,11 @@ router.beforeEach((to, from, next) => {
|
|||||||
// 总是记录非login路由
|
// 总是记录非login路由
|
||||||
if (to.fullPath != '/login') store.state.auth.originalPath = to.fullPath
|
if (to.fullPath != '/login') store.state.auth.originalPath = to.fullPath
|
||||||
const isAuthenticated = store.state.auth.token !== null
|
const isAuthenticated = store.state.auth.token !== null
|
||||||
|
|
||||||
if (to.meta.requiresAuth && !isAuthenticated) {
|
if (to.meta.requiresAuth && !isAuthenticated) {
|
||||||
next('/login')
|
next('/login')
|
||||||
} else {
|
} else {
|
||||||
startNProgress()
|
|
||||||
next()
|
next()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
router.afterEach(() => {
|
|
||||||
doneNProgress()
|
|
||||||
})
|
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export const SystemNavMenus = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '推荐',
|
title: '推荐',
|
||||||
icon: 'mdi-table-star',
|
icon: 'mdi-star-outline',
|
||||||
to: '/ranking',
|
to: '/ranking',
|
||||||
header: '发现',
|
header: '发现',
|
||||||
admin: false,
|
admin: false,
|
||||||
@@ -23,20 +23,23 @@ export const SystemNavMenus = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '电影',
|
title: '电影',
|
||||||
icon: 'mdi-movie-roll',
|
full_title: '电影订阅',
|
||||||
|
icon: 'mdi-movie-open-outline',
|
||||||
to: '/subscribe-movie?tab=mysub',
|
to: '/subscribe-movie?tab=mysub',
|
||||||
header: '订阅',
|
header: '订阅',
|
||||||
admin: false,
|
admin: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '电视剧',
|
title: '电视剧',
|
||||||
icon: 'mdi-television-classic',
|
full_title: '电视剧订阅',
|
||||||
|
icon: 'mdi-television',
|
||||||
to: '/subscribe-tv?tab=mysub',
|
to: '/subscribe-tv?tab=mysub',
|
||||||
header: '订阅',
|
header: '订阅',
|
||||||
admin: false,
|
admin: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '日历',
|
title: '日历',
|
||||||
|
full_title: '订阅日历',
|
||||||
icon: 'mdi-calendar',
|
icon: 'mdi-calendar',
|
||||||
to: '/calendar',
|
to: '/calendar',
|
||||||
header: '订阅',
|
header: '订阅',
|
||||||
|
|||||||
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: [/^\/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))
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -24,14 +24,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.v-dialog > .v-overlay__content {
|
.v-dialog > .v-overlay__content {
|
||||||
|
inline-size: calc(100% - 1rem);
|
||||||
margin-block-start: calc(env(safe-area-inset-top) + 1rem);
|
margin-block-start: calc(env(safe-area-inset-top) + 1rem);
|
||||||
max-block-size: calc(100% - 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{
|
.v-dialog--fullscreen > .v-overlay__content{
|
||||||
inline-size: 100%;
|
inline-size: 100%;
|
||||||
margin-block-start: env(safe-area-inset-top);
|
margin-block-start: env(safe-area-inset-top);
|
||||||
@@ -65,7 +62,6 @@
|
|||||||
color: transparent;
|
color: transparent;
|
||||||
|
|
||||||
--tw-gradient-from: #818cf8;
|
--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-stops: var(--tw-gradient-from),var(--tw-gradient-to);
|
||||||
--tw-gradient-to: #c084fc;
|
--tw-gradient-to: #c084fc;
|
||||||
}
|
}
|
||||||
@@ -188,3 +184,28 @@
|
|||||||
.v-tabs:not(.v-tabs-pill).v-tabs--horizontal {
|
.v-tabs:not(.v-tabs-pill).v-tabs--horizontal {
|
||||||
border-block-end: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,14 +2,7 @@
|
|||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import type { MediaStatistic } from '@/api/types'
|
import type { MediaStatistic } from '@/api/types'
|
||||||
|
|
||||||
const statistics = ref([
|
const statistics = ref<{ [key: string]: string }[]>([])
|
||||||
{
|
|
||||||
title: '',
|
|
||||||
stats: '',
|
|
||||||
icon: '',
|
|
||||||
color: '',
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
// 调用API加载媒体统计数据
|
// 调用API加载媒体统计数据
|
||||||
async function loadMediaStatistic() {
|
async function loadMediaStatistic() {
|
||||||
|
|||||||
@@ -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">
|
<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">
|
<template v-if="mediaDetail.backdrop_path || mediaDetail.poster_path">
|
||||||
<div class="vue-media-back absolute left-0 top-0 w-full h-96">
|
<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>
|
||||||
<div class="vue-media-back absolute left-0 top-0 w-full h-96" />
|
<div class="vue-media-back absolute left-0 top-0 w-full h-96" />
|
||||||
</template>
|
</template>
|
||||||
@@ -861,6 +861,7 @@ onBeforeMount(() => {
|
|||||||
/>
|
/>
|
||||||
<!-- 订阅编辑弹窗 -->
|
<!-- 订阅编辑弹窗 -->
|
||||||
<SubscribeEditDialog
|
<SubscribeEditDialog
|
||||||
|
v-if="subscribeEditDialog"
|
||||||
v-model="subscribeEditDialog"
|
v-model="subscribeEditDialog"
|
||||||
:subid="subscribeId"
|
:subid="subscribeId"
|
||||||
@close="subscribeEditDialog = false"
|
@close="subscribeEditDialog = false"
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import _ from 'lodash'
|
import _ from 'lodash'
|
||||||
import type { Context } from '@/api/types'
|
import type { Context } from '@/api/types'
|
||||||
import TorrentCard from '@/components/cards/TorrentCard.vue'
|
import TorrentCard from '@/components/cards/TorrentCard.vue'
|
||||||
import { useDefer } from '@/@core/utils/dom'
|
|
||||||
|
|
||||||
interface SearchTorrent extends Context {
|
interface SearchTorrent extends Context {
|
||||||
more?: Array<Context>
|
more?: Array<Context>
|
||||||
@@ -73,13 +72,13 @@ const sortSeasonFilterOptions = computed(() => {
|
|||||||
return seasonFilterOptions.value.sort((a, b) => {
|
return seasonFilterOptions.value.sort((a, b) => {
|
||||||
// 按季,集降序排序
|
// 按季,集降序排序
|
||||||
const parseSeasonEpisode = (str: string) => {
|
const parseSeasonEpisode = (str: string) => {
|
||||||
const seasonRangeMatch = str.match(/S(\d+)(?:-S(\d+))?/);
|
const seasonRangeMatch = str.match(/S(\d+)(?:-S(\d+))?/)
|
||||||
const episodeRangeMatch = str.match(/E(\d+)(?:-E(\d+))?/);
|
const episodeRangeMatch = str.match(/E(\d+)(?:-E(\d+))?/)
|
||||||
return {
|
return {
|
||||||
seasonStart : seasonRangeMatch?.[1] ? parseInt(seasonRangeMatch[1]) : 0,
|
seasonStart: seasonRangeMatch?.[1] ? parseInt(seasonRangeMatch[1]) : 0,
|
||||||
seasonEnd : seasonRangeMatch?.[2] ? parseInt(seasonRangeMatch[2]) : 0,
|
seasonEnd: seasonRangeMatch?.[2] ? parseInt(seasonRangeMatch[2]) : 0,
|
||||||
episodeStart : episodeRangeMatch?.[1] ? parseInt(episodeRangeMatch[1]) : 0,
|
episodeStart: episodeRangeMatch?.[1] ? parseInt(episodeRangeMatch[1]) : 0,
|
||||||
episodeEnd : episodeRangeMatch?.[2] ? parseInt(episodeRangeMatch[2]) : 0
|
episodeEnd: episodeRangeMatch?.[2] ? parseInt(episodeRangeMatch[2]) : 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const parsedA = parseSeasonEpisode(a)
|
const parsedA = parseSeasonEpisode(a)
|
||||||
@@ -126,23 +125,18 @@ onMounted(() => {
|
|||||||
groupedDataList.value = groupMap
|
groupedDataList.value = groupMap
|
||||||
})
|
})
|
||||||
|
|
||||||
let defer = (_: number) => true
|
|
||||||
|
|
||||||
// 计算过滤后的列表
|
// 计算过滤后的列表
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
// 清空列表
|
// 清空列表
|
||||||
dataList.value = []
|
dataList.value = []
|
||||||
// 匹配过滤函数,filter中有任一值包含value则返回true
|
// 匹配过滤函数,filter中有任一值包含value则返回true
|
||||||
const match = (filter: Array<string>, value: string | undefined): boolean =>
|
const match = (filter: Array<string>, value: string | undefined) =>
|
||||||
filter.length === 0 || filter.includes(value ?? '') || filter.some(v => value?.includes(v) ?? false)
|
filter.length === 0 || (value && filter.includes(value))
|
||||||
|
|
||||||
groupedDataList.value?.forEach(value => {
|
groupedDataList.value?.forEach(value => {
|
||||||
if (value.length > 0) {
|
if (value.length > 0) {
|
||||||
const matchData = value.filter(data => {
|
const matchData = value.filter(data => {
|
||||||
const {
|
const { meta_info, torrent_info } = data
|
||||||
meta_info,
|
|
||||||
torrent_info,
|
|
||||||
} = data
|
|
||||||
// 季、制作组、视频编码
|
// 季、制作组、视频编码
|
||||||
return (
|
return (
|
||||||
// 站点过滤
|
// 站点过滤
|
||||||
@@ -169,7 +163,6 @@ watchEffect(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
defer = useDefer(dataList.value.length)
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -257,7 +250,7 @@ watchEffect(() => {
|
|||||||
</VCard>
|
</VCard>
|
||||||
<div class="grid gap-3 grid-torrent-card items-start">
|
<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}`">
|
<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" />
|
<TorrentCard :torrent="item" :more="item.more" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { Context } from '@/api/types'
|
import type { Context } from '@/api/types'
|
||||||
import TorrentItem from '@/components/cards/TorrentItem.vue'
|
import TorrentItem from '@/components/cards/TorrentItem.vue'
|
||||||
import { useDefer } from '@/@core/utils/dom'
|
import { useDisplay } from 'vuetify'
|
||||||
|
|
||||||
|
// 显示器宽度
|
||||||
|
const display = useDisplay()
|
||||||
|
|
||||||
|
// APP
|
||||||
|
const appMode = computed(() => {
|
||||||
|
return localStorage.getItem('MP_APPMODE') != '0' && display.mdAndDown.value
|
||||||
|
})
|
||||||
|
|
||||||
// 定义输入参数
|
// 定义输入参数
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -124,7 +132,14 @@ onMounted(() => {
|
|||||||
</VListItem>
|
</VListItem>
|
||||||
</VList>
|
</VList>
|
||||||
<VList v-if="dataList.length !== 0" lines="three" class="rounded p-0 torrent-list-vscroll shadow-lg">
|
<VList v-if="dataList.length !== 0" lines="three" class="rounded p-0 torrent-list-vscroll shadow-lg">
|
||||||
<VVirtualScroll :items="dataList">
|
<VVirtualScroll
|
||||||
|
:items="dataList"
|
||||||
|
:style="
|
||||||
|
appMode
|
||||||
|
? 'height: calc(100vh - 7.5rem - env(safe-area-inset-bottom) - 3.5rem)'
|
||||||
|
: 'height: calc(100vh - 6.5rem - env(safe-area-inset-bottom)'
|
||||||
|
"
|
||||||
|
>
|
||||||
<template #default="{ item }">
|
<template #default="{ item }">
|
||||||
<TorrentItem :torrent="item" :key="`${item.torrent_info.title}_${item.torrent_info.site}`" />
|
<TorrentItem :torrent="item" :key="`${item.torrent_info.title}_${item.torrent_info.site}`" />
|
||||||
</template>
|
</template>
|
||||||
@@ -132,7 +147,11 @@ onMounted(() => {
|
|||||||
</VList>
|
</VList>
|
||||||
</VCol>
|
</VCol>
|
||||||
<VCol xl="2" md="3" class="d-none d-md-block">
|
<VCol xl="2" md="3" class="d-none d-md-block">
|
||||||
<VList lines="one" class="rounded torrent-list-vscroll shadow-lg">
|
<VList
|
||||||
|
lines="one"
|
||||||
|
class="rounded shadow-lg"
|
||||||
|
style="block-size: calc(100vh - 6.5rem - env(safe-area-inset-bottom))"
|
||||||
|
>
|
||||||
<VListSubheader> 排序 </VListSubheader>
|
<VListSubheader> 排序 </VListSubheader>
|
||||||
<VListItem>
|
<VListItem>
|
||||||
<VChipGroup column v-model="sortField">
|
<VChipGroup column v-model="sortField">
|
||||||
@@ -257,15 +276,3 @@ onMounted(() => {
|
|||||||
</VCol>
|
</VCol>
|
||||||
</VRow>
|
</VRow>
|
||||||
</template>
|
</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,7 +8,6 @@ import PluginCard from '@/components/cards/PluginCard.vue'
|
|||||||
import noImage from '@images/logos/plugin.png'
|
import noImage from '@images/logos/plugin.png'
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||||
import { useDefer } from '@/@core/utils/dom'
|
|
||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
import { PluginTabs } from '@/router/menu'
|
import { PluginTabs } from '@/router/menu'
|
||||||
|
|
||||||
@@ -17,8 +16,10 @@ const route = useRoute()
|
|||||||
// 显示器宽度
|
// 显示器宽度
|
||||||
const display = useDisplay()
|
const display = useDisplay()
|
||||||
|
|
||||||
// 延迟加载
|
// APP
|
||||||
let deferApp = (_: number) => true
|
const appMode = computed(() => {
|
||||||
|
return localStorage.getItem('MP_APPMODE') != '0' && display.mdAndDown.value
|
||||||
|
})
|
||||||
|
|
||||||
// 当前标签
|
// 当前标签
|
||||||
const activeTab = ref(route.query.tab)
|
const activeTab = ref(route.query.tab)
|
||||||
@@ -280,8 +281,6 @@ const sortedUninstalledList = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
deferApp = useDefer(ret_list.length)
|
|
||||||
|
|
||||||
if (isNullOrEmptyObject(PluginStatistics.value)) return ret_list
|
if (isNullOrEmptyObject(PluginStatistics.value)) return ret_list
|
||||||
// 数据排序
|
// 数据排序
|
||||||
if (!activeSort.value || activeSort.value === 'count') {
|
if (!activeSort.value || activeSort.value === 'count') {
|
||||||
@@ -418,12 +417,7 @@ onBeforeMount(async () => {
|
|||||||
</div>
|
</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 items-start">
|
||||||
<template v-for="(data, index) in sortedUninstalledList" :key="`${data.id}_v${data.plugin_version}`">
|
<template v-for="(data, index) in sortedUninstalledList" :key="`${data.id}_v${data.plugin_version}`">
|
||||||
<PluginAppCard
|
<PluginAppCard :plugin="data" :count="PluginStatistics[data.id || '0']" @install="pluginInstalled" />
|
||||||
v-if="deferApp(index)"
|
|
||||||
:plugin="data"
|
|
||||||
:count="PluginStatistics[data.id || '0']"
|
|
||||||
@install="pluginInstalled"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<NoDataFound
|
<NoDataFound
|
||||||
@@ -449,6 +443,7 @@ onBeforeMount(async () => {
|
|||||||
app
|
app
|
||||||
appear
|
appear
|
||||||
@click="SearchDialog = true"
|
@click="SearchDialog = true"
|
||||||
|
:class="{ 'mb-12': appMode }"
|
||||||
/>
|
/>
|
||||||
<VDialog
|
<VDialog
|
||||||
v-if="SearchDialog"
|
v-if="SearchDialog"
|
||||||
|
|||||||
@@ -7,6 +7,15 @@ import ReorganizeDialog from '@/components/dialog/ReorganizeDialog.vue'
|
|||||||
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
|
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import router from '@/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 $toast = useToast()
|
||||||
@@ -202,7 +211,7 @@ async function removeSingle(deleteSrc: boolean, deleteDest: boolean) {
|
|||||||
// 删除
|
// 删除
|
||||||
await remove(currentHistory.value, deleteSrc, deleteDest)
|
await remove(currentHistory.value, deleteSrc, deleteDest)
|
||||||
// 刷新
|
// 刷新
|
||||||
reloadPage()
|
fetchData()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 批量删除记录
|
// 批量删除记录
|
||||||
@@ -231,7 +240,7 @@ async function removeBatch(deleteSrc: boolean, deleteDest: boolean) {
|
|||||||
// 隐藏进度条
|
// 隐藏进度条
|
||||||
progressDialog.value = false
|
progressDialog.value = false
|
||||||
// 重新获取数据
|
// 重新获取数据
|
||||||
reloadPage()
|
fetchData()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 响应删除操作
|
// 响应删除操作
|
||||||
@@ -283,7 +292,7 @@ function transferDone() {
|
|||||||
currentHistory.value = undefined
|
currentHistory.value = undefined
|
||||||
selected.value = []
|
selected.value = []
|
||||||
// 刷新
|
// 刷新
|
||||||
reloadPage()
|
fetchData()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 弹出菜单
|
// 弹出菜单
|
||||||
@@ -384,8 +393,12 @@ onMounted(fetchData)
|
|||||||
fixed-header
|
fixed-header
|
||||||
show-select
|
show-select
|
||||||
loading-text="加载中..."
|
loading-text="加载中..."
|
||||||
class="data-table-div"
|
|
||||||
hover
|
hover
|
||||||
|
:style="
|
||||||
|
appMode
|
||||||
|
? 'height: calc(100vh - 15.5rem - env(safe-area-inset-bottom) - 3.5rem)'
|
||||||
|
: 'height: calc(100vh - 14.5rem - env(safe-area-inset-bottom)'
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<template #item.title="{ item }">
|
<template #item.title="{ item }">
|
||||||
<div class="d-flex align-center">
|
<div class="d-flex align-center">
|
||||||
@@ -473,10 +486,11 @@ onMounted(fetchData)
|
|||||||
app
|
app
|
||||||
appear
|
appear
|
||||||
@click="removeHistoryBatch"
|
@click="removeHistoryBatch"
|
||||||
|
:class="{ 'mb-12': appMode }"
|
||||||
/>
|
/>
|
||||||
<VFab
|
<VFab
|
||||||
v-if="selected.length > 0"
|
v-if="selected.length > 0"
|
||||||
class="mb-16"
|
:class="appMode ? 'mb-28' : 'mb-16'"
|
||||||
icon="mdi-redo-variant"
|
icon="mdi-redo-variant"
|
||||||
location="bottom"
|
location="bottom"
|
||||||
size="x-large"
|
size="x-large"
|
||||||
@@ -521,14 +535,4 @@ onMounted(fetchData)
|
|||||||
.v-table th {
|
.v-table th {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.data-table-div {
|
|
||||||
block-size: calc(100vh - 14rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (width <= 768px) {
|
|
||||||
.data-table-div {
|
|
||||||
block-size: calc(100vh - 17rem);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ onMounted(() => {
|
|||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
class="text-indigo-500 transition duration-300 hover:underline"
|
class="text-indigo-500 transition duration-300 hover:underline"
|
||||||
>
|
>
|
||||||
https://github.com/jxxghp/MoviePilot/blob/main/README.md
|
https://wiki.movie-pilot.org
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
</dd>
|
</dd>
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ async function addUser() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.post('user', userForm)
|
const result: { [key: string]: any } = await api.post('user/', userForm)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
$toast.success('用户新增成功!')
|
$toast.success('用户新增成功!')
|
||||||
loadAllUsers()
|
loadAllUsers()
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ const NotificationChannels = [
|
|||||||
title: 'VoceChat',
|
title: 'VoceChat',
|
||||||
value: 'vocechat',
|
value: 'vocechat',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'WebPush',
|
||||||
|
value: 'webpush',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
// 提示框
|
// 提示框
|
||||||
@@ -232,7 +236,7 @@ onMounted(() => {
|
|||||||
<VCol cols="12" md="4">
|
<VCol cols="12" md="4">
|
||||||
<VTextField
|
<VTextField
|
||||||
v-model="notificationSettings.WECHAT_APP_SECRET"
|
v-model="notificationSettings.WECHAT_APP_SECRET"
|
||||||
label="应用Secret"
|
label="应用 Secret"
|
||||||
hint="企业微信自建应用的Secret"
|
hint="企业微信自建应用的Secret"
|
||||||
persistent-hint
|
persistent-hint
|
||||||
/>
|
/>
|
||||||
@@ -430,6 +434,7 @@ onMounted(() => {
|
|||||||
<th scope="col">Slack</th>
|
<th scope="col">Slack</th>
|
||||||
<th scope="col">SynologyChat</th>
|
<th scope="col">SynologyChat</th>
|
||||||
<th scope="col">VoceChat</th>
|
<th scope="col">VoceChat</th>
|
||||||
|
<th scope="col">WebPush</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -452,6 +457,9 @@ onMounted(() => {
|
|||||||
<td>
|
<td>
|
||||||
<VCheckbox v-model="message.vocechat" />
|
<VCheckbox v-model="message.vocechat" />
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
<VCheckbox v-model="message.webpush" />
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="messagemTypes.length === 0">
|
<tr v-if="messagemTypes.length === 0">
|
||||||
<td colspan="6" class="text-center">没有设置任何通知渠道</td>
|
<td colspan="6" class="text-center">没有设置任何通知渠道</td>
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ const downloaderTab = ref('qbittorrent')
|
|||||||
// 媒体服务器选中标签页
|
// 媒体服务器选中标签页
|
||||||
const mediaserverTab = ref('emby')
|
const mediaserverTab = ref('emby')
|
||||||
|
|
||||||
|
// 系统设置项
|
||||||
|
const SystemSettings = ref({
|
||||||
|
APP_DOMAIN: '',
|
||||||
|
})
|
||||||
|
|
||||||
// 下载器设置项
|
// 下载器设置项
|
||||||
const downloaderSettings = ref({
|
const downloaderSettings = ref({
|
||||||
DOWNLOADER_MONITOR: true,
|
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接口重新加载模块
|
// 调用API接口重新加载模块
|
||||||
async function reloadModule() {
|
async function reloadModule() {
|
||||||
try {
|
try {
|
||||||
@@ -223,11 +255,41 @@ async function reloadModule() {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadDownloaderSetting()
|
loadDownloaderSetting()
|
||||||
loadMediaServerSetting()
|
loadMediaServerSetting()
|
||||||
|
loadSystemSettings()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VRow>
|
<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">
|
<VCol cols="12">
|
||||||
<VCard>
|
<VCard>
|
||||||
<VCardItem>
|
<VCardItem>
|
||||||
|
|||||||
@@ -5,6 +5,15 @@ import type { Site } from '@/api/types'
|
|||||||
import SiteCard from '@/components/cards/SiteCard.vue'
|
import SiteCard from '@/components/cards/SiteCard.vue'
|
||||||
import NoDataFound from '@/components/NoDataFound.vue'
|
import NoDataFound from '@/components/NoDataFound.vue'
|
||||||
import SiteAddEditDialog from '@/components/dialog/SiteAddEditDialog.vue'
|
import SiteAddEditDialog from '@/components/dialog/SiteAddEditDialog.vue'
|
||||||
|
import { useDisplay } from 'vuetify'
|
||||||
|
|
||||||
|
// 显示器宽度
|
||||||
|
const display = useDisplay()
|
||||||
|
|
||||||
|
// APP
|
||||||
|
const appMode = computed(() => {
|
||||||
|
return localStorage.getItem('MP_APPMODE') != '0' && display.mdAndDown.value
|
||||||
|
})
|
||||||
|
|
||||||
// 数据列表
|
// 数据列表
|
||||||
const dataList = ref<Site[]>([])
|
const dataList = ref<Site[]>([])
|
||||||
@@ -67,7 +76,16 @@ onBeforeMount(fetchData)
|
|||||||
error-description="已添加并支持的站点将会在这里显示。"
|
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
|
<SiteAddEditDialog
|
||||||
v-if="siteAddDialog"
|
v-if="siteAddDialog"
|
||||||
|
|||||||
@@ -7,6 +7,15 @@ import SubscribeCard from '@/components/cards/SubscribeCard.vue'
|
|||||||
import SubscribeEditDialog from '@/components/dialog/SubscribeEditDialog.vue'
|
import SubscribeEditDialog from '@/components/dialog/SubscribeEditDialog.vue'
|
||||||
import SubscribeHistoryDialog from '@/components/dialog/SubscribeHistoryDialog.vue'
|
import SubscribeHistoryDialog from '@/components/dialog/SubscribeHistoryDialog.vue'
|
||||||
import store from '@/store'
|
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({
|
const props = defineProps({
|
||||||
@@ -97,13 +106,14 @@ onMounted(async () => {
|
|||||||
app
|
app
|
||||||
appear
|
appear
|
||||||
@click="subscribeEditDialog = true"
|
@click="subscribeEditDialog = true"
|
||||||
|
:class="{ 'mb-12': appMode }"
|
||||||
/>
|
/>
|
||||||
<VFab
|
<VFab
|
||||||
v-if="store.state.auth.superUser"
|
v-if="store.state.auth.superUser"
|
||||||
icon="mdi-history"
|
icon="mdi-history"
|
||||||
color="info"
|
color="info"
|
||||||
location="bottom"
|
location="bottom"
|
||||||
class="mb-16"
|
:class="appMode ? 'mb-28' : 'mb-16'"
|
||||||
size="x-large"
|
size="x-large"
|
||||||
fixed
|
fixed
|
||||||
app
|
app
|
||||||
|
|||||||
@@ -281,8 +281,8 @@ onMounted(() => {
|
|||||||
</VCardItem>
|
</VCardItem>
|
||||||
<DialogCloseBtn inner-class="absolute right-3 top-5 text-high-emphasis" @click="emit('close')" />
|
<DialogCloseBtn inner-class="absolute right-3 top-5 text-high-emphasis" @click="emit('close')" />
|
||||||
<VDivider />
|
<VDivider />
|
||||||
<VCardText>
|
<VCardText class="p-0">
|
||||||
<VList lines="one" v-if="searchWord">
|
<VList lines="two" v-if="searchWord">
|
||||||
<!-- 搜索结果 -->
|
<!-- 搜索结果 -->
|
||||||
<VListSubheader v-if="searchWord"> 媒体 & 资源 </VListSubheader>
|
<VListSubheader v-if="searchWord"> 媒体 & 资源 </VListSubheader>
|
||||||
<VHover>
|
<VHover>
|
||||||
@@ -294,7 +294,7 @@ onMounted(() => {
|
|||||||
v-bind="hover.props"
|
v-bind="hover.props"
|
||||||
@click="searchMedia('media')"
|
@click="searchMedia('media')"
|
||||||
>
|
>
|
||||||
<VListItemTitle>
|
<VListItemTitle class="break-words whitespace-break-spaces">
|
||||||
搜索 <span class="font-bold">{{ searchWord }} </span> 相关的【电影、电视剧】 ...
|
搜索 <span class="font-bold">{{ searchWord }} </span> 相关的【电影、电视剧】 ...
|
||||||
</VListItemTitle>
|
</VListItemTitle>
|
||||||
<template #append>
|
<template #append>
|
||||||
@@ -306,7 +306,7 @@ onMounted(() => {
|
|||||||
<VHover>
|
<VHover>
|
||||||
<template #default="hover">
|
<template #default="hover">
|
||||||
<VListItem prepend-icon="mdi-account-search" link v-bind="hover.props" @click="searchMedia('person')">
|
<VListItem prepend-icon="mdi-account-search" link v-bind="hover.props" @click="searchMedia('person')">
|
||||||
<VListItemTitle>
|
<VListItemTitle class="break-words whitespace-break-spaces">
|
||||||
搜索 <span class="font-bold">{{ searchWord }}</span> 相关的【演职人员】 ...
|
搜索 <span class="font-bold">{{ searchWord }}</span> 相关的【演职人员】 ...
|
||||||
</VListItemTitle>
|
</VListItemTitle>
|
||||||
<template #append>
|
<template #append>
|
||||||
@@ -318,7 +318,7 @@ onMounted(() => {
|
|||||||
<VHover>
|
<VHover>
|
||||||
<template #default="hover">
|
<template #default="hover">
|
||||||
<VListItem prepend-icon="mdi-search-web" link v-bind="hover.props" @click="searchTorrent">
|
<VListItem prepend-icon="mdi-search-web" link v-bind="hover.props" @click="searchTorrent">
|
||||||
<VListItemTitle>
|
<VListItemTitle class="break-words whitespace-break-spaces">
|
||||||
搜索 <span class="font-bold">{{ searchWord }}</span> 相关的【站点资源】 ...
|
搜索 <span class="font-bold">{{ searchWord }}</span> 相关的【站点资源】 ...
|
||||||
</VListItemTitle>
|
</VListItemTitle>
|
||||||
<template #append>
|
<template #append>
|
||||||
@@ -330,7 +330,7 @@ onMounted(() => {
|
|||||||
<VHover>
|
<VHover>
|
||||||
<template #default="hover">
|
<template #default="hover">
|
||||||
<VListItem prepend-icon="mdi-history" link v-bind="hover.props" @click="searchHistory">
|
<VListItem prepend-icon="mdi-history" link v-bind="hover.props" @click="searchHistory">
|
||||||
<VListItemTitle>
|
<VListItemTitle class="break-words whitespace-break-spaces">
|
||||||
搜索 <span class="font-bold">{{ searchWord }}</span> 相关的【历史记录】 ...
|
搜索 <span class="font-bold">{{ searchWord }}</span> 相关的【历史记录】 ...
|
||||||
</VListItemTitle>
|
</VListItemTitle>
|
||||||
<template #append>
|
<template #append>
|
||||||
@@ -412,7 +412,7 @@ onMounted(() => {
|
|||||||
<VChip
|
<VChip
|
||||||
v-for="(word, index) in recentSearches"
|
v-for="(word, index) in recentSearches"
|
||||||
:key="index"
|
:key="index"
|
||||||
class="me-2"
|
class="me-2 mb-1"
|
||||||
variant="tonal"
|
variant="tonal"
|
||||||
@click="searchWord = word"
|
@click="searchWord = word"
|
||||||
label
|
label
|
||||||
|
|||||||
@@ -46,7 +46,8 @@
|
|||||||
"esnext",
|
"esnext",
|
||||||
"dom",
|
"dom",
|
||||||
"dom.iterable",
|
"dom.iterable",
|
||||||
"scripthost"
|
"scripthost",
|
||||||
|
"WebWorker"
|
||||||
],
|
],
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"types": [
|
"types": [
|
||||||
@@ -63,11 +64,13 @@
|
|||||||
"src/**/*.vue",
|
"src/**/*.vue",
|
||||||
"themeConfig.ts",
|
"themeConfig.ts",
|
||||||
"auto-imports.d.ts",
|
"auto-imports.d.ts",
|
||||||
"components.d.ts"
|
"components.d.ts",
|
||||||
|
"src/service-worker.ts",
|
||||||
|
"public/service.js"
|
||||||
],
|
],
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"dist",
|
"dist",
|
||||||
"node_modules",
|
"node_modules",
|
||||||
"src/@iconify/*"
|
"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 AutoImport from 'unplugin-auto-import/vite'
|
||||||
import Components from 'unplugin-vue-components/vite'
|
import Components from 'unplugin-vue-components/vite'
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import { VitePWA } from 'vite-plugin-pwa'
|
|
||||||
import vuetify from 'vite-plugin-vuetify'
|
import vuetify from 'vite-plugin-vuetify'
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
@@ -13,8 +13,6 @@ export default defineConfig({
|
|||||||
plugins: [
|
plugins: [
|
||||||
vue(),
|
vue(),
|
||||||
vueJsx(),
|
vueJsx(),
|
||||||
|
|
||||||
// https://github.com/vuetifyjs/vuetify-loader/tree/next/packages/vite-plugin
|
|
||||||
vuetify({
|
vuetify({
|
||||||
styles: {
|
styles: {
|
||||||
configFile: 'src/styles/variables/_vuetify.scss',
|
configFile: 'src/styles/variables/_vuetify.scss',
|
||||||
@@ -29,12 +27,100 @@ export default defineConfig({
|
|||||||
vueTemplate: true,
|
vueTemplate: true,
|
||||||
}),
|
}),
|
||||||
VitePWA({
|
VitePWA({
|
||||||
registerType: 'autoUpdate',
|
|
||||||
injectRegister: 'script',
|
injectRegister: 'script',
|
||||||
manifest: false,
|
registerType: 'autoUpdate',
|
||||||
|
strategies: 'injectManifest',
|
||||||
|
srcDir: 'src',
|
||||||
|
filename: 'service-worker.ts',
|
||||||
workbox: {
|
workbox: {
|
||||||
navigateFallbackDenylist: [
|
globPatterns: ['**/*.{js,css,html,ico,png,svg,jpg,jpeg}'],
|
||||||
/.*\/api\/v\d+\/system\/logging.*/,
|
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?tab=mysub',
|
||||||
|
'icons': [
|
||||||
|
{
|
||||||
|
'src': './clock-icon-192x192.png',
|
||||||
|
'sizes': '192x192',
|
||||||
|
'type': 'image/png',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': '电视剧订阅',
|
||||||
|
'url': './subscribe-tv?tab=mysub',
|
||||||
|
'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: {
|
optimizeDeps: {
|
||||||
exclude: ['vuetify'],
|
exclude: ['vuetify'],
|
||||||
entries: [
|
entries: ['./src/**/*.vue'],
|
||||||
'./src/**/*.vue',
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user