fix settings layout

This commit is contained in:
jxxghp
2024-07-14 11:07:17 +08:00
parent a467fdb43f
commit cfc2e407a4
23 changed files with 523 additions and 2073 deletions

View File

@@ -847,22 +847,124 @@ export interface SystemNotification {
date: string
}
// 下载目录/媒体库目录
export interface MediaDirectory {
// 类型 download/library
type?: string
// 别名
name?: string
// 路径
path?: string
// 媒体类型 电影/电视剧
media_type?: string
// 媒体类别 动画电影/国产剧
category?: string
// 刮削媒体信息
scrape?: boolean
// 自动二级分类,未指定类别时自动分类
auto_category?: boolean
// 优先级
priority?: number
// 下载器配置
export interface DownloaderConf {
// 名称
name: string
// 类型 qbittorrent/transmission
type: string
// 是否默认
default: boolean
// 配置
config?: { [key: string]: any }
// 是否启用
enabled: boolean
}
// 通知配置
export interface NotificationConf {
// 名称
name: string
// 类型 telegram/wechat/vocechat/synologychat
type: string
// 配置
config?: { [key: string]: any }
// 场景开关
switchs?: string[]
// 是否启用
enabled: boolean
}
// 通知场景开关配置
export interface NotificationSwitchConf {
// 场景名称
type: string
// 通知范围 all/user/admin
action: string
}
// 存储配置
export interface StorageConf {
// 类型 local/alipan/u115/rclone
type: string
// 配置
config?: { [key: string]: any }
}
// 媒体服务器配置
export interface MediaServerConf {
// 名称
name: string
// 类型 emby/jellyfin/plex
type: string
// 配置
config?: { [key: string]: any }
// 是否启用
enabled: boolean
// 同步媒体体库列表
sync_libraries?: string[]
}
// 文件整理目录配置
export interface TransferDirectoryConf {
// 名称
name: string
// 优先级
priority: number
// 存储
storage: string
// 下载目录
download_path?: string
// 适用媒体类型
media_type?: string
// 适用媒体类别
media_category?: string
// 下载类型子目录
download_type_folder?: boolean
// 下载类别子目录
download_category_folder?: boolean
// 监控方式 downloader/monitorNone为不监控
monitor_type?: string
// 整理方式 move/copy/link/softlink
transfer_type?: string
// 文件覆盖模式 always/size/never/latest
overwrite_mode?: string
// 整理到媒体库目录
library_path?: string
// 媒体库目录存储
library_storage?: string
// 智能重命名
renaming?: boolean
// 刮削
scraping?: boolean
// 媒体库类型子目录
library_type_folder?: boolean
// 媒体库类别子目录
library_category_folder?: boolean
}
// 自定义规则项
export interface CustomRule {
// 规则ID
id: string
// 名称
name: string
// 包含
include?: string[]
// 排除
exclude?: string[]
// 大小范围
size_range?: string
// 最少做种人数
seeders?: string
}
// 过滤规则组
export interface FilterRuleGroup {
// 名称
name: string
// 规则串
rule_string?: string
// 适用类媒体类别 None-全部 电影/电视剧
media_type?: string
}

View File

@@ -0,0 +1,2 @@
<script setup lang="ts"></script>
<template></template>

View File

@@ -0,0 +1,2 @@
<script setup lang="ts"></script>
<template></template>

View File

@@ -0,0 +1,2 @@
<script setup lang="ts"></script>
<template></template>

View File

@@ -0,0 +1,2 @@
<script setup lang="ts"></script>
<template></template>

View File

@@ -0,0 +1,2 @@
<script setup lang="ts"></script>
<template></template>

View File

@@ -0,0 +1,2 @@
<script setup lang="ts"></script>
<template></template>

View File

@@ -0,0 +1,2 @@
<script setup lang="ts"></script>
<template></template>

View File

@@ -0,0 +1,2 @@
<script setup lang="ts"></script>
<template></template>

View File

@@ -79,7 +79,6 @@ watch(isCompactMode, value => {
<VAvatar class="cursor-pointer ms-3" color="primary" variant="tonal">
<VImg :src="avatar ?? avatar1" />
<!-- SECTION Menu -->
<VMenu activator="parent" width="230" location="bottom end" offset="14px">
<VList>
<!-- 👉 User Avatar & Name -->

View File

@@ -10,6 +10,7 @@ import AccountSettingSubscribe from '@/views/setting/AccountSettingSubscribe.vue
import AccountSettingService from '@/views/setting/AccountSettingService.vue'
import AccountSettingSystem from '@/views/setting/AccountSettingSystem.vue'
import AccountSettingDirectory from '@/views/setting/AccountSettingDirectory.vue'
import AccountSettingRule from '@/views/setting/AccountSettingRule.vue'
import { SettingTabs } from '@/router/menu'
const route = useRoute()
@@ -58,6 +59,13 @@ function jumpTab(tab: string) {
</transition>
</VWindowItem>
<!-- 规则 -->
<VWindowItem value="rule">
<transition name="fade-slide" appear>
<AccountSettingRule />
</transition>
</VWindowItem>
<!-- 搜索 -->
<VWindowItem value="search">
<transition name="fade-slide" appear>

View File

@@ -134,7 +134,7 @@ export const SettingTabs = [
description: '下载器Qbittorrent、Transmission、媒体服务器Emby、Jellyfin、Plex',
},
{
title: '目录',
title: '存储 & 目录',
icon: 'mdi-folder',
tab: 'directory',
description: '下载目录、媒体库目录、整理模式',
@@ -145,6 +145,12 @@ export const SettingTabs = [
tab: 'site',
description: '站点同步、下载优先规则、站点重置',
},
{
title: '规则',
icon: 'mdi-filter',
tab: 'rule',
description: '优先级规则组',
},
{
title: '搜索',
icon: 'mdi-magnify',

View File

@@ -181,6 +181,16 @@
padding-block-end: 1rem;
}
.grid-user-card {
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
padding-block-end: 1rem;
}
.grid-app-card {
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
padding-block-end: 1rem;
}
.v-tabs:not(.v-tabs-pill).v-tabs--horizontal {
border-block-end: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}

View File

@@ -125,7 +125,6 @@ onMounted(() => {
}
})
groupedDataList.value = groupMap
})
// 只监听filterForm和groupedDataList的变化。因为displayDataList的变化不需要清空列表
@@ -265,12 +264,16 @@ function loadMore({ done }: { done: any }) {
</VCol>
</VRow>
</VCard>
<VInfiniteScroll mode="intersect" side="end" :items="displayDataList" class="overflow-hidden"
@load="loadMore">
<template #loading />
<template #empty />
<div class="grid gap-3 grid-torrent-card items-start">
<TorrentCard v-for="item in displayDataList" :key="`${item.torrent_info.page_url}`" :torrent="item" :more="item.more" />
</div>
</VInfiniteScroll>
<VInfiniteScroll mode="intersect" side="end" :items="displayDataList" class="overflow-hidden" @load="loadMore">
<template #loading />
<template #empty />
<div class="grid gap-3 grid-torrent-card items-start">
<TorrentCard
v-for="item in displayDataList"
:key="`${item.torrent_info.page_url}`"
:torrent="item"
:more="item.more"
/>
</div>
</VInfiniteScroll>
</template>

View File

@@ -4,39 +4,14 @@ import { useToast } from 'vue-toast-notification'
import draggable from 'vuedraggable'
import { VRow } from 'vuetify/lib/components/index.mjs'
import api from '@/api'
import { MediaDirectory } from '@/api/types'
import { TransferDirectoryConf, StorageConf } from '@/api/types'
import DirectoryCard from '@/components/cards/DirectoryCard.vue'
// 媒体库设置项
const transferSettings = ref({
TRANSFER_TYPE: 'copy',
OVERWRITE_MODE: 'size',
TRANSFER_SAME_DISK: true,
})
// 转移方式字典
const transferTypeItems = [
{ title: '硬链接', value: 'link' },
{ title: '复制', value: 'copy' },
{ title: '移动', value: 'move' },
{ title: '软链接', value: 'softlink' },
{ title: 'rclone复制', value: 'rclone_copy' },
{ title: 'rclone移动', value: 'rclone_move' },
]
// 覆盖模式字典
const overwriteModeItems = [
{ title: '从不覆盖', value: 'never' },
{ title: '按大小覆盖', value: 'size' },
{ title: '总是覆盖', value: 'always' },
{ title: '仅保留最新版本', value: 'latest' },
]
// 所有下载目录
const downloadDirectories = ref<MediaDirectory[]>([])
const directories = ref<TransferDirectoryConf[]>([])
// 所有媒体库目录
const libraryDirectories = ref<MediaDirectory[]>([])
// 所有存储
const storages = ref<StorageConf[]>([])
// 二级分类策略
const mediaCategories = ref<{ [key: string]: any }>({})
@@ -44,148 +19,32 @@ const mediaCategories = ref<{ [key: string]: any }>({})
// 提示框
const $toast = useToast()
// 加载媒体库设置
async function loadTransferSettings() {
try {
const result: { [key: string]: any } = await api.get('system/env')
if (result.success) {
const { TRANSFER_TYPE, OVERWRITE_MODE, TRANSFER_SAME_DISK } = result.data
transferSettings.value = {
TRANSFER_TYPE,
OVERWRITE_MODE,
TRANSFER_SAME_DISK,
}
}
} catch (error) {
console.log(error)
}
}
// 调用API保存媒体设置
async function saveTransferSetting() {
try {
const result: { [key: string]: any } = await api.post('system/env', transferSettings.value)
if (result.success) $toast.success('保存媒体库设置成功')
else $toast.error('保存媒体库设置失败!')
} catch (error) {
console.log(error)
}
}
// 移动结束
function orderDownloadCards() {
function orderDirectoryCards() {
// 更新所有目录的优先级
downloadDirectories.value.forEach((item, index) => {
directories.value.forEach((item, index) => {
item.priority = index
})
}
// 移动结束
function orderLibraryCards() {
// 更新所有目录的优先级
libraryDirectories.value.forEach((item, index) => {
item.priority = index
})
}
// 查询存储
async function loadStorages() {}
// 关闭目录卡片
function libraryCardClose(name: string) {
libraryDirectories.value = libraryDirectories.value.filter(item => item.name !== name)
}
// 保存存储
async function saveStorages() {}
// 添加存储
function addStorage() {}
// 关闭下载卡片
function downloadCardClose(name: string) {
downloadDirectories.value = downloadDirectories.value.filter(item => item.name !== name)
}
// 查询目录
async function loadDirectories() {}
// 查询下载目录
async function loadDownloadDirectories() {
try {
const result: { [key: string]: any } = await api.get('system/setting/DownloadDirectories')
if (result.success && result.data?.value) {
downloadDirectories.value = result.data.value
}
} catch (error) {
console.log(error)
}
}
// 保存下载目录
async function saveDownloadDirectories() {
orderDownloadCards()
try {
const value = downloadDirectories.value.map(item => {
return {
name: item.name,
path: item.path,
media_type: item.media_type,
category: item.category,
auto_category: item.auto_category,
priority: item.priority,
}
})
const result: { [key: string]: any } = await api.post('system/setting/DownloadDirectories', value)
if (result.success) $toast.success('下载目录设置保存成功!')
} catch (e) {
console.error('保存下载目录设置失败')
}
}
// 添加下载目录
function addDownloadDirectory() {
downloadDirectories.value.push({
name: `下载目录${downloadDirectories.value.length + 1}`,
path: '',
media_type: '全部',
category: '',
})
}
// 查询媒体库目录
async function loadLibraryDirectories() {
try {
const result: { [key: string]: any } = await api.get('system/setting/LibraryDirectories')
if (result.success && result.data?.value) {
libraryDirectories.value = result.data.value
}
} catch (error) {
console.log(error)
}
}
// 保存媒体库目录
async function saveLibraryDirectories() {
orderLibraryCards()
try {
const value = libraryDirectories.value.map(item => {
return {
name: item.name,
path: item.path,
media_type: item.media_type,
category: item.category,
auto_category: item.auto_category,
scrape: item.scrape,
priority: item.priority,
}
})
const result: { [key: string]: any } = await api.post('system/setting/LibraryDirectories', value)
if (result.success) $toast.success('媒体库目录设置保存成功!')
} catch (e) {
console.error('保存媒体库目录设置失败')
}
// 保存目录
async function saveDirectories() {
orderDirectoryCards()
}
// 添加媒体库目录
function addLibraryDirectory() {
libraryDirectories.value.push({
name: `媒体库目录${libraryDirectories.value.length + 1}`,
path: '',
media_type: '全部',
category: '',
scrape: true,
})
}
function addDirectory() {}
// 调用API查询自动分类配置
async function loadMediaCategories() {
@@ -198,10 +57,9 @@ async function loadMediaCategories() {
// 加载数据
onMounted(() => {
loadTransferSettings()
loadDirectories()
loadStorages()
loadMediaCategories()
loadDownloadDirectories()
loadLibraryDirectories()
})
</script>
@@ -210,32 +68,25 @@ onMounted(() => {
<VCol cols="12">
<VCard>
<VCardItem>
<VCardTitle>下载目录</VCardTitle>
<VCardSubtitle>设置下载目录路径和分类按顺序依次匹配使用</VCardSubtitle>
<VCardTitle>存储</VCardTitle>
<VCardSubtitle>设置本地或网盘存储参数</VCardSubtitle>
</VCardItem>
<VCardText>
<draggable
v-model="downloadDirectories"
v-model="storages"
handle=".cursor-move"
item-key="pri"
item-key="name"
tag="div"
@end="orderDownloadCards"
:component-data="{ 'class': 'grid gap-3 grid-directory-card' }"
:component-data="{ 'class': 'grid gap-3 grid-app-card' }"
>
<template #item="{ element }">
<DirectoryCard
type="download"
:directory="element"
:categories="mediaCategories"
@update:modelValue="(value: string) => (element.path = value)"
@close="downloadCardClose(element.name)"
/>
<StorageCard type="download" :storage="element" />
</template>
</draggable>
</VCardText>
<VCardText>
<VBtn type="submit" class="me-2" @click="saveDownloadDirectories"> 保存 </VBtn>
<VBtn color="success" variant="tonal" @click="addDownloadDirectory">
<VBtn type="submit" class="me-2" @click="saveStorages"> 保存 </VBtn>
<VBtn color="success" variant="tonal" @click="addStorage">
<VIcon icon="mdi-plus" />
</VBtn>
</VCardText>
@@ -244,17 +95,17 @@ onMounted(() => {
<VCol cols="12">
<VCard>
<VCardItem>
<VCardTitle>媒体库目录</VCardTitle>
<VCardSubtitle>设置媒体文件整理后存储目录和分类按顺序依次匹配使用</VCardSubtitle>
<VCardTitle>目录</VCardTitle>
<VCardSubtitle>设置媒体文件整理目录结构先后顺序依次匹配</VCardSubtitle>
</VCardItem>
<VCardText>
<draggable
v-model="libraryDirectories"
v-model="directories"
handle=".cursor-move"
item-key="pri"
tag="div"
@end="orderLibraryCards"
:component-data="{ 'class': 'grid gap-3 grid-directory-card' }"
@end="orderDirectoryCards"
:component-data="{ 'class': 'grid gap-3 grid-app-card' }"
>
<template #item="{ element }">
<DirectoryCard
@@ -262,65 +113,17 @@ onMounted(() => {
:directory="element"
:categories="mediaCategories"
@update:modelValue="(value: string) => (element.path = value)"
@close="libraryCardClose(element.name)"
/>
</template>
</draggable>
</VCardText>
<VCardText>
<VBtn type="submit" class="me-2" @click="saveLibraryDirectories"> 保存 </VBtn>
<VBtn color="success" variant="tonal" @click="addLibraryDirectory">
<VBtn type="submit" class="me-2" @click="saveDirectories"> 保存 </VBtn>
<VBtn color="success" variant="tonal" @click="addDirectory">
<VIcon icon="mdi-plus" />
</VBtn>
</VCardText>
</VCard>
</VCol>
<VCol cols="12">
<VCard>
<VCardItem>
<VCardTitle>整理模式</VCardTitle>
<VCardSubtitle>设置文件整理方式和偏好</VCardSubtitle>
</VCardItem>
<VCardText>
<VForm>
<VRow>
<VCol cols="12" md="6">
<VSelect
v-model="transferSettings.TRANSFER_TYPE"
:items="transferTypeItems"
label="整理方式"
hint="文件从下载目录整理到媒体库目录的操作方式"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VSelect
v-model="transferSettings.OVERWRITE_MODE"
:items="overwriteModeItems"
label="覆盖模式"
hint="媒体库中同名文件已存在时的覆盖方式"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="transferSettings.TRANSFER_SAME_DISK"
label="同盘/同根目录优先"
hint="优先整理到与下载目录同一磁盘/同一根路径的媒体库目录中"
persistent-hint
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardText>
<VForm @submit.prevent="() => {}">
<div class="d-flex flex-wrap gap-4 mt-4">
<VBtn mtype="submit" @click="saveTransferSetting"> 保存 </VBtn>
</div>
</VForm>
</VCardText>
</VCard>
</VCol>
</VRow>
</template>

View File

@@ -1,38 +1,12 @@
<script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import api from '@/api'
import type { NotificationSwitch } from '@/api/types'
import draggable from 'vuedraggable'
import type { NotificationConf } from '@/api/types'
import NotificationChannelCard from '@/components/cards/NotificationChannelCard.vue'
const messagemTypes = ref<NotificationSwitch[]>([])
// 选中的消息渠道
const selectedChannels = ref([])
// 消息渠道标签页
const messagerTab = ref('wechat')
// 消息设置
const notificationSettings = ref({
WECHAT_CORPID: '',
WECHAT_APP_SECRET: '',
WECHAT_APP_ID: '',
WECHAT_PROXY: '',
WECHAT_TOKEN: '',
WECHAT_ENCODING_AESKEY: '',
WECHAT_ADMINS: '',
TELEGRAM_TOKEN: '',
TELEGRAM_CHAT_ID: '',
TELEGRAM_USERS: '',
TELEGRAM_ADMINS: '',
SLACK_OAUTH_TOKEN: '',
SLACK_APP_TOKEN: '',
SLACK_CHANNEL: '',
SYNOLOGYCHAT_WEBHOOK: '',
SYNOLOGYCHAT_TOKEN: '',
VOCECHAT_HOST: '',
VOCECHAT_API_KEY: '',
VOCECHAT_CHANNEL_ID: '',
})
// 所有消息渠道
const notifications = ref<NotificationConf[]>([])
// 消息渠道
const NotificationChannels = [
@@ -65,120 +39,8 @@ const NotificationChannels = [
// 提示框
const $toast = useToast()
// 调用API查询消息开关
async function loadNotificationSwitchs() {
try {
const result: NotificationSwitch[] = await api.get('message/switchs')
messagemTypes.value = result
} catch (error) {
console.log(error)
}
}
// 调用API保存消息开关
async function saveNotificationSwitchs() {
try {
const result: { [key: string]: any } = await api.post('message/switchs', messagemTypes.value)
if (result.success) $toast.success('保存通知消息设置成功')
else $toast.error('保存通知消息设置失败!')
} catch (error) {
console.log(error)
}
}
// 调用API查询消息渠道设置
async function loadNotificationSettings() {
try {
const result1: { [key: string]: any } = await api.get('system/setting/MESSAGER')
if (result1.success)
selectedChannels.value = result1.data && result1.data.value ? result1.data.value.split(',') : []
const result2: { [key: string]: any } = await api.get('system/env')
if (result2.success) {
const {
WECHAT_CORPID,
WECHAT_APP_SECRET,
WECHAT_APP_ID,
WECHAT_PROXY,
WECHAT_TOKEN,
WECHAT_ENCODING_AESKEY,
WECHAT_ADMINS,
TELEGRAM_TOKEN,
TELEGRAM_CHAT_ID,
TELEGRAM_USERS,
TELEGRAM_ADMINS,
SLACK_OAUTH_TOKEN,
SLACK_APP_TOKEN,
SLACK_CHANNEL,
SYNOLOGYCHAT_WEBHOOK,
SYNOLOGYCHAT_TOKEN,
VOCECHAT_HOST,
VOCECHAT_API_KEY,
VOCECHAT_CHANNEL_ID,
} = result2.data
notificationSettings.value = {
WECHAT_CORPID,
WECHAT_APP_SECRET,
WECHAT_APP_ID,
WECHAT_PROXY,
WECHAT_TOKEN,
WECHAT_ENCODING_AESKEY,
WECHAT_ADMINS,
TELEGRAM_TOKEN,
TELEGRAM_CHAT_ID,
TELEGRAM_USERS,
TELEGRAM_ADMINS,
SLACK_OAUTH_TOKEN,
SLACK_APP_TOKEN,
SLACK_CHANNEL,
SYNOLOGYCHAT_WEBHOOK,
SYNOLOGYCHAT_TOKEN,
VOCECHAT_HOST,
VOCECHAT_API_KEY,
VOCECHAT_CHANNEL_ID,
}
}
} catch (error) {
console.log(error)
}
}
// 调用API保存消息渠道设置
async function saveNotificationSettings() {
try {
const result1: { [key: string]: any } = await api.post('system/setting/MESSAGER', selectedChannels.value.join(','))
const result2: { [key: string]: any } = await api.post('system/env', notificationSettings.value)
if (result1.success && result2.success) {
$toast.success('保存通知渠道设置成功')
reloadModule()
} else {
$toast.error('保存通知渠道设置失败!')
}
} catch (error) {
console.log(error)
}
}
// 调用API接口重新加载模块
async function reloadModule() {
try {
const result: { [key: string]: any } = await api.get('system/reload')
if (result.success) $toast.success('重新加载模块成功')
else $toast.error('重新加载模块失败!')
} catch (error) {
console.log(error)
}
}
// 加载数据
onMounted(() => {
loadNotificationSwitchs()
loadNotificationSettings()
})
onMounted(() => {})
</script>
<template>
@@ -187,231 +49,28 @@ onMounted(() => {
<VCard>
<VCardItem>
<VCardTitle>通知渠道</VCardTitle>
<VCardSubtitle>只有选中的渠道才会发送消息</VCardSubtitle>
<VCardSubtitle>设置消息发送渠道参数</VCardSubtitle>
</VCardItem>
<VCardText>
<VForm>
<VRow>
<VCol cols="12" md="6">
<VSelect
v-model="selectedChannels"
multiple
chips
:items="NotificationChannels"
label="当前使用通知渠道"
hint="消息通知渠道总开关"
persistent-hint
/>
</VCol>
</VRow>
<VRow>
<VCol>
<VTabs v-model="messagerTab" stacked>
<VTab value="wechat"> 微信 </VTab>
<VTab value="telegram"> Telegram </VTab>
<VTab value="slack"> Slack </VTab>
<VTab value="synologychat"> SynologyChat </VTab>
<VTab value="vocechat"> VoceChat </VTab>
</VTabs>
<VWindow v-model="messagerTab" class="mt-5 disable-tab-transition" :touch="false">
<VWindowItem value="wechat">
<VForm>
<VRow>
<VCol cols="12" md="4">
<VTextField
v-model="notificationSettings.WECHAT_CORPID"
label="企业ID"
hint="企业微信后台企业信息中的企业ID"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="notificationSettings.WECHAT_APP_ID"
label="应用 AgentId"
hint="企业微信自建应用的AgentId"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="notificationSettings.WECHAT_APP_SECRET"
label="应用 Secret"
hint="企业微信自建应用的Secret"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="notificationSettings.WECHAT_PROXY"
label="代理地址"
hint="微信消息的转发代理地址2022年6月20日后创建的自建应用才需要不使用代理时需要保留默认值"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="notificationSettings.WECHAT_TOKEN"
label="Token"
hint="微信企业自建应用->API接收消息配置中的Token"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="notificationSettings.WECHAT_ENCODING_AESKEY"
label="EncodingAESKey"
hint="微信企业自建应用->API接收消息配置中的EncodingAESKey"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="notificationSettings.WECHAT_ADMINS"
label="管理员白名单"
placeholder="多个用,分隔"
hint="可使用管理菜单及命令的用户ID列表多个ID使用,分隔"
persistent-hint
/>
</VCol>
</VRow>
</VForm>
</VWindowItem>
<VWindowItem value="telegram">
<VForm>
<VRow>
<VCol cols="12" md="6">
<VTextField
v-model="notificationSettings.TELEGRAM_TOKEN"
label="Bot Token"
hint="Telegram机器人token格式123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationSettings.TELEGRAM_CHAT_ID"
label="Chat ID"
hint="接受消息通知的用户、群组或频道Chat ID"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationSettings.TELEGRAM_USERS"
label="用户白名单"
placeholder="多个用,分隔"
hint="可使用Telegram机器人的用户ID清单多个用户用,分隔,不填写则所有用户都能使用"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationSettings.TELEGRAM_ADMINS"
label="管理员白名单"
placeholder="多个用,分隔"
hint="可使用管理菜单及命令的用户ID列表多个ID使用,分隔"
persistent-hint
/>
</VCol>
</VRow>
</VForm>
</VWindowItem>
<VWindowItem value="slack">
<VForm>
<VRow>
<VCol cols="12" md="5">
<VTextField
v-model="notificationSettings.SLACK_OAUTH_TOKEN"
label="Slack Bot User OAuth Token"
placeholder="xoxb-xxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx"
hint="Slack应用`OAuth & Permissions`页面中的`Bot User OAuth Token`"
persistent-hint
/>
</VCol>
<VCol cols="12" md="5">
<VTextField
v-model="notificationSettings.SLACK_APP_TOKEN"
label="Slack App-Level Token"
placeholder="xapp-xxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx"
hint="Slack应用`OAuth & Permissions`页面中的`App-Level Token`"
persistent-hint
/>
</VCol>
<VCol cols="12" md="2">
<VTextField
v-model="notificationSettings.SLACK_CHANNEL"
label="频道名称"
placeholder="全体"
hint="消息发送频道,默认`全体`"
persistent-hint
/>
</VCol>
</VRow>
</VForm>
</VWindowItem>
<VWindowItem value="synologychat">
<VForm>
<VRow>
<VCol cols="12" md="6">
<VTextField
v-model="notificationSettings.SYNOLOGYCHAT_WEBHOOK"
label="机器人传入URL"
hint="Synology Chat机器人传入URL"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationSettings.SYNOLOGYCHAT_TOKEN"
label="令牌"
hint="Synology Chat机器人令牌"
persistent-hint
/>
</VCol>
</VRow>
</VForm>
</VWindowItem>
<VWindowItem value="vocechat">
<VForm>
<VRow>
<VCol cols="12" md="4">
<VTextField
v-model="notificationSettings.VOCECHAT_HOST"
label="地址"
hint="VoceChat服务端地址格式http(s)://ip:port"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="notificationSettings.VOCECHAT_API_KEY"
label="机器人密钥"
hint="VoceChat机器人密钥"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="notificationSettings.VOCECHAT_CHANNEL_ID"
label="频道ID"
placeholder="不包含#号"
hint="VoceChat的频道ID不包含#号"
persistent-hint
/>
</VCol>
</VRow>
</VForm>
</VWindowItem>
</VWindow>
</VCol>
</VRow>
</VForm>
<draggable
v-model="notifications"
handle=".cursor-move"
item-key="name"
tag="div"
:component-data="{ 'class': 'grid gap-3 grid-app-card' }"
>
<template #item="{ element }">
<NotificationChannelCard notification="element" />
</template>
</draggable>
</VCardText>
<VCardText>
<VForm @submit.prevent="() => {}">
<div class="d-flex flex-wrap gap-4 mt-4">
<VBtn mtype="submit" @click="saveNotificationSettings"> 保存 </VBtn>
<VBtn mtype="submit" @click=""> 保存 </VBtn>
<VBtn color="success" variant="tonal" @click="">
<VIcon icon="mdi-plus" />
</VBtn>
</div>
</VForm>
</VCardText>
@@ -422,55 +81,14 @@ onMounted(() => {
<VCol cols="12">
<VCard>
<VCardItem>
<VCardTitle>消息类型</VCardTitle>
<VCardSubtitle>对应消息类型只会发送给选中的消息渠道</VCardSubtitle>
<VCardTitle>通知发送范围</VCardTitle>
<VCardSubtitle>对应消息类型只会发送给设定的用户</VCardSubtitle>
</VCardItem>
<VTable class="text-no-wrap">
<thead>
<tr>
<th scope="col">消息类型</th>
<th scope="col">微信</th>
<th scope="col">Telegram</th>
<th scope="col">Slack</th>
<th scope="col">SynologyChat</th>
<th scope="col">VoceChat</th>
<th scope="col">WebPush</th>
</tr>
</thead>
<tbody>
<tr v-for="message in messagemTypes" :key="message.mtype">
<td>
{{ message.mtype }}
</td>
<td>
<VCheckbox v-model="message.wechat" />
</td>
<td>
<VCheckbox v-model="message.telegram" />
</td>
<td>
<VCheckbox v-model="message.slack" />
</td>
<td>
<VCheckbox v-model="message.synologychat" />
</td>
<td>
<VCheckbox v-model="message.vocechat" />
</td>
<td>
<VCheckbox v-model="message.webpush" />
</td>
</tr>
<tr v-if="messagemTypes.length === 0">
<td colspan="6" class="text-center">没有设置任何通知渠道</td>
</tr>
</tbody>
</VTable>
<VDivider />
<VCardText>
<VForm @submit.prevent="() => {}">
<div class="d-flex flex-wrap gap-4 mt-4">
<VBtn mtype="submit" @click="saveNotificationSwitchs"> 保存 </VBtn>
<VBtn mtype="submit" @click=""> 保存 </VBtn>
</div>
</VForm>
</VCardText>

View File

@@ -0,0 +1,157 @@
<!-- eslint-disable sonarjs/no-duplicate-string -->
<script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import draggable from 'vuedraggable'
import { VRow } from 'vuetify/lib/components/index.mjs'
import api from '@/api'
import { CustomRule, FilterRuleGroup } from '@/api/types'
import CustomerRuleCard from '@/components/cards/CustomRuleCard.vue'
import FilterRuleGroupCard from '@/components/cards/FilterRuleGroupCard.vue'
// 自定义规则列表
const customRules = ref<CustomRule[]>([])
// 所有规则组列表
const filterRuleGroups = ref<FilterRuleGroup[]>([])
// 种子优先规则
const selectedTorrentPriority = ref<string>('seeder')
// 提示框
const $toast = useToast()
// 种子优先规则下拉框
const TorrentPriorityItems = [
{ title: '站点优先', value: 'site' },
{ title: '做种数优先', value: 'seeder' },
]
// 保存自定义规则
function saveCustomRules() {}
// 添加自定义规则
function addCustomRule() {}
// 保存规则组
function saveFilterRuleGroups() {}
// 添加规则组
function addFilterRuleGroup() {}
// 查询种子优先规则
async function queryTorrentPriority() {
try {
const result: { [key: string]: any } = await api.get('system/setting/TorrentsPriority')
selectedTorrentPriority.value = result.data?.value
} catch (error) {
console.log(error)
}
}
// 保存种子优先规则
async function saveTorrentPriority() {
try {
// 用户名密码
const result: { [key: string]: any } = await api.post(
'system/setting/TorrentsPriority',
selectedTorrentPriority.value,
)
if (result.success) $toast.success('优先规则保存成功')
else $toast.error('优先规则保存失败!')
} catch (error) {
console.log(error)
}
}
// 加载数据
onMounted(() => {
queryTorrentPriority()
})
</script>
<template>
<VRow>
<VCol cols="12">
<VCard>
<VCardItem>
<VCardTitle>自定义规则</VCardTitle>
<VCardSubtitle>自定义优先级规则项</VCardSubtitle>
</VCardItem>
<VCardText>
<draggable
v-model="customRules"
handle=".cursor-move"
item-key="name"
tag="div"
:component-data="{ 'class': 'grid gap-3 grid-filterrule-card' }"
>
<template #item="{ element }">
<CustomerRuleCard :rule="element" />
</template>
</draggable>
</VCardText>
<VCardText>
<VBtn type="submit" class="me-2" @click="saveCustomRules"> 保存 </VBtn>
<VBtn color="success" variant="tonal" @click="addCustomRule">
<VIcon icon="mdi-plus" />
</VBtn>
</VCardText>
</VCard>
</VCol>
<VCol cols="12">
<VCard>
<VCardItem>
<VCardTitle>优先级规则组</VCardTitle>
<VCardSubtitle>预设优先级规则组以便在搜索和订阅中使用</VCardSubtitle>
</VCardItem>
<VCardText>
<draggable
v-model="filterRuleGroups"
handle=".cursor-move"
item-key="name"
tag="div"
:component-data="{ 'class': 'grid gap-3 grid-filterrule-card' }"
>
<template #item="{ element }">
<FilterRuleGroupCard type="library" :filterrule="element" />
</template>
</draggable>
</VCardText>
<VCardText>
<VBtn type="submit" class="me-2" @click="saveFilterRuleGroups"> 保存 </VBtn>
<VBtn color="success" variant="tonal" @click="addFilterRuleGroup">
<VIcon icon="mdi-plus" />
</VBtn>
</VCardText>
</VCard>
</VCol>
<VCol cols="12">
<VCard>
<VCardItem>
<VCardTitle>下载规则</VCardTitle>
<VCardSubtitle>按站点或做种数量优先下载</VCardSubtitle>
</VCardItem>
<VCardText>
<VForm>
<VRow>
<VCol cols="12" md="6">
<VSelect
v-model="selectedTorrentPriority"
:items="TorrentPriorityItems"
label="当前使用下载优先规则"
hint="同时命中多个站点的多个资源时下载的优先规则"
persistent-hint
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardText>
<VBtn type="submit" @click="saveTorrentPriority"> 保存 </VBtn>
</VCardText>
</VCard>
</VCol>
</VRow>
</template>

View File

@@ -1,26 +1,11 @@
<script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import draggable from 'vuedraggable'
import api from '@/api'
import FilterRuleCard from '@/components/cards/FilterRuleCard.vue'
import type { Site } from '@/api/types'
import { copyToClipboard } from '@/@core/utils/navigator'
import ImportCodeDialog from '@/components/dialog/ImportCodeDialog.vue'
// 规则卡片类型
interface FilterCard {
// 优先级
pri: string
// 已选规则
rules: string[]
}
// 提示框
const $toast = useToast()
// 规则卡片列表
const filterCards = ref<FilterCard[]>([])
// 所有站点
const allSites = ref<Site[]>([])
@@ -54,85 +39,11 @@ const mediaSourcesDict = [
// 当前选中的媒体信息数据源
const selectedMediaSource = ref([])
// 导入代码弹窗
const importCodeDialog = ref(false)
// 导入的代码
const importCodeString = ref('')
// 查询已设置优先级规则
async function queryCustomFilters() {
try {
const result: { [key: string]: any } = await api.get('system/setting/SearchFilterRules')
if (result.success) {
// 保存的是个字符串,需要分割成数组
const groups = result.data?.value?.split('>') ?? []
// 生成规则卡片
filterCards.value = groups?.map((group: string, index: number) => {
return {
pri: (index + 1).toString(),
rules: group.split('&'),
}
})
}
} catch (error) {
console.log(error)
}
}
// 当前选中的过滤规则组
const selectedFilterGroup = ref([])
// 保存用户设置的规则
async function saveCustomFilters() {
try {
// 有值才处理
let value = ''
if (filterCards.value.length !== 0) {
// 将卡片规则接装为字符串
value = filterCards.value
.filter(card => card.rules.length > 0)
.map(card => card.rules.join('&'))
.join('>')
}
// 保存
const result: { [key: string]: any } = await api.post('system/setting/SearchFilterRules', value)
if (result.success) $toast.success('搜索优先级保存成功')
else $toast.error('搜索优先级保存失败!')
} catch (error) {
console.log(error)
}
}
// 更新规则卡片的值
function updateFilterCardValue(pri: string, rules: string[]) {
const card = filterCards.value.find(card => card.pri === pri)
if (card) card.rules = rules
}
// 移除卡片
function filterCardClose(pri: string) {
// 将pri对应的卡片从列表中删除并更新剩余卡片的序号
const updatedCards = filterCards.value
.filter(card => card.pri !== pri)
.map((card, index) => {
card.pri = (index + 1).toString()
return card
})
// 更新 filterCards.value
filterCards.value = updatedCards
}
// 增加卡片
function addFilterCard() {
// 优先级
const pri = (filterCards.value.length + 1).toString()
// 新卡片
const newCard: FilterCard = { pri, rules: [] }
// 添加到列表
filterCards.value.push(newCard)
}
async function saveCustomFilters() {}
// 查询所有站点
async function querySites() {
@@ -171,78 +82,8 @@ async function saveSelectedSites() {
}
}
// 根据列表的拖动顺序更新优先级
function dragOrderEnd() {
filterCards.value = filterCards.value.map((card, index) => {
card.pri = (index + 1).toString()
return card
})
}
// 查询包含与排除规则
async function queryDefaultFilter() {
try {
const result: { [key: string]: any } = await api.get('system/setting/DefaultSearchFilterRules')
if (result.data?.value) defaultFilterRules.value = result.data?.value
} catch (error) {
console.log(error)
}
}
// 保存包含与排除规则
async function saveDefaultFilter() {
try {
const result: { [key: string]: any } = await api.post(
'system/setting/DefaultSearchFilterRules',
defaultFilterRules.value,
)
if (result.success) $toast.success('默认包含/排除规则保存成功')
else $toast.error('默认包含/排除规则保存失败!')
} catch (error) {
console.log(error)
}
}
// 分享规则
function shareRules() {
// 有值才处理
if (filterCards.value.length === 0) return
// 将卡片规则接装为字符串
const value = filterCards.value
.filter(card => card.rules.length > 0)
.map(card => card.rules.join('&'))
.join('>')
// 复制到剪贴板
try {
copyToClipboard(value)
$toast.success('优先级规则已复制到剪贴板')
} catch (error) {
$toast.error('优先级规则复制失败!')
}
}
// 监听导入代码变化
watchEffect(() => {
if (!importCodeString.value) return
// 导入代码需要以空格开头和结束,没有则拼接
if (!importCodeString.value.startsWith(' ')) importCodeString.value = ` ${importCodeString.value}`
if (!importCodeString.value.endsWith(' ')) importCodeString.value = `${importCodeString.value} `
// 将导入的代码转换为规则卡片
const groups = importCodeString.value.split('>')
filterCards.value = groups.map((group: string, index: number) => {
return {
pri: (index + 1).toString(),
rules: group.split('&'),
}
})
})
// 调用API查询下载器设置
async function loadMediaSourceSetting() {
async function loadSearchSetting() {
try {
const result1: { [key: string]: any } = await api.get('system/setting/SEARCH_SOURCE')
if (result1.success) selectedMediaSource.value = result1.data?.value?.split(',')
@@ -251,8 +92,8 @@ async function loadMediaSourceSetting() {
}
}
// 调用API保存下载器设置
async function saveMediaSourceSetting() {
// 调用API保存设置
async function saveSearchSetting() {
try {
const result: { [key: string]: any } = await api.post(
'system/setting/SEARCH_SOURCE',
@@ -270,10 +111,8 @@ async function saveMediaSourceSetting() {
}
onMounted(() => {
queryCustomFilters()
querySites()
queryDefaultFilter()
loadMediaSourceSetting()
loadSearchSetting()
})
</script>
@@ -282,8 +121,8 @@ onMounted(() => {
<VCol cols="12">
<VCard>
<VCardItem>
<VCardTitle>媒体数据源</VCardTitle>
<VCardSubtitle>设定搜索时展示哪些源的媒体信息</VCardSubtitle>
<VCardTitle>搜索数据源 & 规则组</VCardTitle>
<VCardSubtitle>设定数据源规则组等基础信息</VCardSubtitle>
</VCardItem>
<VCardText>
<VRow>
@@ -293,15 +132,26 @@ onMounted(() => {
multiple
chips
:items="mediaSourcesDict"
label="当前使用数据源"
label="媒体数据源"
hint="搜索媒体信息时使用的数据源以及排序"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VSelect
v-model="selectedFilterGroup"
multiple
chips
:items="[]"
label="过滤规则组"
hint="搜索媒体信息时按选定的过滤规则组对结果进行过滤"
persistent-hint
/>
</VCol>
</VRow>
</VCardText>
<VCardText>
<VBtn type="submit" @click="saveMediaSourceSetting"> 保存 </VBtn>
<VBtn type="submit" @click="saveSearchSetting"> 保存 </VBtn>
</VCardText>
</VCard>
</VCol>
@@ -330,118 +180,5 @@ onMounted(() => {
</VCardText>
</VCard>
</VCol>
<VCol cols="12">
<VCard>
<VCardItem>
<template #append>
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem variant="plain" @click="shareRules">
<template #prepend>
<VIcon icon="mdi-share" />
</template>
<VListItemTitle>分享</VListItemTitle>
</VListItem>
<VListItem variant="plain" @click="importCodeDialog = true">
<template #prepend>
<VIcon icon="mdi-import" />
</template>
<VListItemTitle>导入</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</template>
<VCardTitle>搜索优先级</VCardTitle>
<VCardSubtitle>设置在搜索时默认使用的优先级排序未在优先级中的资源将不在搜索结果中显示</VCardSubtitle>
</VCardItem>
<VCardText>
<draggable
v-model="filterCards"
handle=".cursor-move"
item-key="pri"
tag="div"
@end="dragOrderEnd"
:component-data="{ 'class': 'grid gap-3 grid-filterrule-card' }"
>
<template #item="{ element }">
<FilterRuleCard
:pri="element.pri"
:maxpri="filterCards.length.toString()"
:rules="element.rules"
@changed="updateFilterCardValue"
@close="filterCardClose(element.pri)"
/>
</template>
</draggable>
</VCardText>
<VCardText>
<VBtn type="submit" class="me-2" @click="saveCustomFilters()"> 保存 </VBtn>
<VBtn color="success" variant="tonal" @click="addFilterCard()">
<VIcon icon="mdi-plus" />
</VBtn>
</VCardText>
</VCard>
</VCol>
<VCol cols="12">
<VCard>
<VCardItem>
<VCardTitle>默认过滤规则</VCardTitle>
<VCardSubtitle>设置在搜索时默认使用的过滤规则</VCardSubtitle>
</VCardItem>
<VCardText>
<VForm>
<VRow>
<VCol cols="12" md="6">
<VTextField
v-model="defaultFilterRules.include"
type="text"
label="包含(关键字、正则式)"
hint="包含规则,支持正式表达式,多个关键字用 | 分隔表示或"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="defaultFilterRules.exclude"
type="text"
label="排除(关键字、正则式)"
hint="排除规则,支持正式表达式,多个关键字用 | 分隔表示或"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="defaultFilterRules.min_seeders"
type="text"
label="最小做种数"
placeholder="0"
hint="小于该值的资源将被过滤掉0表示不过滤"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="defaultFilterRules.min_seeders_time"
type="text"
label="最少做种数生效发布时间(分钟)"
placeholder="0"
hint="发布时间距当前时间大于该值的资源将生效最小做种数规则0表示不生效"
persistent-hint
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardText>
<VBtn type="submit" @click="saveDefaultFilter"> 保存 </VBtn>
</VCardText>
</VCard>
</VCol>
</VRow>
<VDialog v-model="importCodeDialog" width="60rem" scrollable>
<ImportCodeDialog v-model="importCodeString" title="导入优先级规则" @close="importCodeDialog = false" />
</VDialog>
</template>

View File

@@ -14,9 +14,6 @@ const resetSitesText = ref('重置站点数据')
// 站点重置按钮可用状态
const resetSitesDisabled = ref(false)
// 种子优先规则
const selectedTorrentPriority = ref<string>('seeder')
// CookieCloud设置项
const cookieCloudSetting = ref({
COOKIECLOUD_HOST: '',
@@ -28,12 +25,6 @@ const cookieCloudSetting = ref({
COOKIECLOUD_BLACKLIST: '',
})
// 种子优先规则下拉框
const TorrentPriorityItems = [
{ title: '站点优先', value: 'site' },
{ title: '做种数优先', value: 'seeder' },
]
// 同步间隔下拉框
const CookieCloudIntervalItems = [
{ title: '每小时', value: 60 },
@@ -62,33 +53,6 @@ async function resetSites() {
}
}
// 查询种子优先规则
async function queryTorrentPriority() {
try {
const result: { [key: string]: any } = await api.get('system/setting/TorrentsPriority')
selectedTorrentPriority.value = result.data?.value
} catch (error) {
console.log(error)
}
}
// 保存种子优先规则
async function saveTorrentPriority() {
try {
// 用户名密码
const result: { [key: string]: any } = await api.post(
'system/setting/TorrentsPriority',
selectedTorrentPriority.value,
)
if (result.success) $toast.success('优先规则保存成功')
else $toast.error('优先规则保存失败!')
} catch (error) {
console.log(error)
}
}
// 加载CookieCloud设置
async function loadCookieCloudSettings() {
try {
@@ -132,7 +96,6 @@ async function saveCookieCloudetting() {
// 加载数据
onMounted(() => {
queryTorrentPriority()
loadCookieCloudSettings()
})
</script>
@@ -219,32 +182,7 @@ onMounted(() => {
</VCardText>
</VCard>
</VCol>
<VCol cols="12">
<VCard>
<VCardItem>
<VCardTitle>下载优先规则</VCardTitle>
<VCardSubtitle>按站点或做种数量优先下载</VCardSubtitle>
</VCardItem>
<VCardText>
<VForm>
<VRow>
<VCol cols="12" md="6">
<VSelect
v-model="selectedTorrentPriority"
:items="TorrentPriorityItems"
label="当前使用下载优先规则"
hint="同时命中多个站点的多个资源时下载的优先规则"
persistent-hint
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardText>
<VBtn type="submit" @click="saveTorrentPriority"> 保存 </VBtn>
</VCardText>
</VCard>
</VCol>
<VCol cols="12">
<VCard title="站点重置">
<VCardText>

View File

@@ -1,37 +1,19 @@
<script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import draggable from 'vuedraggable'
import api from '@/api'
import FilterRuleCard from '@/components/cards/FilterRuleCard.vue'
import type { Site } from '@/api/types'
import { copyToClipboard } from '@/@core/utils/navigator'
import ImportCodeDialog from '@/components/dialog/ImportCodeDialog.vue'
// 规则卡片类型
interface FilterCard {
// 优先级
pri: string
// 已选规则
rules: string[]
}
// 提示框
const $toast = useToast()
// 订阅规则卡片列表
const subscribeFilterCards = ref<FilterCard[]>([])
// 洗版规则卡片列表
const bestVersionFilterCards = ref<FilterCard[]>([])
// 所有站点
const allSites = ref<Site[]>([])
// 选中订阅站点
const selectedRssSites = ref<number[]>([])
// 当前规则类型
const currentRuleType = ref('SubscribeFilterRules')
// 选中的优先级规则组
const selectedFilterRuleGroup = ref([])
// 是否开启订阅定时搜索
const enableIntervalSearch = ref(false)
@@ -69,12 +51,6 @@ const rssIntervalItems = [
// 选择的RSS运行周期
const selectedRssInterval = ref<number>(5)
// 导入代码弹窗
const importCodeDialog = ref(false)
// 导入的代码
const importCodeString = ref('')
// 查询用户选中的订阅站点
async function querySelectedRssSites() {
try {
@@ -133,178 +109,8 @@ async function querySites() {
}
}
// 查询已设置优先级规则
async function queryCustomFilters(ruleType: string) {
try {
const result: { [key: string]: any } = await api.get(`system/setting/${ruleType}`)
if (result.success) {
// 保存的是个字符串,需要分割成数组
const groups = result.data?.value?.split('>') ?? []
// 生成规则卡片
const cards = ruleType === 'SubscribeFilterRules' ? subscribeFilterCards : bestVersionFilterCards
cards.value = groups?.map((group: string, index: number) => {
return {
pri: (index + 1).toString(),
rules: group.split('&'),
}
})
}
} catch (error) {
console.log(error)
}
}
// 保存用户设置的规则
async function saveCustomFilters(ruleType: string) {
try {
// 有值才处理
let value = ''
const cards = ruleType === 'SubscribeFilterRules' ? subscribeFilterCards : bestVersionFilterCards
if (cards.value.length !== 0) {
// 将卡片规则接装为字符串
value = cards.value
.filter(card => card.rules.length > 0)
.map(card => card.rules.join('&'))
.join('>')
}
// 保存
const result: { [key: string]: any } = await api.post(`system/setting/${ruleType}`, value)
const msg = ruleType === 'SubscribeFilterRules' ? '订阅优先级' : '洗版优先级'
if (result.success) $toast.success(`${msg}保存成功`)
else $toast.error(`${msg}保存失败!`)
} catch (error) {
console.log(error)
}
}
// 更新规则卡片的值
function updateSubscribeFilterCardValue(pri: string, rules: string[]) {
const card = subscribeFilterCards.value.find(card => card.pri === pri)
if (card) card.rules = rules
}
// 更新洗版规则卡片的值
function updateBestVersionFilterCardValue(pri: string, rules: string[]) {
const card = bestVersionFilterCards.value.find(card => card.pri === pri)
if (card) card.rules = rules
}
// 移除卡片
function filterCardClose(ruleType: string, pri: string) {
// 将pri对应的卡片从列表中删除并更新剩余卡片的序号
const updatedCards = (ruleType === 'SubscribeFilterRules' ? subscribeFilterCards.value : bestVersionFilterCards.value)
.filter(card => card.pri !== pri)
.map((card, index) => {
card.pri = (index + 1).toString()
return card
})
// 更新 subscribeFilterCards.value
if (ruleType === 'SubscribeFilterRules') subscribeFilterCards.value = updatedCards
else bestVersionFilterCards.value = updatedCards
}
// 增加卡片
function addFilterCard(ruleType: string) {
const cards = ruleType === 'SubscribeFilterRules' ? subscribeFilterCards : bestVersionFilterCards
// 优先级
const pri = (cards.value.length + 1).toString()
// 新卡片
const newCard: FilterCard = { pri, rules: [] }
// 添加到列表
cards.value.push(newCard)
}
// 根据列表的拖动顺序更新优先级
function dragOrderEnd(ruleType: string) {
;(ruleType === 'SubscribeFilterRules' ? subscribeFilterCards.value : bestVersionFilterCards.value).map(
(card, index) => {
card.pri = (index + 1).toString()
return card
},
)
}
// 查询包含与排除规则
async function queryDefaultFilter() {
try {
const result: { [key: string]: any } = await api.get('system/setting/DefaultFilterRules')
if (result.data?.value) defaultFilterRules.value = result.data?.value
} catch (error) {
console.log(error)
}
}
// 保存包含与排除规则
async function saveDefaultFilter() {
try {
const result: { [key: string]: any } = await api.post('system/setting/DefaultFilterRules', defaultFilterRules.value)
if (result.success) $toast.success('默认包含/排除规则保存成功')
else $toast.error('默认包含/排除规则保存失败!')
} catch (error) {
console.log(error)
}
}
// 分享规则
function shareRules(ruleType: string) {
let filterCards: Ref<FilterCard[]>
if (ruleType === 'SubscribeFilterRules') filterCards = subscribeFilterCards
else filterCards = bestVersionFilterCards
// 有值才处理
if (filterCards.value.length === 0) return
// 将卡片规则接装为字符串
const value = filterCards.value
.filter(card => card.rules.length > 0)
.map(card => card.rules.join('&'))
.join('>')
// 复制到剪贴板
try {
copyToClipboard(value)
$toast.success('优先级规则已复制到剪贴板')
} catch (error) {
$toast.error('优先级规则复制失败!')
}
}
// 导入规则
async function importRules(ruleType: string) {
currentRuleType.value = ruleType
importCodeString.value = ''
importCodeDialog.value = true
}
// 监听导入代码变化
watchEffect(() => {
if (!importCodeString.value) return
if (!currentRuleType.value) return
// 导入代码需要以空格开头和结束,没有则拼接
if (!importCodeString.value.startsWith(' ')) importCodeString.value = ` ${importCodeString.value}`
if (!importCodeString.value.endsWith(' ')) importCodeString.value = `${importCodeString.value} `
let filterCards: Ref<FilterCard[]>
if (currentRuleType.value === 'SubscribeFilterRules') filterCards = subscribeFilterCards
else filterCards = bestVersionFilterCards
// 将导入的代码转换为规则卡片
const groups = importCodeString.value.split('>')
filterCards.value = groups.map((group: string, index: number) => {
return {
pri: (index + 1).toString(),
rules: group.split('&'),
}
})
})
onMounted(() => {
querySites()
queryCustomFilters('SubscribeFilterRules')
queryCustomFilters('BestVersionFilterRules')
queryDefaultFilter()
})
</script>
@@ -313,23 +119,9 @@ onMounted(() => {
<VCol cols="12">
<VCard>
<VCardItem>
<VCardTitle>订阅站点</VCardTitle>
<VCardSubtitle>只有选中的站点才会在订阅中使用</VCardSubtitle>
<VCardTitle>订阅模式 & 规则</VCardTitle>
<VCardSubtitle>设定订阅模式周期等基础设置</VCardSubtitle>
</VCardItem>
<VCardText>
<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>
</VCardText>
<VCardText>
<VForm>
<VRow>
@@ -351,6 +143,17 @@ onMounted(() => {
persistent-hint
/>
</VCol>
<VCol cols="12" md="12">
<VSelect
v-model="selectedFilterRuleGroup"
:items="[]"
chips
multiple
label="优先级规则组"
hint="按选定的过滤规则组对订阅进行过滤"
persistent-hint
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="6">
@@ -372,190 +175,27 @@ onMounted(() => {
<VCol cols="12">
<VCard>
<VCardItem>
<template #append>
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem variant="plain" @click="shareRules('SubscribeFilterRules')">
<template #prepend>
<VIcon icon="mdi-share" />
</template>
<VListItemTitle>分享</VListItemTitle>
</VListItem>
<VListItem variant="plain" @click="importRules('SubscribeFilterRules')">
<template #prepend>
<VIcon icon="mdi-import" />
</template>
<VListItemTitle>导入</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</template>
<VCardTitle>订阅优先级</VCardTitle>
<VCardSubtitle> 设置在正常订阅时默认使用的优先级未在优先级中的资源将不会自动下载</VCardSubtitle>
<VCardTitle>订阅站点</VCardTitle>
<VCardSubtitle>只有选中的站点才会在订阅中使用</VCardSubtitle>
</VCardItem>
<VCardText>
<draggable
v-model="subscribeFilterCards"
handle=".cursor-move"
item-key="pri"
tag="div"
@end="dragOrderEnd('SubscribeFilterRules')"
:component-data="{ 'class': 'grid gap-3 grid-filterrule-card' }"
>
<template #item="{ element }">
<FilterRuleCard
:pri="element.pri"
:maxpri="subscribeFilterCards.length.toString()"
:rules="element.rules"
@changed="updateSubscribeFilterCardValue"
@close="filterCardClose('SubscribeFilterRules', element.pri)"
/>
</template>
</draggable>
<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>
</VCardText>
<VCardText>
<VBtn type="submit" class="me-2" @click="saveCustomFilters('SubscribeFilterRules')"> 保存 </VBtn>
<VBtn color="success" variant="tonal" @click="addFilterCard('SubscribeFilterRules')">
<VIcon icon="mdi-plus" />
</VBtn>
</VCardText>
</VCard>
</VCol>
<VCol cols="12">
<VCard>
<VCardItem>
<VCardTitle>洗版优先级</VCardTitle>
<template #append>
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem variant="plain" @click="shareRules('BestVersionFilterRules')">
<template #prepend>
<VIcon icon="mdi-share" />
</template>
<VListItemTitle>分享</VListItemTitle>
</VListItem>
<VListItem variant="plain" @click="importRules('BestVersionFilterRules')">
<template #prepend>
<VIcon icon="mdi-import" />
</template>
<VListItemTitle>导入</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</template>
<VCardSubtitle> 设置在订阅洗版时使用的优先级匹配优先级1时洗版完成</VCardSubtitle>
</VCardItem>
<VCardText>
<draggable
v-model="bestVersionFilterCards"
handle=".cursor-move"
item-key="pri"
tag="div"
@end="dragOrderEnd('BestVersionFilterRules')"
:component-data="{ 'class': 'grid gap-3 grid-filterrule-card' }"
>
<template #item="{ element }">
<FilterRuleCard
:pri="element.pri"
:maxpri="bestVersionFilterCards.length.toString()"
:rules="element.rules"
@changed="updateBestVersionFilterCardValue"
@close="filterCardClose('BestVersionFilterRules', element.pri)"
/>
</template>
</draggable>
</VCardText>
<VCardText>
<VBtn type="submit" class="me-2" @click="saveCustomFilters('BestVersionFilterRules')"> 保存 </VBtn>
<VBtn color="success" variant="tonal" @click="addFilterCard('BestVersionFilterRules')">
<VIcon icon="mdi-plus" />
</VBtn>
</VCardText>
</VCard>
</VCol>
<VCol cols="12">
<VCard>
<VCardItem>
<VCardTitle>默认过滤规则</VCardTitle>
<VCardSubtitle> 设置在订阅时默认使用的过滤规则</VCardSubtitle>
</VCardItem>
<VCardText>
<VForm>
<VRow>
<VCol cols="12" md="6">
<VTextField
v-model="defaultFilterRules.include"
type="text"
label="包含(关键字、正则式)"
hint="包含规则,支持正式表达式,多个关键字用 | 分隔表示或"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="defaultFilterRules.exclude"
type="text"
label="排除(关键字、正则式)"
hint="排除规则,支持正式表达式,多个关键字用 | 分隔表示或"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="defaultFilterRules.movie_size"
type="text"
label="电影文件大小GB"
placeholder="0-30"
hint="文件大小范围格式0-30表示0-30GB之间的资源"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="defaultFilterRules.tv_size"
type="text"
label="剧集单集文件大小GB"
placeholder="0-10"
hint="单集文件大小范围格式0-10表示0-10GB之间的资源"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="defaultFilterRules.min_seeders"
type="text"
label="最小做种数"
placeholder="0"
hint="小于该值的资源将被过滤掉0表示不过滤"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="defaultFilterRules.min_seeders_time"
type="text"
label="最少做种数生效发布时间(分钟)"
placeholder="0"
hint="发布时间距当前时间大于该值的资源将生效最小做种数规则0表示不生效"
persistent-hint
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardText>
<VBtn type="submit" @click="saveDefaultFilter"> 保存 </VBtn>
<VBtn type="submit" @click="saveSelectedRssSites"> 保存 </VBtn>
</VCardText>
</VCard>
</VCol>
</VRow>
<VDialog v-model="importCodeDialog" width="60rem" scrollable>
<ImportCodeDialog v-model="importCodeString" title="导入优先级规则" @close="importCodeDialog = false" />
</VDialog>
</template>

View File

@@ -2,216 +2,37 @@
<script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import { VRow } from 'vuetify/lib/components/index.mjs'
import draggable from 'vuedraggable'
import api from '@/api'
// 选中的媒体服务器
const selectedMediaServers = ref([])
// 选中的下载器
const selectedDownloaders = ref([])
// 下载器选中标签页
const downloaderTab = ref('qbittorrent')
// 媒体服务器选中标签页
const mediaserverTab = ref('emby')
import { DownloaderConf, MediaServerConf } from '@/api/types'
import DownloaderCard from '@/components/cards/DownloaderCard.vue'
import MediaServerCard from '@/components/cards/MediaServerCard.vue'
// 系统设置项
const SystemSettings = ref({
APP_DOMAIN: '',
})
// 下载器设置项
const downloaderSettings = ref({
DOWNLOADER_MONITOR: true,
TORRENT_TAG: '',
QB_HOST: '',
QB_USER: '',
QB_PASSWORD: '',
QB_CATEGORY: false,
QB_SEQUENTIAL: false,
QB_FORCE_RESUME: false,
TR_HOST: '',
TR_USER: '',
TR_PASSWORD: '',
})
// 选中的媒体服务器
const mediaServers = ref<MediaServerConf[]>([])
// 媒体服务器设置项
const mediaServerSettings = ref({
MEDIASERVER_SYNC_INTERVAL: 6,
MEDIASERVER_SYNC_BLACKLIST: '',
EMBY_HOST: '',
EMBY_PLAY_HOST: '',
EMBY_API_KEY: '',
JELLYFIN_HOST: '',
JELLYFIN_PLAY_HOST: '',
JELLYFIN_API_KEY: '',
PLEX_HOST: '',
PLEX_PLAY_HOST: '',
PLEX_TOKEN: '',
})
// 下载器字典项
const Downloaders = [
{
title: 'Qbittorrent',
value: 'qbittorrent',
},
{
title: 'Transmission',
value: 'transmission',
},
]
// 媒体服务器字典项
const MediaServers = [
{
title: 'Emby',
value: 'emby',
},
{
title: 'Jellyfin',
value: 'jellyfin',
},
{
title: 'Plex',
value: 'plex',
},
]
// 媒体库同步周期字典
const syncIntervalItems = [
{ title: '从不', value: 0 },
{ title: '每小时', value: 1 },
{ title: '每6小时', value: 6 },
{ title: '每12小时', value: 12 },
{ title: '每天', value: 24 },
{ title: '每周', value: 168 },
]
// 下载器
const downloaders = ref<DownloaderConf[]>([])
// 提示框
const $toast = useToast()
// 调用API查询下载器设置
async function loadDownloaderSetting() {
try {
const result1: { [key: string]: any } = await api.get('system/setting/DOWNLOADER')
if (result1.success) selectedDownloaders.value = result1.data?.value?.split(',')
const result2: { [key: string]: any } = await api.get('system/env')
if (result2.success) {
const {
DOWNLOADER_MONITOR,
TORRENT_TAG,
QB_HOST,
QB_USER,
QB_PASSWORD,
QB_CATEGORY,
QB_SEQUENTIAL,
QB_FORCE_RESUME,
TR_HOST,
TR_USER,
TR_PASSWORD,
} = result2.data
downloaderSettings.value = {
DOWNLOADER_MONITOR,
TORRENT_TAG,
QB_HOST,
QB_USER,
QB_PASSWORD,
QB_CATEGORY,
QB_SEQUENTIAL,
QB_FORCE_RESUME,
TR_HOST,
TR_USER,
TR_PASSWORD,
}
}
} catch (error) {
console.log(error)
}
}
async function loadDownloaderSetting() {}
// 调用API保存下载器设置
async function saveDownloaderSetting() {
try {
const result1: { [key: string]: any } = await api.post(
'system/setting/DOWNLOADER',
selectedDownloaders.value.join(','),
)
const result2: { [key: string]: any } = await api.post('system/env', downloaderSettings.value)
if (result1.success && result2.success) {
$toast.success('保存下载器设置成功')
reloadModule()
} else {
$toast.error('保存下载器设置失败!')
}
} catch (error) {
console.log(error)
}
}
async function saveDownloaderSetting() {}
// 调用API查询媒体服务器设置
async function loadMediaServerSetting() {
try {
const result1: { [key: string]: any } = await api.get('system/setting/MEDIASERVER')
if (result1.success) selectedMediaServers.value = result1.data?.value?.split(',')
const result2: { [key: string]: any } = await api.get('system/env')
if (result2.success) {
const {
MEDIASERVER_SYNC_INTERVAL,
MEDIASERVER_SYNC_BLACKLIST,
EMBY_HOST,
EMBY_PLAY_HOST,
EMBY_API_KEY,
JELLYFIN_HOST,
JELLYFIN_PLAY_HOST,
JELLYFIN_API_KEY,
PLEX_HOST,
PLEX_PLAY_HOST,
PLEX_TOKEN,
} = result2.data
mediaServerSettings.value = {
MEDIASERVER_SYNC_INTERVAL,
MEDIASERVER_SYNC_BLACKLIST,
EMBY_HOST,
EMBY_PLAY_HOST,
EMBY_API_KEY,
JELLYFIN_HOST,
JELLYFIN_PLAY_HOST,
JELLYFIN_API_KEY,
PLEX_HOST,
PLEX_PLAY_HOST,
PLEX_TOKEN,
}
}
} catch (error) {
console.log(error)
}
}
async function loadMediaServerSetting() {}
// 调用API保存媒体服务器设置
async function saveMediaServerSetting() {
try {
const result1: { [key: string]: any } = await api.post(
'system/setting/MEDIASERVER',
selectedMediaServers.value.join(','),
)
const result2: { [key: string]: any } = await api.post('system/env', mediaServerSettings.value)
if (result1.success && result2.success) {
$toast.success('保存媒体服务器设置成功')
reloadModule()
} else {
$toast.error('保存媒体服务器设置失败!')
}
} catch (error) {
console.log(error)
}
}
async function saveMediaServerSetting() {}
// 加载系统设置
async function loadSystemSettings() {
@@ -240,16 +61,11 @@ async function saveSystemSetting() {
}
}
// 调用API接口重新加载模块
async function reloadModule() {
try {
const result: { [key: string]: any } = await api.get('system/reload')
if (result.success) $toast.success('重新加载模块成功')
else $toast.error('重新加载模块失败!')
} catch (error) {
console.log(error)
}
}
// 添加下载器
function addDownloader() {}
// 添加媒体服务器
function addMediaServer() {}
// 加载数据
onMounted(() => {
@@ -265,7 +81,7 @@ onMounted(() => {
<VCard>
<VCardItem>
<VCardTitle>系统</VCardTitle>
<VCardSubtitle>设置服务使用的域名等信息</VCardSubtitle>
<VCardSubtitle>设置服务使用的域名等基础信息</VCardSubtitle>
</VCardItem>
<VCardText>
<VForm>
@@ -294,147 +110,28 @@ onMounted(() => {
<VCard>
<VCardItem>
<VCardTitle>下载器</VCardTitle>
<VCardSubtitle>只有选中的第1个下载器才会被默认使用</VCardSubtitle>
<VCardSubtitle>只有第1个启用的下载器才会被默认使用</VCardSubtitle>
</VCardItem>
<VCardText>
<VForm>
<VRow>
<VCol cols="12" md="6">
<VSelect
v-model="selectedDownloaders"
multiple
chips
:items="Downloaders"
label="当前使用下载器"
hint="启用下载器只有第1个会被默认下载使用"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderSettings.TORRENT_TAG"
label="下载器种子标签"
hint="MoviePilot添加的下载任务标签"
persistent-hint
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderSettings.DOWNLOADER_MONITOR"
label="下载文件自动整理"
hint="任务下载完成时自动整理文件到媒体库"
persistent-hint
/>
</VCol>
</VRow>
<VRow>
<VCol>
<VTabs v-model="downloaderTab" stacked>
<VTab value="qbittorrent"> Qbittorrent </VTab>
<VTab value="transmission"> Transmission </VTab>
</VTabs>
<VWindow v-model="downloaderTab" class="mt-5 disable-tab-transition" :touch="false">
<VWindowItem value="qbittorrent">
<VForm>
<VRow>
<VCol cols="12" md="4">
<VTextField
v-model="downloaderSettings.QB_HOST"
label="地址"
placeholder="http(s)://ip:port"
hint="服务端地址格式http(s)://ip:port"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="downloaderSettings.QB_USER"
label="用户名"
placeholder="admin"
hint="登录使用的用户名"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="downloaderSettings.QB_PASSWORD"
type="password"
label="密码"
hint="登录使用的密码"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VSwitch
v-model="downloaderSettings.QB_CATEGORY"
label="自动分类管理"
hint="由下载器自动管理分类和下载目录"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VSwitch
v-model="downloaderSettings.QB_SEQUENTIAL"
label="顺序下载"
hint="按顺序依次下载文件"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VSwitch
v-model="downloaderSettings.QB_FORCE_RESUME"
label="强制继续"
hint="强制继续、强制上传模式"
persistent-hint
/>
</VCol>
</VRow>
</VForm>
</VWindowItem>
<VWindowItem value="transmission">
<VForm>
<VRow>
<VCol cols="12" md="4">
<VTextField
v-model="downloaderSettings.TR_HOST"
label="地址"
placeholder="http(s)://ip:port"
hint="服务端地址格式http(s)://ip:port"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="downloaderSettings.TR_USER"
label="用户名"
placeholder="admin"
hint="登录使用的用户名"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="downloaderSettings.TR_PASSWORD"
type="password"
label="密码"
hint="登录使用的密码"
persistent-hint
/>
</VCol>
</VRow>
</VForm>
</VWindowItem>
</VWindow>
</VCol>
</VRow>
</VForm>
<draggable
v-model="downloaders"
handle=".cursor-move"
item-key="name"
tag="div"
:component-data="{ 'class': 'grid gap-3 grid-app-card' }"
>
<template #item="{ element }">
<DownloaderCard :downloader="element" />
</template>
</draggable>
</VCardText>
<VCardText>
<VForm @submit.prevent="() => {}">
<div class="d-flex flex-wrap gap-4 mt-4">
<VBtn mtype="submit" @click="saveDownloaderSetting"> 保存 </VBtn>
<VBtn color="success" variant="tonal" @click="addDownloader">
<VIcon icon="mdi-plus" />
</VBtn>
</div>
</VForm>
</VCardText>
@@ -446,154 +143,28 @@ onMounted(() => {
<VCard>
<VCardItem>
<VCardTitle>媒体服务器</VCardTitle>
<VCardSubtitle>只有选中的媒体服务器会被默认使用</VCardSubtitle>
<VCardSubtitle>所有启用的媒体服务器会被使用</VCardSubtitle>
</VCardItem>
<VCardText>
<VForm>
<VRow>
<VCol cols="12" md="4">
<VSelect
v-model="selectedMediaServers"
multiple
chips
:items="MediaServers"
label="当前使用媒体服务器"
hint="启用媒体服务器,入库展示、下载控重等将使用"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VSelect
v-model="mediaServerSettings.MEDIASERVER_SYNC_INTERVAL"
:items="syncIntervalItems"
label="同步周期"
hint="同步媒体库数据到MoviePilot的时间间隔"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="mediaServerSettings.MEDIASERVER_SYNC_BLACKLIST"
label="媒体库同步黑名单"
placeholder="使用,分隔"
hint="不同步数据的媒体库名称,多个使用,分隔"
persistent-hint
/>
</VCol>
</VRow>
<VRow>
<VCol>
<VTabs v-model="mediaserverTab" stacked>
<VTab value="emby"> Emby </VTab>
<VTab value="jellyfin"> Jellyfin </VTab>
<VTab value="plex"> Plex </VTab>
</VTabs>
<VWindow v-model="mediaserverTab" class="mt-5 disable-tab-transition" :touch="false">
<VWindowItem value="emby">
<VForm>
<VRow>
<VCol cols="12" md="4">
<VTextField
v-model="mediaServerSettings.EMBY_HOST"
label="地址"
placeholder="http(s)://ip:port"
hint="服务端地址格式http(s)://ip:port"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="mediaServerSettings.EMBY_PLAY_HOST"
label="外网播放地址"
placeholder="http(s)://domain:port"
hint="跳转播放页面使用的地址格式http(s)://domain:port"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="mediaServerSettings.EMBY_API_KEY"
label="API密钥"
hint="Emby设置->高级->API密钥中生成的密钥"
persistent-hint
/>
</VCol>
</VRow>
</VForm>
</VWindowItem>
<VWindowItem value="jellyfin">
<VForm>
<VRow>
<VCol cols="12" md="4">
<VTextField
v-model="mediaServerSettings.JELLYFIN_HOST"
label="地址"
placeholder="http(s)://ip:port"
hint="服务端地址格式http(s)://ip:port"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="mediaServerSettings.JELLYFIN_PLAY_HOST"
label="外网播放地址"
placeholder="http(s)://domain:port"
hint="跳转播放页面使用的地址格式http(s)://domain:port"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="mediaServerSettings.JELLYFIN_API_KEY"
label="API密钥"
hint="Jellyfin设置->高级->API密钥中生成的密钥"
persistent-hint
/>
</VCol>
</VRow>
</VForm>
</VWindowItem>
<VWindowItem value="plex">
<VForm>
<VRow>
<VCol cols="12" md="4">
<VTextField
v-model="mediaServerSettings.PLEX_HOST"
label="地址"
placeholder="http(s)://ip:port"
hint="服务端地址格式http(s)://ip:port"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="mediaServerSettings.PLEX_PLAY_HOST"
label="外网播放地址"
placeholder="http(s)://domain:port"
hint="跳转播放页面使用的地址格式http(s)://domain:port"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="mediaServerSettings.PLEX_TOKEN"
label="X-Plex-Token"
hint="浏览器F12->网络从Plex请求URL中获取的X-Plex-Token"
persistent-hint
/>
</VCol>
</VRow>
</VForm>
</VWindowItem>
</VWindow>
</VCol>
</VRow>
</VForm>
<draggable
v-model="mediaServers"
handle=".cursor-move"
item-key="name"
tag="div"
:component-data="{ 'class': 'grid gap-3 grid-app-card' }"
>
<template #item="{ element }">
<MediaServerCard :downloader="element" />
</template>
</draggable>
</VCardText>
<VCardText>
<VForm @submit.prevent="() => {}">
<div class="d-flex flex-wrap gap-4 mt-4">
<VBtn mtype="submit" @click="saveMediaServerSetting"> 保存 </VBtn>
<VBtn color="success" variant="tonal" @click="addMediaServer">
<VIcon icon="mdi-plus" />
</VBtn>
</div>
</VForm>
</VCardText>

View File

@@ -6,7 +6,6 @@ import SiteCard from '@/components/cards/SiteCard.vue'
import NoDataFound from '@/components/NoDataFound.vue'
import SiteAddEditDialog from '@/components/dialog/SiteAddEditDialog.vue'
import { useDisplay } from 'vuetify'
import { isLength } from 'lodash'
// 显示器宽度
const display = useDisplay()

View File

@@ -1,11 +1,10 @@
<script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import { VForm } from 'vuetify/lib/components/index.mjs'
import { requiredValidator } from '@/@validators'
import api from '@/api'
import type { User } from '@/api/types'
import avatar1 from '@images/avatars/avatar-1.png'
import { useDisplay } from 'vuetify'
import NoDataFound from '@/components/NoDataFound.vue'
import UserCard from '@/components/cards/UserCard.vue'
import UserAddEditDialog from '@/components/dialog/UserAddEditDialog.vue'
// APP
const display = useDisplay()
@@ -13,108 +12,26 @@ const appMode = computed(() => {
return localStorage.getItem('MP_APPMODE') != '0' && display.mdAndDown.value
})
// 提示框
const $toast = useToast()
// 是否刷新过
const isRefreshed = ref(false)
// 是否加载中
const loading = ref(false)
// 新增用户窗口
const addUserDialog = ref(false)
const isPasswordVisible = ref(false)
// 新增用户表单
const userForm = reactive({
name: '',
password: '',
email: '',
})
// 当前用户信息
const accountInfo = ref<User>({
id: 0,
name: '',
password: '',
email: '',
is_active: false,
is_superuser: false,
avatar: '',
is_otp: false,
permissions: {},
settings: {},
})
// 所有用户信息
const allUsers = ref<User[]>([])
// 调用API加载当前用户数据
async function loadAccountInfo() {
try {
const user: User = await api.get('user/current')
console.log(user)
accountInfo.value = user
if (!accountInfo.value.avatar) accountInfo.value.avatar = avatar1
} catch (error) {
console.log(error)
}
}
// 调用API查询所有用户
async function loadAllUsers() {
try {
loading.value = true
const result: User[] = await api.get('/user/')
allUsers.value = result
} catch (error) {
console.log(error)
}
}
// 删除用户
async function deleteUser(user: User) {
try {
const result: { [key: string]: any } = await api.delete(`user/${user.name}`)
if (result.success) {
$toast.success('用户删除成功!')
loadAllUsers()
} else {
$toast.error(`用户删除失败:${result.message}`)
}
} catch (error) {
console.log(error)
}
}
// 冻结用户
async function deactivateUser(user: User) {
try {
user.is_active = !user.is_active
const result: { [key: string]: any } = await api.put('user/', user)
if (result.success) {
$toast.success('用户冻结成功!')
loadAllUsers()
} else {
$toast.error(`用户冻结失败:${result.message}`)
}
} catch (error) {
console.log(error)
}
}
// 新增用户
async function addUser() {
if (!userForm.name || !userForm.password || !userForm.email) {
$toast.error('请填写完整信息!')
return
}
try {
const result: { [key: string]: any } = await api.post('user/', userForm)
if (result.success) {
$toast.success('用户新增成功!')
loadAllUsers()
addUserDialog.value = false
} else {
$toast.error(`用户新增失败:${result.message}`)
}
loading.value = false
isRefreshed.value = true
} catch (error) {
console.log(error)
}
@@ -122,107 +39,30 @@ async function addUser() {
// 加载当前用户数据
onMounted(() => {
loadAccountInfo()
loadAllUsers()
})
onActivated(() => {
if (!loading.value) {
loadAllUsers()
}
})
</script>
<template>
<div>
<VRow>
<VCol v-if="accountInfo.is_superuser" cols="12">
<!-- 👉 Accounts -->
<VCard title="所有用户">
<template #append>
<IconBtn @click.stop="addUserDialog = true">
<VIcon icon="mdi-plus" />
</IconBtn>
</template>
<VTable class="text-no-wrap">
<thead>
<tr>
<th scope="col">用户名</th>
<th scope="col">邮箱</th>
<th scope="col">状态</th>
<th scope="col">管理员</th>
<th scope="col" class="w-5" />
</tr>
</thead>
<tbody>
<tr v-for="user in allUsers" :key="user.name">
<td>
{{ user.name }}
</td>
<td>{{ user.email }}</td>
<td>
<VChip v-if="user.is_active" color="success" text-color="white"> 激活 </VChip>
<VChip v-else color="error" text-color="white"> 冻结 </VChip>
</td>
<td>{{ user.is_superuser ? '是' : '否' }}</td>
<td>
<IconBtn v-show="accountInfo.is_superuser && accountInfo.name !== user.name">
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem variant="plain" @click="deactivateUser(user)">
<template #prepend>
<VIcon icon="mdi-lock" />
</template>
<VListItemTitle>
{{ user.is_active ? '冻结' : '解冻' }}
</VListItemTitle>
</VListItem>
<VListItem variant="plain" base-color="error" @click="deleteUser(user)">
<template #prepend>
<VIcon icon="mdi-delete" />
</template>
<VListItemTitle>删除</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</td>
</tr>
</tbody>
</VTable>
</VCard>
</VCol>
</VRow>
<!-- =弹窗 -->
<VDialog v-model="addUserDialog" max-width="50rem" persistent z-index="1010">
<!-- Dialog Content -->
<VCard title="新增用户">
<VCardText>
<VForm @submit.prevent="() => {}">
<VRow>
<VCol cols="12" md="6">
<VTextField v-model="userForm.name" label="用户名" :rules="[requiredValidator]" />
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="userForm.password"
label="密码"
:rules="[requiredValidator]"
:type="isPasswordVisible ? 'text' : 'password'"
:append-inner-icon="isPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
@click:append-inner="isPasswordVisible = !isPasswordVisible"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField v-model="userForm.email" :rules="[requiredValidator]" label="邮箱" />
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions>
<VBtn @click="addUserDialog = false"> 取消 </VBtn>
<VSpacer />
<VBtn @click="addUser"> 确定 </VBtn>
</VCardActions>
</VCard>
</VDialog>
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
<div v-if="allUsers.length > 0" class="grid gap-3 grid-user-card items-start">
<UserCard v-for="user in allUsers" :user="user" />
</div>
<NoDataFound
v-if="allUsers.length === 0 && isRefreshed"
error-code="404"
error-title="没有用户"
error-description="点击右下角按钮添加用户"
/>
<VFab
icon="mdi-plus"
location="bottom"
@@ -233,4 +73,7 @@ onMounted(() => {
@click="addUserDialog = true"
:class="{ 'mb-12': appMode }"
/>
<!-- 弹窗 -->
<UserAddEditDialog v-model="addUserDialog" max-width="50rem" persistent z-index="1010" />
</template>