Compare commits

...

26 Commits

Author SHA1 Message Date
jxxghp
4d8f369ba0 fix icon 2024-03-21 21:31:23 +08:00
jxxghp
824e2d72c7 fix icon 2024-03-21 16:30:48 +08:00
jxxghp
143aa79797 fix defer 2024-03-19 12:53:23 +08:00
jxxghp
0e1120f407 fix 减少无效查询 2024-03-19 12:34:21 +08:00
jxxghp
c8dbb9672a fix 2024-03-19 11:47:00 +08:00
jxxghp
372b74776f fix bug 2024-03-18 23:42:24 +08:00
jxxghp
abcc3c6411 fix 2024-03-18 23:31:21 +08:00
jxxghp
ec4ab8762c add Bangumi 2024-03-18 19:03:13 +08:00
jxxghp
bc93de8ff2 fix SSE 2024-03-18 11:42:56 +08:00
jxxghp
b426f3c6f2 更新 PluginAppCard.vue 2024-03-16 22:04:51 +08:00
jxxghp
99d9bb29ce 更新 PluginAppCard.vue 2024-03-16 21:55:59 +08:00
jxxghp
5e109c666b fix plugin card 2024-03-16 21:39:05 +08:00
jxxghp
0bed216735 fix 消息控重 2024-03-16 21:07:09 +08:00
jxxghp
d55bb8d336 fix label 2024-03-16 19:44:10 +08:00
jxxghp
7c32b3edf0 Merge pull request #88 from lingjiameng/main 2024-03-16 18:51:56 +08:00
jxxghp
121cb7e442 v1.7.3 2024-03-16 18:28:32 +08:00
ljmeng
dec3e1ea92 更新注释 2024-03-16 18:26:11 +08:00
ljmeng
664b6610f3 支持本地CookieCloud服务器 2024-03-16 18:23:24 +08:00
jxxghp
44163f0fb2 fix bug 2024-03-16 17:20:49 +08:00
jxxghp
d43865fcad fix message ui 2024-03-16 17:16:10 +08:00
jxxghp
fed92f3853 fix message ui 2024-03-16 16:47:41 +08:00
jxxghp
823d2a816e fix 2024-03-16 08:40:44 +08:00
jxxghp
046c21edf6 add message view 2024-03-15 18:15:31 +08:00
jxxghp
8236d80b42 feat:优化插件升级使用体验 2024-03-12 21:31:56 +08:00
jxxghp
90e7eb1c79 Merge pull request #86 from WangEdward/main 2024-03-11 16:32:30 +08:00
WangEdward
ef09868af1 fix: display search_imdbid status 2024-03-11 16:30:24 +08:00
30 changed files with 1210 additions and 497 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "moviepilot",
"version": "1.7.1-1",
"version": "1.7.4-1",
"private": true,
"bin": "dist/service.js",
"scripts": {
@@ -109,4 +109,4 @@
"resolutions": {
"postcss": "8"
}
}
}

View File

