mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 15:36:49 +08:00
Merge remote-tracking branch 'origin/main'
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "moviepilot",
|
"name": "moviepilot",
|
||||||
"version": "1.0.3",
|
"version": "1.0.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host",
|
"dev": "vite --host",
|
||||||
|
|||||||
@@ -95,3 +95,17 @@ export function parseDate(dateString: string): Date {
|
|||||||
|
|
||||||
return new Date(year, month - 1, day)
|
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]}`
|
||||||
|
}
|
||||||
|
|||||||
+20
-3
@@ -2,7 +2,7 @@
|
|||||||
import { useToast } from 'vue-toast-notification'
|
import { useToast } from 'vue-toast-notification'
|
||||||
import { useTheme } from 'vuetify'
|
import { useTheme } from 'vuetify'
|
||||||
import api from './api'
|
import api from './api'
|
||||||
import type { User } from './api/types'
|
import type { Setting, User } from './api/types'
|
||||||
import store from './store'
|
import store from './store'
|
||||||
import avatar1 from '@images/avatars/avatar-1.png'
|
import avatar1 from '@images/avatars/avatar-1.png'
|
||||||
|
|
||||||
@@ -47,6 +47,9 @@ const accountInfo = ref<User>({
|
|||||||
avatar: avatar1,
|
avatar: avatar1,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 环境设置信息
|
||||||
|
const systemEnv = ref<Setting>()
|
||||||
|
|
||||||
// 调用API,加载当前用户数据
|
// 调用API,加载当前用户数据
|
||||||
async function loadAccountInfo() {
|
async function loadAccountInfo() {
|
||||||
try {
|
try {
|
||||||
@@ -61,14 +64,28 @@ async function loadAccountInfo() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 调用API,加载当前系统环境设置
|
||||||
|
async function loadSystemSettings() {
|
||||||
|
try {
|
||||||
|
const result: { [key: string]: any } = await api.get('system/env')
|
||||||
|
if (result.success)
|
||||||
|
systemEnv.value = result.data
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.log(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 页面加载时,加载当前用户数据
|
// 页面加载时,加载当前用户数据
|
||||||
onMounted(() => {
|
onBeforeMount(async () => {
|
||||||
loadAccountInfo()
|
await loadAccountInfo()
|
||||||
|
await loadSystemSettings()
|
||||||
startSSEMessager()
|
startSSEMessager()
|
||||||
})
|
})
|
||||||
|
|
||||||
// 提供给所有元素复用
|
// 提供给所有元素复用
|
||||||
provide('accountInfo', accountInfo)
|
provide('accountInfo', accountInfo)
|
||||||
|
provide('systemEnv', systemEnv)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
+23
-14
@@ -833,20 +833,8 @@ export interface NotificationSwitch {
|
|||||||
|
|
||||||
// 环境设置
|
// 环境设置
|
||||||
export interface Setting {
|
export interface Setting {
|
||||||
// 媒体服务器 emby/jellyfin/plex
|
// 下载目录
|
||||||
MEDIASERVER: string
|
DOWNLOAD_PATH: 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 自定义订阅
|
// 自定义订阅
|
||||||
@@ -897,3 +885,24 @@ export interface Rss {
|
|||||||
// 状态 0-停用,1-启用
|
// 状态 0-停用,1-启用
|
||||||
state?: number
|
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[]
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
<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 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 path = ref(props.path)
|
||||||
|
// 加载次数
|
||||||
|
const loading = ref(0)
|
||||||
|
// 当前存储
|
||||||
|
const activeStorage = ref('local')
|
||||||
|
// 刷新
|
||||||
|
const refreshPending = ref(false)
|
||||||
|
// 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) {
|
||||||
|
path.value = _path
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
onBeforeMount(() => {
|
||||||
|
activeStorage.value = props.storage ?? 'local'
|
||||||
|
axiosInstance.value = props.axios ?? axios.create(props.axiosconfig)
|
||||||
|
if (!path.value)
|
||||||
|
pathChanged('/')
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VCard class="mx-auto" :loading="loading > 0">
|
||||||
|
<Toolbar
|
||||||
|
:path="path"
|
||||||
|
:storages="storagesArray"
|
||||||
|
:storage="activeStorage"
|
||||||
|
:endpoints="props.endpoints"
|
||||||
|
:axios="axiosInstance"
|
||||||
|
@storagechanged="storageChanged"
|
||||||
|
@pathchanged="pathChanged"
|
||||||
|
@foldercreated="refreshPending = true"
|
||||||
|
/>
|
||||||
|
<VRow no-gutters>
|
||||||
|
<VCol v-if="tree" sm="auto" class="d-none d-md-block">
|
||||||
|
<Tree
|
||||||
|
:path="path"
|
||||||
|
:storage="activeStorage"
|
||||||
|
:icons="fileIcons"
|
||||||
|
:endpoints="endpoints"
|
||||||
|
:axios="axiosInstance"
|
||||||
|
:refreshpending="refreshPending"
|
||||||
|
@pathchanged="pathChanged"
|
||||||
|
@loading="loadingChanged"
|
||||||
|
@refreshed="refreshPending = false"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
<VDivider v-if="tree" vertical />
|
||||||
|
<VCol>
|
||||||
|
<List
|
||||||
|
:path="path"
|
||||||
|
:storage="activeStorage"
|
||||||
|
:icons="fileIcons"
|
||||||
|
:endpoints="endpoints"
|
||||||
|
:axios="axiosInstance"
|
||||||
|
:refreshpending="refreshPending"
|
||||||
|
@pathchanged="pathChanged"
|
||||||
|
@loading="loadingChanged"
|
||||||
|
@refreshed="refreshPending = false"
|
||||||
|
@filedeleted="refreshPending = true"
|
||||||
|
@renamed="refreshPending = true"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
</VCard>
|
||||||
|
</template>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useTheme } from 'vuetify'
|
import { useTheme } from 'vuetify'
|
||||||
import misc404 from '@images/pages/404.png'
|
import miscpose from '@images/pages/pose-fs-9.png'
|
||||||
import miscMaskDark from '@images/pages/misc-mask-dark.png'
|
import miscMaskDark from '@images/pages/misc-mask-dark.png'
|
||||||
import miscMaskLight from '@images/pages/misc-mask-light.png'
|
import miscMaskLight from '@images/pages/misc-mask-light.png'
|
||||||
import tree from '@images/pages/tree.png'
|
import tree from '@images/pages/tree.png'
|
||||||
@@ -29,11 +29,12 @@ interface Props {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 👉 Image -->
|
<!-- 👉 Image -->
|
||||||
<div class="misc-avatar w-100 text-center">
|
<div class="misc-avatar text-center">
|
||||||
<VImg
|
<VImg
|
||||||
:src="misc404"
|
:src="miscpose"
|
||||||
:max-width="800"
|
class="mx-auto pt-10"
|
||||||
class="mx-auto"
|
max-width="250"
|
||||||
|
cover
|
||||||
/>
|
/>
|
||||||
<slot name="button" />
|
<slot name="button" />
|
||||||
</div>
|
</div>
|
||||||
@@ -41,7 +42,7 @@ interface Props {
|
|||||||
<!-- 👉 Footer -->
|
<!-- 👉 Footer -->
|
||||||
<VImg
|
<VImg
|
||||||
:src="tree"
|
:src="tree"
|
||||||
class="misc-footer-tree d-none d-md-block"
|
class="misc-footer-tree d-none d-lg-block"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<VImg
|
<VImg
|
||||||
@@ -52,21 +53,23 @@ interface Props {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
@use "@configured-variables" as variables;
|
||||||
@use '@core/scss/pages/misc.scss';
|
@use '@core/scss/pages/misc.scss';
|
||||||
|
|
||||||
.misc-wrapper {
|
.misc-wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
.misc-footer-tree {
|
.misc-footer-tree {
|
||||||
position: absolute;
|
position: fixed;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
inline-size: 15.625rem;
|
inline-size: 15.625rem;
|
||||||
inset-block-end: 3.5rem;
|
inset-block-end: 3.5rem;
|
||||||
inset-inline-start: 0.375rem;
|
inset-inline-start: 0.375rem;
|
||||||
|
left: variables.$layout-vertical-nav-width;
|
||||||
}
|
}
|
||||||
|
|
||||||
.misc-footer-img {
|
.misc-footer-img {
|
||||||
position: absolute;
|
position: fixed;
|
||||||
inline-size: 100%;
|
inline-size: 100%;
|
||||||
inset-block-end: 0;
|
inset-block-end: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ async function deleteDownload() {
|
|||||||
:class="getTextClass()"
|
:class="getTextClass()"
|
||||||
>
|
>
|
||||||
{{ props.info?.media.title || props.info?.name }}
|
{{ 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>
|
</VCardTitle>
|
||||||
|
|
||||||
<VCardSubtitle
|
<VCardSubtitle
|
||||||
|
|||||||
@@ -349,6 +349,7 @@ function handleSearch() {
|
|||||||
: `douban:${props.media?.douban_id}`
|
: `douban:${props.media?.douban_id}`
|
||||||
}`,
|
}`,
|
||||||
type: props.media?.type,
|
type: props.media?.type,
|
||||||
|
area: 'title',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,8 +65,8 @@ function getPercentage() {
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
return Math.round(
|
return Math.round(
|
||||||
(((props.media?.total_episode || 0) - (props.media?.lack_episode || 0))
|
(((props.media?.total_episode ?? 0) - (props.media?.lack_episode ?? 0))
|
||||||
/ (props.media?.total_episode || 1))
|
/ (props.media?.total_episode ?? 1))
|
||||||
* 100,
|
* 100,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,636 @@
|
|||||||
|
<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 { EndPoints, FileItem } from '@/api/types'
|
||||||
|
import store from '@/store'
|
||||||
|
import api from '@/api'
|
||||||
|
|
||||||
|
// 输入参数
|
||||||
|
const inProps = defineProps({
|
||||||
|
icons: Object,
|
||||||
|
storage: String,
|
||||||
|
path: String,
|
||||||
|
endpoints: Object as PropType<EndPoints>,
|
||||||
|
axios: Object as PropType<Axios>,
|
||||||
|
refreshpending: Boolean,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 对外事件
|
||||||
|
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 newName = ref('')
|
||||||
|
|
||||||
|
// 当前名称
|
||||||
|
const currentItem = ref<FileItem>()
|
||||||
|
|
||||||
|
// 文件转移表单
|
||||||
|
const transferForm = reactive({
|
||||||
|
path: '',
|
||||||
|
target: '',
|
||||||
|
tmdbid: 0,
|
||||||
|
season: 0,
|
||||||
|
type_name: '电影',
|
||||||
|
transfer_type: '',
|
||||||
|
episode_format: '',
|
||||||
|
episode_detail: '',
|
||||||
|
episode_part: '',
|
||||||
|
episode_offset: null,
|
||||||
|
min_filesize: 0,
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
// 生成1到50季的下拉框选项
|
||||||
|
const seasonItems = ref(
|
||||||
|
Array.from({ length: 50 }, (_, i) => i + 1).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)
|
||||||
|
if (isDir.value) {
|
||||||
|
const url = inProps.endpoints?.list.url
|
||||||
|
.replace(/{storage}/g, storage.value)
|
||||||
|
.replace(/{path}/g, inProps.path)
|
||||||
|
|
||||||
|
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: 600,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (confirmed) {
|
||||||
|
emit('loading', true)
|
||||||
|
const url = inProps.endpoints?.delete.url
|
||||||
|
.replace(/{storage}/g, storage.value)
|
||||||
|
.replace(/{path}/g, 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, 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, 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, currentItem.value?.path)
|
||||||
|
.replace(/{newname}/g, 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
|
||||||
|
const result: { [key: string]: any } = await api.post('transfer/manual', {}, {
|
||||||
|
params: transferForm,
|
||||||
|
})
|
||||||
|
if (result.success) {
|
||||||
|
$toast.success(`${currentItem.value?.name} 整理成功!`)
|
||||||
|
// 重新加载
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$toast.error(`${currentItem.value?.name} 整理失败:${result.message}!`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
console.log(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听path变化
|
||||||
|
watch(
|
||||||
|
() => inProps.path,
|
||||||
|
async () => {
|
||||||
|
items.value = []
|
||||||
|
await load()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// 监听refreshPending变化
|
||||||
|
watch(
|
||||||
|
() => inProps.refreshpending,
|
||||||
|
async () => {
|
||||||
|
if (inProps.refreshpending) {
|
||||||
|
await load()
|
||||||
|
emit('refreshed')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// 弹出菜单
|
||||||
|
const dropdownItems = ref([
|
||||||
|
{
|
||||||
|
title: '重命名',
|
||||||
|
value: 1,
|
||||||
|
props: {
|
||||||
|
prependIcon: 'mdi-rename',
|
||||||
|
click: showRenmae,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '整理',
|
||||||
|
value: 2,
|
||||||
|
props: {
|
||||||
|
prependIcon: 'mdi-folder-arrow-right',
|
||||||
|
click: showTransfer,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '删除',
|
||||||
|
value: 3,
|
||||||
|
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="grow d-flex justify-center align-center break-all"
|
||||||
|
>
|
||||||
|
文件: {{ path }}<br>
|
||||||
|
</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.stop="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="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.stop="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="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" />
|
||||||
|
<VBtn v-if="isFile" @click="download(inProps.path || '')">
|
||||||
|
<VIcon>mdi-download</VIcon>
|
||||||
|
</VBtn>
|
||||||
|
<VBtn v-if="!isFile" @click="load">
|
||||||
|
<VIcon>mdi-refresh</VIcon>
|
||||||
|
</VBtn>
|
||||||
|
</VToolbar>
|
||||||
|
</VCard>
|
||||||
|
<!-- 重命名弹窗 -->
|
||||||
|
<VDialog
|
||||||
|
v-model="renamePopper"
|
||||||
|
max-width="600"
|
||||||
|
>
|
||||||
|
<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: '电视剧' }]"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
<VCol
|
||||||
|
cols="12"
|
||||||
|
md="4"
|
||||||
|
>
|
||||||
|
<VTextField
|
||||||
|
v-model="transferForm.tmdbid"
|
||||||
|
label="TMDBID"
|
||||||
|
: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
|
||||||
|
:disabled="!transferForm.tmdbid"
|
||||||
|
depressed
|
||||||
|
@click="transfer"
|
||||||
|
>
|
||||||
|
开始整理
|
||||||
|
</VBtn>
|
||||||
|
</VCardActions>
|
||||||
|
</VCard>
|
||||||
|
</VDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.v-card {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.v-toolbar{
|
||||||
|
background: rgb(var(--v-table-header-background));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
<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'])
|
||||||
|
|
||||||
|
// 新建文件夹名称
|
||||||
|
const newFolderPopper = ref(false)
|
||||||
|
|
||||||
|
// 新建文件名称
|
||||||
|
const newFolderName = ref('')
|
||||||
|
|
||||||
|
// 计算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, 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')
|
||||||
|
}
|
||||||
|
</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 v-if="pathSegments.length > 0" @click="goUp">
|
||||||
|
<VIcon icon="mdi-arrow-up-bold-outline" />
|
||||||
|
</IconBtn>
|
||||||
|
<VDialog
|
||||||
|
v-model="newFolderPopper"
|
||||||
|
max-width="600"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
@@ -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>
|
||||||
@@ -134,6 +134,13 @@ import UserProfile from '@/layouts/components/UserProfile.vue'
|
|||||||
to: '/history',
|
to: '/history',
|
||||||
}"
|
}"
|
||||||
/>
|
/>
|
||||||
|
<VerticalNavLink
|
||||||
|
:item="{
|
||||||
|
title: '文件管理',
|
||||||
|
icon: 'mdi-folder-multiple-outline',
|
||||||
|
to: '/filemanager',
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- 👉 系统 -->
|
<!-- 👉 系统 -->
|
||||||
<VerticalNavSectionTitle
|
<VerticalNavSectionTitle
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import FileBrowserView from '@/views/reorganize/FileBrowserView.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<FileBrowserView />
|
||||||
|
</template>
|
||||||
@@ -9,6 +9,9 @@ const keyword = route.query?.keyword?.toString() ?? ''
|
|||||||
|
|
||||||
// 查询类型
|
// 查询类型
|
||||||
const type = route.query?.type?.toString() ?? ''
|
const type = route.query?.type?.toString() ?? ''
|
||||||
|
|
||||||
|
// 搜索字段
|
||||||
|
const area = route.query?.area?.toString() ?? ''
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -16,6 +19,7 @@ const type = route.query?.type?.toString() ?? ''
|
|||||||
<TorrentCardListView
|
<TorrentCardListView
|
||||||
:keyword="keyword"
|
:keyword="keyword"
|
||||||
:type="type"
|
:type="type"
|
||||||
|
:area="area"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -128,6 +128,13 @@ const router = createRouter({
|
|||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/filemanager',
|
||||||
|
component: () => import('../pages/filemanager.vue'),
|
||||||
|
meta: {
|
||||||
|
requiresAuth: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -379,12 +379,13 @@ function joinArray(arr: string[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 开始搜索
|
// 开始搜索
|
||||||
function handleSearch() {
|
function handleSearch(area: string) {
|
||||||
router.push({
|
router.push({
|
||||||
path: '/resource',
|
path: '/resource',
|
||||||
query: {
|
query: {
|
||||||
keyword: `tmdb:${mediaDetail.value.tmdb_id}`,
|
keyword: `tmdb:${mediaDetail.value.tmdb_id}`,
|
||||||
type: mediaDetail.value.type,
|
type: mediaDetail.value.type,
|
||||||
|
area,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -442,11 +443,31 @@ onBeforeMount(() => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="media-actions">
|
<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>
|
<template #prepend>
|
||||||
<VIcon icon="mdi-magnify" />
|
<VIcon icon="mdi-magnify" />
|
||||||
</template>
|
</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>
|
||||||
<VBtn v-if="mediaDetail.type === '电影'" class="ms-2" :color="getSubscribeColor" variant="tonal" @click="handleSubscribe(0)">
|
<VBtn v-if="mediaDetail.type === '电影'" class="ms-2" :color="getSubscribeColor" variant="tonal" @click="handleSubscribe(0)">
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ const props = defineProps({
|
|||||||
|
|
||||||
// 类型
|
// 类型
|
||||||
type: String,
|
type: String,
|
||||||
|
|
||||||
|
// 搜索字段
|
||||||
|
area: String,
|
||||||
})
|
})
|
||||||
|
|
||||||
interface SearchTorrent extends Context {
|
interface SearchTorrent extends Context {
|
||||||
@@ -124,6 +127,7 @@ async function fetchData(): Promise<Array<Context>> {
|
|||||||
let searchData: Array<Context>
|
let searchData: Array<Context>
|
||||||
const keyword = props.keyword ?? ''
|
const keyword = props.keyword ?? ''
|
||||||
const mtype = props.type ?? ''
|
const mtype = props.type ?? ''
|
||||||
|
const area = props.area ?? ''
|
||||||
if (!keyword) {
|
if (!keyword) {
|
||||||
// 查询上次搜索结果
|
// 查询上次搜索结果
|
||||||
searchData = await api.get('search/last')
|
searchData = await api.get('search/last')
|
||||||
@@ -136,6 +140,7 @@ async function fetchData(): Promise<Array<Context>> {
|
|||||||
searchData = await api.get(`search/media/${props.keyword}`, {
|
searchData = await api.get(`search/media/${props.keyword}`, {
|
||||||
params: {
|
params: {
|
||||||
mtype,
|
mtype,
|
||||||
|
area,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import api from '@/api'
|
||||||
|
import type { Setting } from '@/api/types'
|
||||||
|
import FileBrowser from '@/components/FileBrowser.vue'
|
||||||
|
|
||||||
|
const endpoints = {
|
||||||
|
list: { url: '/filebrowser/list?path={path}', 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 systemEnv: Setting = inject('systemEnv') ?? {
|
||||||
|
DOWNLOAD_PATH: '/',
|
||||||
|
}
|
||||||
|
|
||||||
|
if (systemEnv?.DOWNLOAD_PATH && !systemEnv.DOWNLOAD_PATH?.endsWith('/'))
|
||||||
|
systemEnv.DOWNLOAD_PATH += '/'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<FileBrowser storages="local" :tree="false" :path="systemEnv?.DOWNLOAD_PATH" :endpoints="endpoints" :axios="api" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -40,7 +40,7 @@ async function querySelectedSites() {
|
|||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.get('system/setting/IndexerSites')
|
const result: { [key: string]: any } = await api.get('system/setting/IndexerSites')
|
||||||
|
|
||||||
selectedSites.value = result.data?.value
|
selectedSites.value = result.data?.value ?? []
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
|
|||||||
Reference in New Issue
Block a user