Compare commits

...

98 Commits

Author SHA1 Message Date
jxxghp
444aaa5cdc fix plugin ui 2023-09-09 11:38:17 +08:00
jxxghp
25669d18fc v1.1.7 2023-09-09 11:03:49 +08:00
jxxghp
03d6e46eca fix 过滤规则删除问题 2023-09-09 09:40:06 +08:00
jxxghp
c44c7ed0f0 fix filemanager 2023-09-09 08:01:00 +08:00
jxxghp
47ac7437c0 feat 优化插件界面交互 2023-09-08 15:36:54 +08:00
jxxghp
37f982e0ea add icon 2023-09-08 15:08:32 +08:00
jxxghp
139646369f fix icon 2023-09-08 10:02:43 +08:00
jxxghp
3d4b84dc09 更新 package.json 2023-09-07 17:52:20 +08:00
jxxghp
02866754e0 add icon 2023-09-07 15:50:08 +08:00
jxxghp
e4e1a75d44 fix ui 2023-09-06 21:19:30 +08:00
jxxghp
4e5dd03456 fix ui 2023-09-06 20:22:32 +08:00
jxxghp
506b6eea09 fix rss ui 2023-09-06 20:18:45 +08:00
jxxghp
403ee4b925 fix 2023-09-06 17:26:20 +08:00
jxxghp
193d1f550f fix ui 2023-09-06 15:50:05 +08:00
jxxghp
d2a02a830c fix restart ui 2023-09-06 15:30:10 +08:00
jxxghp
6cc9c1ac57 feat 内建重启 2023-09-06 12:56:49 +08:00
jxxghp
fffb1c6c02 v1.1.4 2023-09-05 19:52:01 +08:00
jxxghp
985d1baff5 feat 新增紫色主题 2023-09-05 10:15:20 +08:00
jxxghp
a70e467b69 fix ui 2023-09-05 09:53:02 +08:00
jxxghp
b07010bebd Merge pull request #35 from amtoaer/memory_percent 2023-09-04 23:18:20 +08:00
amtoaer
353bdc5989 feat: 内存占用图使用百分比 2023-09-04 23:05:16 +08:00
jxxghp
f135804c4b fix ui 2023-09-04 21:30:03 +08:00
jxxghp
ae6d0ead2c v1.1.3 2023-09-04 21:13:27 +08:00
jxxghp
6db3ad4e0d fix ui 2023-09-04 20:07:59 +08:00
jxxghp
09e42d5a08 build 2023-09-04 17:56:32 +08:00
jxxghp
b9a09fd1be fix 用户权限控制 2023-09-04 17:49:17 +08:00
jxxghp
b08235b9f6 fix ui 2023-09-04 12:37:34 +08:00
jxxghp
43e67893b4 fix ui 2023-09-04 12:36:05 +08:00
jxxghp
633b38da01 fix dashboard 2023-09-04 11:07:56 +08:00
jxxghp
68a4818be0 build 2023-09-04 07:08:00 +08:00
jxxghp
be3e4a7b13 fix user menu 2023-09-04 07:07:38 +08:00
jxxghp
2bc616ebbb px => rem 2023-09-03 22:36:48 +08:00
jxxghp
7058472784 feat 媒体信息组件 2023-09-03 19:25:46 +08:00
jxxghp
8d9f28b3c8 fix ui 2023-09-03 19:15:58 +08:00
jxxghp
0d2bba78d9 UI 1.1.3 2023-09-03 18:39:14 +08:00
jxxghp
23a62d33eb feat 过滤规则测试 2023-09-03 18:36:43 +08:00
jxxghp
93a2a4a772 fix logging ui 2023-09-03 15:42:01 +08:00
jxxghp
38e74f0c1b fix build 2023-09-03 13:16:56 +08:00
jxxghp
d2d6ca75be feat 文件管理添加快速识别按钮 2023-09-03 13:01:18 +08:00
jxxghp
a4a9f9e7c5 更新 package.json 2023-09-03 09:53:14 +08:00
jxxghp
4d5d1094ed feat 支持设置转移屏蔽词 2023-09-03 09:22:25 +08:00
jxxghp
9f8eaa5722 feat 实时日志放快捷栏&&美化日志界面 2023-09-03 09:15:42 +08:00
jxxghp
79e07d2b3d fix color schema 2023-09-02 09:58:28 +08:00
jxxghp
822d457bff v1.1.1 2023-09-02 08:23:04 +08:00
jxxghp
8e391af0b4 Merge pull request #33 from amtoaer/main 2023-09-02 06:42:38 +08:00
amtoaer
b522b4d355 fix: 修复 tmdb 未记录发行日期的剧集全部堆叠在今天的问题 2023-09-02 00:36:06 +08:00
jxxghp
9c5ef8f5b4 fix nodatafound page 2023-09-01 17:17:07 +08:00
jxxghp
4dc1ad35d2 feat 历史记录批量操作 2023-09-01 10:54:40 +08:00
jxxghp
9b405d9e59 Merge pull request #31 from thofx/fix_28 2023-08-31 22:36:47 +08:00
thofx
2351ec7b85 fix: 筛选出现的bug #28 2023-08-31 22:34:39 +08:00
jxxghp
6fc3228334 fix color 2023-08-31 19:17:08 +08:00
jxxghp
81b1f5b14d Merge pull request #30 from thsrite/main
下载器文件同步插件logo
2023-08-31 15:23:20 +08:00
thsrite
b8450ad28b Merge remote-tracking branch 'origin/main' 2023-08-31 14:38:07 +08:00
thsrite
70371a5001 feat 下载器文件同步插件logo 2023-08-31 14:38:00 +08:00
jxxghp
87f4cf772b v1.0.10 2023-08-30 19:21:50 +08:00
jxxghp
970ed8ff86 fix icons 2023-08-30 15:19:47 +08:00
jxxghp
3f77f1037a Merge pull request #29 from thsrite/main 2023-08-30 11:06:43 +08:00
thsrite
6cc770dced fix #328 管理员账户无法删除 2023-08-30 10:09:58 +08:00
jxxghp
355172dbf6 fix 豆瓣搜索跳转
feat 推荐新增正在热映
2023-08-30 08:29:11 +08:00
jxxghp
3bfcd38e65 更新 List.vue 2023-08-29 17:46:36 +08:00
jxxghp
909857f146 fix ui 2023-08-29 12:11:09 +08:00
jxxghp
1cc64c7d21 更新 List.vue 2023-08-28 19:19:46 +08:00
jxxghp
891db2be21 v1.0.9 UI 2023-08-28 19:04:54 +08:00
jxxghp
54f3451456 feat 手动整理进度显示 2023-08-28 18:06:59 +08:00
jxxghp
563471ccf5 fix ui 2023-08-28 17:46:37 +08:00
jxxghp
2e70b61e60 fix bug 2023-08-28 17:26:16 +08:00
jxxghp
cd0d786a4c Merge pull request #27 from thsrite/main 2023-08-28 17:21:32 +08:00
thsrite
611ae13777 feat 订阅站点单独配置 2023-08-28 13:30:25 +08:00
thsrite
026214bd3f Merge remote-tracking branch 'origin/main' 2023-08-28 11:04:20 +08:00
thsrite
7c29c9ad27 fix 媒体详情页增加tmdbid 2023-08-28 11:03:55 +08:00
jxxghp
cba5586f05 fix 文件管理默认路径、特殊字符问题 2023-08-28 08:22:01 +08:00
jxxghp
25a8d0cc2a 更新 List.vue 2023-08-27 10:06:19 +08:00
jxxghp
2396a6b5fc fix ui 2023-08-27 09:40:08 +08:00
jxxghp
c94add8d6b fix ui 2023-08-27 09:35:49 +08:00
jxxghp
8b893dc6f2 fix ui 2023-08-27 09:23:13 +08:00
jxxghp
51816da3d3 v1.0.8 2023-08-27 08:46:32 +08:00
jxxghp
5e2d144828 fix 手动整理UI 2023-08-26 23:52:15 +08:00
jxxghp
04f56692a5 fix 文件管理UI 2023-08-26 20:58:13 +08:00
jxxghp
d3fb71c289 fix ui 2023-08-26 20:23:24 +08:00
jxxghp
e565ec5b62 fix 2023-08-26 20:01:31 +08:00
jxxghp
e5f7467d5b fix 重命名 2023-08-26 19:58:36 +08:00
jxxghp
3a9e85c821 feat 图片显示&文件下载 2023-08-26 19:28:04 +08:00
jxxghp
f2b51107fa fix FileManager 2023-08-26 14:50:35 +08:00
jxxghp
e3df4000bb fix FileManager 2023-08-26 14:30:51 +08:00
jxxghp
f4aef8f9dd fix 2023-08-26 12:52:46 +08:00
jxxghp
476bd4bd19 fix 2023-08-26 12:48:04 +08:00
jxxghp
4681c947c7 feat FileManager 2023-08-26 11:00:33 +08:00
jxxghp
5db4d97568 need fix 2023-08-25 22:05:57 +08:00
jxxghp
235942157e v1.0.7 2023-08-25 12:47:29 +08:00
jxxghp
8ef2be1c81 fix 2023-08-24 20:56:46 +08:00
jxxghp
ed45f3438f fix #251 2023-08-24 20:18:45 +08:00
jxxghp
2ca0920131 fix downloading title 2023-08-24 12:26:57 +08:00
jxxghp
4491e80f6a feat 详情页支持IMDBID搜索 2023-08-24 08:32:58 +08:00
jxxghp
55abc47d0e 更新 package.json 2023-08-23 21:08:13 +08:00
jxxghp
6ad6d5a45b v1.0.4 2023-08-22 08:31:50 +08:00
jxxghp
ed8364a0c2 fix UI 2023-08-22 08:30:47 +08:00
jxxghp
03f01c6134 fix ui 2023-08-20 08:41:06 +08:00
jxxghp
14258b6b76 fix UI 2023-08-20 08:40:55 +08:00
64 changed files with 2761 additions and 399 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "moviepilot",
"version": "1.0.3",
"version": "1.1.7-1",
"private": true,
"scripts": {
"dev": "vite --host",
@@ -59,6 +59,7 @@
"@iconify/vue": "4.1.1",
"@intlify/unplugin-vue-i18n": "^0.10.0",
"@tailwindcss/aspect-ratio": "^0.4.2",
"@types/lodash": "^4.14.197",
"@types/node": "^20.1.4",
"@types/webfontloader": "^1.6.34",
"@typescript-eslint/eslint-plugin": "^5.59.5",

BIN
public/plugin/brush.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

BIN
public/plugin/database.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
public/plugin/delete.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

BIN
public/plugin/sync_file.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -50,7 +50,7 @@
rgba(#{variables.$vertical-nav-background-color-rgb}, 30%) 75%,
transparent
);
block-size: calc(env(safe-area-inset-top) + 64px);
block-size: calc(env(safe-area-inset-top) + 4rem);
inline-size: 100%;
inset-block-start: calc(#{variables.$vertical-nav-header-height} - 2px);
opacity: 0;

View File

@@ -88,10 +88,24 @@ export function formatSeconds(seconds: number) {
}
// YYYY-MM-DD 转化为Date
export function parseDate(dateString: string): Date {
export function parseDate(dateString: string): Date | null {
if (!dateString)
return new Date()
return null
const [year, month, day] = dateString.split('-').map(Number)
return new Date(year, month - 1, day)
}
// 文件大小格式化
export function formatBytes(bytes: number, decimals = 2) {
if (bytes === 0)
return '0 bytes'
const k = 1024
const dm = decimals < 0 ? 0 : decimals
const sizes = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`
}

View File

@@ -111,7 +111,7 @@ export default defineComponent({
.layout-navbar {
position: fixed;
width: calc(100vw - variables.$layout-vertical-nav-width - 1rem);
width: calc(100vw - variables.$layout-vertical-nav-width - 0.5rem);
z-index: variables.$layout-vertical-nav-layout-navbar-z-index;
inset-block-start: 0;

View File

@@ -38,7 +38,7 @@ body,
overflow: hidden;
// TODO: Use grid gutter variable here
padding-block: 1.5rem;
padding-top: calc(env(safe-area-inset-top) + 65px);
padding-top: calc(env(safe-area-inset-top) + 4.25rem);
// display: flex;
@@ -49,7 +49,7 @@ body,
& > div:first-child {
flex: auto;
position: relative;
width: calc(100vw - variables.$layout-vertical-nav-width - 1rem);
width: calc(100vw - variables.$layout-vertical-nav-width - 0.5rem);
}
}
}

View File

@@ -2,24 +2,24 @@
// 👉 Vertical nav
$layout-vertical-nav-z-index: 12 !default;
$layout-vertical-nav-width: 260px !default;
$layout-vertical-nav-collapsed-width: 80px !default;
$layout-vertical-nav-width: 16.25rem !default;
$layout-vertical-nav-collapsed-width: 5rem !default;
// 👉 Horizontal nav
$layout-horizontal-nav-z-index: 11 !default;
$layout-horizontal-nav-navbar-height: 64px !default;
$layout-horizontal-nav-navbar-height: 4rem !default;
// 👉 Navbar
$layout-vertical-nav-navbar-height: 64px !default;
$layout-vertical-nav-navbar-height: 4rem !default;
$layout-vertical-nav-navbar-is-contained: true !default;
$layout-vertical-nav-layout-navbar-z-index: 11 !default;
$layout-horizontal-nav-layout-navbar-z-index: 11 !default;
// 👉 Main content
$layout-boxed-content-width: 1440px !default;
$layout-boxed-content-width: 90rem !default;
// 👉Footer
$layout-vertical-nav-footer-height: 56px !default;
$layout-vertical-nav-footer-height: 3.5rem !default;
// 👉 Layout overlay
$layout-overlay-z-index: 11 !default;

View File

@@ -1,10 +1,7 @@
<script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import { useTheme } from 'vuetify'
import api from './api'
import type { User } from './api/types'
import store from './store'
import avatar1 from '@images/avatars/avatar-1.png'
// 第一时间应用主题
const { global: globalTheme } = useTheme()
@@ -36,39 +33,10 @@ function startSSEMessager() {
}
}
// 当前用户信息
const accountInfo = ref<User>({
id: 0,
name: '',
password: '',
email: '',
is_active: false,
is_superuser: false,
avatar: avatar1,
})
// 调用API加载当前用户数据
async function loadAccountInfo() {
try {
const user: User = await api.get('user/current')
accountInfo.value = user
if (!accountInfo.value.avatar)
accountInfo.value.avatar = avatar1
}
catch (error) {
console.log(error)
}
}
// 页面加载时,加载当前用户数据
onMounted(() => {
loadAccountInfo()
onBeforeMount(async () => {
startSSEMessager()
})
// 提供给所有元素复用
provide('accountInfo', accountInfo)
</script>
<template>

View File

@@ -526,6 +526,9 @@ export interface Plugin {
// 运行状态
state?: boolean
// 是否有详情页面
has_page?: boolean
}
// 种子信息
@@ -833,20 +836,8 @@ export interface NotificationSwitch {
// 环境设置
export interface Setting {
// 媒体服务器 emby/jellyfin/plex
MEDIASERVER: string
// EMBY服务器地址IP:PORT
EMBY_HOST: string
// EMBY Api Key
EMBY_API_KEY: string
// Jellyfin服务器地址IP:PORT
JELLYFIN_HOST: string
// Jellyfin Api Key
JELLYFIN_API_KEY: string
// Plex服务器地址IP:PORT
PLEX_HOST: string
// Plex Token
PLEX_TOKEN: string
// 下载目录
DOWNLOAD_PATH: string
}
// 自定义订阅
@@ -897,3 +888,25 @@ export interface Rss {
// 状态 0-停用1-启用
state?: number
}
// 文件浏览接口
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
}

View File

@@ -0,0 +1,147 @@
<script lang="ts" setup>
import type { Axios } from 'axios'
import axios from 'axios'
import Toolbar from './filebrowser/Toolbar.vue'
import Tree from './filebrowser/Tree.vue'
import List from './filebrowser/List.vue'
import type { EndPoints } from '@/api/types'
// 输入参数
const props = defineProps({
storages: String,
storage: String,
path: String,
tree: Boolean,
endpoints: Object as PropType<EndPoints>,
axios: Object as PropType<Axios>,
axiosconfig: Object,
})
// 对外事件
const emit = defineEmits(['pathchanged'])
const availableStorages = [
{
name: '本地',
code: 'local',
icon: 'mdi-folder-multiple-outline',
},
]
const fileIcons = {
zip: 'mdi-folder-zip-outline',
rar: 'mdi-folder-zip-outline',
htm: 'mdi-language-html5',
html: 'mdi-language-html5',
js: 'mdi-nodejs',
json: 'mdi-file-document-outline',
md: 'mdi-language-markdown-outline',
pdf: 'mdi-file-pdf',
png: 'mdi-file-image',
jpg: 'mdi-file-image',
jpeg: 'mdi-file-image',
mp4: 'mdi-filmstrip',
mkv: 'mdi-filmstrip',
avi: 'mdi-filmstrip',
wmv: 'mdi-filmstrip',
mov: 'mdi-filmstrip',
txt: 'mdi-file-document-outline',
xls: 'mdi-file-excel',
other: 'mdi-file-outline',
}
// 加载次数
const loading = ref(0)
// 当前存储
const activeStorage = ref('local')
// 刷新
const refreshPending = ref(false)
// 排序
const sort = ref('name')
// axios实例
const axiosInstance = ref<Axios>()
// 计算属性
const storagesArray = computed(() => {
const storageCodes = props.storages?.split(',')
return availableStorages.filter(item => storageCodes?.includes(item.code))
})
// 方法
function loadingChanged(loading: number) {
if (loading)
loading++
else if (loading > 0)
loading--
}
function storageChanged(storage: string) {
activeStorage.value = storage
}
// 路径变化
function pathChanged(_path: string) {
emit('pathchanged', _path)
}
// 排序变化
function sortChanged(s: string) {
sort.value = s
refreshPending.value = true
}
// 初始化
onBeforeMount(() => {
activeStorage.value = props.storage ?? 'local'
axiosInstance.value = props.axios ?? axios.create(props.axiosconfig)
})
</script>
<template>
<VCard class="mx-auto" :loading="loading > 0">
<Toolbar
:path="props.path"
:storages="storagesArray"
:storage="activeStorage"
:endpoints="props.endpoints"
:axios="axiosInstance"
@storagechanged="storageChanged"
@pathchanged="pathChanged"
@foldercreated="refreshPending = true"
@sortchanged="sortChanged"
/>
<VRow no-gutters>
<VCol v-if="tree" sm="auto" class="d-none d-md-block">
<Tree
:path="props.path"
:storage="activeStorage"
:icons="fileIcons"
:endpoints="endpoints"
:axios="axiosInstance"
:refreshpending="refreshPending"
@pathchanged="pathChanged"
@loading="loadingChanged"
@refreshed="refreshPending = false"
/>
</VCol>
<VDivider v-if="tree" vertical />
<VCol>
<List
:path="props.path"
:storage="activeStorage"
:icons="fileIcons"
:endpoints="endpoints"
:axios="axiosInstance"
:refreshpending="refreshPending"
:sort="sort"
@pathchanged="pathChanged"
@loading="loadingChanged"
@refreshed="refreshPending = false"
@filedeleted="refreshPending = true"
@renamed="refreshPending = true"
/>
</VCol>
</VRow>
</VCard>
</template>

View File

@@ -1,18 +1,8 @@
<script setup lang="ts">
import { useTheme } from 'vuetify'
import misc404 from '@images/pages/404.png'
import miscMaskDark from '@images/pages/misc-mask-dark.png'
import miscMaskLight from '@images/pages/misc-mask-light.png'
import tree from '@images/pages/tree.png'
import miscpose from '@images/pages/pose-fs-9.png'
const props = defineProps<Props>()
const vuetifyTheme = useTheme()
const authThemeMask = computed(() => {
return vuetifyTheme.global.name.value === 'light' ? miscMaskLight : miscMaskDark
})
interface Props {
errorCode?: string
errorTitle?: string
@@ -21,7 +11,7 @@ interface Props {
</script>
<template>
<div class="misc-wrapper">
<div class="flex flex-col">
<ErrorHeader
:error-code="props.errorCode"
:error-title="props.errorTitle"
@@ -29,46 +19,17 @@ interface Props {
/>
<!-- 👉 Image -->
<div class="misc-avatar w-100 text-center">
<div class="text-center">
<VImg
:src="misc404"
:max-width="800"
class="mx-auto"
:src="miscpose"
class="mx-auto pt-10"
max-width="250"
cover
/>
<slot name="button" />
</div>
<!-- 👉 Footer -->
<VImg
:src="tree"
class="misc-footer-tree d-none d-md-block"
/>
<VImg
:src="authThemeMask"
class="misc-footer-img d-none d-md-block"
/>
</div>
</template>
<style lang="scss">
@use '@core/scss/pages/misc.scss';
.misc-wrapper {
position: relative;
.misc-footer-tree {
position: absolute;
z-index: 1;
inline-size: 15.625rem;
inset-block-end: 3.5rem;
inset-inline-start: 0.375rem;
}
.misc-footer-img {
position: absolute;
inline-size: 100%;
inset-block-end: 0;
}
}
</style>

View File

@@ -84,7 +84,7 @@ async function deleteDownload() {
:class="getTextClass()"
>
{{ props.info?.media.title || props.info?.name }}
{{ props.info?.season_episode }}
{{ props.info?.media.episode ? `${props.info?.media.season} ${props.info?.media.episode}` : props.info?.season_episode }}
</VCardTitle>
<VCardSubtitle

View File

@@ -50,9 +50,6 @@ const selectFilterOptions = ref<{ [key: string]: string }[]>([
{ title: '排除: 国语配音', value: ' !CNVOI ' },
{ title: '促销: 免费', value: ' FREE ' },
])
// 已选择的过滤规则
const selectedFilters = ref<string[]>(props.rules ?? [])
</script>
<template>
@@ -64,7 +61,7 @@ const selectedFilters = ref<string[]>(props.rules ?? [])
<VCol>
<VSelect
:key="props.pri"
v-model="selectedFilters"
v-model="props.rules"
variant="underlined"
:items="selectFilterOptions"
chips

View File

@@ -349,6 +349,7 @@ function handleSearch() {
: `douban:${props.media?.douban_id}`
}`,
type: props.media?.type,
area: 'title',
},
})
}
@@ -461,7 +462,7 @@ const getImgUrl: Ref<string> = computed(() => {
</VHover>
<VDialog
v-model="subscribeSeasonDialog"
max-width="600"
max-width="50rem"
content-class="whitespace-nowrap"
scrollable
>

View File

@@ -0,0 +1,147 @@
<script lang="ts" setup>
import type { PropType } from 'vue'
import type { Context } from '@/api/types'
// 输入参数
const props = defineProps({
context: Object as PropType<Context>,
})
// TMDB图片转换为w500大小
function getW500Image(url = '') {
if (!url)
return ''
return url.replace('original', 'w500')
}
// 打开TMDB详情页面
function openTmdbPage(type: string, tmdbId: number) {
if (!type || !tmdbId)
return
const url = `https://www.themoviedb.org/${type === '电影' ? 'movie' : 'tv'}/${tmdbId}`
window.open(url, '_blank')
}
</script>
<template>
<div v-show="context">
<VCol>
<div
v-if="context?.meta_info?.name"
class="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row"
>
<div
v-if="context?.media_info?.poster_path"
class="ma-auto"
>
<VImg
width="10rem"
aspect-ratio="2/3"
class="object-cover aspect-w-2 aspect-h-3 rounded-lg ring-1 ring-gray-500"
:src="getW500Image(context?.media_info?.poster_path)"
cover
>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
</VImg>
</div>
<div>
<VCardItem class="pb-1">
<VCardTitle class="text-center text-md-left">
{{ context?.media_info?.title || context?.meta_info?.name }}
{{ context?.meta_info?.season_episode }}
</VCardTitle>
<VCardSubtitle class="text-center text-md-left">
{{ context?.media_info?.year || context?.meta_info?.year }}
</VCardSubtitle>
</VCardItem>
<VCardText
v-if="context?.media_info?.overview"
class="line-clamp-4 overflow-hidden text-ellipsis text-center text-md-left ..."
>
{{ context?.media_info?.overview }}
</VCardText>
<VCardItem class="text-center text-md-left">
<!-- 类型 -->
<VChip
v-if="context?.media_info?.type || context?.meta_info?.type"
variant="elevated"
class="me-1 mb-1 text-white bg-blue-500"
>
{{
context?.media_info?.type || context?.meta_info?.type
}}
</VChip>
<!-- 二级分类 -->
<VChip
v-if="context?.media_info?.category"
variant="elevated"
class="me-1 mb-1 text-white bg-blue-500"
>
{{ context?.media_info?.category }}
</VChip>
<!-- TMDBID -->
<VChip
v-if="context?.media_info?.tmdb_id"
variant="elevated"
color="success"
class="me-1 mb-1"
@click="openTmdbPage(context?.media_info?.type || '', context?.media_info?.tmdb_id)"
>
{{ context?.media_info?.tmdb_id }}
</VChip>
<!-- meta_info -->
<VChip
v-if="context?.meta_info?.edition"
variant="elevated"
class="me-1 mb-1 text-white bg-red-500"
>
{{ context?.meta_info?.edition }}
</VChip>
<VChip
v-if="context?.meta_info?.resource_pix"
variant="elevated"
class="me-1 mb-1 text-white bg-red-500"
>
{{ context?.meta_info?.resource_pix }}
</VChip>
<VChip
v-if="context?.meta_info?.video_encode"
variant="elevated"
class="me-1 mb-1 text-white bg-orange-500"
>
{{ context?.meta_info?.video_encode }}
</VChip>
<VChip
v-if="context?.meta_info?.audio_encode"
variant="elevated"
class="me-1 mb-1 text-white bg-orange-500"
>
{{ context?.meta_info?.audio_encode }}
</VChip>
<VChip
v-if="context?.meta_info?.resource_team"
variant="elevated"
class="me-1 mb-1 text-white bg-cyan-500"
>
{{ context?.meta_info?.resource_team }}
</VChip>
</VCardItem>
</div>
</div>
<VAlert
v-if="!context?.meta_info?.name"
icon="mdi-alert-circle-outline"
>
识别失败无法识别到有效信息
</VAlert>
</VCol>
</div>
</template>

View File

@@ -119,6 +119,8 @@ async function savePluginConf() {
// 显示插件详情
async function showPluginInfo() {
// 加载详情
await loadPluginPage()
pluginConfigDialog.value = false
pluginInfoDialog.value = true
}
@@ -129,17 +131,35 @@ async function showPluginConfig() {
await loadPluginForm()
// 加载配置
await loadPluginConf()
// 加载详情
await loadPluginPage()
// 显示对话框
pluginInfoDialog.value = false
pluginConfigDialog.value = true
}
// 弹出菜单
const dropdownItems = ref([
{
title: '卸载',
title: '查看详情',
value: 1,
show: props.plugin?.has_page,
props: {
prependIcon: 'mdi-information-outline',
click: showPluginInfo,
},
},
{
title: '配置',
value: 2,
show: true,
props: {
prependIcon: 'mdi-cog-outline',
click: showPluginConfig,
},
},
{
title: '插件',
value: 3,
show: true,
props: {
prependIcon: 'mdi-trash-can-outline',
color: 'error',
@@ -155,7 +175,12 @@ const dropdownItems = ref([
v-if="isVisible"
:width="props.width"
:height="props.height"
@click="showPluginConfig"
@click="() => {
if (props.plugin?.has_page)
showPluginInfo()
else
showPluginConfig()
}"
>
<div
class="relative pa-4 text-center card-cover-blurred"
@@ -171,6 +196,7 @@ const dropdownItems = ref([
<VList>
<VListItem
v-for="(item, i) in dropdownItems"
v-show="item.show"
:key="i"
variant="plain"
:base-color="item.props.color"
@@ -214,7 +240,7 @@ const dropdownItems = ref([
scrollable
persistent
>
<VCard :title="props.plugin?.plugin_name">
<VCard :title="`${props.plugin?.plugin_name} - 配置`">
<DialogCloseBtn @click="pluginConfigDialog = false" />
<VCardText>
<FormRender
@@ -226,7 +252,7 @@ const dropdownItems = ref([
</VCardText>
<VCardActions>
<VBtn v-if="pluginPageItems.length > 0" @click="showPluginInfo">
详情
查看详情
</VBtn>
<VSpacer />
<VBtn @click="savePluginConf">
@@ -243,7 +269,7 @@ const dropdownItems = ref([
scrollable
persistent
>
<VCard :title="`${props.plugin?.plugin_name} - 详情`">
<VCard :title="`${props.plugin?.plugin_name}`">
<DialogCloseBtn @click="pluginInfoDialog = false" />
<VCardText>
<PageRender
@@ -253,6 +279,9 @@ const dropdownItems = ref([
/>
</VCardText>
<VCardActions>
<VBtn @click="showPluginConfig">
配置
</VBtn>
<VSpacer />
<VBtn @click="pluginInfoDialog = false">
关闭

View File

@@ -368,6 +368,7 @@ onMounted(() => {
>
<!-- Dialog Content -->
<VCard :title="`订阅 - ${props.media?.name}`">
<DialogCloseBtn @click="rssInfoDialog = false" />
<VCardText class="pt-2">
<VForm @submit.prevent="() => {}">
<VRow>
@@ -416,6 +417,7 @@ onMounted(() => {
md="6"
>
<VSelect
v-show="rssForm.type === '电视剧'"
v-model="rssForm.season"
label="季"
:items="seasonItems"
@@ -424,6 +426,7 @@ onMounted(() => {
</VCol>
<VCol
cols="12"
md="6"
>
<VTextField
v-model="rssForm.include"
@@ -432,6 +435,7 @@ onMounted(() => {
</VCol>
<VCol
cols="12"
md="6"
>
<VTextField
v-model="rssForm.exclude"
@@ -440,6 +444,7 @@ onMounted(() => {
</VCol>
<VCol
cols="12"
md="6"
>
<VTextField
v-model="rssForm.save_path"

View File

@@ -389,7 +389,7 @@ onMounted(() => {
<!-- 更新站点Cookie & UA弹窗 -->
<VDialog
v-model="siteCookieDialog"
max-width="600"
max-width="50rem"
>
<!-- Dialog Content -->
<VCard title="更新站点Cookie & UA">

View File

@@ -65,8 +65,8 @@ function getPercentage() {
return 0
return Math.round(
(((props.media?.total_episode || 0) - (props.media?.lack_episode || 0))
/ (props.media?.total_episode || 1))
(((props.media?.total_episode ?? 0) - (props.media?.lack_episode ?? 0))
/ (props.media?.total_episode ?? 1))
* 100,
)
}
@@ -136,7 +136,7 @@ async function updateSubscribeInfo() {
// 获取站点列表数据
async function loadSites() {
try {
const data: Site[] = await api.get('site')
const data: Site[] = await api.get('site/rss')
// 过滤站点,只有启用的站点才显示
siteList.value = data.filter(item => item.is_active)

View File

@@ -62,7 +62,7 @@ async function handleAddDownload(_site: any = undefined,
confirmationText: '确认',
cancellationText: '取消',
dialogProps: {
maxWidth: 600,
maxWidth: '50rem',
},
})
@@ -163,7 +163,7 @@ const dropdownItems = ref([
</VAvatar>
</template>
<VCardItem class="py-1">
<VCardTitle>
<VCardTitle class="break-words overflow-visible whitespace-break-spaces">
{{ media?.title }} {{ meta?.season_episode }}
<span class="text-green-700 ms-2 text-sm">{{ torrent?.seeders }}</span>
<span class="text-orange-700 ms-2 text-sm">{{ torrent?.peers }}</span>

View File

@@ -0,0 +1,778 @@
<script lang="ts" setup>
import type { Axios } from 'axios'
import type { PropType } from 'vue'
import { useConfirm } from 'vuetify-use-dialog'
import axios from 'axios'
import { useToast } from 'vue-toast-notification'
import { numberValidator } from '@/@validators'
import { formatBytes } from '@core/utils/formatters'
import type { Context, EndPoints, FileItem } from '@/api/types'
import store from '@/store'
import api from '@/api'
import MediaInfoCard from '@/components/cards/MediaInfoCard.vue'
// 输入参数
const inProps = defineProps({
icons: Object,
storage: String,
path: String,
endpoints: Object as PropType<EndPoints>,
axios: Object as PropType<Axios>,
refreshpending: Boolean,
sort: String,
})
// 对外事件
const emit = defineEmits(['loading', 'pathchanged', 'refreshed', 'filedeleted', 'renamed'])
// 提示框
const $toast = useToast()
// 是否正在加载
const loading = ref(true)
// 确认框
const createConfirm = useConfirm()
// 存储空间类型
const storage = ref(inProps.storage ?? '')
// axios实例
const axiosInstance = ref<Axios>(inProps.axios ?? axios)
// 内容列表
const items = ref<FileItem[]>([])
// 过滤条件
const filter = ref('')
// 重命名弹窗
const renamePopper = ref(false)
// 整理弹窗
const transferPopper = ref(false)
// 整理进度条
const progressDialog = ref(false)
// 整理进度文本
const progressText = ref('请稍候 ...')
// 整理进度
const progressValue = ref(0)
// 加载进度SSE
const progressEventSource = ref<EventSource>()
// 新名称
const newName = ref('')
// 当前名称
const currentItem = ref<FileItem>()
// 文件转移表单
const transferForm = reactive({
path: '',
target: '',
tmdbid: null,
season: null,
type_name: '',
transfer_type: '',
episode_format: '',
episode_detail: '',
episode_part: '',
episode_offset: null,
min_filesize: 0,
})
// 识别结果
const nameTestResult = ref<Context>()
// 识别结果对话框
const nameTestDialog = ref(false)
// 生成1到50季的下拉框选项
const seasonItems = ref(
Array.from({ length: 51 }, (_, i) => i).map(item => ({
title: `${item}`,
value: item,
})),
)
// 目录过滤
const dirs = computed(() =>
items.value.filter(item => item.type === 'dir' && item.basename.includes(filter.value)),
)
// 文件过滤
const files = computed(() =>
items.value.filter(item => item.type === 'file' && item.basename.includes(filter.value)),
)
// 是否目录
const isDir = computed(() => inProps.path?.endsWith('/'))
// 是否文件
const isFile = computed(() => !isDir.value)
// 是否为图片文件
const isImage = computed(() => {
const ext = inProps.path?.split('.').pop()?.toLowerCase()
return ['png', 'jpg', 'jpeg', 'gif', 'bmp'].includes(ext ?? '')
})
// 调API加载内容
async function load() {
loading.value = true
emit('loading', true)
// 参数
const url = inProps.endpoints?.list.url
.replace(/{storage}/g, storage.value)
.replace(/{path}/g, encodeURIComponent(inProps.path || ''))
.replace(/{sort}/g, inProps.sort || 'name')
const config = {
url,
method: inProps.endpoints?.list.method || 'get',
}
// 加载数据
items.value = await axiosInstance.value.request(config) ?? []
emit('loading', false)
loading.value = false
}
// 删除项目
async function deleteItem(item: FileItem) {
const confirmed = await createConfirm({
title: '确认',
content: `是否确认删除${
item.type === 'dir' ? '目录' : '文件'
} ${item.basename}`,
confirmationText: '确认',
cancellationText: '取消',
dialogProps: {
maxWidth: '50rem',
},
})
if (confirmed) {
emit('loading', true)
const url = inProps.endpoints?.delete.url
.replace(/{storage}/g, storage.value)
.replace(/{path}/g, encodeURIComponent(item.path))
const config = {
url,
method: inProps.endpoints?.delete.method || 'post',
}
await axiosInstance.value.request(config)
emit('filedeleted')
emit('loading', false)
// 重新加载
load()
}
}
// 切换路径
function changePath(_path: string) {
emit('pathchanged', _path)
}
// 新窗口中下载文件
function download(path: string) {
if (!path)
return
const token = store.state.auth.token
const url_path = inProps.endpoints?.download.url
.replace(/{storage}/g, storage.value)
.replace(/{path}/g, encodeURIComponent(path))
const url = `${import.meta.env.VITE_API_BASE_URL}${url_path.slice(1)}&token=${token}`
// 下载文件
window.open(url, '_blank')
}
// 显示图片
function getImgLink(path: string) {
if (!path)
return ''
const token = store.state.auth.token
const url_path = inProps.endpoints?.image.url
.replace(/{storage}/g, storage.value)
.replace(/{path}/g, encodeURIComponent(path))
return `${import.meta.env.VITE_API_BASE_URL}${url_path.slice(1)}&token=${token}`
}
// 显示重命名弹窗
function showRenmae(item: FileItem) {
currentItem.value = item
newName.value = item.name
renamePopper.value = true
}
// 重命名
async function rename() {
emit('loading', true)
const url = inProps.endpoints?.rename.url
.replace(/{storage}/g, inProps.storage)
.replace(/{path}/g, encodeURIComponent(currentItem.value?.path || ''))
.replace(/{newname}/g, encodeURIComponent(newName.value))
const config = {
url,
method: inProps.endpoints?.mkdir.method || 'post',
}
// 调API
await inProps.axios?.request(config)
renamePopper.value = false
newName.value = ''
emit('loading', false)
// 通知重新加载
emit('renamed')
}
// 显示整理对话框
function showTransfer(item: FileItem) {
currentItem.value = item
transferPopper.value = true
}
// 整理文件
async function transfer() {
transferForm.path = currentItem.value?.path ?? ''
// 开始整理文件
try {
// 关闭弹窗
transferPopper.value = false
// 显示进度条
progressDialog.value = true
// 开始监听进度
startLoadingProgress()
// 异步调API结束后关闭进度条
api.post('transfer/manual', {}, {
params: transferForm,
}).then((res: any) => {
// 关闭进度条
progressDialog.value = false
// 停止监听进度
stopLoadingProgress()
// 显示结果
if (res.success) {
$toast.success(`${currentItem.value?.name} 整理成功!`)
// 重新加载
load()
}
else {
$toast.error(`${currentItem.value?.name} 整理失败:${res.message}`)
}
})
}
catch (e) {
console.log(e)
}
}
// 将文件修改时间timestape转换为本地时间
function formatTime(timestape: number) {
return new Date(timestape * 1000).toLocaleString()
}
// 监听path变化
watch(
() => inProps.path,
async () => {
items.value = []
nameTestResult.value = undefined
nameTestDialog.value = false
await load()
},
)
// 监听refreshPending变化
watch(
() => inProps.refreshpending,
async () => {
if (inProps.refreshpending) {
await load()
emit('refreshed')
}
},
)
// 使用SSE监听加载进度
function startLoadingProgress() {
progressText.value = '请稍候 ...'
const token = store.state.auth.token
progressEventSource.value = new EventSource(
`${import.meta.env.VITE_API_BASE_URL}system/progress/filetransfer?token=${token}`,
)
progressEventSource.value.onmessage = (event) => {
const progress = JSON.parse(event.data)
if (progress) {
progressText.value = progress.text
progressValue.value = progress.value
}
}
}
// 停止监听加载进度
function stopLoadingProgress() {
progressEventSource.value?.close()
}
// 调用API识别
async function recognize(path: string) {
try {
// 显示进度条
progressDialog.value = true
progressText.value = `正在识别 ${path} ...`
progressValue.value = 0
nameTestResult.value = await api.get('media/recognize_file', {
params: {
path,
},
})
// 关闭进度条
progressDialog.value = false
if (!nameTestResult.value)
$toast.error(`${path} 识别失败!`)
nameTestDialog.value = !!nameTestResult.value?.meta_info?.name
}
catch (error) {
console.error(error)
}
}
// 弹出菜单
const dropdownItems = ref([
{
title: '识别',
value: 1,
props: {
prependIcon: 'mdi-text-recognition',
click: (_item: FileItem) => {
recognize(_item.path || '')
},
},
}, {
title: '重命名',
value: 2,
props: {
prependIcon: 'mdi-rename',
click: showRenmae,
},
},
{
title: '整理',
value: 3,
props: {
prependIcon: 'mdi-folder-arrow-right',
click: showTransfer,
},
},
{
title: '删除',
value: 4,
props: {
prependIcon: 'mdi-delete-outline',
color: 'error',
click: deleteItem,
},
},
])
onMounted(() => {
load()
})
</script>
<template>
<VCard class="d-flex flex-column">
<VCardText
v-if="loading"
class="text-center flex flex-col items-center"
>
<VProgressCircular
size="48"
indeterminate
color="primary"
/>
</VCardText>
<VCardText
v-if="!path"
class="grow d-flex justify-center align-center grey--text"
>
选择目录或文件
</VCardText>
<VCardText
v-else-if="isFile && !isImage"
class="text-center break-all"
>
<strong>{{ items[0]?.name }}</strong><br>
大小{{ formatBytes(items[0]?.size || 0) }}<br>
修改时间{{ formatTime(items[0]?.modify_time || 0) }}
</VCardText>
<VCardText
v-else-if="isFile && isImage"
class="grow d-flex justify-center align-center"
>
<VImg :src="getImgLink(path)" max-width="100%" max-height="100%" />
</VCardText>
<VCardText v-else-if="dirs.length || files.length" class="p-0">
<VList v-if="dirs.length" subheader>
<VListSubheader>目录</VListSubheader>
<VListItem
v-for="(item, index) in dirs"
:key="index"
class="px-3 pe-1"
@click="changePath(item.path)"
>
<template #prepend>
<VIcon icon="mdi-folder-outline" />
</template>
<VListItemTitle v-text="item.name" />
<template #append>
<IconBtn class="d-sm-none">
<VIcon
icon="mdi-dots-vertical"
/>
<VMenu
activator="parent"
close-on-content-click
>
<VList>
<VListItem
v-for="(menu, i) in dropdownItems"
:key="i"
variant="plain"
:base-color="menu.props.color"
@click="menu.props.click(item)"
>
<template #prepend>
<VIcon :icon="menu.props.prependIcon" />
</template>
<VListItemTitle v-text="menu.title" />
</VListItem>
</VList>
</VMenu>
</IconBtn>
<IconBtn class="d-none d-sm-block" @click.stop="recognize(item.path)">
<VIcon icon="mdi-text-recognition" />
</IconBtn>
<IconBtn class="d-none d-sm-block" @click.stop="showRenmae(item)">
<VIcon icon="mdi-rename" />
</IconBtn>
<IconBtn class="d-none d-sm-block" @click.stop="showTransfer(item)">
<VIcon icon="mdi-folder-arrow-right" />
</IconBtn>
<IconBtn class="d-none d-sm-block" @click.stop="deleteItem(item)">
<VIcon icon="mdi-delete-outline" />
</IconBtn>
</template>
</VListItem>
</VList>
<VDivider v-if="dirs.length && files.length" />
<VList v-if="files.length" subheader>
<VListSubheader>文件</VListSubheader>
<VListItem
v-for="(item, index) in files"
:key="index"
class="pl-3 pe-1"
@click="changePath(item.path)"
>
<template #prepend>
<VIcon v-if="inProps.icons" :icon="inProps.icons[item.extension.toLowerCase()] || inProps.icons?.other" />
</template>
<VListItemTitle v-text="item.name" />
<VListItemSubtitle> {{ formatBytes(item.size) }}</VListItemSubtitle>
<template #append>
<IconBtn class="d-sm-none">
<VIcon
icon="mdi-dots-vertical"
/>
<VMenu
activator="parent"
close-on-content-click
>
<VList>
<VListItem
v-for="(menu, i) in dropdownItems"
:key="i"
variant="plain"
:base-color="menu.props.color"
@click="menu.props.click(item)"
>
<template #prepend>
<VIcon :icon="menu.props.prependIcon" />
</template>
<VListItemTitle v-text="menu.title" />
</VListItem>
</VList>
</VMenu>
</IconBtn>
<IconBtn class="d-none d-sm-block" @click.stop="recognize(item.path)">
<VIcon icon="mdi-text-recognition" />
</IconBtn>
<IconBtn class="d-none d-sm-block" @click.stop="showRenmae(item)">
<VIcon icon="mdi-rename" />
</IconBtn>
<IconBtn class="d-none d-sm-block" @click.stop="showTransfer(item)">
<VIcon icon="mdi-folder-arrow-right" />
</IconBtn>
<IconBtn class="d-none d-sm-block" @click.stop="deleteItem(item)">
<VIcon icon="mdi-delete-outline" />
</IconBtn>
</template>
</VListItem>
</VList>
</VCardText>
<VCardText
v-else-if="filter"
class="grow d-flex justify-center align-center grey--text py-5"
>
没有目录或文件
</VCardText>
<VCardText
v-else-if="!loading"
class="grow d-flex justify-center align-center grey--text py-5"
>
空目录
</VCardText>
<VDivider v-if="path" />
<VToolbar v-if="!loading" density="compact" flat color="gray">
<VTextField
v-if="!isFile"
v-model="filter"
hide-details
flat
density="compact"
variant="solo-filled"
placeholder="搜索 ..."
prepend-inner-icon="mdi-filter-outline"
class="me-2"
/>
<VSpacer v-if="isFile" />
<IconBtn v-if="isFile" @click="recognize(inProps.path || '')">
<VIcon color="primary">
mdi-text-recognition
</VIcon>
</IconBtn>
<IconBtn v-if="isFile" @click="download(inProps.path || '')">
<VIcon color="primary">
mdi-download
</VIcon>
</IconBtn>
<IconBtn v-if="!isFile" @click="load">
<VIcon color="primary">
mdi-refresh
</VIcon>
</IconBtn>
</VToolbar>
</VCard>
<!-- 重命名弹窗 -->
<VDialog
v-model="renamePopper"
max-width="50rem"
>
<template #activator="{ props }">
<IconBtn title="重命名" v-bind="props">
<VIcon icon="mdi-rename-outline" />
</IconBtn>
</template>
<VCard title="重命名">
<VCardText>
<VTextField v-model="newName" label="名称" />
</VCardText>
<VCardActions>
<div class="flex-grow-1" />
<VBtn depressed @click="renamePopper = false">
取消
</VBtn>
<VBtn
:disabled="!newName"
depressed
@click="rename"
>
重命名
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- 文件整理弹窗 -->
<VDialog
v-model="transferPopper"
max-width="800"
scrollable
>
<template #activator="{ props }">
<IconBtn title="整理" v-bind="props">
<VIcon icon="mdi-folder-arrow-right-outline" />
</IconBtn>
</template>
<VCard :title="`文件整理 - ${currentItem?.name}`">
<VCardText class="pt-2">
<VForm @submit.prevent="() => {}">
<VRow>
<VCol
cols="12"
md="8"
>
<VTextField
v-model="transferForm.target"
label="目的路径"
/>
</VCol>
<VCol
cols="12"
md="4"
>
<VSelect
v-model="transferForm.transfer_type"
label="整理方式"
:items="[
{ title: '默认', value: '' },
{ title: '移动', value: 'move' },
{ title: '复制', value: 'copy' },
{ title: '硬链接', value: 'link' },
{ title: '软链接', value: 'softlink' },
]"
/>
</VCol>
</VRow>
<VRow>
<VCol
cols="12"
md="4"
>
<VSelect
v-model="transferForm.type_name"
label="类型"
:items="[{ title: '请选择', value: '' }, { title: '电影', value: '电影' }, { title: '电视剧', value: '电视剧' }]"
/>
</VCol>
<VCol
cols="12"
md="4"
>
<VTextField
v-model="transferForm.tmdbid"
label="TMDBID"
placeholder="留空自动识别"
:rules="[numberValidator]"
/>
</VCol>
<VCol
cols="12"
md="4"
>
<VSelect
v-show="transferForm.type_name === '电视剧'"
v-model.number="transferForm.season"
label="季"
:items="seasonItems"
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="8">
<VTextField
v-model="transferForm.episode_format"
label="集数定位"
placeholder="使用{ep}定位集数"
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="transferForm.episode_detail"
label="指定集数"
placeholder="起始集,终止集如1或1,2"
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="transferForm.episode_part"
label="指定Part"
placeholder="如part1"
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model.number="transferForm.episode_offset"
label="集数偏移"
placeholder="如-10"
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model.number="transferForm.min_filesize"
label="最小文件大小MB"
:rules="[numberValidator]"
placeholder="0"
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions>
<div class="flex-grow-1" />
<VBtn depressed @click="transferPopper = false">
取消
</VBtn>
<VBtn
@click="transfer"
>
开始整理
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- 手动整理进度框 -->
<vDialog
v-model="progressDialog"
:scrim="false"
width="400"
>
<vCard
color="primary"
>
<vCardText class="text-center">
{{ progressText }}
<vProgressLinear
v-if="progressValue"
color="white"
class="mb-0 mt-1"
:model-value="progressValue"
/>
</vCardText>
</vCard>
</vDialog>
<!-- 识别结果对话框 -->
<vDialog
v-model="nameTestDialog"
width="800"
>
<vCard>
<DialogCloseBtn @click="nameTestDialog = false" />
<VCardItem>
<MediaInfoCard :context="nameTestResult" />
</VCardItem>
</vCard>
</vDialog>
</template>
<style lang="scss" scoped>
.v-card {
height: 100%;
}
.v-toolbar{
background: rgb(var(--v-table-header-background));
}
</style>

View File

@@ -0,0 +1,188 @@
<script lang="ts" setup>
import type { Axios } from 'axios'
import type { EndPoints } from '@/api/types'
// 输入参数
const inProps = defineProps({
storages: Array as PropType<any[]>,
storage: String,
path: String,
endpoints: Object as PropType<EndPoints>,
axios: Object as PropType<Axios>,
})
// 对外事件
const emit = defineEmits(['storagechanged', 'pathchanged', 'loading', 'foldercreated', 'sortchanged'])
// 新建文件夹名称
const newFolderPopper = ref(false)
// 新建文件名称
const newFolderName = ref('')
// 排序方式
const sort = ref('name')
// 调整排序方式
function changeSort() {
if (sort.value === 'name')
sort.value = 'time'
else
sort.value = 'name'
emit('sortchanged', sort.value)
}
// 计算PATH面包屑
const pathSegments = computed(() => {
let path_str = ''
const isFolder = inProps.path?.endsWith('/')
const segments = inProps.path?.split('/').filter(item => item)
return segments?.map((item, index) => {
path_str += item + ((index < segments.length - 1 || isFolder) ? '/' : '')
return {
name: item,
path: path_str,
}
}) ?? []
})
const storageObject = computed(() => {
return inProps.storages?.find(item => item.code === inProps.storage)
})
// 切换存储
function changeStorage(code: string) {
if (inProps.storage !== code) {
emit('storagechanged', code)
emit('pathchanged', '')
}
}
// 路径变化
function changePath(_path: string) {
emit('pathchanged', _path)
}
// 返回上一级
function goUp() {
const segments = pathSegments.value ?? []
const path = segments?.length === 1 ? '/' : segments[segments.length - 2].path
changePath(path)
}
// 创建目录
async function mkdir() {
emit('loading', true)
const url = inProps.endpoints?.mkdir.url
.replace(/{storage}/g, inProps.storage)
.replace(/{path}/g, encodeURIComponent(inProps.path + newFolderName.value))
const config = {
url,
method: inProps.endpoints?.mkdir.method || 'post',
}
// 调API
await inProps.axios?.request(config)
newFolderPopper.value = false
newFolderName.value = ''
emit('loading', false)
// 通知重新加载
emit('foldercreated')
}
// 计算排序图标
const sortIcon = computed(() => {
if (sort.value === 'time')
return 'mdi-sort-clock-ascending-outline'
else
return 'mdi-sort-alphabetical-ascending'
})
</script>
<template>
<VToolbar flat dense>
<VToolbarItems class="overflow-hidden">
<VMenu v-if="inProps.storages?.length || 0 > 1" offset-y>
<template #activator="{ props }">
<VBtn v-bind="props">
<VIcon icon="mdi-arrow-down-drop-circle-outline" />
</VBtn>
</template>
<VList>
<VListItem
v-for="(item, index) in storages"
:key="index"
:disabled="item.code === storageObject?.code"
@click="changeStorage(item.code)"
>
<template #prepend>
<Icon :icon="item.icon" />
</template>
<VListItemTitle>{{ item.name }}</VListItemTitle>
</VListItem>
</VList>
</VMenu>
<VBtn variant="text" :input-value="path === '/'" class="px-1" @click="changePath('/')">
<VIcon :icon="storageObject?.icon" class="mr-2" />
{{ storageObject?.name }}
</VBtn>
<template v-for="(segment, index) in pathSegments" :key="index">
<VBtn
variant="text"
:input-value="index === pathSegments.length - 1"
class="px-1 d-none d-md-block"
@click="changePath(segment.path)"
>
<VIcon icon=" mdi-chevron-right" />
{{ segment.name }}
</VBtn>
</template>
</VToolbarItems>
<div class="flex-grow-1" />
<IconBtn @click="changeSort">
<VIcon :icon="sortIcon" />
</IconBtn>
<IconBtn v-if="pathSegments.length > 0" @click="goUp">
<VIcon icon="mdi-arrow-up-bold-outline" />
</IconBtn>
<VDialog
v-model="newFolderPopper"
max-width="50rem"
>
<template #activator="{ props }">
<IconBtn title="新建文件夹" v-bind="props">
<VIcon icon="mdi-folder-plus-outline" />
</IconBtn>
</template>
<VCard title="新建文件夹">
<VCardText>
<VTextField v-model="newFolderName" label="名称" />
</VCardText>
<VCardActions>
<div class="flex-grow-1" />
<VBtn depressed @click="newFolderPopper = false">
取消
</VBtn>
<VBtn
:disabled="!newFolderName"
depressed
@click="mkdir"
>
新建
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</VToolbar>
</template>
<style lang="scss" scoped>
.v-toolbar{
background: rgb(var(--v-table-header-background));
}
</style>

View File

@@ -0,0 +1,202 @@
<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,
}]
}
// 调用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

@@ -9,6 +9,10 @@ import NavbarThemeSwitcher from '@/layouts/components/NavbarThemeSwitcher.vue'
import SearchBar from '@/layouts/components/SearchBar.vue'
import ShortcutBar from '@/layouts/components/ShortcutBar.vue'
import UserProfile from '@/layouts/components/UserProfile.vue'
import store from '@/store'
// 从Vuex Store中获取superuser信息
const superUser = store.state.auth.superUser
</script>
<template>
@@ -87,6 +91,7 @@ import UserProfile from '@/layouts/components/UserProfile.vue'
}"
/>
<VerticalNavLink
v-if="superUser"
:item="{
title: '电影',
icon: 'mdi-movie-check-outline',
@@ -94,6 +99,7 @@ import UserProfile from '@/layouts/components/UserProfile.vue'
}"
/>
<VerticalNavLink
v-if="superUser"
:item="{
title: '电视剧',
icon: 'mdi-television-classic',
@@ -101,6 +107,7 @@ import UserProfile from '@/layouts/components/UserProfile.vue'
}"
/>
<VerticalNavLink
v-if="superUser"
:item="{
title: '自定义',
icon: 'mdi-rss',
@@ -128,20 +135,31 @@ import UserProfile from '@/layouts/components/UserProfile.vue'
}"
/>
<VerticalNavLink
v-if="superUser"
:item="{
title: '历史记录',
icon: 'mdi-history',
to: '/history',
}"
/>
<VerticalNavLink
v-if="superUser"
:item="{
title: '文件管理',
icon: 'mdi-folder-multiple-outline',
to: '/filemanager',
}"
/>
<!-- 👉 系统 -->
<VerticalNavSectionTitle
v-if="superUser"
:item="{
heading: '系统',
}"
/>
<VerticalNavLink
v-if="superUser"
:item="{
title: '插件',
icon: 'mdi-apps',
@@ -149,6 +167,7 @@ import UserProfile from '@/layouts/components/UserProfile.vue'
}"
/>
<VerticalNavLink
v-if="superUser"
:item="{
title: '站点管理',
icon: 'mdi-web',
@@ -156,6 +175,7 @@ import UserProfile from '@/layouts/components/UserProfile.vue'
}"
/>
<VerticalNavLink
v-if="superUser"
:item="{
title: '设定',
icon: 'mdi-cog',

View File

@@ -10,6 +10,10 @@ const themes: ThemeSwitcherTheme[] = [
name: 'dark',
icon: 'mdi-weather-night',
},
{
name: 'purple',
icon: 'mdi-brightness-4',
},
]
</script>

View File

@@ -31,7 +31,7 @@ function search() {
>
<VDialog
v-model="searchDialog"
max-width="600"
max-width="50rem"
transition="dialog-top-transition"
>
<!-- Dialog Activator -->

View File

@@ -1,6 +1,8 @@
<script lang="ts" setup>
import NameTestView from '@/views/system/NameTestView.vue'
import NetTestView from '@/views/system/NetTestView.vue'
import LoggingView from '@/views/system/LoggingView.vue'
import RuleTestView from '@/views/system/RuleTestView.vue'
// App捷径
const appsMenu = ref(false)
@@ -10,6 +12,12 @@ const nameTestDialog = ref(false)
// 网络测试弹窗
const netTestDialog = ref(false)
// 实时日志弹窗
const loggingDialog = ref(false)
// 过滤规则弹窗
const ruleTestDialog = ref(false)
</script>
<template>
@@ -86,13 +94,57 @@ const netTestDialog = ref(false)
</VListItem>
</VCol>
</VRow>
<VRow class="ma-0 mt-n1 border-t">
<VCol
cols="6"
class="text-center cursor-pointer pa-0 shortcut-icon border-e"
@click="() => {}"
>
<VListItem
class="pa-4"
@click="loggingDialog = true"
>
<VAvatar
size="48"
variant="tonal"
>
<VIcon icon="mdi-file-document-outline" />
</VAvatar>
<h6 class="text-base font-weight-medium mt-2 mb-0">
日志
</h6>
<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="ruleTestDialog = true"
>
<VAvatar
size="48"
variant="tonal"
>
<VIcon icon="mdi-filter-cog-outline" />
</VAvatar>
<h6 class="text-base font-weight-medium mt-2 mb-0">
规则
</h6>
<span class="text-sm">过滤规则测试</span>
</VListItem>
</VCol>
</VRow>
</div>
</VCard>
</VMenu>
<!-- 名称测试弹窗 -->
<VDialog
v-model="nameTestDialog"
max-width="800"
max-width="50rem"
>
<VCard title="名称识别测试">
<DialogCloseBtn @click="nameTestDialog = false" />
@@ -104,7 +156,7 @@ const netTestDialog = ref(false)
<!-- 网络测试弹窗 -->
<VDialog
v-model="netTestDialog"
max-width="600"
max-width="35rem"
>
<VCard title="网络测试">
<DialogCloseBtn @click="netTestDialog = false" />
@@ -113,4 +165,30 @@ const netTestDialog = ref(false)
</VCardItem>
</VCard>
</VDialog>
<!-- 实时日志弹窗 -->
<VDialog
v-model="loggingDialog"
class="w-full lg:w-4/5"
scrollable
>
<VCard title="实时日志">
<DialogCloseBtn @click="loggingDialog = false" />
<VCardText>
<LoggingView />
</VCardText>
</VCard>
</VDialog>
<!-- 规则测试弹窗 -->
<VDialog
v-model="ruleTestDialog"
max-width="50rem"
scrollable
>
<VCard title="过滤规则测试">
<DialogCloseBtn @click="ruleTestDialog = false" />
<VCardText>
<RuleTestView />
</VCardText>
</VCard>
</VDialog>
</template>

View File

@@ -1,10 +1,23 @@
<script setup lang="ts">
import { useStore } from 'vuex'
import { useConfirm } from 'vuetify-use-dialog'
import { useToast } from 'vue-toast-notification'
import router from '@/router'
import avatar1 from '@images/avatars/avatar-1.png'
import api from '@/api'
// Vuex Store
const store = useStore()
// 确认框
const createConfirm = useConfirm()
// 提示框
const $toast = useToast()
// 进度框
const progressDialog = ref(false)
// 执行注销操作
function logout() {
// 清除登录状态信息
@@ -14,8 +27,45 @@ function logout() {
router.push('/login')
}
// 获取当前用户信息
const accountInfo: any = inject('accountInfo')
// 执行重启操作
async function restart() {
// 弹出提示
const confirmed = await createConfirm({
title: '确认',
content: '确认重启系统吗?',
confirmationText: '确认',
cancellationText: '取消',
dialogProps: {
maxWidth: '30rem',
},
})
if (confirmed) {
// 调用API重启
try {
// 显示等待框
progressDialog.value = true
const result: { [key: string]: any } = await api.get('system/restart')
if (!result?.success) {
// 隐藏等待框
progressDialog.value = false
// 重启不成功
$toast.error(result.message)
return
}
}
catch (error) {
console.error(error)
}
// 注销
logout()
}
}
// 从Vuex Store中获取信息
const superUser = store.state.auth.superUser
const userName = store.state.auth.userName
const avatar = store.state.auth.avatar
</script>
<template>
@@ -24,7 +74,7 @@ const accountInfo: any = inject('accountInfo')
color="primary"
variant="tonal"
>
<VImg :src="accountInfo.avatar" />
<VImg :src="avatar ?? avatar1" />
<!-- SECTION Menu -->
<VMenu
@@ -42,20 +92,21 @@ const accountInfo: any = inject('accountInfo')
color="primary"
variant="tonal"
>
<VImg :src="accountInfo.avatar" />
<VImg :src="avatar ?? avatar1" />
</VAvatar>
</VListItemAction>
</template>
<VListItemTitle class="font-weight-semibold">
{{ accountInfo.is_superuser ? "管理员" : "普通用户" }}
{{ superUser ? "管理员" : "普通用户" }}
</VListItemTitle>
<VListItemSubtitle>{{ accountInfo.name }}</VListItemSubtitle>
<VListItemSubtitle>{{ userName }}</VListItemSubtitle>
</VListItem>
<VDivider class="my-2" />
<!-- 👉 Profile -->
<VListItem
v-if="superUser"
link
to="setting"
>
@@ -89,6 +140,19 @@ const accountInfo: any = inject('accountInfo')
<!-- Divider -->
<VDivider class="my-2" />
<!-- 👉 restart -->
<VListItem @click="restart">
<template #prepend>
<VIcon
class="me-2"
icon="mdi-restart"
size="22"
/>
</template>
<VListItemTitle>重启</VListItemTitle>
</VListItem>
<!-- 👉 Logout -->
<VListItem @click="logout">
<template #prepend>
@@ -105,4 +169,22 @@ const accountInfo: any = inject('accountInfo')
</VMenu>
<!-- !SECTION -->
</VAvatar>
<!-- 重启进度框 -->
<vDialog
v-model="progressDialog"
width="400"
>
<vCard
color="primary"
>
<vCardText class="text-center">
正在重启 ...
<vProgressLinear
indeterminate
color="white"
class="mb-0 mt-1"
/>
</vCardText>
</vCard>
</vDialog>
</template>

View File

@@ -1,10 +1,11 @@
<script setup lang="ts">
import AnalyticsMediaStatistic from '@/views/dashboard/AnalyticsMediaStatistic.vue'
import AnalyticsProcesses from '@/views/dashboard/AnalyticsProcesses.vue'
import AnalyticsScheduler from '@/views/dashboard/AnalyticsScheduler.vue'
import AnalyticsSpeed from '@/views/dashboard/AnalyticsSpeed.vue'
import AnalyticsStorage from '@/views/dashboard/AnalyticsStorage.vue'
import AnalyticsWeeklyOverview from '@/views/dashboard/AnalyticsWeeklyOverview.vue'
import AnalyticsCpu from '@/views/dashboard/AnalyticsCpu.vue'
import AnalyticsMemory from '@/views/dashboard/AnalyticsMemory.vue'
</script>
<template>
@@ -44,8 +45,18 @@ import AnalyticsWeeklyOverview from '@/views/dashboard/AnalyticsWeeklyOverview.v
<AnalyticsScheduler />
</VCol>
<VCol cols="12">
<AnalyticsProcesses />
<VCol
cols="12"
md="6"
>
<AnalyticsCpu />
</VCol>
<VCol
cols="12"
md="6"
>
<AnalyticsMemory />
</VCol>
</VRow>
</template>

View File

@@ -0,0 +1,7 @@
<script setup lang="ts">
import FileBrowserView from '@/views/reorganize/FileBrowserView.vue'
</script>
<template>
<FileBrowserView />
</template>

View File

@@ -66,10 +66,16 @@ function login() {
.then((response: any) => {
// 获取token
const token = response.access_token
const superuser = response.super_user
const username = response.user_name
const avatar = response.avatar
// 更新token和remember状态到Vuex Store
store.dispatch('auth/updateToken', token)
store.dispatch('auth/updateRemember', form.value.remember)
store.dispatch('auth/updateSuperUser', superuser)
store.dispatch('auth/updateUserName', username)
store.dispatch('auth/updateAvatar', avatar)
// 跳转到首页
router.push('/')

View File

@@ -10,6 +10,12 @@ import MediaCardSlideView from '@/views/discover/MediaCardSlideView.vue'
title="流行趋势"
/>
<MediaCardSlideView
apipath="douban/showing"
linkurl="/browse/douban/showing?title=正在热映"
title="正在热映"
/>
<MediaCardSlideView
apipath="tmdb/movies"
linkurl="/browse/tmdb/movies?title=热门电影"

View File

@@ -9,6 +9,9 @@ const keyword = route.query?.keyword?.toString() ?? ''
// 查询类型
const type = route.query?.type?.toString() ?? ''
// 搜索字段
const area = route.query?.area?.toString() ?? ''
</script>
<template>
@@ -16,6 +19,7 @@ const type = route.query?.type?.toString() ?? ''
<TorrentCardListView
:keyword="keyword"
:type="type"
:area="area"
/>
</div>
</template>

View File

@@ -5,7 +5,6 @@ import AccountSettingNotification from '@/views/setting/AccountSettingNotificati
import AccountSettingRule from '@/views/setting/AccountSettingRule.vue'
import AccountSettingSite from '@/views/setting/AccountSettingSite.vue'
import AccountSettingWords from '@/views/setting/AccountSettingWords.vue'
import AccountSettingLogging from '@/views/setting/AccountSettingLogging.vue'
import AccountSettingAbout from '@/views/setting/AccountSettingAbout.vue'
const route = useRoute()
@@ -39,11 +38,6 @@ const tabs = [
icon: 'mdi-file-word-box',
tab: 'words',
},
{
title: '日志',
icon: 'mdi-text-box',
tab: 'logging',
},
{
title: '关于',
icon: 'mdi-information',
@@ -96,12 +90,6 @@ const tabs = [
<AccountSettingWords />
</transition>
</VWindowItem>
<!-- Logging -->
<VWindowItem value="logging">
<transition name="fade-slide" appear>
<AccountSettingLogging />
</transition>
</VWindowItem>
<!-- About -->
<VWindowItem value="about">
<transition name="fade-slide" appear>

View File

@@ -55,6 +55,56 @@ const theme: VuetifyOptions['theme'] = {
},
},
dark: {
dark: true,
colors: {
'primary': '#6E66ED',
'secondary': '#8A8D93',
'on-secondary': '#fff',
'success': '#56CA00',
'info': '#16B1FF',
'warning': '#FFB400',
'error': '#FF4C51',
'on-primary': '#FFFFFF',
'on-success': '#FFFFFF',
'on-warning': '#FFFFFF',
'background': '#111827',
'on-background': '#E7E3FC',
'surface': '#161D2C',
'on-surface': '#E7E3FC',
'grey-50': '#2A2E42',
'grey-100': '#474360',
'grey-200': '#4A5072',
'grey-300': '#5E6692',
'grey-400': '#7983BB',
'grey-500': '#8692D0',
'grey-600': '#AAB3DE',
'grey-700': '#B6BEE3',
'grey-800': '#CFD3EC',
'grey-900': '#E7E9F6',
'perfect-scrollbar-thumb': '#4A5072',
'skin-bordered-background': '#312d4b',
'skin-bordered-surface': '#312d4b',
},
variables: {
'code-color': '#d400ff',
'overlay-scrim-background': '#1F2937',
'overlay-scrim-opacity': 0.6,
'hover-opacity': 0.04,
'focus-opacity': 0.1,
'selected-opacity': 0.12,
'activated-opacity': 0.1,
'pressed-opacity': 0.14,
'dragged-opacity': 0.1,
'border-color': '#E7E3FC',
'table-header-background': '#1F2937',
'custom-background': '#373452',
// Shadows
'shadow-key-umbra-opacity': 'rgba(20, 18, 33, 0.08)',
'shadow-key-penumbra-opacity': 'rgba(20, 18, 33, 0.12)',
'shadow-key-ambient-opacity': 'rgba(20, 18, 33, 0.04)',
},
},
purple: {
dark: true,
colors: {
'primary': '#9155FD',

View File

@@ -128,6 +128,13 @@ const router = createRouter({
requiresAuth: true,
},
},
{
path: '/filemanager',
component: () => import('../pages/filemanager.vue'),
meta: {
requiresAuth: true,
},
},
],
},
{

View File

@@ -4,6 +4,9 @@ import type { Module } from 'vuex'
interface AuthState {
token: string | null
remember: boolean
superUser: boolean
userName: string
avatar: string
}
// 定义根状态类型
@@ -17,6 +20,9 @@ const authModule: Module<AuthState, RootState> = {
state: {
token: null,
remember: false,
superUser: false,
userName: '',
avatar: '',
},
mutations: {
setToken(state, token: string) {
@@ -28,6 +34,15 @@ const authModule: Module<AuthState, RootState> = {
setRemember(state, remember: boolean) {
state.remember = remember
},
setSuperUser(state, superUser: boolean) {
state.superUser = superUser
},
setUserName(state, userName: string) {
state.userName = userName
},
setAvatar(state, avatar: string) {
state.avatar = avatar
},
},
actions: {
updateToken({ commit }, token: string) {
@@ -39,10 +54,22 @@ const authModule: Module<AuthState, RootState> = {
updateRemember({ commit }, remember: boolean) {
commit('setRemember', remember)
},
updateSuperUser({ commit }, superUser: boolean) {
commit('setSuperUser', superUser)
},
updateUserName({ commit }, userName: string) {
commit('setUserName', userName)
},
updateAvatar({ commit }, avatar: string) {
commit('setAvatar', avatar)
},
},
getters: {
getToken: state => state.token,
getRemember: state => state.remember,
getSuperUser: state => state.superUser,
getUserName: state => state.userName,
getAvatar: state => state.avatar,
},
}

View File

@@ -3,14 +3,13 @@
@tailwind components;
@tailwind utilities;
#nprogress .bar {
background: #7D34FD !important;
background: rgb(var(--v-theme-primary)) !important;
top: env(safe-area-inset-top) !important;
}
#nprogress .peg {
box-shadow: 0 0 10px #7D34FD, 0 0 5px #7D34FD !important;
box-shadow: 0 0 10px rgb(var(--v-theme-primary)), 0 0 5px rgb(var(--v-theme-primary)) !important;
-webkit-transform: rotate(0deg) translate(0px, -1px);
-ms-transform: rotate(0deg) translate(0px, -1px);
transform: rotate(0deg) translate(0px, -1px);
@@ -112,3 +111,14 @@
background: #a1a1a1;
}
}
.v-alert--variant-elevated, .v-alert--variant-flat {
background: rgb(var(--v-table-header-background));
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
}
.backdrop-blur {
--tw-backdrop-blur: blur(8px)!important;
-webkit-backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)!important;
backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)!important;
}

View File

@@ -0,0 +1,134 @@
<script setup lang="ts">
import VueApexCharts from 'vue3-apexcharts'
import { useTheme } from 'vuetify'
import { hexToRgb } from '@layouts/utils'
import api from '@/api'
const vuetifyTheme = useTheme()
const currentTheme = controlledComputed(() => vuetifyTheme.name.value, () => vuetifyTheme.current.value.colors)
const variableTheme = controlledComputed(() => vuetifyTheme.name.value, () => vuetifyTheme.current.value.variables)
// 定时器
let refreshTimer: NodeJS.Timer | null = null
// 时间序列
const series = ref([
{
data: [0],
},
])
// 当前值
const current = ref(0)
const chartOptions = controlledComputed(() => vuetifyTheme.name.value, () => {
return {
chart: {
parentHeightOffset: 0,
toolbar: { show: false },
animations: { enabled: false },
},
tooltip: { enabled: false },
grid: {
borderColor: `rgba(${hexToRgb(String(variableTheme.value['border-color']))},${variableTheme.value['border-opacity']})`,
strokeDashArray: 6,
xaxis: {
lines: { show: false },
},
yaxis: {
lines: { show: true },
},
padding: {
top: -10,
left: -7,
right: 5,
bottom: 5,
},
},
stroke: {
width: 3,
lineCap: 'butt',
curve: 'smooth',
},
colors: [currentTheme.value.primary],
markers: {
size: 6,
offsetY: 4,
offsetX: -2,
strokeWidth: 3,
colors: ['transparent'],
strokeColors: 'transparent',
discrete: [
{
size: 5.5,
seriesIndex: 0,
strokeColor: currentTheme.value.primary,
fillColor: currentTheme.value.surface,
},
],
hover: { size: 7 },
},
xaxis: {
labels: { show: false },
axisTicks: { show: false },
axisBorder: { show: false },
},
yaxis: {
labels: { show: false },
max: 100,
},
}
})
// 调用API接口获取最新CPU使用率
async function getCpuUsage() {
try {
// 请求数据
current.value = await api.get('dashboard/cpu') ?? 0
// 添加到序列
series.value[0].data.push(current.value)
// 序列超过30条记录时清掉前面的
if (series.value[0].data.length > 30)
series.value[0].data.shift()
}
catch (e) {
console.log(e)
}
}
onMounted(() => {
getCpuUsage()// 启动定时器
refreshTimer = setInterval(() => {
getCpuUsage()
}, 2000)
})
// 组件卸载时停止定时器
onUnmounted(() => {
if (refreshTimer) {
clearInterval(refreshTimer)
refreshTimer = null
}
})
</script>
<template>
<VCard>
<VCardText>
<h6 class="text-h6">
CPU
</h6>
<VueApexCharts
type="line"
:options="chartOptions"
:series="series"
:height="150"
/>
<p class="text-center font-weight-medium mb-0">
当前{{ current }}%
</p>
</VCardText>
</VCard>
</template>

View File

@@ -0,0 +1,140 @@
<script setup lang="ts">
import VueApexCharts from 'vue3-apexcharts'
import { useTheme } from 'vuetify'
import { hexToRgb } from '@layouts/utils'
import api from '@/api'
import { formatBytes } from '@/@core/utils/formatters'
const vuetifyTheme = useTheme()
const currentTheme = controlledComputed(() => vuetifyTheme.name.value, () => vuetifyTheme.current.value.colors)
const variableTheme = controlledComputed(() => vuetifyTheme.name.value, () => vuetifyTheme.current.value.variables)
// 定时器
let refreshTimer: NodeJS.Timer | null = null
// 时间序列
const series = ref([
{
data: [0],
},
])
// 占用的内存
const usedMemory = ref(0)
// 内存使用百分比
const memoryUsage = ref(0)
const chartOptions = controlledComputed(() => vuetifyTheme.name.value, () => {
return {
chart: {
parentHeightOffset: 0,
toolbar: { show: false },
animations: { enabled: false },
},
tooltip: { enabled: false },
grid: {
borderColor: `rgba(${hexToRgb(String(variableTheme.value['border-color']))},${variableTheme.value['border-opacity']})`,
strokeDashArray: 6,
xaxis: {
lines: { show: false },
},
yaxis: {
lines: { show: true },
},
padding: {
top: -10,
left: -7,
right: 5,
bottom: 5,
},
},
stroke: {
width: 3,
lineCap: 'butt',
curve: 'smooth',
},
colors: [currentTheme.value.primary],
markers: {
size: 6,
offsetY: 4,
offsetX: -2,
strokeWidth: 3,
colors: ['transparent'],
strokeColors: 'transparent',
discrete: [
{
size: 5.5,
seriesIndex: 0,
strokeColor: currentTheme.value.primary,
fillColor: currentTheme.value.surface,
},
],
hover: { size: 7 },
},
dataLabels: {
enabled: false,
},
xaxis: {
labels: { show: false },
axisTicks: { show: false },
axisBorder: { show: false },
},
yaxis: {
labels: { show: false },
max: 100,
},
}
})
// 调用API接口获取最新内存使用量
async function getMemorgUsage() {
try {
// 请求数据
[usedMemory.value, memoryUsage.value] = await api.get('dashboard/memory')
series.value[0].data.push(memoryUsage.value)
// 序列超过30条记录时清掉前面的
if (series.value[0].data.length > 30)
series.value[0].data.shift()
}
catch (e) {
console.log(e)
}
}
onMounted(() => {
getMemorgUsage()
// 启动定时器
refreshTimer = setInterval(() => {
getMemorgUsage()
}, 3000)
})
// 组件卸载时停止定时器
onUnmounted(() => {
if (refreshTimer) {
clearInterval(refreshTimer)
refreshTimer = null
}
})
</script>
<template>
<VCard>
<VCardText>
<h6 class="text-h6">
内存
</h6>
<VueApexCharts
type="area"
:options="chartOptions"
:series="series"
:height="150"
/>
<p class="text-center font-weight-medium mb-0">
当前{{ formatBytes(usedMemory) }}
</p>
</VCardText>
</VCard>
</template>

View File

@@ -379,12 +379,13 @@ function joinArray(arr: string[]) {
}
// 开始搜索
function handleSearch() {
function handleSearch(area: string) {
router.push({
path: '/resource',
query: {
keyword: `tmdb:${mediaDetail.value.tmdb_id}`,
type: mediaDetail.value.type,
area,
},
})
}
@@ -429,9 +430,13 @@ onBeforeMount(() => {
<div class="relative z-20 flex items-center false"><span>已入库</span></div>
</span>
</div>
<h1 class="flex flex-row items-baseline justify-start lg:justify-center">
<span>{{ mediaDetail.title }}</span>
<span v-if="mediaDetail.year" class="text-lg">{{ mediaDetail.year }}</span>
<h1 class="d-flex flex-column flex-lg-row align-baseline justify-center justify-lg-start">
<div class="align-self-center align-self-lg-end">
{{ mediaDetail.title }}
</div>
<div v-if="mediaDetail.year" class="text-lg align-self-center align-self-lg-end">
{{ mediaDetail.year }}
</div>
</h1>
<span class="media-attributes">
<span v-if="mediaDetail.runtime || mediaDetail.episode_run_time[0]">{{ mediaDetail.runtime || mediaDetail.episode_run_time[0] }} 分钟</span>
@@ -440,11 +445,31 @@ onBeforeMount(() => {
</span>
</div>
<div class="media-actions">
<VBtn v-if="mediaDetail.tmdb_id" variant="tonal" color="info" @click="handleSearch">
<VBtn v-if="mediaDetail.tmdb_id" variant="tonal" color="info">
<template #prepend>
<VIcon icon="mdi-magnify" />
</template>
搜索
搜索资源
<VMenu
activator="parent"
close-on-content-click
>
<VList>
<VListItem
variant="plain"
@click="handleSearch('title')"
>
<VListItemTitle>标题</VListItemTitle>
</VListItem>
<VListItem
v-show="mediaDetail.imdb_id"
variant="plain"
@click="handleSearch('imdbid')"
>
<VListItemTitle>IMDB链接</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</VBtn>
<VBtn v-if="mediaDetail.type === '电影'" class="ms-2" :color="getSubscribeColor" variant="tonal" @click="handleSubscribe(0)">
<template #prepend>
@@ -587,6 +612,10 @@ onBeforeMount(() => {
readonly
/>
</div>
<div v-if="mediaDetail.tmdb_id" class="media-fact">
<span>ID</span>
<span class="media-fact-value">{{ mediaDetail.tmdb_id }}</span>
</div>
<div v-if="mediaDetail.original_title || mediaDetail.original_name" class="media-fact">
<span>原始标题</span>
<span class="media-fact-value">{{ mediaDetail.original_title || mediaDetail.original_name }}</span>

View File

@@ -1,5 +1,6 @@
<script lang="ts" setup>
import { ref } from 'vue'
import _ from 'lodash'
import api from '@/api'
import type { Context } from '@/api/types'
import TorrentCard from '@/components/cards/TorrentCard.vue'
@@ -13,6 +14,9 @@ const props = defineProps({
// 类型
type: String,
// 搜索字段
area: String,
})
interface SearchTorrent extends Context {
@@ -108,7 +112,7 @@ watchEffect(() => {
)
})
if (matchData.length > 0) {
const firstData = matchData[0] as SearchTorrent
const firstData = _.cloneDeepWith(matchData[0]) as SearchTorrent
if (matchData.length > 1)
firstData.more = matchData.slice(1)
@@ -124,18 +128,19 @@ async function fetchData(): Promise<Array<Context>> {
let searchData: Array<Context>
const keyword = props.keyword ?? ''
const mtype = props.type ?? ''
const area = props.area ?? ''
if (!keyword) {
// 查询上次搜索结果
searchData = await api.get('search/last')
}
else {
startLoadingProgress()
const qualify = props.keyword?.startsWith('tmdb:') || props.keyword?.startsWith('douban:')
// 优先按TMDBID精确查询
if (qualify) {
if (props.keyword?.startsWith('tmdb:') || props.keyword?.startsWith('douban:')) {
searchData = await api.get(`search/media/${props.keyword}`, {
params: {
mtype,
area,
},
})
}
@@ -308,7 +313,7 @@ onMounted(initData)
<span>{{ progressText }}</span>
</div>
<div v-if="dataList.length > 0" class="grid gap-3 grid-torrent-card items-start">
<TorrentCard v-for="data in dataList" :key="`${data.torrent_info.title}_${data.torrent_info.site}`" :torrent="data" :more="data.more" />
<TorrentCard v-for="data in dataList" :key="`${data.torrent_info.title}_${data.torrent_info.site_name}_${data.torrent_info.page_url}`" :torrent="data" :more="data.more" />
</div>
<NoDataFound
v-if="dataList.length === 0 && isRefreshed"

View File

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

View File

@@ -30,6 +30,9 @@ const redoTypeItems = ref([
// 当前操作记录
const currentHistory = ref<TransferHistory>()
// 已选中的数据
const selected = ref<TransferHistory[]>([])
// 表头
const headers = [
{ title: '标题', key: 'title', sortable: false },
@@ -59,6 +62,15 @@ const itemsPerPage = ref(25)
// 当前页码
const currentPage = ref(1)
// 进度条
const progressDialog = ref(false)
// 进度文本
const progressText = ref('请稍候 ...')
// 进度值
const progressValue = ref(0)
// 获取订阅列表数据
async function fetchData({
page,
@@ -113,21 +125,30 @@ const TransferDict: { [key: string]: string } = {
// 删除历史记录
async function removeHistory(item: TransferHistory) {
const isConfirmed = await createConfirm({
title: '确认',
content: `同步删除 ${item.title} 对应的媒体库文件 ?`,
confirmationText: '同步删除文件',
cancellationText: '仅删除历史记录',
dialogProps: {
maxWidth: '50rem',
},
confirmationButtonProps: {
color: 'error',
},
})
if (isConfirmed === undefined)
return
// 执行删除
remove(item, isConfirmed || false)
// 清空选中项
selected.value = []
}
// 调用API删除记录
async function remove(item: TransferHistory, deleteFile: boolean) {
try {
const isConfirmed = await createConfirm({
title: '确认',
content: `同步删除 ${item.title} 对应的媒体库文件 ?`,
confirmationText: '同步删除文件',
cancellationText: '仅删除历史记录',
dialogProps: {
maxWidth: 600,
},
})
let deleteFile = false
if (isConfirmed)
deleteFile = true
// 调用删除API
const result: { [key: string]: any } = await api.delete(`history/transfer?delete_file=${deleteFile}`, {
data: item,
@@ -148,6 +169,54 @@ async function removeHistory(item: TransferHistory) {
}
}
// 批量删除历史记录
async function removeHistoryBatch() {
if (selected.value.length === 0)
return
// 确认
const isConfirmed = await createConfirm({
title: '确认',
content: `同步删除 ${selected.value.length} 条记录对应的媒体库文件 ?`,
confirmationText: '同步删除文件',
cancellationText: '仅删除历史记录',
dialogProps: {
maxWidth: '50rem',
},
confirmationButtonProps: {
color: 'error',
},
})
if (isConfirmed === undefined)
return
console.log(selected.value)
// 总条数
const total = selected.value.length
// 已处理条数
let handled = 0
// 显示进度条
progressDialog.value = true
// 循环调用removeHistory
for (const item of selected.value) {
// 开始删除
progressText.value = `正在删除 ${item.title} ${item.seasons}${item.episodes} ...`
await remove(item, isConfirmed || false)
// 删除完成
handled++
progressValue.value = handled / total * 100
}
// 清空选中项
selected.value = []
// 隐藏进度条
progressDialog.value = false
// 重新获取数据
fetchData({
page: currentPage.value,
itemsPerPage: itemsPerPage.value,
})
}
// 重新整理
async function rehandleHistory() {
try {
@@ -238,6 +307,7 @@ const dropdownItems = ref([
</VCardTitle>
</VCardItem>
<VDataTableServer
v-model="selected"
v-model:items-per-page="itemsPerPage"
:headers="headers"
:items="dataList"
@@ -248,6 +318,7 @@ const dropdownItems = ref([
item-value="id"
return-object
fixed-header
show-select
items-per-page-text="每页条数"
page-text="{0}-{1} {2} "
@update:options="fetchData"
@@ -322,7 +393,7 @@ const dropdownItems = ref([
</VCard>
<VDialog
v-model="redoDialog"
max-width="600"
max-width="50rem"
>
<!-- Dialog Content -->
<VCard title="重新整理">
@@ -357,6 +428,33 @@ const dropdownItems = ref([
</VCardActions>
</VCard>
</VDialog>
<span v-if="selected.length > 0" class="fixed right-5 bottom-5">
<VBtn
icon="mdi-trash-can-outline"
color="error"
size="x-large"
@click="removeHistoryBatch"
/>
</span>
<!-- 进度框 -->
<vDialog
v-model="progressDialog"
:scrim="false"
width="400"
>
<vCard
color="primary"
>
<vCardText class="text-center">
{{ progressText }}
<vProgressLinear
color="white"
class="mb-0 mt-1"
:model-value="progressValue"
/>
</vCardText>
</vCard>
</vDialog>
</template>
<style lang="scss">

View File

@@ -365,7 +365,7 @@ onMounted(() => {
</td>
<td>{{ user.is_superuser ? "是" : "否" }}</td>
<td>
<IconBtn v-show="!user.is_superuser">
<IconBtn v-show="accountInfo.is_superuser && accountInfo.name != user.name">
<VIcon icon="mdi-dots-vertical" />
<VMenu
activator="parent"

View File

@@ -1,55 +0,0 @@
<script lang="ts" setup>
import store from '@/store'
// 日志列表
const logs = ref<string[]>([])
// SSE持续获取日志
function startSSELogging() {
const token = store.state.auth.token
if (token) {
const eventSource = new EventSource(
`${import.meta.env.VITE_API_BASE_URL}system/logging?token=${token}`,
)
eventSource.addEventListener('message', (event) => {
const message = event.data
if (message)
logs.value.push(message)
})
onBeforeUnmount(() => {
eventSource.close()
})
}
}
onMounted(() => {
startSSELogging()
})
</script>
<template>
<VRow>
<VCol cols="12">
<VCard title="实时日志">
<VCardText>
<div
v-if="logs.length === 0"
class="mt-5 w-full text-center text-gray-500 text-sm flex flex-col items-center"
>
<VProgressCircular
size="48"
indeterminate
color="primary"
/>
<span class="mt-3">正在刷新 ...</span>
</div>
<div v-for="(log, i) in logs" :key="i">
{{ log }}
</div>
</VCardText>
</VCard>
</VCol>
</VRow>
</template>

View File

@@ -5,15 +5,10 @@ import FilterRuleCard from '@/components/cards/FilterRuleCard.vue'
// 规则卡片类型
interface FilterCard {
// 优先级
pri: string
// 已选规则
rules: string[]
// 是否可见
visible: boolean
}
// 提示框
@@ -48,7 +43,6 @@ async function queryCustomFilters(ruleType: string) {
return {
pri: (index + 1).toString(),
rules: group.split('&'),
visible: true,
}
})
}
@@ -138,23 +132,18 @@ function updateFilterCardValue2(pri: string, rules: string[]) {
// 移除卡片
function filterCardClose(ruleType: string, pri: string) {
// 将卡片从列表中删除,并更新剩余卡片的序号
const cards = ruleType === 'FilterRules' ? filterCards : filterCards2
const index = cards.value.findIndex(card => card.pri === pri)
if (index !== -1) {
// 创建新的数组,然后使用 splice 方法来删除元素
const updatedCards = [...cards.value]
updatedCards.splice(index, 1)
// 更新剩余卡片的序号
updatedCards.forEach((card, i) => {
card.pri = (i + 1).toString()
// 将pri对应的卡片从列表中删除,并更新剩余卡片的序号
const updatedCards = (ruleType === 'FilterRules' ? filterCards.value : filterCards2.value)
.filter(card => card.pri !== pri)
.map((card, index) => {
card.pri = (index + 1).toString()
return card
})
// 更新 filterCards.value
cards.value = updatedCards
}
// 更新 filterCards.value
if (ruleType === 'FilterRules')
filterCards.value = updatedCards
else
filterCards2.value = updatedCards
}
// 增加卡片
@@ -164,7 +153,7 @@ function addFilterCard(ruleType: string) {
const pri = (cards.value.length + 1).toString()
// 新卡片
const newCard: FilterCard = { pri, rules: [], visible: true }
const newCard: FilterCard = { pri, rules: [] }
// 添加到列表
cards.value.push(newCard)
@@ -189,7 +178,6 @@ onMounted(() => {
:key="index"
:pri="card.pri"
:rules="card.rules"
:visible="card.visible"
@changed="updateFilterCardValue"
@close="filterCardClose('FilterRules', card.pri)"
/>
@@ -224,7 +212,6 @@ onMounted(() => {
:key="index"
:pri="card.pri"
:rules="card.rules"
:visible="card.visible"
@changed="updateFilterCardValue2"
@close="filterCardClose('FilterRules2', card.pri)"
/>

View File

@@ -6,8 +6,10 @@ import type { Site } from '@/api/types'
// 提示框
const $toast = useToast()
// 选中站点
// 选中搜索站点
const selectedSites = ref<number[]>([])
// 选中订阅站点
const selectedRssSites = ref<number[]>([])
// 所有站点
const allSites = ref<Site[]>([])
@@ -29,6 +31,7 @@ async function querySites() {
// 过滤站点,只有启用的站点才显示
allSites.value = data.filter(item => item.is_active)
querySelectedSites()
querySelectedRssSites()
}
catch (error) {
console.log(error)
@@ -40,7 +43,7 @@ async function querySelectedSites() {
try {
const result: { [key: string]: any } = await api.get('system/setting/IndexerSites')
selectedSites.value = result.data?.value
selectedSites.value = result.data?.value ?? []
}
catch (error) {
console.log(error)
@@ -54,9 +57,36 @@ async function saveSelectedSites() {
const result: { [key: string]: any } = await api.post('system/setting/IndexerSites', selectedSites.value)
if (result.success)
$toast.success('索站点保存成功')
$toast.success('索站点保存成功')
else
$toast.error('索站点保存失败!')
$toast.error('索站点保存失败!')
}
catch (error) {
console.log(error)
}
}
// 查询用户选中的订阅站点
async function querySelectedRssSites() {
try {
const result: { [key: string]: any } = await api.get('system/setting/RssSites')
selectedRssSites.value = result.data?.value ?? []
}
catch (error) {
console.log(error)
}
}
// 保存用户选中的订阅站点
async function saveSelectedRssSites() {
try {
const result: { [key: string]: any } = await api.post('system/setting/RssSites', selectedRssSites.value)
if (result.success)
$toast.success('订阅站点保存成功')
else
$toast.error('订阅站点保存失败!')
}
catch (error) {
console.log(error)
@@ -93,8 +123,8 @@ onMounted(() => {
<template>
<VRow>
<VCol cols="12">
<VCard title="索站点">
<VCardSubtitle> 只有选中的站点才会在搜索和订阅中使用</VCardSubtitle>
<VCard title="索站点">
<VCardSubtitle> 只有选中的站点才会在搜索中使用</VCardSubtitle>
<VCardItem>
<VChipGroup v-model="selectedSites" column multiple>
@@ -118,6 +148,32 @@ onMounted(() => {
</VCardItem>
</VCard>
</VCol>
<VCol cols="12">
<VCard title="订阅站点">
<VCardSubtitle> 只有选中的站点才会在订阅中使用</VCardSubtitle>
<VCardItem>
<VChipGroup v-model="selectedRssSites" column multiple>
<VChip
v-for="site in allSites"
:key="site.id"
:color="selectedRssSites.includes(site.id) ? 'primary' : ''"
filter
variant="outlined"
:value="site.id"
>
{{ site.name }}
</VChip>
</VChipGroup>
</VCardItem>
<VCardItem>
<VBtn type="submit" @click="saveSelectedRssSites">
保存
</VBtn>
</VCardItem>
</VCard>
</VCol>
<VCol cols="12">
<VCard title="站点重置">
<VCardText>

View File

@@ -11,6 +11,9 @@ const customIdentifiers = ref('')
// 自定义制作组
const customReleaseGroups = ref('')
// 文件整理屏蔽词
const transferExcludeWords = ref('')
// 查询已设置的识别词
async function queryCustomIdentifiers() {
try {
@@ -39,6 +42,20 @@ async function queryCustomReleaseGroups() {
}
}
// 查询已设置的屏蔽词
async function queryTransferExcludeWords() {
try {
const result: { [key: string]: any } = await api.get(
'system/setting/TransferExcludeWords',
)
transferExcludeWords.value = result.data?.value.join('\n')
}
catch (error) {
console.log(error)
}
}
// 保存用户设置的识别词
async function saveCustomIdentifiers() {
try {
@@ -77,9 +94,29 @@ async function saveCustomReleaseGroups() {
}
}
// 保存文件整理屏蔽词
async function saveTransferExcludeWords() {
try {
// 用户名密码
const result: { [key: string]: any } = await api.post(
'system/setting/TransferExcludeWords',
transferExcludeWords.value.split('\n'),
)
if (result.success)
$toast.success('文件整理屏蔽词保存成功')
else
$toast.error('文件整理屏蔽词保存失败!')
}
catch (error) {
console.log(error)
}
}
onMounted(() => {
queryCustomIdentifiers()
queryCustomReleaseGroups()
queryTransferExcludeWords()
})
</script>
@@ -128,5 +165,25 @@ onMounted(() => {
</VCardItem>
</VCard>
</VCol>
<VCol cols="12">
<VCard title="文件整理屏蔽词">
<VCardSubtitle> 目录名或文件名中包含屏蔽词时不进行整理 </VCardSubtitle>
<VCardItem>
<VTextarea
v-model="transferExcludeWords"
auto-grow
placeholder="支持正则表达式,特殊字符需要\转义,一行代表一个屏蔽词"
/>
</VCardItem>
<VCardItem>
<VBtn
type="submit"
@click="saveTransferExcludeWords"
>
保存
</VBtn>
</VCardItem>
</VCard>
</VCol>
</VRow>
</template>

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import type { CalendarOptions } from '@fullcalendar/core'
import type { CalendarOptions, EventSourceInput } from '@fullcalendar/core'
import dayGridPlugin from '@fullcalendar/daygrid'
import interactionPlugin from '@fullcalendar/interaction'
import timeGridPlugin from '@fullcalendar/timegrid'
@@ -89,7 +89,7 @@ async function getSubscribes() {
// 合并事件
const events = [...subEvents, ...rssEvents]
calendarOptions.value.events = events.flat()
calendarOptions.value.events = events.flat().filter(event => event.start) as EventSourceInput
}
catch (error) {
console.error(error)

View File

@@ -176,6 +176,7 @@ function onRefresh() {
<!-- Dialog Content -->
<VCard title="新增自定义订阅">
<DialogCloseBtn @click="rssAddDialog = false" />
<VCardText class="pt-2">
<VForm @submit.prevent="() => {}">
<VRow>
@@ -232,6 +233,7 @@ function onRefresh() {
</VCol>
<VCol
cols="12"
md="6"
>
<VTextField
v-model="rssForm.include"
@@ -241,6 +243,7 @@ function onRefresh() {
</VCol>
<VCol
cols="12"
md="6"
>
<VTextField
v-model="rssForm.exclude"
@@ -250,6 +253,7 @@ function onRefresh() {
</VCol>
<VCol
cols="12"
md="6"
>
<VTextField
v-model="rssForm.save_path"

View File

@@ -0,0 +1,121 @@
<script lang="ts" setup>
import store from '@/store'
// 日志列表
const logs = ref<string[]>([])
// SSE持续获取日志
function startSSELogging() {
const token = store.state.auth.token
if (token) {
const eventSource = new EventSource(
`${import.meta.env.VITE_API_BASE_URL}system/logging?token=${token}`,
)
eventSource.addEventListener('message', (event) => {
const message = event.data
if (message)
logs.value.push(message)
})
onBeforeUnmount(() => {
eventSource.close()
})
}
}
// 从日志中提取日志详情
function extractLogDetailsFromLogs(logs: string[]): { level: string; time: string; program: string; content: string }[] {
const logDetails: { level: string; time: string; program: string; content: string }[] = []
const logPattern = /^【(.*?)】[0-9\-:]*\s(.*?)\s-\s(.*?)\s-\s(.*)$/
for (const log of logs) {
const matches = RegExp(logPattern).exec(log)
if (matches && matches.length === 5) {
const [_, level, time, program, content] = matches
logDetails.push({ level, time, program, content })
}
}
return logDetails
}
// 计算日志颜色
function getLogColor(level: string): string {
switch (level) {
case 'DEBUG':
return 'primary'
case 'INFO':
return 'secondary'
case 'WARNING':
return 'warning'
case 'ERROR':
return 'error'
default:
return 'secondary'
}
}
// 拆分日志数据计算属性
const extractLogDetails = computed(() => {
return extractLogDetailsFromLogs(logs.value)
})
onMounted(() => {
startSSELogging()
})
</script>
<template>
<div
v-if="logs.length === 0"
class="mt-5 w-full text-center flex flex-col items-center"
>
<VProgressCircular
size="48"
indeterminate
color="primary"
/>
<span class="mt-3">正在刷新 ...</span>
</div>
<div>
<VTable
class="table-rounded"
hide-default-footer
disable-sort
>
<tbody>
<tr v-for="(log, i) in extractLogDetails" :key="i" class="text-sm">
<td
class="text-sm"
>
<VChip
size="small"
:color="getLogColor(log.level)"
variant="elevated"
v-text="log.level"
/>
</td>
<!-- name -->
<td
class="text-sm"
>
{{ log.time }}
</td>
<td
class="text-sm"
>
<h6 class="text-sm font-weight-medium">
{{ log.program }}
</h6>
</td>
<td
class="text-sm"
v-text="log.content"
/>
</tr>
</tbody>
</VTable>
</div>
</template>

View File

@@ -3,6 +3,7 @@ import { reactive, ref } from 'vue'
import { requiredValidator } from '@/@validators'
import api from '@/api'
import type { Context } from '@/api/types'
import MediaInfoCard from '@/components/cards/MediaInfoCard.vue'
// 识别结果
const nameTestResult = ref<Context>()
@@ -45,22 +46,6 @@ async function nameTest() {
console.error(error)
}
}
// 打开TMDB详情页面
function openTmdbPage(type: string, tmdbId: number) {
if (!type || !tmdbId)
return
const url = `https://www.themoviedb.org/${type === '电影' ? 'movie' : 'tv'}/${tmdbId}`
window.open(url, '_blank')
}
// TMDB图片转换为w500大小
function getW500Image(url = '') {
if (!url)
return ''
return url.replace('original', 'w500')
}
</script>
<template>
@@ -101,123 +86,7 @@ function getW500Image(url = '') {
</VForm>
<VExpandTransition>
<div v-show="showResult">
<VCol>
<div
v-if="nameTestResult?.meta_info?.name"
class="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row"
>
<div
v-if="nameTestResult?.media_info?.poster_path"
class="ma-auto"
>
<VImg
width="10rem"
aspect-ratio="2/3"
class="object-cover aspect-w-2 aspect-h-3 rounded-lg ring-1 ring-gray-500"
:src="getW500Image(nameTestResult?.media_info?.poster_path)"
cover
>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
</VImg>
</div>
<div>
<VCardItem class="pb-1">
<VCardTitle>
{{ nameTestResult?.media_info?.title || nameTestResult?.meta_info?.name }}
{{ nameTestResult?.meta_info?.season_episode }}
</VCardTitle>
<VCardSubtitle>
{{ nameTestResult?.media_info?.year || nameTestResult?.meta_info?.year }}
</VCardSubtitle>
</VCardItem>
<VCardText
v-if="nameTestResult?.media_info?.overview"
class="line-clamp-4 overflow-hidden text-ellipsis ..."
>
{{ nameTestResult?.media_info?.overview }}
</VCardText>
<VCardItem>
<!-- 类型 -->
<VChip
v-if="nameTestResult?.media_info?.type || nameTestResult?.meta_info?.type"
variant="elevated"
class="me-1 mb-1 text-white bg-blue-500"
>
{{
nameTestResult?.media_info?.type || nameTestResult?.meta_info?.type
}}
</VChip>
<!-- 二级分类 -->
<VChip
v-if="nameTestResult?.media_info?.category"
variant="elevated"
class="me-1 mb-1 text-white bg-blue-500"
>
{{ nameTestResult?.media_info?.category }}
</VChip>
<!-- TMDBID -->
<VChip
v-if="nameTestResult?.media_info?.tmdb_id"
variant="elevated"
color="success"
class="me-1 mb-1"
@click="openTmdbPage(nameTestResult?.media_info?.type || '', nameTestResult?.media_info?.tmdb_id)"
>
{{ nameTestResult?.media_info?.tmdb_id }}
</VChip>
<!-- meta_info -->
<VChip
v-if="nameTestResult?.meta_info?.edition"
variant="elevated"
class="me-1 mb-1 text-white bg-red-500"
>
{{ nameTestResult?.meta_info?.edition }}
</VChip>
<VChip
v-if="nameTestResult?.meta_info?.resource_pix"
variant="elevated"
class="me-1 mb-1 text-white bg-red-500"
>
{{ nameTestResult?.meta_info?.resource_pix }}
</VChip>
<VChip
v-if="nameTestResult?.meta_info?.video_encode"
variant="elevated"
class="me-1 mb-1 text-white bg-orange-500"
>
{{ nameTestResult?.meta_info?.video_encode }}
</VChip>
<VChip
v-if="nameTestResult?.meta_info?.audio_encode"
variant="elevated"
class="me-1 mb-1 text-white bg-orange-500"
>
{{ nameTestResult?.meta_info?.audio_encode }}
</VChip>
<VChip
v-if="nameTestResult?.meta_info?.resource_team"
variant="elevated"
class="me-1 mb-1 text-white bg-cyan-500"
>
{{ nameTestResult?.meta_info?.resource_team }}
</VChip>
</VCardItem>
</div>
</div>
<VAlert
v-if="!nameTestResult?.meta_info?.name"
icon="mdi-alert-circle-outline"
>
识别失败无法识别到有效信息
</VAlert>
</VCol>
<MediaInfoCard :context="nameTestResult" />
</div>
</VExpandTransition>
</template>

View File

@@ -0,0 +1,117 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { requiredValidator } from '@/@validators'
import api from '@/api'
// 识别结果
const ruleTestResult = ref('')
// 名称识别表单
const ruleTestForm = reactive({
title: '',
subtitle: '',
ruletype: '1',
})
// 识别按钮状态
const ruleTestLoading = ref(false)
// 识别按钮文本
const ruleTestText = ref('测试')
// 是否显示结果
const showResult = ref(false)
// 调用API识别
async function ruleTest() {
if (!ruleTestForm.title)
return
try {
ruleTestLoading.value = true
ruleTestText.value = '正在测试...'
showResult.value = false
const result: { [key: string]: any } = await api.get('system/ruletest', {
params: {
title: ruleTestForm.title,
subtitle: ruleTestForm.subtitle,
ruletype: ruleTestForm.ruletype,
},
})
if (result.success)
ruleTestResult.value = `优先级:${result.data.priority}`
else
ruleTestResult.value = '未命中任何优先级规则!'
ruleTestLoading.value = false
ruleTestText.value = '重新测试'
showResult.value = true
}
catch (error) {
console.error(error)
}
}
</script>
<template>
<VForm @submit.prevent="() => {}">
<VRow class="pt-2">
<VCol cols="12" md="8">
<VTextField
v-model="ruleTestForm.title"
label="标题"
:rules="[requiredValidator]"
/>
</VCol>
<VCol cols="12" md="4">
<VSelect
v-model="ruleTestForm.ruletype"
label="规则类型"
:items="[{
title: '默认规则',
value: '1',
}, {
title: '洗版规则',
value: '2',
}]"
/>
</VCol>
<VCol cols="12">
<VTextarea
v-model="ruleTestForm.subtitle"
label="副标题"
rows="2"
auto-grow
/>
</VCol>
</VRow>
<VRow>
<VCol
cols="12"
class="text-center"
>
<VBtn
:disabled="ruleTestLoading"
@click="ruleTest"
>
<template #prepend>
<VIcon icon="mdi-filter-check-outline" />
</template>
{{ ruleTestText }}
</VBtn>
</VCol>
</VRow>
</VForm>
<VExpandTransition>
<div v-show="showResult">
<VCol>
<VAlert
icon="mdi-alert-circle-outline"
>
{{ ruleTestResult }}
</VAlert>
</VCol>
</div>
</VExpandTransition>
</template>

View File

@@ -1836,6 +1836,11 @@
resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==
"@types/lodash@^4.14.197":
version "4.14.198"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.198.tgz#4d27465257011aedc741a809f1269941fa2c5d4c"
integrity sha512-trNJ/vtMZYMLhfN45uLq4ShQSw0/S7xCTLLVM+WM1rmFpba/VS42jVUgaO3w/NOLiWR/09lnYk0yMaA/atdIsg==
"@types/mdast@^3.0.0":
version "3.0.11"
resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-3.0.11.tgz#dc130f7e7d9306124286f6d6cee40cf4d14a3dc0"