@@ -75,6 +75,26 @@ http {
# 超时设置
proxy_read_timeout 600s;
}
location /cookiecloud {
# 后端cookiecloud地址
proxy_pass http://backend_api;
rewrite ^.+mock-server/?(.*)$ /$1 break;
proxy_http_version 1.1;
proxy_buffering off;
proxy_cache off;
proxy_redirect off;
proxy_set_header Connection "";
proxy_set_header Upgrade $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_set_header X-Nginx-Proxy true;
# 超时设置
proxy_read_timeout 600s;
}
}
upstream backend_api {

View File

@@ -25,6 +25,16 @@ app.use(
})
);
// 配置代理中间件将CookieCloud请求转发给后端API
app.use(
'/cookiecloud',
proxy(`${proxyConfig.URL}:${proxyConfig.PORT}`, {
// 路径加上 /cookiecloud 前缀
proxyReqPathResolver: (req) => {
return `/cookiecloud${req.url}`
}
})
);
// 处理根路径的请求
app.get('/', (req, res) => {

View File

@@ -147,3 +147,23 @@ export function formatEp(nums: number[]): string {
return formattedRanges.join('、')
}
// 将yyyy-mm-dd hh:mm:ss转换为时间差1小时前1天前
export function formatDateDifference(dateString: string): string {
const date = new Date(dateString)
const currentDate = new Date()
const timeDifference = currentDate.getTime() - date.getTime()
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 '刚刚'
}

View File

@@ -81,7 +81,7 @@ export interface Subscribe {
best_version: any
// 使用 imdbid 搜索
search_imdbid?: boolean
search_imdbid?: any
// 当前优先级
current_priority: number
@@ -181,6 +181,9 @@ export interface MediaInfo {
// 豆瓣ID
douban_id?: string
// Bangumi ID
bangumi_id?: string
// 媒体原语种
original_language?: string
@@ -282,6 +285,9 @@ export interface MediaInfo {
// 下一集
next_episode_to_air?: object
// 别名
names?: string[]
}
// TMDB季信息
@@ -422,6 +428,28 @@ export interface DoubanPerson {
}
// Bangumi人物信息
export interface BangumiPerson {
// ID
id?: number
// 名称
name?: string
// 类型
type?: number
// 角色
career?: string[]
// images large/normal
images?: { [key: string]: string }
// 关系
relation?: string
}
// 站点
export interface Site {
@@ -802,18 +830,34 @@ export interface Context {
// 用户信息
export interface User {
// 用户ID
id: number
// 用户名称
name: string
// 用户密码
password: string
// 用户邮箱
email: string
// 是否激活
is_active: boolean
// 是否管理员
is_superuser: boolean
// 头像
avatar: string
}
// 存储空间
export interface Storage {
// 总空间
total_storage: number
// 已使用空间
used_storage: number
}
@@ -918,45 +962,132 @@ export interface Setting {
// 文件浏览接口
export interface EndPoints {
// 文件列表
list: any
// 创建目录
mkdir: any
// 删除文件
delete: any
// 下载文件
download: any
// 图片预览
image: any
// 重命名
rename: any
}
// 文件浏览项目
export interface FileItem {
// 类型
type: string
// 文件名
name: string
// 文件名不含扩展名
basename: string
// 文件路径
path: string
// 文件扩展名
extension: string
// 文件大小
size: number
// 文件子元素
children: FileItem[]
// 文件创建时间
modify_time: number
}
// 媒体服务器播放条目
export interface MediaServerPlayItem {
// ID
id?: string | number
// 标题
title: string
// 副标题
subtitle?: string
// 类型
type?: string
// 海报
image?: string
// 链接
link?: string
// 播放百分比
percent?: number
}
// 媒体服务器媒体库
export interface MediaServerLibrary {
// 服务器名称
server: string
// ID
id?: string | number
// 名称
name: string
// 路径
path?: string
// 类型
type?: string
// 图片
image?: string
// 图片列表
image_list?: string[]
// 链接
link?: string
}
// 消息通知
export interface Message {
// 消息类型
mtype?: string
// 消息标题
title?: string
// 消息内容
text?: string
// 消息链接
link?: string
// 消息图片
image?: string
// 消息时间
date?: string
// 登记时间
reg_time?: string
// 用户ID
userid?: string
// 消息方向0-接收1-发送
action?: number
// JSON
note?: string
}

View File

@@ -4,7 +4,6 @@ import axios from 'axios'
import List from './filebrowser/List.vue'
import Toolbar from './filebrowser/Toolbar.vue'
import Tree from './filebrowser/Tree.vue'
import type { EndPoints } from '@/api/types'
// 输入参数
@@ -70,12 +69,10 @@ const storagesArray = computed(() => {
// 方法
function loadingChanged(loading: number) {
if (loading) {
if (loading)
loading++
}
else if (loading > 0) {
else if (loading > 0)
loading--
}
}
function storageChanged(storage: string) {
@@ -115,20 +112,6 @@ onMounted(() => {
@sortchanged="sortChanged"
/>
<VRow no-gutters>
<VCol v-if="tree" sm="auto" class="d-none d-md-block">
<Tree
:path="path"
:storage="activeStorage"
:icons="fileIcons"
:endpoints="endpoints"
:axios="axiosInstance"
:refreshpending="refreshPending"
@pathchanged="pathChanged"
@loading="loadingChanged"
@refreshed="refreshPending = false"
/>
</VCol>
<VDivider v-if="tree" vertical />
<VCol>
<List
:path="path"

View File

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

View File

@@ -25,8 +25,8 @@ const isDownloading = ref(props.info?.state === 'downloading')
// 监听props.info?.state的变化
watch(() => props.info?.state, (newValue) => {
isDownloading.value = newValue === 'downloading';
});
isDownloading.value = newValue === 'downloading'
})
// 图片是否加载完成
const imageLoaded = ref(false)

View File

@@ -16,11 +16,6 @@ const props = defineProps({
height: String,
})
// 订阅规则
const subscribeRules = ref({
show_edit_dialog: false,
})
// 提示框
const $toast = useToast()
@@ -57,6 +52,15 @@ const seasonInfos = ref<TmdbSeason[]>([])
// 选中的订阅季
const seasonsSelected = ref<TmdbSeason[]>([])
// 获得mediaid
function getMediaId() {
return props.media?.tmdb_id
? `tmdb:${props.media?.tmdb_id}`
: props.media?.douban_id
? `douban:${props.media?.douban_id}`
: `bangumi:${props.media?.bangumi_id}`
}
// 订阅弹窗选择的多季
function subscribeSeasons() {
subscribeSeasonDialog.value = false
@@ -131,6 +135,7 @@ async function addSubscribe(season = 0) {
year: props.media?.year,
tmdbid: props.media?.tmdb_id,
doubanid: props.media?.douban_id,
bangumiid: props.media?.bangumi_id,
season,
best_version,
})
@@ -151,9 +156,12 @@ async function addSubscribe(season = 0) {
)
// 弹出订阅编辑弹窗
if (result.success && seasonsSelected.value.length <= 1 && subscribeRules.value.show_edit_dialog) {
subscribeId.value = result.data.id
subscribeEditDialog.value = true
if (result.success && seasonsSelected.value.length <= 1) {
const show_edit_dialog = await querySubscribeRules()
if (show_edit_dialog) {
subscribeId.value = result.data.id
subscribeEditDialog.value = true
}
}
}
catch (error) {
@@ -186,9 +194,7 @@ async function removeSubscribe() {
// 开始处理
startNProgress()
try {
const mediaid = props.media?.tmdb_id
? `tmdb:${props.media?.tmdb_id}`
: `douban:${props.media?.douban_id}`
const mediaid = getMediaId()
const result: { [key: string]: any } = await api.delete(
`subscribe/media/${mediaid}`,
@@ -249,9 +255,7 @@ async function handleCheckExists() {
// 调用API检查是否已订阅电视剧需要指定季
async function checkSubscribe(season = 0) {
try {
const mediaid = props.media?.tmdb_id
? `tmdb:${props.media?.tmdb_id}`
: `douban:${props.media?.douban_id}`
const mediaid = getMediaId()
const result: Subscribe = await api.get(`subscribe/media/${mediaid}`, {
params: {
@@ -314,11 +318,12 @@ async function querySubscribeRules() {
'system/setting/DefaultFilterRules',
)
if (result.data?.value)
subscribeRules.value = result.data?.value
return result.data.value.show_edit_dialog
}
catch (error) {
console.log(error)
}
return false
}
// 爱心订阅按钮响应
@@ -362,11 +367,7 @@ function goMediaDetail() {
router.push({
path: '/media',
query: {
mediaid: `${
props.media?.tmdb_id
? `tmdb:${props.media?.tmdb_id}`
: `douban:${props.media?.douban_id}`
}`,
mediaid: getMediaId(),
type: props.media?.type,
},
})
@@ -377,11 +378,7 @@ function handleSearch() {
router.push({
path: '/resource',
query: {
keyword: `${
props.media?.tmdb_id
? `tmdb:${props.media?.tmdb_id}`
: `douban:${props.media?.douban_id}`
}`,
keyword: getMediaId(),
type: props.media?.type,
area: 'title',
},
@@ -392,7 +389,6 @@ function handleSearch() {
onBeforeMount(() => {
handleCheckSubscribe()
handleCheckExists()
querySubscribeRules()
})
// 计算图片地址

View File

@@ -0,0 +1,112 @@
<script lang="ts" setup>
import type { Message } from '@/api/types'
import { formatDateDifference } from '@core/utils/formatters'
// 输入参数
const props = defineProps({
message: Object as PropType<Message>,
width: String,
height: String,
})
// 图片是否加载完成
const isImageLoaded = ref(false)
// 图片是否加载失败
const imageLoadError = ref(false)
// 图片加载完成
async function imageLoaded() {
isImageLoaded.value = true
}
// 链接打开新窗口
function openLink() {
if (props.message?.link)
window.open(props.message.link, '_blank')
}
// 将note转换为json
function noteToJson() {
if (props.message?.note) {
try {
return JSON.parse(props.message.note)
}
catch (error) {
console.error(error)
}
}
return {}
}
// 将\n转换为html属性的换行符
function replaceNewLine(value: string) {
if (!value)
return ''
return value.replace(/\n/g, '<br/>')
}
</script>
<template>
<VCard
:width="props.width"
:height="props.height"
variant="tonal"
@click="openLink"
>
<div
v-if="props.message?.image"
class="relative text-center card-cover-blurred"
>
<VImg
:src="props.message?.image"
aspect-ratio="4/3"
cover
:class="{ shadow: isImageLoaded }"
@load="imageLoaded"
@error="imageLoadError = true"
/>
</div>
<VCardTitle v-if="props.message?.title" class="whitespace-break-spaces">
{{ props.message?.title }}
</VCardTitle>
<VAlert
v-if="props.message?.text && props.message?.action === 0"
variant="tonal"
type="success"
>
<template #prepend />
{{ props.message?.text }}
</VAlert>
<VCardText
v-if="props.message?.text && props.message?.action === 1"
v-html="replaceNewLine(props.message?.text)"
/>
<VCardText v-if="props.message?.note">
<VList>
<VListItem
v-for="(value, key) in noteToJson()"
:key="key"
two-line
>
<VListItemTitle v-if="value.title_year" class="font-bold">
{{ key + 1 }}. {{ value.title_year }}
</VListItemTitle>
<VListItemTitle v-if="value.enclosure" class="font-bold whitespace-break-spaces">
{{ key + 1 }}. {{ value.title }} {{ value.volume_factor }} {{ value.seeders }}
</VListItemTitle>
<VListItemSubtitle v-if="value.type">
类型{{ value.type }} 评分{{ value.vote_average }}
</VListItemSubtitle>
<VListItemSubtitle v-if="value.enclosure" class="whitespace-break-spaces">
{{ value.description }}
</VListItemSubtitle>
</VListItem>
</VList>
</VCardText>
<div class="text-end">
<span v-if="props.message?.action === 0" class="text-sm italic me-2">{{ props.message?.userid }}</span>
<span class="text-sm italic me-2">{{ formatDateDifference(props.message?.reg_time || props.message?.date || '') }}</span>
</div>
</VCard>
</template>

View File

@@ -49,7 +49,7 @@ async function installPlugin() {
try {
// 显示等待提示框
progressDialog.value = true
progressText.value = `正在安装 ${props.plugin?.plugin_name} ${props?.plugin?.plugin_version} 插件...`
progressText.value = `正在安装 ${props.plugin?.plugin_name} v${props?.plugin?.plugin_version} ...`
const result: { [key: string]: any } = await api.get(
`plugin/install/${props.plugin?.id}`,
@@ -163,15 +163,6 @@ const dropdownItems = ref([
</VMenu>
</IconBtn>
</div>
<div
v-if="props.plugin?.has_update"
class="me-n3 absolute top-0 left-1"
>
<VIcon
icon="mdi-new-box"
class="text-white"
/>
</div>
<VAvatar
size="8rem"
>
@@ -186,20 +177,22 @@ const dropdownItems = ref([
/>
</VAvatar>
</div>
<VCardTitle>{{ props.plugin?.plugin_name }}</VCardTitle>
<VCardText>
<VCardTitle>
{{ props.plugin?.plugin_name }}
<span class="text-sm text-gray-500">v{{ props.plugin?.plugin_version }}</span>
</VCardTitle>
<VCardText class="pb-2">
{{ props.plugin?.plugin_desc }}
</VCardText>
<VCardText>
作者<a
<VCardText class="flex items-center justify-start pb-2">
<VIcon icon="mdi-account" class="me-1" />
<a
:href="props.plugin?.author_url"
target="_blank"
@click.stop
>
{{ props.plugin?.plugin_author }}
</a><br>
版本{{ props.plugin?.plugin_version }}
</a>
</VCardText>
</VCard>
<!-- 安装插件进度框 -->

View File

@@ -41,12 +41,18 @@ const pluginConfigDialog = ref(false)
// 插件配置表单数据
const pluginConfigForm = ref({})
// 进度框
const progressDialog = ref(false)
// 插件表单配置项
let pluginFormItems = reactive([])
// 插件数据页面
const pluginInfoDialog = ref(false)
// 进度框文本
const progressText = ref('正在更新插件...')
// 插件数据页面配置项
let pluginPageItems = reactive([])
@@ -83,7 +89,12 @@ async function uninstallPlugin() {
return
try {
// 显示等待提示框
progressDialog.value = true
progressText.value = `正在卸载 ${props.plugin?.plugin_name} ...`
const result: { [key: string]: any } = await api.delete(`plugin/${props.plugin?.id}`)
// 隐藏等待提示框
progressDialog.value = false
if (result.success) {
$toast.success(`插件 ${props.plugin?.plugin_name} 已卸载`)
@@ -221,6 +232,41 @@ async function resetPlugin() {
}
}
// 更新插件
async function updatePlugin() {
try {
// 显示等待提示框
progressDialog.value = true
progressText.value = `正在更新 ${props.plugin?.plugin_name} ...`
const result: { [key: string]: any } = await api.get(
`plugin/install/${props.plugin?.id}`,
{
params: {
repo_url: props.plugin?.repo_url,
force: true,
},
},
)
// 隐藏等待提示框
progressDialog.value = false
if (result.success) {
$toast.success(`插件 ${props.plugin?.plugin_name} 更新成功!`)
// 通知父组件刷新
emit('save')
}
else {
$toast.error(`插件 ${props.plugin?.plugin_name} 更新失败:${result.message}`)
}
}
catch (error) {
console.error(error)
}
}
// 访问作者主页
function visitAuthorPage() {
window.open(props.plugin?.author_url, '_blank')
@@ -254,8 +300,18 @@ const dropdownItems = ref([
},
},
{
title: '重置',
title: '更新',
value: 3,
show: props.plugin?.has_update,
props: {
prependIcon: 'mdi-arrow-up-circle-outline',
color: 'success',
click: updatePlugin,
},
},
{
title: '重置',
value: 4,
show: true,
props: {
prependIcon: 'mdi-cancel',
@@ -265,7 +321,7 @@ const dropdownItems = ref([
},
{
title: '卸载',
value: 4,
value: 5,
show: true,
props: {
prependIcon: 'mdi-trash-can-outline',
@@ -275,7 +331,7 @@ const dropdownItems = ref([
},
{
title: '查看日志',
value: 5,
value: 6,
show: true,
props: {
prependIcon: 'mdi-file-document-outline',
@@ -286,7 +342,7 @@ const dropdownItems = ref([
},
{
title: '作者主页',
value: 5,
value: 7,
show: true,
props: {
prependIcon: 'mdi-home-circle-outline',
@@ -294,6 +350,13 @@ const dropdownItems = ref([
},
},
])
// 监听插件状态变化
watch(() => props.plugin?.has_update, (newHasUpdate, oldHasUpdate) => {
const updateItemIndex = dropdownItems.value.findIndex(item => item.value === 3)
if (updateItemIndex !== -1)
dropdownItems.value[updateItemIndex].show = newHasUpdate
})
</script>
<template>
@@ -313,6 +376,15 @@ const dropdownItems = ref([
class="relative pa-4 text-center card-cover-blurred"
:style="{ background: `${backgroundColor}` }"
>
<div
v-if="props.plugin?.has_update"
class="me-n3 absolute top-0 left-1"
>
<VIcon
icon="mdi-new-box"
class="text-white"
/>
</div>
<div class="me-n3 absolute top-0 right-3">
<IconBtn>
<VIcon icon="mdi-dots-vertical" class="text-white" />
@@ -355,7 +427,7 @@ const dropdownItems = ref([
<VCardItem class="py-2">
<VCardTitle class="flex items-center flex-row">
<VBadge v-if="props.plugin?.state" dot inline color="success" class="me-1 mb-1" />
{{ props.plugin?.plugin_name }}<span class="text-sm ms-2 mt-1 text-gray-500">{{ props.plugin?.plugin_version }}</span>
{{ props.plugin?.plugin_name }}<span class="text-sm ms-2 mt-1 text-gray-500">v{{ props.plugin?.plugin_version }}</span>
</VCardTitle>
</VCardItem>
<VCardText>
@@ -430,6 +502,25 @@ const dropdownItems = ref([
</VCardActions>
</VCard>
</VDialog>
<!-- 更新插件进度框 -->
<VDialog
v-model="progressDialog"
:scrim="false"
width="25rem"
>
<VCard
color="primary"
>
<VCardText class="text-center">
{{ progressText }}
<VProgressLinear
indeterminate
color="white"
class="mb-0 mt-1"
/>
</VCardText>
</VCard>
</VDialog>
</template>
<style lang="scss" scoped>

View File

@@ -2,10 +2,10 @@
import type { PropType } from 'vue'
import { useToast } from 'vue-toast-notification'
import SiteAddEditForm from '../form/SiteAddEditForm.vue'
import { formatFileSize } from '@core/utils/formatters'
import SiteTorrentTable from '../table/SiteTorrentTable.vue'
import { requiredValidator } from '@/@validators'
import api from '@/api'
import type { Site, TorrentInfo } from '@/api/types'
import type { Site } from '@/api/types'
import ExistIcon from '@core/components/ExistIcon.vue'
// 输入参数
@@ -51,31 +51,6 @@ const progressDialog = ref(false)
// 进度文本
const progressText = ref('请稍候 ...')
// 资源浏览表头
const resourceHeaders = [
{ title: '标题', key: 'title', sortable: false },
{ title: '时间', key: 'pubdate', sortable: true },
{ title: '大小', key: 'size', sortable: true },
{ title: '做种', key: 'seeders', sortable: true },
{ title: '下载', key: 'peers', sortable: true },
{ title: '', key: 'actions', sortable: false },
]
// 数据列表
const resourceDataList = ref<TorrentInfo[]>([])
// 搜索
const resourceSearch = ref('')
// 加载状态
const resourceLoading = ref(false)
// 总条数
const resourceTotalItems = ref(0)
// 每页条数
const resourceItemsPerPage = ref(25)
// 用户名密码表单
const userPwForm = ref({
username: '',
@@ -83,16 +58,6 @@ const userPwForm = ref({
code: '',
})
// 打开种子详情页面
function openTorrentDetail(page_url: string) {
window.open(page_url, '_blank')
}
// 下载种子文件
async function downloadTorrentFile(enclosure: string) {
window.open(enclosure, '_blank')
}
// 查询站点图标
async function getSiteIcon() {
try {
@@ -131,7 +96,6 @@ async function handleSiteUpdate() {
// 打开资源浏览弹窗
async function handleResourceBrowse() {
resourceDialog.value = true
getResourceList()
}
// 调用API更新站点Cookie UA
@@ -171,30 +135,6 @@ async function updateSiteCookie() {
}
}
// 促销Chip类
function getVolumeFactorClass(downloadVolume: number, uploadVolume: number) {
if (downloadVolume === 0)
return 'text-white bg-lime-500'
else if (downloadVolume < 1)
return 'text-white bg-green-500'
else if (uploadVolume !== 1)
return 'text-white bg-sky-500'
else
return 'text-white bg-gray-500'
}
// 调用API查询站点资源
async function getResourceList() {
resourceLoading.value = true
try {
resourceDataList.value = await api.get(`site/resource/${cardProps.site?.id}`)
resourceLoading.value = false
}
catch (error) {
console.error(error)
}
}
// 打开站点页面
function openSitePage() {
window.open(cardProps.site?.url, '_blank')
@@ -397,127 +337,13 @@ onMounted(() => {
v-model="resourceDialog"
max-width="80rem"
scrollable
z-index="1010"
>
<!-- Dialog Content -->
<VCard :title="`浏览站点 - ${cardProps.site?.name}`">
<DialogCloseBtn @click="resourceDialog = false" />
<VCardText class="pt-2">
<VDataTable
v-model:items-per-page="resourceItemsPerPage"
:headers="resourceHeaders"
:items="resourceDataList"
:items-length="resourceTotalItems"
:search="resourceSearch"
:loading="resourceLoading"
density="compact"
item-value="title"
return-object
fixed-header
items-per-page-text="每页条数"
page-text="{0}-{1} {2} "
>
<template #item.title="{ item }">
<div class="text-high-emphasis pt-1">
{{ item.raw.title }}
</div>
<div class="text-sm my-1">
{{ item.raw.description }}
</div>
<VChip
v-if="item.raw?.hit_and_run"
variant="elevated"
size="small"
class="me-1 mb-1 text-white bg-black"
>
H&R
</VChip>
<VChip
v-if="item.raw?.freedate_diff"
variant="elevated"
color="secondary"
size="small"
class="me-1 mb-1"
>
{{ item.raw?.freedate_diff }}
</VChip>
<VChip
v-for="(label, index) in item.raw?.labels"
:key="index"
variant="elevated"
size="small"
color="primary"
class="me-1 mb-1"
>
{{ label }}
</VChip>
<VChip
v-if="item.raw?.downloadvolumefactor !== 1 || item.raw?.uploadvolumefactor !== 1"
:class="
getVolumeFactorClass(item.raw?.downloadvolumefactor, item.raw?.uploadvolumefactor)
"
variant="elevated"
size="small"
class="me-1 mb-1"
>
{{ item.raw?.volume_factor }}
</VChip>
</template>
<template #item.pubdate="{ item }">
<div>{{ item.raw.date_elapsed }}</div>
<div class="text-sm">
{{ item.raw.pubdate }}
</div>
</template>
<template #item.size="{ item }">
<div class="text-nowrap whitespace-nowrap">
{{ formatFileSize(item.raw.size) }}
</div>
</template>
<template #item.seeders="{ item }">
<div>{{ item.raw.seeders }}</div>
</template>
<template #item.peers="{ item }">
<div>{{ item.raw.peers }}</div>
</template>
<template #item.actions="{ item }">
<div class="me-n3">
<IconBtn>
<VIcon
icon="mdi-dots-vertical"
/>
<VMenu
activator="parent"
close-on-content-click
>
<VList>
<VListItem
variant="plain"
@click="openTorrentDetail(item.raw.page_url)"
>
<template #prepend>
<VIcon icon="mdi-information" />
</template>
<VListItemTitle>查看详情</VListItemTitle>
</VListItem>
<VListItem
v-if="item.raw.enclosure?.startsWith('http')"
variant="plain"
@click="downloadTorrentFile(item.raw.enclosure)"
>
<template #prepend>
<VIcon icon="mdi-download" />
</template>
<VListItemTitle>下载种子文件</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</div>
</template>
<template #no-data>
没有数据
</template>
</VDataTable>
<SiteTorrentTable :site="cardProps.site?.id" />
</VCardText>
</VCard>
</VDialog>

View File

@@ -10,6 +10,7 @@ import type { Context, EndPoints, FileItem } from '@/api/types'
import store from '@/store'
import api from '@/api'
import MediaInfoCard from '@/components/cards/MediaInfoCard.vue'
import { useDefer } from '@/@core/utils/dom'
// 输入参数
const inProps = defineProps({
@@ -73,6 +74,9 @@ const nameTestResult = ref<Context>()
// 识别结果对话框
const nameTestDialog = ref(false)
// 延迟加载
let defer = (_: number) => true
// 目录过滤
const dirs = computed(() =>
items.value.filter(item => item.type === 'dir' && item.basename.includes(filter.value)),
@@ -111,6 +115,7 @@ async function load() {
}
// 加载数据
items.value = await axiosInstance.value.request(config) ?? []
defer = useDefer(items.value.length)
emit('loading', false)
loading.value = false
}
@@ -382,6 +387,7 @@ onMounted(() => {
>
<template #default="hover">
<VListItem
v-if="defer(index)"
v-bind="hover.props"
class="px-3 pe-1"
@click="changePath(item.path)"
@@ -466,6 +472,7 @@ onMounted(() => {
>
<template #default="hover">
<VListItem
v-if="defer(index)"
v-bind="hover.props"
class="pl-3 pe-1"
@click="changePath(item.path)"

View File

@@ -1,203 +0,0 @@
<script lang="ts" setup>
import type { Axios } from 'axios'
import type { EndPoints, FileItem } from '@/api/types'
// 输入参数
const props = defineProps({
icons: Object,
storage: String,
path: String,
endpoints: Object as PropType<EndPoints>,
axios: Object as PropType<Axios>,
refreshpending: Boolean,
})
// 对外事件
const emit = defineEmits(['pathchanged', 'loading', 'refreshed'])
// 变量
const open = ref<string[]>([])
// 活跃的文件夹
const active = ref<string[]>([])
// 内容
const items = ref<FileItem[]>([])
// 过滤
const filter = ref('')
// 方法
function init() {
open.value = []
items.value = [{
type: 'dir',
path: '/',
basename: 'root',
extension: '',
name: 'root',
children: [],
size: 0,
modify_time: 0,
}]
}
// 调用API读取文件夹
async function readFolder(item: FileItem) {
emit('loading', true)
const url = props.endpoints?.list.url
.replace(/{storage}/g, props.storage)
.replace(/{path}/g, item.path)
const config = {
url,
method: props.endpoints?.list.method || 'get',
}
const response: FileItem[] = await props.axios?.request(config) ?? []
item.children = response.map((item: FileItem) => {
if (item.type === 'dir')
item.children = []
return item
})
emit('loading', false)
}
// 选中变化
function activeChanged(_active: string[]) {
let path = ''
if (active.value.length)
path = active.value[0]
if (props.path !== path)
emit('pathchanged', path)
}
// 查找文件
function findItem(path: string) {
const stack: FileItem[] = []
stack.push(items.value[0])
while (stack.length > 0) {
const node = stack.pop()
if (node?.path === path) {
return node
}
else if (node?.children && node.children.length) {
for (const element of node.children)
stack.push(element)
}
}
return null
}
// 监听存储空间变量
watch(() => props.storage, () => {
init()
})
// 监听路径变化
watch(
() => props.path,
() => {
if (props.path) {
active.value = [props.path]
if (!open.value.includes(props.path))
open.value.push(props.path)
}
})
// 监听 refreshPending
watch(
() => props.refreshpending,
async () => {
if (props.refreshpending && props.path) {
const item = findItem(props.path)
if (item) {
await readFolder(item)
emit('refreshed')
}
}
},
)
onMounted(() => {
init()
})
</script>
<template>
<VCard flat width="250" min-height="500" class="d-flex flex-column folders-tree-card">
<div class="grow scroll-x">
<VTreeview
:open="open"
:active="active"
:items="items"
:search="filter"
:load-children="readFolder"
item-key="path"
item-text="basename"
dense
activatable
transition
class="folders-tree"
@update:active="activeChanged"
>
<template #prepend="{ item, open }">
<VIcon
v-if="item.type === 'dir'"
>
{{ open ? 'mdi-folder-open-outline' : 'mdi-folder-outline' }}
</VIcon>
<VIcon v-else-if="props.icons" :icon="props.icons[item.extension.toLowerCase()] || props.icons.other" />
</template>
<template #label="{ item }">
{{ item.basename }}
<VBtn
v-if="item.type === 'dir'"
icon
class="ml-1"
@click.stop="readFolder(item)"
>
<VIcon class="pa-0 mdi-18px" color="grey lighten-1">
mdi-refresh
</VIcon>
</VBtn>
</template>
</VTreeview>
</div>
<VDivider />
<VToolbar
density="compact"
>
<VBtn icon @click="init">
<VIcon icon="mdi-collapse-all-outline" />
</VBtn>
</VToolbar>
</VCard>
</template>
<style lang="scss" scoped>
.folders-tree-card {
height: 100%;
.scroll-x {
overflow-x: auto;
}
::v-deep .folders-tree {
width: fit-content;
min-width: 250px;
.v-treeview-node {
cursor: pointer;
&:hover {
background-color: rgba(0, 0, 0, 0.02);
}
}
}
}
.v-toolbar{
background: rgb(var(--v-table-header-background));
}
</style>

View File

@@ -30,7 +30,7 @@ const subscribeForm = ref<Subscribe>({
total_episode: 0,
start_episode: 0,
best_version: 0,
search_imdbid: false,
search_imdbid: 0,
sites: [],
type: '',
name: '',
@@ -100,6 +100,7 @@ async function getSubscribeInfo() {
)
subscribeForm.value = result
subscribeForm.value.best_version = subscribeForm.value.best_version === 1
subscribeForm.value.search_imdbid = subscribeForm.value.search_imdbid === 1
}
catch (e) {
console.log(e)

View File

@@ -0,0 +1,244 @@
<script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import { useConfirm } from 'vuetify-use-dialog'
import api from '@/api'
import type { TorrentInfo } from '@/api/types'
import { doneNProgress, startNProgress } from '@/api/nprogress'
import { formatFileSize } from '@core/utils/formatters'
// 输入参数
const props = defineProps({
site: Number,
width: String,
height: String,
})
// 提示框
const $toast = useToast()
// 确认框
const createConfirm = useConfirm()
// 数据列表
const resourceDataList = ref<TorrentInfo[]>([])
// 搜索
const resourceSearch = ref('')
// 总条数
const resourceTotalItems = ref(0)
// 每页条数
const resourceItemsPerPage = ref(25)
// 加载状态
const resourceLoading = ref(false)
// 资源浏览表头
const resourceHeaders = [
{ title: '标题', key: 'title', sortable: false },
{ title: '时间', key: 'pubdate', sortable: true },
{ title: '大小', key: 'size', sortable: true },
{ title: '做种', key: 'seeders', sortable: true },
{ title: '下载', key: 'peers', sortable: true },
{ title: '', key: 'actions', sortable: false },
]
// 打开种子详情页面
function openTorrentDetail(page_url: string) {
window.open(page_url, '_blank')
}
// 下载种子文件
async function downloadTorrentFile(enclosure: string) {
window.open(enclosure, '_blank')
}
// 调用API查询站点资源
async function getResourceList() {
resourceLoading.value = true
try {
resourceDataList.value = await api.get(`site/resource/${props.site}`)
resourceLoading.value = false
}
catch (error) {
console.error(error)
}
}
// 促销Chip类
function getVolumeFactorClass(downloadVolume: number, uploadVolume: number) {
if (downloadVolume === 0)
return 'text-white bg-lime-500'
else if (downloadVolume < 1)
return 'text-white bg-green-500'
else if (uploadVolume !== 1)
return 'text-white bg-sky-500'
else
return 'text-white bg-gray-500'
}
// 添加下载
async function addDownload(_torrent: any) {
const isConfirmed = await createConfirm({
title: '确认',
content: `是否确认下载【${_torrent.site_name}${_torrent?.title} ?`,
confirmationText: '确认',
cancellationText: '取消',
dialogProps: {
maxWidth: '50rem',
},
confirmationButtonProps: {
variant: 'tonal',
},
})
if (!isConfirmed)
return
startNProgress()
try {
const result: { [key: string]: any } = await api.post('download/add', _torrent)
if (result.success) {
// 添加下载成功
$toast.success(`${_torrent?.site_name} ${_torrent?.title} 添加下载成功!`)
}
else {
// 添加下载失败
$toast.error(`${_torrent?.site_name} ${_torrent?.title} 添加下载失败:${result.message || '未知错误'}`)
}
}
catch (error) {
console.error(error)
}
doneNProgress()
}
// 装载时查询站点图标
onMounted(() => {
getResourceList()
})
</script>
<template>
<VDataTable
v-model:items-per-page="resourceItemsPerPage"
:headers="resourceHeaders"
:items="resourceDataList"
:items-length="resourceTotalItems"
:search="resourceSearch"
:loading="resourceLoading"
density="compact"
item-value="title"
return-object
fixed-header
hover
items-per-page-text="每页条数"
page-text="{0}-{1} {2} "
>
<template #item.title="{ item }">
<a href="javascript:void(0)" @click.stop="addDownload(item.raw)">
<div class="text-high-emphasis pt-1">
{{ item.raw.title }}
</div>
<div class="text-sm my-1">
{{ item.raw.description }}
</div>
<VChip
v-if="item.raw?.hit_and_run"
variant="elevated"
size="small"
class="me-1 mb-1 text-white bg-black"
>
H&R
</VChip>
<VChip
v-if="item.raw?.freedate_diff"
variant="elevated"
color="secondary"
size="small"
class="me-1 mb-1"
>
{{ item.raw?.freedate_diff }}
</VChip>
<VChip
v-for="(label, index) in item.raw?.labels"
:key="index"
variant="elevated"
size="small"
color="primary"
class="me-1 mb-1"
>
{{ label }}
</VChip>
<VChip
v-if="item.raw?.downloadvolumefactor !== 1 || item.raw?.uploadvolumefactor !== 1"
:class="
getVolumeFactorClass(item.raw?.downloadvolumefactor, item.raw?.uploadvolumefactor)
"
variant="elevated"
size="small"
class="me-1 mb-1"
>
{{ item.raw?.volume_factor }}
</VChip>
</a>
</template>
<template #item.pubdate="{ item }">
<div>{{ item.raw.date_elapsed }}</div>
<div class="text-sm">
{{ item.raw.pubdate }}
</div>
</template>
<template #item.size="{ item }">
<div class="text-nowrap whitespace-nowrap">
{{ formatFileSize(item.raw.size) }}
</div>
</template>
<template #item.seeders="{ item }">
<div>{{ item.raw.seeders }}</div>
</template>
<template #item.peers="{ item }">
<div>{{ item.raw.peers }}</div>
</template>
<template #item.actions="{ item }">
<div class="me-n3">
<IconBtn>
<VIcon
icon="mdi-dots-vertical"
/>
<VMenu
activator="parent"
close-on-content-click
>
<VList>
<VListItem
variant="plain"
@click="openTorrentDetail(item.raw.page_url)"
>
<template #prepend>
<VIcon icon="mdi-information" />
</template>
<VListItemTitle>查看详情</VListItemTitle>
</VListItem>
<VListItem
v-if="item.raw.enclosure?.startsWith('http')"
variant="plain"
@click="downloadTorrentFile(item.raw.enclosure)"
>
<template #prepend>
<VIcon icon="mdi-download" />
</template>
<VListItemTitle>下载种子文件</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</div>
</template>
<template #no-data>
没有数据
</template>
</VDataTable>
</template>

View File

@@ -44,7 +44,7 @@ const superUser = store.state.auth.superUser
</IconBtn>
<!-- 👉 Shortcuts -->
<ShortcutBar />
<ShortcutBar v-if="superUser" />
<!-- 👉 Theme -->
<NavbarThemeSwitcher class="me-2" />

View File

@@ -4,7 +4,9 @@ import NetTestView from '@/views/system/NetTestView.vue'
import LoggingView from '@/views/system/LoggingView.vue'
import RuleTestView from '@/views/system/RuleTestView.vue'
import ModuleTestView from '@/views/system/ModuleTestView.vue'
import MessageView from '@/views/system/MessageView.vue'
import store from '@/store'
import api from '@/api'
// App捷径
const appsMenu = ref(false)
@@ -24,11 +26,53 @@ const ruleTestDialog = ref(false)
// 系统健康检查弹窗
const systemTestDialog = ref(false)
// 消息中心弹窗
const messageDialog = ref(false)
// 输入消息
const user_message = ref('')
// 发送按钮是否可用
const sendButtonDisabled = ref(false)
// 聊天容器
const chatContainer = ref<HTMLDivElement>()
// 滚动到底部
function scrollMessageToEnd() {
nextTick(() => {
if (chatContainer.value) {
const scrollDiv = chatContainer.value.$el
scrollDiv.scrollTop = scrollDiv.scrollHeight
}
})
}
// 拼接全部日志url
function allLoggingUrl() {
const token = store.state.auth.token
return `${import.meta.env.VITE_API_BASE_URL}system/logging?token=${token}&length=-1`
}
// 发送消息
async function sendMessage() {
if (user_message.value) {
try {
sendButtonDisabled.value = true
await api.post(`message/web?text=${user_message.value}`)
user_message.value = ''
sendButtonDisabled.value = false
scrollMessageToEnd()
}
catch (error) {
console.error(error)
}
}
}
onMounted(() => {
scrollMessageToEnd()
})
</script>
<template>
@@ -124,7 +168,7 @@ function allLoggingUrl() {
<h6 class="text-base font-weight-medium mt-2 mb-0">
日志
</h6>
<span class="text-sm">查看实时日志</span>
<span class="text-sm">实时日志</span>
</VListItem>
</VCol>
<VCol
@@ -145,7 +189,7 @@ function allLoggingUrl() {
<h6 class="text-base font-weight-medium mt-2 mb-0">
网络
</h6>
<span class="text-sm">测试网速连通性</span>
<span class="text-sm">网速连通性测试</span>
</VListItem>
</VCol>
</VRow>
@@ -168,7 +212,28 @@ function allLoggingUrl() {
<h6 class="text-base font-weight-medium mt-2 mb-0">
系统
</h6>
<span class="text-sm">系统健康检查</span>
<span class="text-sm">健康检查</span>
</VListItem>
</VCol>
<VCol
cols="6"
class="text-center cursor-pointer pa-0 shortcut-icon border-e"
@click="() => {}"
>
<VListItem
class="pa-4"
@click="messageDialog = true"
>
<VAvatar
size="48"
variant="tonal"
>
<VIcon icon="mdi-message-outline" />
</VAvatar>
<h6 class="text-base font-weight-medium mt-2 mb-0">
消息
</h6>
<span class="text-sm">消息中心</span>
</VListItem>
</VCol>
</VRow>
@@ -249,4 +314,41 @@ function allLoggingUrl() {
</VCardText>
</VCard>
</VDialog>
<!-- 消息中心弹窗 -->
<VDialog
v-model="messageDialog"
max-width="60rem"
scrollable
>
<VCard title="消息中心">
<DialogCloseBtn @click="messageDialog = false" />
<VCardText ref="chatContainer">
<MessageView @scroll="scrollMessageToEnd" />
</VCardText>
<VCardItem>
<VTextField
v-model="user_message"
placeholder="输入消息或命令"
outlined
hide-details
single-line
clearable
density="compact"
:disabled="sendButtonDisabled"
@keydown.enter="sendMessage"
>
<template #append>
<VBtn
color="primary"
:disabled="sendButtonDisabled"
@click="sendMessage"
>
发送
</VBtn>
</template>
</VTextField>
</VCardItem>
</VCard>
</VDialog>
</template>

View File

@@ -16,6 +16,12 @@ import MediaCardSlideView from '@/views/discover/MediaCardSlideView.vue'
title="正在热映"
/>
<MediaCardSlideView
apipath="bangumi/calendar"
linkurl="/browse/bangumi/calendar?title=Bangumi每日放送"
title="Bangumi每日放送"
/>
<MediaCardSlideView
apipath="tmdb/movies"
linkurl="/browse/tmdb/movies?title=热门电影"

View File

@@ -75,7 +75,7 @@ async function fetchData() {
else {
startLoadingProgress()
// 优先按TMDBID精确查询
if (keyword?.startsWith('tmdb:') || keyword?.startsWith('douban:')) {
if (keyword?.startsWith('tmdb:') || keyword?.startsWith('douban:') || keyword?.startsWith('bangumi:')) {
dataList.value = await api.get(`search/media/${keyword}`, {
params: {
mtype: type,

View File

@@ -44,7 +44,7 @@ onMounted(fetchData)
<template #content>
<template
v-for="data in dataList"
:key="data.tmdb_id || data.douban_id"
:key="data.tmdb_id || data.douban_id || data.bangumi_id"
>
<MediaCard
:media="data"

View File

@@ -51,6 +51,15 @@ const subscribeRules = ref({
show_edit_dialog: false,
})
// 获得mediaid
function getMediaId() {
return mediaDetail.value?.tmdb_id
? `tmdb:${mediaDetail.value?.tmdb_id}`
: mediaDetail.value?.douban_id
? `douban:${mediaDetail.value?.douban_id}`
: `bangumi:${mediaDetail.value?.bangumi_id}`
}
// 调用API查询详情
async function getMediaDetail() {
if (mediaProps.mediaid && mediaProps.type) {
@@ -60,7 +69,7 @@ async function getMediaDetail() {
},
})
isRefreshed.value = true
if (!mediaDetail.value.tmdb_id && !mediaDetail.value.douban_id)
if (!mediaDetail.value.tmdb_id && !mediaDetail.value.douban_id && !mediaDetail.value.bangumi_id)
return
// 检查存在状态
@@ -113,7 +122,7 @@ async function checkExists() {
// 查询当前媒体是否已订阅
async function checkSubscribe(season = 0) {
try {
const mediaid = mediaDetail.value.tmdb_id ? `tmdb:${mediaDetail.value.tmdb_id}` : `douban:${mediaDetail.value.douban_id}`
const mediaid = getMediaId()
const result: Subscribe = await api.get(`subscribe/media/${mediaid}`, {
params: {
@@ -198,6 +207,7 @@ async function addSubscribe(season = 0) {
year: mediaDetail.value?.year,
tmdbid: mediaDetail.value?.tmdb_id,
doubanid: mediaDetail.value?.douban_id,
bangumiid: mediaDetail.value?.bangumi_id,
season,
best_version,
})
@@ -253,9 +263,7 @@ async function removeSubscribe(season: number) {
// 开始处理
startNProgress()
try {
const mediaid = mediaDetail.value?.tmdb_id
? `tmdb:${mediaDetail.value?.tmdb_id}`
: `douban:${mediaDetail.value?.douban_id}`
const mediaid = getMediaId()
const result: { [key: string]: any } = await api.delete(
`subscribe/media/${mediaid}`,
@@ -330,6 +338,11 @@ function getTvdbLink() {
return `https://www.thetvdb.com/series/${mediaDetail.value.tvdb_id}`
}
// 拼装Bangumi地址
function getBangumiLink() {
return `https://bgm.tv/subject/${mediaDetail.value.bangumi_id}`
}
// 拼装集图片地址
function getEpisodeImage(stillPath: string) {
if (!stillPath)
@@ -405,7 +418,7 @@ function joinArray(arr: string[]) {
// 开始搜索
function handleSearch(area: string) {
const keyword = mediaDetail.value.tmdb_id ? `tmdb:${mediaDetail.value.tmdb_id}` : `douban:${mediaDetail.value.douban_id}`
const keyword = getMediaId()
router.push({
path: '/resource',
query: {
@@ -453,7 +466,7 @@ onBeforeMount(() => {
color="primary"
/>
</div>
<div v-if="mediaDetail.tmdb_id || mediaDetail.douban_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">
<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 />
@@ -492,7 +505,7 @@ onBeforeMount(() => {
</span>
</div>
<div class="media-actions">
<VBtn v-if="mediaDetail.tmdb_id || mediaDetail.douban_id" variant="tonal" color="info" class="mb-2">
<VBtn v-if="mediaDetail.tmdb_id || mediaDetail.douban_id || mediaDetail.bangumi_id" variant="tonal" color="info" class="mb-2">
<template #prepend>
<VIcon icon="mdi-magnify" />
</template>
@@ -518,7 +531,7 @@ onBeforeMount(() => {
</VList>
</VMenu>
</VBtn>
<VBtn v-if="mediaDetail.type === '电影' || mediaDetail.douban_id" class="ms-2 mb-2" :color="getSubscribeColor" variant="tonal" @click="handleSubscribe(0)">
<VBtn v-if="mediaDetail.type === '电影' || mediaDetail.douban_id || mediaDetail.bangumi_id" class="ms-2 mb-2" :color="getSubscribeColor" variant="tonal" @click="handleSubscribe(0)">
<template #prepend>
<VIcon :icon="getSubscribeIcon" />
</template>
@@ -580,6 +593,12 @@ onBeforeMount(() => {
<span class="ms-1">TheTvDb</span>
</div>
</a>
<a v-if="mediaDetail.bangumi_id" class="mb-2 mr-2 inline-flex last:mr-0" :href="getBangumiLink()" target="_blank">
<div class="inline-flex cursor-pointer items-center rounded-full bg-gray-600 px-2 py-1 text-sm text-gray-200 ring-1 ring-gray-500 transition hover:bg-gray-700">
<VIcon icon="mdi-link" />
<span class="ms-1">Bangumi</span>
</div>
</a>
</div>
<h2 v-if="mediaDetail.type === '电视剧' && mediaDetail.tmdb_id" class="py-4">
@@ -740,6 +759,33 @@ onBeforeMount(() => {
</div>
</div>
</div>
<div v-else-if="mediaDetail.bangumi_id" class="media-overview-right">
<div class="media-facts">
<div v-if="mediaDetail.vote_average" class="media-ratings">
<VRating
v-model="mediaDetail.vote_average"
density="compact"
length="10"
class="ma-2"
readonly
/>
</div>
<div v-if="mediaDetail.bangumi_id" class="media-fact">
<span>ID</span>
<span class="media-fact-value">{{ mediaDetail.bangumi_id }}</span>
</div>
<div v-if="mediaDetail.original_title" class="media-fact">
<span>原始标题</span>
<span class="media-fact-value">{{ mediaDetail.original_title }}</span>
</div>
<div v-if="mediaDetail.release_date" class="media-fact border-b-0">
<span>上映日期</span>
<span class="media-fact-value">
{{ mediaDetail.release_date }}
</span>
</div>
</div>
</div>
</div>
<div v-if="mediaDetail.tmdb_id">
<PersonCardSlideView
@@ -757,6 +803,14 @@ onBeforeMount(() => {
type="douban"
/>
</div>
<div v-else-if="mediaDetail.bangumi_id">
<PersonCardSlideView
:apipath="`bangumi/credits/${mediaDetail.bangumi_id}`"
:linkurl="`/credits/bangumi/credits/${mediaDetail.bangumi_id}?title=演员阵容&type=bangumi`"
title="演员阵容"
type="bangumi"
/>
</div>
<div v-if="mediaDetail.tmdb_id">
<MediaCardSlideView
:apipath="`tmdb/recommend/${mediaDetail.tmdb_id}/${mediaProps.type}`"
@@ -771,6 +825,13 @@ onBeforeMount(() => {
title="推荐"
/>
</div>
<div v-else-if="mediaDetail.bangumi_id">
<MediaCardSlideView
:apipath="`bangumi/recommend/${mediaDetail.bangumi_id}`"
:linkurl="`/browse/bangumi/recommend/${mediaDetail.bangumi_id}?title=推荐`"
title="推荐"
/>
</div>
<div v-if="mediaDetail.tmdb_id">
<MediaCardSlideView
:apipath="`tmdb/similar/${mediaDetail.tmdb_id}/${mediaProps.type}`"
@@ -781,7 +842,7 @@ onBeforeMount(() => {
</div>
</div>
<NoDataFound
v-if="!mediaDetail.tmdb_id && !mediaDetail.douban_id && isRefreshed"
v-if="!mediaDetail.tmdb_id && !mediaDetail.douban_id && !mediaDetail.bangumi_id && isRefreshed"
error-code="500"
error-title="出错啦"
error-description="未识别到媒体信息"

View File

@@ -3,6 +3,7 @@ import TmdbPersonCard from '@/components/cards/TmdbPersonCard.vue'
import api from '@/api'
import SlideView from '@/components/slide/SlideView.vue'
import DoubanPersonCard from '@/components/cards/DoubanPersonCard.vue'
import BangumiPersonCard from '@/components/cards/BangumiPersonCard.vue'
// 输入参数
const props = defineProps({
@@ -59,6 +60,12 @@ onMounted(fetchData)
height="15rem"
width="10rem"
/>
<BangumiPersonCard
v-if="props.type === 'bangumi'"
:person="data"
height="15rem"
width="10rem"
/>
</template>
</template>
</SlideView>

View File

@@ -1,4 +1,5 @@
<script lang="ts" setup>
import { useDefer } from '@/@core/utils/dom'
import api from '@/api'
import type { Plugin } from '@/api/types'
import NoDataFound from '@/components/NoDataFound.vue'
@@ -20,6 +21,9 @@ const isAppMarketLoaded = ref(false)
// APP市场窗口
const PluginAppDialog = ref(false)
// 延迟加载
let defer = (_: number) => true
// 关闭插件市场窗口
function pluginDialogClose() {
PluginAppDialog.value = false
@@ -55,17 +59,39 @@ async function fetchUninstalledPlugins() {
state: 'market',
},
})
// 设置APP市场加载完成
isAppMarketLoaded.value = true
// 设置更新状态
for (const uninstalled of uninstalledList.value) {
for (const data of dataList.value) {
if (uninstalled.id === data.id) {
data.has_update = true
data.repo_url = uninstalled.repo_url
}
}
}
}
catch (error) {
console.error(error)
}
}
// 加载时获取数据
onBeforeMount(() => {
// 加载所有数据
function refreshData() {
fetchInstalledPlugins()
fetchUninstalledPlugins()
}
// 获取没有更新的插件
const getUnupdatedPlugins = computed(() => {
const list = uninstalledList.value.filter(item => !item.has_update)
defer = useDefer(list.length)
return list
})
// 加载时获取数据
onBeforeMount(() => {
refreshData()
})
</script>
@@ -89,8 +115,8 @@ onBeforeMount(() => {
v-for="data in dataList"
:key="data.id"
:plugin="data"
@remove="fetchInstalledPlugins"
@save="fetchInstalledPlugins"
@remove="refreshData"
@save="refreshData"
/>
</div>
<NoDataFound
@@ -152,13 +178,18 @@ onBeforeMount(() => {
color="primary"
/>
</div>
<div v-if="isAppMarketLoaded" class="grid gap-4 grid-plugin-card">
<PluginAppCard
v-for="data in uninstalledList"
:key="data.id"
:plugin="data"
@install="pluginInstalled"
/>
<div v-if="isAppMarketLoaded" class="grid gap-4 grid-plugin-card items-start">
<div
v-for="(data, index) in getUnupdatedPlugins"
:key="index"
>
<PluginAppCard
v-if="defer(index)"
:key="data.id"
:plugin="data"
@install="pluginInstalled"
/>
</div>
</div>
<NoDataFound
v-if="uninstalledList.length === 0 && isAppMarketLoaded"
@@ -173,7 +204,7 @@ onBeforeMount(() => {
<style lang="scss">
.grid-plugin-card {
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
padding-block-end: 1rem;
}
</style>

View File

@@ -369,7 +369,7 @@ onMounted(() => {
</td>
<td>{{ user.is_superuser ? "是" : "否" }}</td>
<td>
<IconBtn v-show="accountInfo.is_superuser && accountInfo.name != user.name">
<IconBtn v-show="accountInfo.is_superuser && accountInfo.name !== user.name">
<VIcon icon="mdi-dots-vertical" />
<VMenu
activator="parent"
@@ -409,11 +409,12 @@ onMounted(() => {
</VCard>
</VCol>
</VRow>
<!-- 站点编辑弹窗 -->
<!-- =弹窗 -->
<VDialog
v-model="addUserDialog"
max-width="50rem"
persistent
z-index="1010"
>
<!-- Dialog Content -->
<VCard title="新增用户">

View File

@@ -24,6 +24,7 @@ const cookieCloudSetting = ref({
COOKIECLOUD_PASSWORD: '',
COOKIECLOUD_INTERVAL: 0,
USER_AGENT: '',
COOKIECLOUD_ENABLE_LOCAL: '',
})
// 种子优先规则下拉框
@@ -108,6 +109,7 @@ async function loadCookieCloudSettings() {
COOKIECLOUD_PASSWORD,
COOKIECLOUD_INTERVAL,
USER_AGENT,
COOKIECLOUD_ENABLE_LOCAL,
} = result.data
cookieCloudSetting.value = {
COOKIECLOUD_HOST,
@@ -115,6 +117,7 @@ async function loadCookieCloudSettings() {
COOKIECLOUD_PASSWORD,
COOKIECLOUD_INTERVAL,
USER_AGENT,
COOKIECLOUD_ENABLE_LOCAL,
}
}
}
@@ -155,12 +158,18 @@ onMounted(() => {
<VCardSubtitle> 从CookieCloud快速同步站点数据 </VCardSubtitle>
<VCardText>
<VForm>
<VRow>
<VCol cols="12" md="6">
<VCheckbox v-model="cookieCloudSetting.COOKIECLOUD_ENABLE_LOCAL" label="启用本地CookieCloud服务器" />
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="6">
<VTextField
v-model="cookieCloudSetting.COOKIECLOUD_HOST"
label="CookieCloud服务器地址"
label="远程CookieCloud服务器地址"
placeholder="https://movie-pilot.org/cookiecloud"
:disabled="cookieCloudSetting.COOKIECLOUD_ENABLE_LOCAL"
/>
</VCol>
<VCol cols="12" md="6">

View File

@@ -4,6 +4,7 @@ import type { Site } from '@/api/types'
import SiteCard from '@/components/cards/SiteCard.vue'
import NoDataFound from '@/components/NoDataFound.vue'
import SiteAddEditForm from '@/components/form/SiteAddEditForm.vue'
import { useDefer } from '@/@core/utils/dom'
// 数据列表
const dataList = ref<Site[]>([])
@@ -14,11 +15,15 @@ const isRefreshed = ref(false)
// 新增站点对话框
const siteAddDialog = ref(false)
// 延迟加载
let defer = (_: number) => true
// 获取站点列表数据
async function fetchData() {
try {
dataList.value = await api.get('site/')
isRefreshed.value = true
defer = useDefer(dataList.value.length)
}
catch (error) {
console.error(error)
@@ -45,13 +50,18 @@ onBeforeMount(fetchData)
v-if="dataList.length > 0"
class="grid gap-3 grid-site-card"
>
<SiteCard
v-for="data in dataList"
:key="data.id"
:site="data"
@remove="fetchData"
@update="fetchData"
/>
<div
v-for="(data, index) in dataList"
:key="index"
>
<SiteCard
v-if="defer(index)"
:key="data.id"
:site="data"
@remove="fetchData"
@update="fetchData"
/>
</div>
</div>
<NoDataFound
v-if="dataList.length === 0 && isRefreshed"

View File

@@ -4,11 +4,14 @@ import store from '@/store'
// 日志列表
const logs = ref<string[]>([])
// SSE消息对象
let eventSource: EventSource | null = null
// SSE持续获取日志
function startSSELogging() {
const token = store.state.auth.token
if (token) {
const eventSource = new EventSource(
eventSource = new EventSource(
`${import.meta.env.VITE_API_BASE_URL}system/logging?token=${token}`,
)
@@ -17,10 +20,6 @@ function startSSELogging() {
if (message)
logs.value.push(message)
})
onBeforeUnmount(() => {
eventSource.close()
})
}
}
@@ -65,6 +64,11 @@ const extractLogDetails = computed(() => {
onMounted(() => {
startSSELogging()
})
onBeforeUnmount(() => {
if (eventSource)
eventSource.close()
})
</script>
<template>

View File

@@ -0,0 +1,156 @@
<script lang="ts" setup>
import store from '@/store'
import type { Message } from '@/api/types'
import MessageCard from '@/components/cards/MessageCard.vue'
import api from '@/api'
// 定义事件
const emit = defineEmits(['scroll'])
// 消息列表
const messages = ref<Message[]>([])
// 当前页数据
const currData = ref<Message[]>([])
// 是否完成加载
const isLoaded = ref(false)
// 是否加载中
const loading = ref(false)
// 当前页码
const page = ref(1)
// 存量消息最新时间
const lastTime = ref('')
// SSE消息对象
let eventSource: EventSource | null = null
// SSE持续获取消息
function startSSEMessager() {
const token = store.state.auth.token
if (token) {
eventSource = new EventSource(
`${import.meta.env.VITE_API_BASE_URL}system/message?token=${token}&role=user`,
)
eventSource.addEventListener('message', (event) => {
const message = event.data
if (message) {
const object = JSON.parse(message)
if (compareTime(object.date, lastTime.value) <= 0)
return
messages.value.push(object)
emit('scroll')
}
})
}
}
// 调用API加载存量消息
async function loadMessages({ done }: { done: any }) {
// 如果正在加载中,直接返回
if (loading.value) {
done('ok')
return
}
// 设置加载中
loading.value = true
try {
currData.value = await api.get('message/web', {
params: {
page: page.value,
size: 20,
},
})
if (currData.value.length > 0) {
// 取最后一条时间为存量消息最新时间
lastTime.value = currData.value[currData.value.length - 1].reg_time ?? ''
// 合并数据
messages.value = [...currData.value, ...messages.value]
// 加载完成
done('ok')
if (page.value === 1) {
// 滚动到底部
emit('scroll')
// 监听SSE消息
startSSEMessager()
}
// 页码+1
page.value++
}
else {
done('ok')
// 监听SSE消息
startSSEMessager()
}
loading.value = false
isLoaded.value = true
}
catch (error) {
console.error(error)
}
}
// 比较yyyy-MM-dd HH:mm:ss时间大小
function compareTime(time1: string, time2: string) {
if (!time1)
return -1
if (!time2)
return 1
return new Date(time1).getTime() - new Date(time2).getTime()
}
onBeforeUnmount(() => {
if (eventSource)
eventSource.close()
})
</script>
<template>
<VInfiniteScroll
mode="intersect"
side="start"
:items="messages"
class="overflow-hidden"
@load="loadMessages"
>
<template #loading>
<VProgressCircular
v-if="loading"
indeterminate
size="48"
class="mb-5"
color="primary"
/>
</template>
<div>
<VRow
v-for="(msg, index) in messages"
:key="index"
:class="{
'justify-end': msg.action === 0,
'justify-start': msg.action === 1,
}"
>
<VCol
cols="10"
lg="6"
xl="4"
style="position: relative;"
>
<MessageCard
:message="msg"
/>
</VCol>
</VRow>
</div>
<div
v-if="messages.length === 0 && isLoaded && !loading"
class="w-full text-center flex flex-col items-center"
>
<span class="mb-3">当前没有消息</span>
</div>
</VInfiniteScroll>
</template>