Compare commits
81 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
822d457bff | ||
|
|
8e391af0b4 | ||
|
|
b522b4d355 | ||
|
|
9c5ef8f5b4 | ||
|
|
4dc1ad35d2 | ||
|
|
9b405d9e59 | ||
|
|
2351ec7b85 | ||
|
|
6fc3228334 | ||
|
|
81b1f5b14d | ||
|
|
b8450ad28b | ||
|
|
70371a5001 | ||
|
|
87f4cf772b | ||
|
|
970ed8ff86 | ||
|
|
3f77f1037a | ||
|
|
6cc770dced | ||
|
|
355172dbf6 | ||
|
|
3bfcd38e65 | ||
|
|
909857f146 | ||
|
|
1cc64c7d21 | ||
|
|
891db2be21 | ||
|
|
54f3451456 | ||
|
|
563471ccf5 | ||
|
|
2e70b61e60 | ||
|
|
cd0d786a4c | ||
|
|
611ae13777 | ||
|
|
026214bd3f | ||
|
|
7c29c9ad27 | ||
|
|
cba5586f05 | ||
|
|
25a8d0cc2a | ||
|
|
2396a6b5fc | ||
|
|
c94add8d6b | ||
|
|
8b893dc6f2 | ||
|
|
51816da3d3 | ||
|
|
5e2d144828 | ||
|
|
04f56692a5 | ||
|
|
d3fb71c289 | ||
|
|
e565ec5b62 | ||
|
|
e5f7467d5b | ||
|
|
3a9e85c821 | ||
|
|
f2b51107fa | ||
|
|
e3df4000bb | ||
|
|
f4aef8f9dd | ||
|
|
476bd4bd19 | ||
|
|
4681c947c7 | ||
|
|
5db4d97568 | ||
|
|
235942157e | ||
|
|
8ef2be1c81 | ||
|
|
ed45f3438f | ||
|
|
2ca0920131 | ||
|
|
4491e80f6a | ||
|
|
55abc47d0e | ||
|
|
6ad6d5a45b | ||
|
|
ed8364a0c2 | ||
|
|
03f01c6134 | ||
|
|
14258b6b76 | ||
|
|
5e74b26c52 | ||
|
|
e8e68e0195 | ||
|
|
73b5e4948e | ||
|
|
b3f7ad26b0 | ||
|
|
943c009ac6 | ||
|
|
68b9822aac | ||
|
|
26699c08d6 | ||
|
|
61bdf6e30c | ||
|
|
43f76fbf62 | ||
|
|
0a0e57fe61 | ||
|
|
5bbf1b54a7 | ||
|
|
e2e63cc506 | ||
|
|
79fec43fc2 | ||
|
|
309e85c024 | ||
|
|
76592cfbb8 | ||
|
|
cf6ab0e3de | ||
|
|
9dd1a97ba9 | ||
|
|
511ea625a1 | ||
|
|
7616b35d3e | ||
|
|
d6a6ff662b | ||
|
|
f1244fb25e | ||
|
|
b8711a2dda | ||
|
|
d894010da0 | ||
|
|
e88a0230ae | ||
|
|
e60c97302e | ||
|
|
42d837a0a6 |
35
.github/workflows/build.yml
vendored
@@ -1,7 +1,12 @@
|
||||
name: Build moviepilot frontend
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- package.json
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -13,30 +18,30 @@ jobs:
|
||||
- name: Release version
|
||||
id: release_version
|
||||
run: |
|
||||
frontend_version=$(jq -r '.version' package.json)
|
||||
echo "frontend_version=v$frontend_version" >> $GITHUB_ENV
|
||||
frontend_version=$(jq -r '.version' package.json)
|
||||
echo "frontend_version=v$frontend_version" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'yarn'
|
||||
node-version: '18'
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Build frontend
|
||||
id: build_frontend
|
||||
run: |
|
||||
yarn
|
||||
yarn build
|
||||
zip -r dist.zip dist
|
||||
yarn
|
||||
yarn build
|
||||
zip -r dist.zip dist
|
||||
|
||||
- name: Generate Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ env.frontend_version }}
|
||||
name: ${{ env.frontend_version }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
files: |
|
||||
dist.zip
|
||||
tag_name: ${{ env.frontend_version }}
|
||||
name: ${{ env.frontend_version }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
files: |
|
||||
dist.zip
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "moviepilot",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.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/database.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 14 KiB |
BIN
public/plugin/rss.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 28 KiB |
BIN
public/plugin/sync_file.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 16 KiB |
@@ -11,7 +11,7 @@ function onClick() {
|
||||
<template>
|
||||
<IconBtn
|
||||
class="absolute right-3 top-3"
|
||||
@click="onClick"
|
||||
@click.stop="onClick"
|
||||
>
|
||||
<VIcon icon="mdi-close" />
|
||||
</IconBtn>
|
||||
|
||||
@@ -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]}`
|
||||
}
|
||||
|
||||
@@ -62,8 +62,8 @@ async function loadAccountInfo() {
|
||||
}
|
||||
|
||||
// 页面加载时,加载当前用户数据
|
||||
onMounted(() => {
|
||||
loadAccountInfo()
|
||||
onBeforeMount(async () => {
|
||||
await loadAccountInfo()
|
||||
startSSEMessager()
|
||||
})
|
||||
|
||||
|
||||
@@ -626,7 +626,7 @@ export interface MetaInfo {
|
||||
year?: string
|
||||
|
||||
// 总季数
|
||||
total_seasons: number
|
||||
total_season: number
|
||||
|
||||
// 识别的开始季 数字
|
||||
begin_season?: number
|
||||
@@ -833,20 +833,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 +885,24 @@ 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[]
|
||||
}
|
||||
|
||||
137
src/components/FileBrowser.vue
Normal file
@@ -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 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)
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 初始化
|
||||
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"
|
||||
/>
|
||||
<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"
|
||||
@pathchanged="pathChanged"
|
||||
@loading="loadingChanged"
|
||||
@refreshed="refreshPending = false"
|
||||
@filedeleted="refreshPending = true"
|
||||
@renamed="refreshPending = true"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCard>
|
||||
</template>
|
||||
@@ -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>
|
||||
|
||||
@@ -40,8 +40,8 @@ function getTextClass() {
|
||||
async function toggleDownload() {
|
||||
const operation = isDownloading.value ? 'stop' : 'start'
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.put(
|
||||
`download/${props.info?.hash}/${operation}`,
|
||||
const result: { [key: string]: any } = await api.get(
|
||||
`download/${operation}/${props.info?.hash}`,
|
||||
)
|
||||
|
||||
if (result.success)
|
||||
@@ -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
|
||||
@@ -109,9 +109,10 @@ async function deleteDownload() {
|
||||
</VCardText>
|
||||
|
||||
<VCardActions class="justify-space-between">
|
||||
<VBtn @click="toggleDownload">
|
||||
<span class="ms-2">{{ isDownloading ? "暂停" : "开始" }}</span>
|
||||
</VBtn>
|
||||
<VBtn
|
||||
:icon="`${isDownloading ? 'mdi-pause' : 'mdi-play'}`"
|
||||
@click="toggleDownload"
|
||||
/>
|
||||
<VBtn
|
||||
color="error"
|
||||
icon="mdi-trash-can-outline"
|
||||
|
||||
@@ -349,6 +349,7 @@ function handleSearch() {
|
||||
: `douban:${props.media?.douban_id}`
|
||||
}`,
|
||||
type: props.media?.type,
|
||||
area: 'title',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ const emit = defineEmits(['install'])
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 图片是否加载完成
|
||||
const isImageLoaded = ref(false)
|
||||
|
||||
// 安装插件
|
||||
async function installPlugin() {
|
||||
try {
|
||||
@@ -51,12 +54,13 @@ async function installPlugin() {
|
||||
>
|
||||
<VAvatar
|
||||
size="128"
|
||||
class="shadow"
|
||||
:class="{ shadow: isImageLoaded }"
|
||||
>
|
||||
<VImg
|
||||
:src="`/plugin/${props.plugin?.plugin_icon}`"
|
||||
aspect-ratio="4/3"
|
||||
cover
|
||||
@load="isImageLoaded = true"
|
||||
/>
|
||||
</VAvatar>
|
||||
</div>
|
||||
|
||||
@@ -37,6 +37,9 @@ const pluginInfoDialog = ref(false)
|
||||
// 插件详情页面配置项
|
||||
let pluginPageItems = reactive([])
|
||||
|
||||
// 图片是否加载完成
|
||||
const isImageLoaded = ref(false)
|
||||
|
||||
// 调用API卸载插件
|
||||
async function uninstallPlugin() {
|
||||
try {
|
||||
@@ -184,12 +187,13 @@ const dropdownItems = ref([
|
||||
</div>
|
||||
<VAvatar
|
||||
size="128"
|
||||
class="shadow"
|
||||
:class="{ shadow: isImageLoaded }"
|
||||
>
|
||||
<VImg
|
||||
:src="`/plugin/${props.plugin?.plugin_icon}`"
|
||||
aspect-ratio="4/3"
|
||||
cover
|
||||
:class="{ shadow: isImageLoaded }"
|
||||
/>
|
||||
</VAvatar>
|
||||
</div>
|
||||
|
||||
@@ -49,38 +49,12 @@ const previewDataList = ref<TorrentInfo[]>([])
|
||||
const siteName = ref('')
|
||||
|
||||
// 订阅编辑表单
|
||||
const rssForm = reactive({
|
||||
id: props.media?.id,
|
||||
// RSS地址
|
||||
url: props.media?.url,
|
||||
// 类型
|
||||
type: props.media?.type,
|
||||
// 标题
|
||||
title: props.media?.title,
|
||||
// 年份
|
||||
year: props.media?.year,
|
||||
// TMDBID
|
||||
tmdbid: props.media?.tmdbid,
|
||||
// 季号
|
||||
season: props.media?.season,
|
||||
// 总集数
|
||||
total_episode: props.media?.total_episode,
|
||||
// 包含
|
||||
include: props.media?.include,
|
||||
// 排除
|
||||
exclude: props.media?.exclude,
|
||||
// 洗版
|
||||
best_version: !!props.media?.best_version,
|
||||
// 是否使用代理服务器
|
||||
proxy: !!props.media?.proxy,
|
||||
// 是否使用过滤规则
|
||||
filter: !!props.media?.filter,
|
||||
// 保存路径
|
||||
save_path: props.media?.save_path,
|
||||
// 状态 0-停用,1-启用
|
||||
state: props.media?.state,
|
||||
const rssForm = reactive<any>(props.media ?? {})
|
||||
|
||||
})
|
||||
// 类型转换
|
||||
rssForm.best_version = rssForm.best_version === 1
|
||||
rssForm.proxy = rssForm.proxy === 1
|
||||
rssForm.filter = rssForm.filter === 1
|
||||
|
||||
// 上一次更新时间
|
||||
const lastUpdateText = ref(
|
||||
@@ -164,7 +138,7 @@ async function querySiteName() {
|
||||
}
|
||||
catch (e) {
|
||||
// 截取URL中的主域名作为站点名称
|
||||
siteName.value = props.media?.url?.split('/')[2] || '未知'
|
||||
siteName.value = props.media?.url?.split('/')[2] ?? '未知'
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
@@ -442,6 +416,7 @@ onMounted(() => {
|
||||
md="6"
|
||||
>
|
||||
<VSelect
|
||||
v-show="rssForm.type === '电视剧'"
|
||||
v-model="rssForm.season"
|
||||
label="季"
|
||||
:items="seasonItems"
|
||||
|
||||
@@ -14,6 +14,9 @@ const cardProps = defineProps({
|
||||
height: String,
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['remove', 'update'])
|
||||
|
||||
// 密码输入
|
||||
const isPasswordVisible = ref(false)
|
||||
|
||||
@@ -70,7 +73,7 @@ const resourceTotalItems = ref(0)
|
||||
const resourceItemsPerPage = ref(25)
|
||||
|
||||
// 当前页码
|
||||
const resourceCurrentPage = ref(1)
|
||||
const resourceCurrentPage = ref(0)
|
||||
|
||||
// 用户名密码表单
|
||||
const userPwForm = ref({
|
||||
@@ -84,59 +87,20 @@ const statusItems = [
|
||||
{ title: '停用', value: false },
|
||||
]
|
||||
|
||||
// 生成1到50的优先级下拉框选项
|
||||
const priorityItems = ref(
|
||||
Array.from({ length: 50 }, (_, i) => i + 1).map(item => ({
|
||||
title: item,
|
||||
value: item,
|
||||
})),
|
||||
)
|
||||
|
||||
// 站点编辑表单数据
|
||||
const siteForm = reactive({
|
||||
// ID
|
||||
id: cardProps.site?.id,
|
||||
const siteForm = reactive<any>(cardProps.site ?? {})
|
||||
|
||||
// 站点名称
|
||||
name: cardProps.site?.name,
|
||||
|
||||
// 站点主域名Key
|
||||
domain: cardProps.site?.domain,
|
||||
|
||||
// 站点地址
|
||||
url: cardProps.site?.url,
|
||||
|
||||
// 站点优先级
|
||||
pri: cardProps.site?.pri,
|
||||
|
||||
// RSS地址
|
||||
rss: cardProps.site?.rss,
|
||||
|
||||
// Cookie
|
||||
cookie: cardProps.site?.cookie,
|
||||
|
||||
// User-Agent
|
||||
ua: cardProps.site?.ua,
|
||||
|
||||
// 是否使用代理
|
||||
proxy: !!cardProps.site?.proxy,
|
||||
|
||||
// 过滤规则
|
||||
filter: cardProps.site?.filter,
|
||||
|
||||
// 是否演染
|
||||
render: !!cardProps.site?.render,
|
||||
|
||||
// 是否公开站点
|
||||
public: cardProps.site?.public,
|
||||
|
||||
// 备注
|
||||
note: cardProps.site?.note,
|
||||
|
||||
// 流控单位周期
|
||||
limit_interval: cardProps.site?.limit_interval,
|
||||
|
||||
// 流控次数
|
||||
limit_count: cardProps.site?.limit_count,
|
||||
|
||||
// 流控间隔
|
||||
limit_seconds: cardProps.site?.limit_seconds,
|
||||
|
||||
// 是否启用
|
||||
is_active: cardProps.site?.is_active,
|
||||
})
|
||||
// 类型转换
|
||||
siteForm.proxy = siteForm.proxy === 1
|
||||
siteForm.render = siteForm.render === 1
|
||||
|
||||
// 打开种子详情页面
|
||||
function openTorrentDetail(page_url: string) {
|
||||
@@ -228,6 +192,23 @@ async function updateSiteCookie() {
|
||||
}
|
||||
}
|
||||
|
||||
// 调用API删除站点信息
|
||||
async function deleteSiteInfo() {
|
||||
try {
|
||||
siteInfoDialog.value = false
|
||||
const result: { [key: string]: any } = await api.delete(`site/${cardProps.site?.id}`)
|
||||
if (result.success) {
|
||||
$toast.success(`${cardProps.site?.name} 删除成功!`)
|
||||
emit('remove')
|
||||
}
|
||||
else { $toast.error(`${cardProps.site?.name} 删除失败:${result.message}`) }
|
||||
}
|
||||
catch (error) {
|
||||
$toast.error(`${cardProps.site?.name} 删除失败!`)
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 调用API更新站点信息
|
||||
async function updateSiteInfo() {
|
||||
try {
|
||||
@@ -235,10 +216,11 @@ async function updateSiteInfo() {
|
||||
siteInfoDialog.value = false
|
||||
|
||||
const result: { [key: string]: any } = await api.put('site', siteForm)
|
||||
if (result.success)
|
||||
if (result.success) {
|
||||
$toast.success(`${cardProps.site?.name} 更新成功!`)
|
||||
else
|
||||
$toast.error(`${cardProps.site?.name} 更新失败:${result.message}`)
|
||||
emit('update')
|
||||
}
|
||||
else { $toast.error(`${cardProps.site?.name} 更新失败:${result.message}`) }
|
||||
}
|
||||
catch (error) {
|
||||
$toast.error(`${cardProps.site?.name} 更新失败!`)
|
||||
@@ -246,6 +228,18 @@ async function updateSiteInfo() {
|
||||
}
|
||||
}
|
||||
|
||||
// 促销Chip类
|
||||
function getVolumeFactorClass(downloadVolume: number, uploadVolume: number) {
|
||||
if (downloadVolume === 0)
|
||||
return 'text-white bg-lime-500'
|
||||
else if (downloadVolume < 1)
|
||||
return 'text-white bg-green-500'
|
||||
else if (uploadVolume !== 1)
|
||||
return 'text-white bg-sky-500'
|
||||
else
|
||||
return 'text-white bg-gray-500'
|
||||
}
|
||||
|
||||
// 调用API,查询站点资源
|
||||
async function getResourceList() {
|
||||
resourceLoading.value = true
|
||||
@@ -264,6 +258,11 @@ async function getResourceList() {
|
||||
}
|
||||
}
|
||||
|
||||
// 打开站点页面
|
||||
function openSitePage() {
|
||||
window.open(cardProps.site?.url, '_blank')
|
||||
}
|
||||
|
||||
// 装载时查询站点图标
|
||||
onMounted(() => {
|
||||
getSiteIcon()
|
||||
@@ -288,7 +287,7 @@ onMounted(() => {
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VCardItem>
|
||||
<VCardTitle class="font-bold">
|
||||
<VCardTitle class="font-bold" @click.stop="openSitePage">
|
||||
{{ cardProps.site?.name }}
|
||||
</VCardTitle>
|
||||
<VCardSubtitle>{{ cardProps.site?.url }}</VCardSubtitle>
|
||||
@@ -356,7 +355,7 @@ onMounted(() => {
|
||||
|
||||
<VDivider
|
||||
class="opacity-75"
|
||||
style="border-color: rgba(var(--v-theme-on-background), var(--v-selected-opacity))"
|
||||
style="border-color: rgba(var(--v-theme-on-background), var(--v-selected-opacity));"
|
||||
/>
|
||||
|
||||
<VCardActions>
|
||||
@@ -445,6 +444,7 @@ onMounted(() => {
|
||||
<!-- Dialog Content -->
|
||||
<VCard :title="`编辑站点 - ${cardProps.site?.name}`">
|
||||
<VCardText class="pt-2">
|
||||
<DialogCloseBtn @click="siteInfoDialog = false" />
|
||||
<VForm @submit.prevent="() => {}">
|
||||
<VRow>
|
||||
<VCol
|
||||
@@ -464,7 +464,7 @@ onMounted(() => {
|
||||
<VSelect
|
||||
v-model="siteForm.pri"
|
||||
label="优先级"
|
||||
:items="[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]"
|
||||
:items="priorityItems"
|
||||
:rules="[requiredValidator]"
|
||||
/>
|
||||
</VCol>
|
||||
@@ -549,8 +549,8 @@ onMounted(() => {
|
||||
</VCardText>
|
||||
|
||||
<VCardActions>
|
||||
<VBtn @click="siteInfoDialog = false">
|
||||
取消
|
||||
<VBtn color="error" @click="deleteSiteInfo">
|
||||
删除
|
||||
</VBtn>
|
||||
<VSpacer />
|
||||
<VBtn @click="updateSiteInfo">
|
||||
@@ -600,6 +600,17 @@ onMounted(() => {
|
||||
>
|
||||
{{ label }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-if="item.raw?.downloadvolumefactor !== 1 || item.raw?.uploadvolumefactor !== 1"
|
||||
:class="
|
||||
getVolumeFactorClass(item.raw?.downloadvolumefactor, item.raw?.uploadvolumefactor)
|
||||
"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ item.raw?.volume_factor }}
|
||||
</VChip>
|
||||
</template>
|
||||
<template #item.pubdate="{ item }">
|
||||
<div>{{ item.raw.date_elapsed }}</div>
|
||||
|
||||
@@ -30,34 +30,10 @@ const siteList = ref<Site[]>([])
|
||||
const selectSitesOptions = ref<{ [key: number]: string }[]>([])
|
||||
|
||||
// 订阅编辑表单
|
||||
const subscribeForm = reactive({
|
||||
id: props.media?.id,
|
||||
const subscribeForm = reactive<any>(props.media ?? {})
|
||||
|
||||
// 搜索关键字
|
||||
keyword: props.media?.keyword,
|
||||
|
||||
// 过滤规则
|
||||
filter: props.media?.filter,
|
||||
|
||||
// 包含
|
||||
include: props.media?.include,
|
||||
|
||||
// 排除
|
||||
exclude: props.media?.exclude,
|
||||
|
||||
// 总集数
|
||||
total_episode: props.media?.total_episode,
|
||||
|
||||
// 开始集数
|
||||
start_episode: props.media?.start_episode,
|
||||
|
||||
// 订阅站点
|
||||
sites: props.media?.sites,
|
||||
|
||||
// 是否洗版
|
||||
best_version: !!props.media?.best_version,
|
||||
|
||||
})
|
||||
// 类型转换
|
||||
subscribeForm.best_version = subscribeForm.best_version === 1
|
||||
|
||||
// 上一次更新时间
|
||||
const lastUpdateText = ref(
|
||||
@@ -89,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,
|
||||
)
|
||||
}
|
||||
@@ -160,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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
701
src/components/filebrowser/List.vue
Normal file
@@ -0,0 +1,701 @@
|
||||
<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 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,
|
||||
|
||||
})
|
||||
|
||||
// 生成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)
|
||||
if (isDir.value) {
|
||||
const url = inProps.endpoints?.list.url
|
||||
.replace(/{storage}/g, storage.value)
|
||||
.replace(/{path}/g, encodeURIComponent(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, 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 监听path变化
|
||||
watch(
|
||||
() => inProps.path,
|
||||
async () => {
|
||||
items.value = []
|
||||
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()
|
||||
}
|
||||
|
||||
// 弹出菜单
|
||||
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="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="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: '电影' }, { 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
|
||||
color="white"
|
||||
class="mb-0 mt-1"
|
||||
:model-value="progressValue"
|
||||
/>
|
||||
</vCardText>
|
||||
</vCard>
|
||||
</vDialog>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.v-card {
|
||||
height: 100%;
|
||||
}
|
||||
.v-toolbar{
|
||||
background: rgb(var(--v-table-header-background));
|
||||
}
|
||||
</style>
|
||||
164
src/components/filebrowser/Toolbar.vue
Normal file
@@ -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, 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')
|
||||
}
|
||||
</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>
|
||||
202
src/components/filebrowser/Tree.vue
Normal 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>
|
||||
@@ -134,6 +134,13 @@ import UserProfile from '@/layouts/components/UserProfile.vue'
|
||||
to: '/history',
|
||||
}"
|
||||
/>
|
||||
<VerticalNavLink
|
||||
:item="{
|
||||
title: '文件管理',
|
||||
icon: 'mdi-folder-multiple-outline',
|
||||
to: '/filemanager',
|
||||
}"
|
||||
/>
|
||||
|
||||
<!-- 👉 系统 -->
|
||||
<VerticalNavSectionTitle
|
||||
|
||||
7
src/pages/filemanager.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import FileBrowserView from '@/views/reorganize/FileBrowserView.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FileBrowserView />
|
||||
</template>
|
||||
@@ -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=热门电影"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -6,6 +6,7 @@ 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()
|
||||
|
||||
@@ -43,6 +44,11 @@ const tabs = [
|
||||
icon: 'mdi-text-box',
|
||||
tab: 'logging',
|
||||
},
|
||||
{
|
||||
title: '关于',
|
||||
icon: 'mdi-information',
|
||||
tab: 'about',
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
@@ -96,6 +102,12 @@ const tabs = [
|
||||
<AccountSettingLogging />
|
||||
</transition>
|
||||
</VWindowItem>
|
||||
<!-- About -->
|
||||
<VWindowItem value="about">
|
||||
<transition name="fade-slide" appear>
|
||||
<AccountSettingAbout />
|
||||
</transition>
|
||||
</VWindowItem>
|
||||
</VWindow>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -67,9 +67,9 @@ const theme: VuetifyOptions['theme'] = {
|
||||
'on-primary': '#FFFFFF',
|
||||
'on-success': '#FFFFFF',
|
||||
'on-warning': '#FFFFFF',
|
||||
'background': '#28243D',
|
||||
'background': '#111520',
|
||||
'on-background': '#E7E3FC',
|
||||
'surface': '#312D4B',
|
||||
'surface': '#181F2A',
|
||||
'on-surface': '#E7E3FC',
|
||||
'grey-50': '#2A2E42',
|
||||
'grey-100': '#474360',
|
||||
@@ -87,7 +87,7 @@ const theme: VuetifyOptions['theme'] = {
|
||||
},
|
||||
variables: {
|
||||
'code-color': '#d400ff',
|
||||
'overlay-scrim-background': '#2C2942',
|
||||
'overlay-scrim-background': '#1F2937',
|
||||
'overlay-scrim-opacity': 0.6,
|
||||
'hover-opacity': 0.04,
|
||||
'focus-opacity': 0.1,
|
||||
@@ -96,9 +96,8 @@ const theme: VuetifyOptions['theme'] = {
|
||||
'pressed-opacity': 0.14,
|
||||
'dragged-opacity': 0.1,
|
||||
'border-color': '#E7E3FC',
|
||||
'table-header-background': '#3D3759',
|
||||
'table-header-background': '#1E2430',
|
||||
'custom-background': '#373452',
|
||||
|
||||
// Shadows
|
||||
'shadow-key-umbra-opacity': 'rgba(20, 18, 33, 0.08)',
|
||||
'shadow-key-penumbra-opacity': 'rgba(20, 18, 33, 0.12)',
|
||||
|
||||
@@ -128,6 +128,13 @@ const router = createRouter({
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/filemanager',
|
||||
component: () => import('../pages/filemanager.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -18,10 +18,12 @@
|
||||
|
||||
.v-toast--bottom {
|
||||
margin-bottom: env(safe-area-inset-bottom);
|
||||
z-index: 2500;
|
||||
}
|
||||
|
||||
.v-toast--top {
|
||||
margin-top: env(safe-area-inset-top);
|
||||
z-index: 2500;
|
||||
}
|
||||
|
||||
.v-dialog > .v-overlay__content {
|
||||
@@ -110,3 +112,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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -440,11 +441,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 +608,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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
44
src/views/reorganize/FileBrowserView.vue
Normal 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}', 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>
|
||||
@@ -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,27 +125,33 @@ const TransferDict: { [key: string]: string } = {
|
||||
|
||||
// 删除历史记录
|
||||
async function removeHistory(item: TransferHistory) {
|
||||
const isConfirmed = await createConfirm({
|
||||
title: '确认',
|
||||
content: `同步删除 ${item.title} 对应的媒体库文件 ?`,
|
||||
confirmationText: '同步删除文件',
|
||||
cancellationText: '仅删除历史记录',
|
||||
dialogProps: {
|
||||
maxWidth: 600,
|
||||
},
|
||||
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', {
|
||||
data: {
|
||||
...item,
|
||||
delete_file: deleteFile,
|
||||
},
|
||||
const result: { [key: string]: any } = await api.delete(`history/transfer?delete_file=${deleteFile}`, {
|
||||
data: item,
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
@@ -151,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: 600,
|
||||
},
|
||||
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 {
|
||||
@@ -241,6 +307,7 @@ const dropdownItems = ref([
|
||||
</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDataTableServer
|
||||
v-model="selected"
|
||||
v-model:items-per-page="itemsPerPage"
|
||||
:headers="headers"
|
||||
:items="dataList"
|
||||
@@ -251,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"
|
||||
@@ -360,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">
|
||||
|
||||
240
src/views/setting/AccountSettingAbout.vue
Normal file
@@ -0,0 +1,240 @@
|
||||
<script lang="ts" setup>
|
||||
import { calculateTimeDifference } from '@/@core/utils'
|
||||
import api from '@/api'
|
||||
|
||||
// 系统环境变量
|
||||
const systemEnv = ref<any>({})
|
||||
|
||||
// 所有Release
|
||||
const allRelease = ref<any>([])
|
||||
|
||||
// 变更日志对话框
|
||||
const releaseDialog = ref(false)
|
||||
|
||||
// 最新版本
|
||||
const latestRelease = ref('')
|
||||
|
||||
// 变更日志对话框标题
|
||||
const releaseDialogTitle = ref('')
|
||||
|
||||
// 变更日志对话框内容
|
||||
const releaseDialogBody = ref('')
|
||||
|
||||
// 打开日志对话框
|
||||
function showReleaseDialog(title: string, body: string) {
|
||||
releaseDialogTitle.value = title
|
||||
releaseDialogBody.value = body.replaceAll('\r\n', '<br />')
|
||||
releaseDialog.value = true
|
||||
}
|
||||
|
||||
// 查询系统环境变量
|
||||
async function querySystemEnv() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get(
|
||||
'system/env',
|
||||
)
|
||||
|
||||
systemEnv.value = result.data
|
||||
}
|
||||
catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 查询所有Release
|
||||
async function queryAllRelease() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get(
|
||||
'system/versions',
|
||||
)
|
||||
|
||||
allRelease.value = result.data ?? []
|
||||
|
||||
// 最新版本
|
||||
if (allRelease.value.length > 0)
|
||||
latestRelease.value = allRelease.value[0].tag_name
|
||||
}
|
||||
catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 计算发布时间
|
||||
function releaseTime(releaseDate: string) {
|
||||
// 上一次更新时间
|
||||
return `${calculateTimeDifference(releaseDate)}前`
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
querySystemEnv()
|
||||
queryAllRelease()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-3">
|
||||
<div class="section">
|
||||
<div>
|
||||
<h3 class="heading">
|
||||
关于 MoviePilot
|
||||
</h3>
|
||||
</div>
|
||||
<div class="section border-t border-gray-800">
|
||||
<dl>
|
||||
<div>
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">
|
||||
当前版本
|
||||
</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow flex flex-row items-center truncate">
|
||||
<code class="truncate">{{ systemEnv.VERSION }}</code>
|
||||
<a v-if="latestRelease === systemEnv.VERSION" href="https://github.com/jxxghp/MoviePilot/releases" target="_blank" rel="noopener noreferrer">
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full whitespace-nowrap bg-green-500 bg-opacity-80 border border-green-500 !text-green-100 ml-2 !cursor-pointer transition hover:bg-green-400">
|
||||
最新
|
||||
</span>
|
||||
</a>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">
|
||||
配置目录
|
||||
</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow undefined">
|
||||
<code>{{ systemEnv.CONFIG_DIR }}</code>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">
|
||||
数据目录
|
||||
</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow undefined"><code>/moviepilot</code></span>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">
|
||||
时区
|
||||
</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow undefined">
|
||||
<code>{{ systemEnv.TZ }}</code>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div>
|
||||
<h3 class="heading">
|
||||
支援
|
||||
</h3>
|
||||
</div>
|
||||
<div class="section border-t border-gray-800">
|
||||
<dl>
|
||||
<div>
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">
|
||||
文档
|
||||
</dt><dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow undefined">
|
||||
<a href="https://github.com/jxxghp/MoviePilot/blob/main/README.md" target="_blank" rel="noreferrer" class="text-indigo-500 transition duration-300 hover:underline">
|
||||
https://github.com/jxxghp/MoviePilot/blob/main/README.md
|
||||
</a>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</div><div>
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">
|
||||
问题反馈
|
||||
</dt><dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow undefined">
|
||||
<a href="https://github.com/jxxghp/MoviePilot/issues/new/choose" target="_blank" rel="noreferrer" class="text-indigo-500 transition duration-300 hover:underline">
|
||||
https://github.com/jxxghp/MoviePilot/issues/new/choose
|
||||
</a>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</div><div>
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">
|
||||
发布频道
|
||||
</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow undefined">
|
||||
<a href="https://t.me/moviepilot_channel" target="_blank" rel="noreferrer" class="text-indigo-500 transition duration-300 hover:underline">
|
||||
https://t.me/moviepilot_channel
|
||||
</a>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div>
|
||||
<h3 class="heading">
|
||||
软件版本
|
||||
</h3>
|
||||
<div class="section space-y-3">
|
||||
<div>
|
||||
<div v-for="release in allRelease" :key="release.tag_name" class="mb-3 flex w-full flex-col space-y-3 rounded-md px-4 py-2 shadow-md ring-1 ring-gray-400 sm:flex-row sm:space-y-0 sm:space-x-3">
|
||||
<div class="flex w-full flex-grow items-center justify-start space-x-2 truncate sm:justify-start">
|
||||
<span class="truncate text-lg font-bold">
|
||||
<span class="mr-2 whitespace-nowrap text-xs font-normal">{{ releaseTime(release.published_at) }}</span>
|
||||
{{ release.tag_name }}
|
||||
</span>
|
||||
<span v-if="release.tag_name === latestRelease" class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full whitespace-nowrap cursor-default bg-green-500 bg-opacity-80 border border-green-500 !text-green-100">
|
||||
最新软件版本
|
||||
</span>
|
||||
<span v-if="release.tag_name === systemEnv.VERSION" class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full whitespace-nowrap cursor-default bg-indigo-500 bg-opacity-80 border border-indigo-500 !text-indigo-100">
|
||||
当前版本
|
||||
</span>
|
||||
</div>
|
||||
<VBtn @click.stop="showReleaseDialog(release.tag_name, release.body)">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-text-box-outline" />
|
||||
</template>
|
||||
查看变更日志
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<VDialog v-model="releaseDialog" width="600" scrollable>
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<DialogCloseBtn @click="releaseDialog = false" />
|
||||
<VCardTitle>{{ releaseDialogTitle }} 变更日志</VCardTitle>
|
||||
</VCardItem>
|
||||
<VCardText v-html="releaseDialogBody" />
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style type="scss">
|
||||
.heading {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
line-height: 2rem;
|
||||
|
||||
--tw-text-opacity: 1;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-block: 0.5rem 2.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -221,7 +221,7 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<p class="text-body-1 mb-0">
|
||||
允许 JPG、GIF 或 PNG 格式, 最大尽寸 800K。
|
||||
允许 JPG、GIF 或 PNG 格式, 最大尺寸 800K。
|
||||
</p>
|
||||
</form>
|
||||
</VCardText>
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import api from '@/api'
|
||||
import type { Site } from '@/api/types'
|
||||
import SiteCard from '@/components/cards/SiteCard.vue'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import { numberValidator, requiredValidator } from '@/@validators'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 数据列表
|
||||
const dataList = ref<Site[]>([])
|
||||
@@ -10,6 +16,45 @@ const dataList = ref<Site[]>([])
|
||||
// 是否刷新过
|
||||
const isRefreshed = ref(false)
|
||||
|
||||
// 新增按钮文本
|
||||
const addBtnText = ref('新增站点')
|
||||
// 新增按钮状态
|
||||
const addBtnState = ref(false)
|
||||
|
||||
// 新增站点对话框
|
||||
const siteAddDialog = ref(false)
|
||||
|
||||
// 状态下拉项
|
||||
const statusItems = [
|
||||
{ title: '启用', value: true },
|
||||
{ title: '停用', value: false },
|
||||
]
|
||||
|
||||
// 生成1到50的优先级下拉框选项
|
||||
const priorityItems = ref(
|
||||
Array.from({ length: 50 }, (_, i) => i + 1).map(item => ({
|
||||
title: item,
|
||||
value: item,
|
||||
})),
|
||||
)
|
||||
|
||||
// 站点编辑表单数据
|
||||
const siteForm = reactive<Site>({
|
||||
id: 0,
|
||||
url: '',
|
||||
pri: 1,
|
||||
is_active: true,
|
||||
cookie: '',
|
||||
ua: '',
|
||||
limit_interval: 0,
|
||||
limit_seconds: 0,
|
||||
limit_count: 0,
|
||||
proxy: 0,
|
||||
render: 0,
|
||||
name: '',
|
||||
domain: '',
|
||||
})
|
||||
|
||||
// 获取站点列表数据
|
||||
async function fetchData() {
|
||||
try {
|
||||
@@ -21,6 +66,38 @@ async function fetchData() {
|
||||
}
|
||||
}
|
||||
|
||||
// 调用API 新增站点
|
||||
async function addSite() {
|
||||
if (!siteForm.url)
|
||||
return
|
||||
|
||||
startNProgress()
|
||||
|
||||
addBtnText.value = '新增中...'
|
||||
addBtnState.value = true
|
||||
|
||||
try {
|
||||
const result: { [key: string]: string } = await api.post('site', siteForm)
|
||||
if (result.success) {
|
||||
$toast.success('新增站点成功')
|
||||
|
||||
// 刷新数据
|
||||
fetchData()
|
||||
}
|
||||
|
||||
else { $toast.error(`新增站点失败:${result.message}`) }
|
||||
siteAddDialog.value = false
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
|
||||
doneNProgress()
|
||||
|
||||
addBtnText.value = '新增站点'
|
||||
addBtnState.value = false
|
||||
}
|
||||
|
||||
// 加载时获取数据
|
||||
onBeforeMount(fetchData)
|
||||
</script>
|
||||
@@ -45,6 +122,8 @@ onBeforeMount(fetchData)
|
||||
v-for="data in dataList"
|
||||
:key="data.id"
|
||||
:site="data"
|
||||
@remove="fetchData"
|
||||
@update="fetchData"
|
||||
/>
|
||||
</div>
|
||||
<NoDataFound
|
||||
@@ -53,6 +132,143 @@ onBeforeMount(fetchData)
|
||||
error-title="没有站点"
|
||||
error-description="已添加并支持的站点将会在这里显示。"
|
||||
/>
|
||||
<!-- Dialog Content -->
|
||||
<VDialog
|
||||
v-model="siteAddDialog"
|
||||
max-width="800"
|
||||
persistent
|
||||
scrollable
|
||||
>
|
||||
<!-- Dialog Activator -->
|
||||
<template #activator="{ props }">
|
||||
<VBtn
|
||||
icon="mdi-plus"
|
||||
v-bind="props"
|
||||
size="x-large"
|
||||
class="fixed right-5 bottom-5"
|
||||
/>
|
||||
</template>
|
||||
<VCard title="新增站点">
|
||||
<VCardText class="pt-2">
|
||||
<VForm @submit.prevent="() => {}">
|
||||
<VRow>
|
||||
<VCol
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<VTextField
|
||||
v-model="siteForm.url"
|
||||
label="站点地址"
|
||||
:rules="[requiredValidator]"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="12"
|
||||
md="3"
|
||||
>
|
||||
<VSelect
|
||||
v-model="siteForm.pri"
|
||||
label="优先级"
|
||||
:items="priorityItems"
|
||||
:rules="[requiredValidator]"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="12"
|
||||
md="3"
|
||||
>
|
||||
<VSelect
|
||||
v-model="siteForm.is_active"
|
||||
:items="statusItems"
|
||||
label="状态"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VTextarea
|
||||
v-model="siteForm.cookie"
|
||||
label="站点Cookie"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="siteForm.ua"
|
||||
label="站点User-Agent"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol
|
||||
cols="12"
|
||||
md="4"
|
||||
>
|
||||
<VTextField
|
||||
v-model="siteForm.limit_interval"
|
||||
label="单位周期(秒)"
|
||||
:rules="[numberValidator]"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="12"
|
||||
md="4"
|
||||
>
|
||||
<VTextField
|
||||
v-model="siteForm.limit_seconds"
|
||||
label="访问次数"
|
||||
:rules="[numberValidator]"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="12"
|
||||
md="4"
|
||||
>
|
||||
<VTextField
|
||||
v-model="siteForm.limit_seconds"
|
||||
label="访问间隔(秒)"
|
||||
:rules="[numberValidator]"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<VSwitch
|
||||
v-model="siteForm.proxy"
|
||||
label="代理"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<VSwitch
|
||||
v-model="siteForm.render"
|
||||
label="仿真"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VBtn
|
||||
@click="siteAddDialog = false"
|
||||
>
|
||||
取消
|
||||
</VBtn>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
color="primary"
|
||||
:disabled="addBtnState"
|
||||
@click="addSite"
|
||||
>
|
||||
{{ addBtnText }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -82,11 +82,11 @@ async function addRss() {
|
||||
const result: { [key: string]: string } = await api.post('rss', rssForm)
|
||||
if (result.success) {
|
||||
$toast.success('新增自定义订阅成功')
|
||||
|
||||
// 刷新数据
|
||||
fetchData()
|
||||
}
|
||||
|
||||
else { $toast.error(`新增自定义订阅失败:${result.message}`) }
|
||||
// 刷新数据
|
||||
rssAddDialog.value = false
|
||||
}
|
||||
catch (error) {
|
||||
@@ -161,7 +161,6 @@ function onRefresh() {
|
||||
<VDialog
|
||||
v-model="rssAddDialog"
|
||||
max-width="800"
|
||||
transition="dialog-bottom-transition"
|
||||
persistent
|
||||
scrollable
|
||||
>
|
||||
@@ -177,7 +176,7 @@ function onRefresh() {
|
||||
|
||||
<!-- Dialog Content -->
|
||||
<VCard title="新增自定义订阅">
|
||||
<VCardText>
|
||||
<VCardText class="pt-2">
|
||||
<VForm @submit.prevent="() => {}">
|
||||
<VRow>
|
||||
<VCol
|
||||
|
||||