mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-05-09 09:42:41 +08:00
Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41ce095505 | ||
|
|
0e2290ce8a | ||
|
|
1b8db5b7f1 | ||
|
|
0cb42c1117 | ||
|
|
a289fe3da5 | ||
|
|
f53192cfa2 | ||
|
|
235e014542 | ||
|
|
211b05c643 | ||
|
|
3e1bd687f1 | ||
|
|
072fb01a04 | ||
|
|
81fbf4f5ba | ||
|
|
88c86f49bf | ||
|
|
3023214072 | ||
|
|
6ea6f89ab2 | ||
|
|
43c6672ab1 | ||
|
|
5cb56127d5 | ||
|
|
afa333243f | ||
|
|
047e99e27c | ||
|
|
eef6f37ace | ||
|
|
e8ede6e606 | ||
|
|
bfb4ea4123 | ||
|
|
51b0403f64 | ||
|
|
a5cd396de6 | ||
|
|
754bc3d3c9 | ||
|
|
07a2bcfb97 | ||
|
|
20222201ae | ||
|
|
a2a5ddd66c | ||
|
|
7bfc7602a7 | ||
|
|
b52b2cedad | ||
|
|
e93df6ba2c | ||
|
|
f9f29ccc3c | ||
|
|
3bd63ab7c8 | ||
|
|
301ea445bb | ||
|
|
475bee28c6 | ||
|
|
cd69920b41 | ||
|
|
83aab4e47d | ||
|
|
e12093c966 | ||
|
|
f21d546d18 | ||
|
|
26c8a6ba43 | ||
|
|
827bb8ba69 | ||
|
|
d1d2ef37d2 | ||
|
|
659594898b | ||
|
|
7569401fe0 | ||
|
|
dc9c86273d | ||
|
|
0e816e678a | ||
|
|
ff1c2a890c | ||
|
|
b802ad8a75 | ||
|
|
c11fb54b0b | ||
|
|
856dec3991 | ||
|
|
1d8c71da3f |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "moviepilot",
|
||||
"version": "2.1.7",
|
||||
"version": "2.2.1",
|
||||
"private": true,
|
||||
"bin": "dist/service.js",
|
||||
"scripts": {
|
||||
@@ -26,6 +26,7 @@
|
||||
"@fullcalendar/timegrid": "^6.1.7",
|
||||
"@fullcalendar/vue3": "^6.1.8",
|
||||
"@iconify/utils": "^2.1.22",
|
||||
"@vue-js-cron/vuetify": "^5.0.9",
|
||||
"@vueuse/core": "^10.1.2",
|
||||
"@vueuse/math": "^10.1.2",
|
||||
"ace-builds": "^1.32.6",
|
||||
@@ -103,4 +104,4 @@
|
||||
"resolutions": {
|
||||
"postcss": "8"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ body {
|
||||
html {
|
||||
overflow: hidden auto;
|
||||
background: var(--initial-loader-bg, #fff);
|
||||
min-block-size: calc(100% + env(safe-area-inset-top));
|
||||
min-block-size: calc(100% + env(safe-area-inset-top) + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
#loading-bg {
|
||||
@@ -82,4 +82,4 @@ html {
|
||||
opacity: 1;
|
||||
transform: rotate(1turn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { ThemeSwitcherTheme } from '@layouts/types'
|
||||
import api from '@/api'
|
||||
import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { VAceEditor } from 'vue3-ace-editor'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.auth-wrapper {
|
||||
min-block-size: calc(var(--vh, 1vh) * 100 + env(safe-area-inset-top));
|
||||
min-block-size: calc(var(--vh, 1vh) * 100 + env(safe-area-inset-top) + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.auth-footer-mask {
|
||||
|
||||
@@ -92,8 +92,7 @@
|
||||
.fc-header-toolbar {
|
||||
flex-wrap: wrap;
|
||||
margin: 1.25rem;
|
||||
column-gap: 0.5rem;
|
||||
row-gap: 1rem;
|
||||
gap: 1rem 0.5rem;
|
||||
}
|
||||
|
||||
.fc-toolbar-chunk {
|
||||
@@ -238,7 +237,7 @@
|
||||
inline-size: 1.5625rem;
|
||||
margin-inline-end: 0.25rem;
|
||||
|
||||
@media (max-width: 1264px) {
|
||||
@media (width <= 1264px) {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
@use "@configured-variables" as variables;
|
||||
|
||||
html {
|
||||
min-height: calc(100% + env(safe-area-inset-top));
|
||||
min-height: calc(100% + env(safe-area-inset-top) + env(safe-area-inset-bottom));
|
||||
background: rgb(var(--v-theme-background));
|
||||
overflow-y: overlay;
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
|
||||
html {
|
||||
box-sizing: border-box;
|
||||
min-height: calc(100% + env(safe-area-inset-top))
|
||||
min-height: calc(100% + env(safe-area-inset-top) + env(safe-area-inset-bottom))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import type { TransferDirectoryConf } from '@/api/types'
|
||||
import { VDivider, VSpacer, VTextField } from 'vuetify/lib/components/index.mjs'
|
||||
import PathInput from '@/components/input/PathInput.vue'
|
||||
import api from '@/api'
|
||||
import { nextTick } from 'vue'
|
||||
import { storageOptions } from '@/api/constants'
|
||||
@@ -105,13 +105,13 @@ async function loadTransferTypeItems() {
|
||||
// 整理方式无数据提示
|
||||
const computedNoDataText = computed(() => {
|
||||
if (!props.directory.library_storage && !props.directory.storage) {
|
||||
return '无可用整理方式!请先选择下载器储存与媒体库储存!'
|
||||
return '请选择储存'
|
||||
} else if (!props.directory.library_storage) {
|
||||
return '无可用整理方式!请先选择媒体库储存!'
|
||||
return '请选择媒体库储存'
|
||||
} else if (!props.directory.storage) {
|
||||
return '无可用整理方式!请先选择下载器储存!'
|
||||
return '请选择下载器储存'
|
||||
} else {
|
||||
return '选择的存储没有支持的整理方法!'
|
||||
return '选择的存储类型没有支持的整理方式'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -131,24 +131,6 @@ function onClose() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// 下载路径更新
|
||||
function updateDownloadPath(value: string) {
|
||||
downloadPath.value = value
|
||||
emit('update:modelValue', {
|
||||
download: downloadPath.value,
|
||||
library: libraryPath.value,
|
||||
})
|
||||
}
|
||||
|
||||
// 媒体库路径更新
|
||||
function updateLibraryPath(value: string) {
|
||||
libraryPath.value = value
|
||||
emit('update:modelValue', {
|
||||
download: downloadPath.value,
|
||||
library: libraryPath.value,
|
||||
})
|
||||
}
|
||||
|
||||
// 根据选中的媒体类型,获取对应的媒体类别
|
||||
const getCategories = computed(() => {
|
||||
const default_value = [{ title: '全部', value: '' }]
|
||||
@@ -228,16 +210,16 @@ watch(
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="8">
|
||||
<VPathField @update:modelValue="updateDownloadPath" :storage="props.directory.storage">
|
||||
<PathInput v-model="props.directory.download_path" :storage="props.directory.storage">
|
||||
<template #activator="{ menuprops }">
|
||||
<VTextField
|
||||
v-model="props.directory.download_path"
|
||||
:model-value="props.directory.download_path"
|
||||
v-bind="menuprops"
|
||||
variant="underlined"
|
||||
label="下载目录/源目录"
|
||||
/>
|
||||
</template>
|
||||
</VPathField>
|
||||
</PathInput>
|
||||
</VCol>
|
||||
<VCol cols="6" v-if="!props.directory.media_type || props.directory.media_type === ''">
|
||||
<VSwitch v-model="props.directory.download_type_folder" label="按类型分类"></VSwitch>
|
||||
@@ -275,16 +257,16 @@ watch(
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="8">
|
||||
<VPathField @update:modelValue="updateLibraryPath" :storage="props.directory.library_storage">
|
||||
<PathInput v-model="props.directory.library_path" :storage="props.directory.library_storage">
|
||||
<template #activator="{ menuprops }">
|
||||
<VTextField
|
||||
v-model="props.directory.library_path"
|
||||
:modelValue="props.directory.library_path"
|
||||
v-bind="menuprops"
|
||||
variant="underlined"
|
||||
label="媒体库目录"
|
||||
/>
|
||||
</template>
|
||||
</VPathField>
|
||||
</PathInput>
|
||||
</VCol>
|
||||
<VCol cols="4">
|
||||
<VSelect
|
||||
|
||||
@@ -6,7 +6,7 @@ import { formatSeason } from '@/@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import type { MediaInfo, NotExistMediaInfo, Subscribe, TmdbSeason } from '@/api/types'
|
||||
import router from '@/router'
|
||||
import router, { registerAbortController } from '@/router'
|
||||
import noImage from '@images/no-image.jpeg'
|
||||
import tmdbImage from '@images/logos/tmdb.png'
|
||||
import doubanImage from '@images/logos/douban-black.png'
|
||||
@@ -59,7 +59,11 @@ const seasonInfos = ref<TmdbSeason[]>([])
|
||||
|
||||
// 选中的订阅季
|
||||
const seasonsSelected = ref<TmdbSeason[]>([])
|
||||
let abortController: AbortController | null = null;
|
||||
|
||||
abortController = new AbortController();
|
||||
registerAbortController(abortController);
|
||||
const { signal } = abortController;
|
||||
// 来源角标字典
|
||||
const sourceIconDict: { [key: string]: any } = {
|
||||
themoviedb: tmdbImage,
|
||||
@@ -215,6 +219,7 @@ async function removeSubscribe() {
|
||||
// 查询当前媒体是否已订阅
|
||||
async function handleCheckSubscribe() {
|
||||
try {
|
||||
|
||||
const result = await checkSubscribe(props.media?.season)
|
||||
if (result) isSubscribed.value = true
|
||||
} catch (error) {
|
||||
@@ -225,6 +230,7 @@ async function handleCheckSubscribe() {
|
||||
// 查询当前媒体是否已入库
|
||||
async function handleCheckExists() {
|
||||
try {
|
||||
|
||||
const result: { [key: string]: any } = await api.get('mediaserver/exists', {
|
||||
params: {
|
||||
tmdbid: props.media?.tmdb_id,
|
||||
@@ -233,6 +239,7 @@ async function handleCheckExists() {
|
||||
season: props.media?.season,
|
||||
mtype: props.media?.type,
|
||||
},
|
||||
signal
|
||||
})
|
||||
|
||||
if (result.success) isExists.value = true
|
||||
@@ -244,6 +251,7 @@ async function handleCheckExists() {
|
||||
// 调用API检查是否已订阅,电视剧需要指定季
|
||||
async function checkSubscribe(season = 0) {
|
||||
try {
|
||||
|
||||
const mediaid = getMediaId()
|
||||
|
||||
const result: Subscribe = await api.get(`subscribe/media/${mediaid}`, {
|
||||
@@ -251,6 +259,7 @@ async function checkSubscribe(season = 0) {
|
||||
season,
|
||||
title: props.media?.title,
|
||||
},
|
||||
signal
|
||||
})
|
||||
|
||||
return result.id || null
|
||||
@@ -444,25 +453,13 @@ function onRemoveSubscribe() {
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<div ref="mediaCardRef">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:height="props.height"
|
||||
:width="props.width"
|
||||
class="outline-none shadow ring-gray-500 rounded-lg"
|
||||
:class="{
|
||||
<VCard v-bind="hover.props" :height="props.height" :width="props.width"
|
||||
class="outline-none shadow ring-gray-500 rounded-lg" :class="{
|
||||
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
|
||||
'ring-1': isImageLoaded,
|
||||
}"
|
||||
@click.stop="goMediaDetail(hover.isHovering ?? false)"
|
||||
>
|
||||
<VImg
|
||||
aspect-ratio="2/3"
|
||||
:src="getImgUrl"
|
||||
class="object-cover aspect-w-2 aspect-h-3"
|
||||
cover
|
||||
@load="isImageLoaded = true"
|
||||
@error="imageLoadError = true"
|
||||
>
|
||||
}" @click.stop="goMediaDetail(hover.isHovering ?? false)">
|
||||
<VImg aspect-ratio="2/3" :src="getImgUrl" class="object-cover aspect-w-2 aspect-h-3" cover
|
||||
@load="isImageLoaded = true" @error="imageLoadError = true">
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
@@ -470,11 +467,9 @@ function onRemoveSubscribe() {
|
||||
</template>
|
||||
</VImg>
|
||||
<!-- 详情 -->
|
||||
<VCardText
|
||||
v-show="hover.isHovering || imageLoadError"
|
||||
<VCardText v-show="hover.isHovering || imageLoadError"
|
||||
class="w-full h-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2"
|
||||
style="background: linear-gradient(rgba(45, 55, 72, 40%) 0%, rgba(45, 55, 72, 90%) 100%)"
|
||||
>
|
||||
style="background: linear-gradient(rgba(45, 55, 72, 40%) 0%, rgba(45, 55, 72, 90%) 100%)">
|
||||
<span class="font-bold">{{ props.media?.year }}</span>
|
||||
<h1 class="mb-1 text-white font-extrabold text-xl line-clamp-2 overflow-hidden text-ellipsis ...">
|
||||
{{ props.media?.title }}
|
||||
@@ -488,35 +483,21 @@ function onRemoveSubscribe() {
|
||||
</div>
|
||||
</VCardText>
|
||||
<!-- 类型角标 -->
|
||||
<VChip
|
||||
v-show="isImageLoaded"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
:class="getChipColor(props.media?.type || '')"
|
||||
class="absolute left-2 top-2 bg-opacity-80 shadow-md text-white font-bold"
|
||||
>
|
||||
<VChip v-show="isImageLoaded" variant="elevated" size="small" :class="getChipColor(props.media?.type || '')"
|
||||
class="absolute left-2 top-2 bg-opacity-80 shadow-md text-white font-bold">
|
||||
{{ props.media?.type }}
|
||||
</VChip>
|
||||
<!-- 本地存在标识 -->
|
||||
<ExistIcon v-if="isExists && !hover.isHovering" />
|
||||
<!-- 评分角标 -->
|
||||
<VChip
|
||||
v-if="isImageLoaded && props.media?.vote_average && !(isExists && !hover.isHovering)"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
:class="getChipColor('rating')"
|
||||
class="absolute right-2 top-2 bg-opacity-80 shadow-md text-white font-bold"
|
||||
>
|
||||
<VChip v-if="isImageLoaded && props.media?.vote_average && !(isExists && !hover.isHovering)"
|
||||
variant="elevated" size="small" :class="getChipColor('rating')"
|
||||
class="absolute right-2 top-2 bg-opacity-80 shadow-md text-white font-bold">
|
||||
{{ props.media?.vote_average }}
|
||||
</VChip>
|
||||
<!--来源图标-->
|
||||
<VAvatar
|
||||
size="24"
|
||||
density="compact"
|
||||
class="absolute bottom-1 right-1"
|
||||
tile
|
||||
v-if="!hover.isHovering && isImageLoaded && props.media?.source"
|
||||
>
|
||||
<VAvatar size="24" density="compact" class="absolute bottom-1 right-1" tile
|
||||
v-if="!hover.isHovering && isImageLoaded && props.media?.source">
|
||||
<VImg cover :src="sourceIconDict[props.media?.source]" class="shadow-lg" />
|
||||
</VAvatar>
|
||||
</VCard>
|
||||
@@ -535,14 +516,8 @@ function onRemoveSubscribe() {
|
||||
<VList v-model:selected="seasonsSelected" lines="three" select-strategy="classic">
|
||||
<VListItem v-for="(item, i) in seasonInfos" :key="i" :value="item">
|
||||
<template #prepend>
|
||||
<VImg
|
||||
height="90"
|
||||
width="60"
|
||||
:src="getSeasonPoster(item.poster_path || '')"
|
||||
aspect-ratio="2/3"
|
||||
class="object-cover rounded shadow ring-gray-500 me-3"
|
||||
cover
|
||||
>
|
||||
<VImg height="90" width="60" :src="getSeasonPoster(item.poster_path || '')" aspect-ratio="2/3"
|
||||
class="object-cover rounded shadow ring-gray-500 me-3" cover>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
@@ -581,12 +556,6 @@ function onRemoveSubscribe() {
|
||||
</VCard>
|
||||
</VBottomSheet>
|
||||
<!-- 订阅编辑弹窗 -->
|
||||
<SubscribeEditDialog
|
||||
v-if="subscribeEditDialog"
|
||||
v-model="subscribeEditDialog"
|
||||
:subid="subscribeId"
|
||||
@close="subscribeEditDialog = false"
|
||||
@save="subscribeEditDialog = false"
|
||||
@remove="onRemoveSubscribe"
|
||||
/>
|
||||
<SubscribeEditDialog v-if="subscribeEditDialog" v-model="subscribeEditDialog" :subid="subscribeId"
|
||||
@close="subscribeEditDialog = false" @save="subscribeEditDialog = false" @remove="onRemoveSubscribe" />
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import type { Message } from '@/api/types'
|
||||
import { formatDateDifference } from '@core/utils/formatters'
|
||||
|
||||
@@ -45,24 +46,31 @@ function replaceNewLine(value: string) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard variant="tonal" :width="props.width" :height="props.height" @click="openLink">
|
||||
<VCard variant="tonal" :width="props.width" :height="props.height" @click="openLink" max-width="23rem">
|
||||
<div v-if="props.message?.image" class="relative text-center card-cover-blurred">
|
||||
<VImg
|
||||
:src="props.message?.image"
|
||||
aspect-ratio="4/3"
|
||||
aspect-ratio="3/2"
|
||||
cover
|
||||
position="top"
|
||||
:class="{ shadow: isImageLoaded }"
|
||||
@load="imageLoaded"
|
||||
@error="imageLoadError = true"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="props.message?.title && !props.message?.image && !props.message?.note"
|
||||
v-if="
|
||||
props.message?.title &&
|
||||
!props.message?.text &&
|
||||
!props.message?.image &&
|
||||
isNullOrEmptyObject(props.message?.note) &&
|
||||
props.message?.action === 0
|
||||
"
|
||||
class="rounded-md text-body-1 py-2 px-4 elevation-2 bg-primary text-white chat-right mb-1"
|
||||
>
|
||||
<p class="mb-0">{{ props.message?.title }}</p>
|
||||
</div>
|
||||
<VCardTitle v-else-if="props.message?.title">
|
||||
<VCardTitle v-else-if="props.message?.title" class="break-words whitespace-break-spaces">
|
||||
{{ props.message?.title }}
|
||||
</VCardTitle>
|
||||
<div
|
||||
@@ -72,13 +80,13 @@ function replaceNewLine(value: string) {
|
||||
<p class="mb-0">{{ props.message?.text }}</p>
|
||||
</div>
|
||||
<VCardText v-if="props.message?.text && props.message?.action === 1" v-html="replaceNewLine(props.message?.text)" />
|
||||
<VCardText v-if="props.message?.note">
|
||||
<VCardText v-if="!isNullOrEmptyObject(props.message?.note)">
|
||||
<VList>
|
||||
<VListItem v-for="(value, key) in noteToJson()" :key="key" two-line>
|
||||
<VListItemTitle v-if="value.title_year" class="font-bold">
|
||||
<VListItemTitle v-if="value.title_year" class="font-bold break-words whitespace-break-spaces">
|
||||
{{ key + 1 }}. {{ value.title_year }}
|
||||
</VListItemTitle>
|
||||
<VListItemTitle v-if="value.enclosure" class="font-bold whitespace-break-spaces">
|
||||
<VListItemTitle v-if="value.enclosure" class="font-bold break-words whitespace-break-spaces">
|
||||
{{ key + 1 }}. {{ value.title }} {{ value.volume_factor }} ↑{{ value.seeders }}
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle v-if="value.type">
|
||||
|
||||
@@ -14,6 +14,8 @@ import ProgressDialog from '../dialog/ProgressDialog.vue'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
// APP
|
||||
const appMode = inject('pwaMode') && display.mdAndDown.value
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -488,7 +490,7 @@ watch(
|
||||
<DialogCloseBtn v-model="pluginConfigDialog" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<FormRender v-for="(item, index) in pluginFormItems" :key="index" :config="item" :form="pluginConfigForm" />
|
||||
<FormRender v-for="(item, index) in pluginFormItems" :key="index" :config="item" :model="pluginConfigForm" />
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn v-if="pluginPageItems.length > 0" @click="showPluginInfo" variant="outlined" color="info">
|
||||
@@ -507,7 +509,16 @@ watch(
|
||||
<VCardText class="min-h-40">
|
||||
<PageRender @action="loadPluginPage" v-for="(item, index) in pluginPageItems" :key="index" :config="item" />
|
||||
</VCardText>
|
||||
<VFab icon="mdi-cog" location="bottom" size="x-large" fixed app appear @click="showPluginConfig" />
|
||||
<VFab
|
||||
icon="mdi-cog"
|
||||
location="bottom"
|
||||
size="x-large"
|
||||
fixed
|
||||
app
|
||||
appear
|
||||
@click="showPluginConfig"
|
||||
:class="{ 'mb-10': appMode }"
|
||||
/>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ const siteIcon = ref<string>('')
|
||||
const $toast = useToast()
|
||||
|
||||
// 测试按钮文字
|
||||
const testButtonText = ref('测试')
|
||||
const testButtonText = ref('连通性测试')
|
||||
|
||||
// 测试按钮可用性
|
||||
const testButtonDisable = ref(false)
|
||||
@@ -43,9 +43,6 @@ const resourceDialog = ref(false)
|
||||
// 用户数据弹窗
|
||||
const siteUserDataDialog = ref(false)
|
||||
|
||||
// 站点操作显示
|
||||
const siteActionShow = ref(false)
|
||||
|
||||
// 站点使用统计
|
||||
const siteStats = ref<SiteStatistic>({})
|
||||
|
||||
@@ -68,7 +65,7 @@ async function testSite() {
|
||||
if (result.success) $toast.success(`${cardProps.site?.name} 连通性测试成功,可正常使用!`)
|
||||
else $toast.error(`${cardProps.site?.name} 连通性测试失败:${result.message}`)
|
||||
|
||||
testButtonText.value = '测试'
|
||||
testButtonText.value = '连通性测试'
|
||||
testButtonDisable.value = false
|
||||
|
||||
getSiteStats()
|
||||
@@ -155,7 +152,7 @@ onMounted(() => {
|
||||
<div>
|
||||
<VCard
|
||||
:variant="cardProps.site?.is_active ? 'elevated' : 'outlined'"
|
||||
class="overflow-hidden"
|
||||
class="overflow-hidden h-full flex flex-col"
|
||||
@click="siteEditDialog = true"
|
||||
>
|
||||
<template #image>
|
||||
@@ -171,7 +168,7 @@ onMounted(() => {
|
||||
<span @click.stop="openSitePage">{{ cardProps.site?.url }}</span>
|
||||
</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VCardText class="py-1" style="block-size: 36px">
|
||||
<VCardText class="py-1">
|
||||
<VTooltip v-if="cardProps.site?.limit_interval" text="流控">
|
||||
<template #activator="{ props }">
|
||||
<VIcon color="primary" class="me-2" v-bind="props" icon="mdi-speedometer" />
|
||||
@@ -194,46 +191,42 @@ onMounted(() => {
|
||||
</VTooltip>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VBtn
|
||||
:icon="siteActionShow ? 'mdi-chevron-up' : 'mdi-chevron-down'"
|
||||
@click.stop="siteActionShow = !siteActionShow"
|
||||
/>
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-chevron-down" color="primary" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem variant="plain" v-if="!cardProps.site?.public" @click="handleSiteUpdate">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-refresh" />
|
||||
</template>
|
||||
<VListItemTitle>更新 Cookie & UA</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem variant="plain" :disabled="testButtonDisable" @click.stop="testSite">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-link" />
|
||||
</template>
|
||||
<VListItemTitle>{{ testButtonText }}</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem variant="plain" @click="handleResourceBrowse">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-web" />
|
||||
</template>
|
||||
<VListItemTitle>资源预览</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem variant="plain" @click="handleSiteUserData">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-chart-bell-curve" />
|
||||
</template>
|
||||
<VListItemTitle>站点数据</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
<span class="text-sm">
|
||||
↑ {{ formatFileSize(cardProps.data?.upload || 0) }} / ↓ {{ formatFileSize(cardProps.data?.download || 0) }}
|
||||
</span>
|
||||
<VSpacer />
|
||||
</VCardActions>
|
||||
<VExpandTransition v-show="siteActionShow">
|
||||
<div>
|
||||
<VDivider />
|
||||
<div class="py-1 pe-12">
|
||||
<VBtn v-if="!cardProps.site?.public" @click.stop="handleSiteUpdate" variant="text">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-refresh" />
|
||||
</template>
|
||||
更新
|
||||
</VBtn>
|
||||
<VBtn :disabled="testButtonDisable" @click.stop="testSite" variant="text">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-link" />
|
||||
</template>
|
||||
{{ testButtonText }}
|
||||
</VBtn>
|
||||
<VBtn @click.stop="handleResourceBrowse" variant="text">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-web" />
|
||||
</template>
|
||||
浏览
|
||||
</VBtn>
|
||||
<VBtn @click.stop="handleSiteUserData" variant="text">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-chart-bell-curve" />
|
||||
</template>
|
||||
数据
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
</VExpandTransition>
|
||||
<StatIcon v-if="cardProps.site?.is_active" :color="statColor" />
|
||||
<span class="absolute top-1 right-8">
|
||||
<VIcon class="cursor-move">mdi-drag</VIcon>
|
||||
|
||||
@@ -124,7 +124,7 @@ function finishForkSubscribe(subid: number) {
|
||||
<div class="text-subtitle-2 me-4 text-white">
|
||||
{{ props.media?.share_user }}
|
||||
</div>
|
||||
<IconBtn v-if="props.media?.count" icon="mdi-fire" color="error" class="me-1" />
|
||||
<IconBtn v-if="props.media?.count" icon="mdi-fire" color="white" class="me-1" />
|
||||
<span v-if="props.media?.count" class="text-subtitle-2 me-4 text-white">
|
||||
{{ props.media?.count.toLocaleString() }}
|
||||
</span>
|
||||
|
||||
@@ -20,6 +20,9 @@ const globalSettings: any = inject('globalSettings')
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 处理中
|
||||
const processing = ref(false)
|
||||
|
||||
// 计算海报图片地址
|
||||
const posterUrl = computed(() => {
|
||||
const url = props.media?.poster
|
||||
@@ -45,9 +48,9 @@ async function doFork() {
|
||||
// 开始处理
|
||||
startNProgress()
|
||||
try {
|
||||
processing.value = true
|
||||
// 请求API
|
||||
const result: { [key: string]: any } = await api.post('subscribe/fork', props.media)
|
||||
|
||||
// 订阅状态
|
||||
if (result.success) {
|
||||
$toast.success(`${props.media?.share_title} 添加订阅成功!`)
|
||||
@@ -59,6 +62,7 @@ async function doFork() {
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
processing.value = false
|
||||
doneNProgress()
|
||||
}
|
||||
}
|
||||
@@ -103,9 +107,15 @@ async function doFork() {
|
||||
<span class="text-body-1"> {{ media?.share_user }}</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem class="ps-0" v-if="media?.keyword">
|
||||
<VListItemTitle class="text-center text-md-left">
|
||||
<span class="font-weight-medium">搜索词:</span>
|
||||
<span class="text-body-1"> {{ media?.keyword }}</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem class="ps-0" v-if="media?.custom_words">
|
||||
<VListItemTitle
|
||||
class="text-center text-md-left break-words whitespace-break-spaces line-clamp-3 overflow-hidden text-ellipsis ..."
|
||||
class="text-center text-md-left break-words whitespace-break-spaces line-clamp-10 overflow-hidden text-ellipsis ..."
|
||||
>
|
||||
<span class="font-weight-medium">识别词:</span>
|
||||
<span class="text-body-1"> {{ media?.custom_words }}</span>
|
||||
@@ -113,7 +123,18 @@ async function doFork() {
|
||||
</VListItem>
|
||||
</VList>
|
||||
<div class="text-center text-md-left">
|
||||
<VBtn color="primary" @click="doFork" prepend-icon="mdi-heart">添加到我的订阅</VBtn>
|
||||
<VBtn
|
||||
color="primary"
|
||||
:disabled="processing"
|
||||
@click="doFork"
|
||||
prepend-icon="mdi-heart"
|
||||
:loading="processing"
|
||||
>
|
||||
添加到我的订阅
|
||||
</VBtn>
|
||||
<div class="text-xs mt-2" v-if="props.media?.count">
|
||||
<VIcon icon="mdi-fire" />共 {{ props.media?.count?.toLocaleString() }} 次复用
|
||||
</div>
|
||||
</div>
|
||||
</VCardItem>
|
||||
</div>
|
||||
|
||||
@@ -34,7 +34,7 @@ async function saveHandle() {
|
||||
if (result.success) {
|
||||
$toast.success('插件仓库保存成功')
|
||||
emit('save')
|
||||
} else $toast.error('插件仓库保存失败!')
|
||||
} else $toast.error(`插件仓库保存失败:${result?.message}!`)
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { Site, SiteUserData } from '@/api/types'
|
||||
import api from '@/api'
|
||||
import { useDisplay, useTheme } from 'vuetify'
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import VueApexCharts from 'vue3-apexcharts'
|
||||
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
|
||||
|
||||
// 显示器宽度
|
||||
@@ -440,7 +439,7 @@ onBeforeMount(async () => {
|
||||
<VCol>
|
||||
<VCard title="历史流量">
|
||||
<VCardText>
|
||||
<VueApexCharts type="line" :options="historyChartOptions" :series="historySeries" :height="300" />
|
||||
<VApexChart type="line" :options="historyChartOptions" :series="historySeries" :height="300" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
@@ -449,7 +448,7 @@ onBeforeMount(async () => {
|
||||
<VCol>
|
||||
<VCard title="做种分布">
|
||||
<VCardText>
|
||||
<VueApexCharts type="scatter" :options="seedingChartOptions" :series="seedingSeries" :height="300" />
|
||||
<VApexChart type="scatter" :options="seedingChartOptions" :series="seedingSeries" :height="300" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { requiredValidator } from '@/@validators'
|
||||
import api from '@/api'
|
||||
import type { Subscribe, SubscribeShare } from '@/api/types'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { formatSeason } from '@/@core/utils/formatters'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
@@ -16,16 +17,22 @@ const props = defineProps({
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
// 分享处理状态
|
||||
const shareDoing = ref(false)
|
||||
|
||||
// 订阅编辑表单
|
||||
const shareForm = ref<SubscribeShare>({
|
||||
subscribe_id: props.sub?.id ?? 0,
|
||||
share_title: `${props.sub?.name} ${formatSeason(props.sub?.season ? props.sub?.season.toString() : '')}`,
|
||||
})
|
||||
|
||||
// 分享订阅
|
||||
async function doShare() {
|
||||
if (!shareForm.value.share_title || !shareForm.value.share_comment || !shareForm.value.share_user) return
|
||||
try {
|
||||
shareDoing.value = true
|
||||
const result: { [key: string]: any } = await api.post('subscribe/share', shareForm.value)
|
||||
shareDoing.value = false
|
||||
// 提示
|
||||
if (result.success) {
|
||||
$toast.success(`${props.sub?.name} 分享成功!`)
|
||||
@@ -56,8 +63,8 @@ const $toast = useToast()
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="shareForm.share_title"
|
||||
readonly
|
||||
label="标题"
|
||||
hint="给分享取一个便于识别的名称"
|
||||
:rules="[requiredValidator]"
|
||||
persistent-hint
|
||||
/>
|
||||
@@ -67,7 +74,7 @@ const $toast = useToast()
|
||||
v-model="shareForm.share_comment"
|
||||
label="说明"
|
||||
:rules="[requiredValidator]"
|
||||
hint="关于该订阅的说明"
|
||||
hint="填写关于该订阅的说明,订阅中的搜索词、识别词等将会默认包含在分享中"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
@@ -85,7 +92,16 @@ const $toast = useToast()
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VSpacer />
|
||||
<VBtn variant="elevated" @click="doShare" prepend-icon="mdi-share" class="px-5"> 确认分享 </VBtn>
|
||||
<VBtn
|
||||
variant="elevated"
|
||||
:disabled="shareDoing"
|
||||
@click="doShare"
|
||||
prepend-icon="mdi-share"
|
||||
class="px-5"
|
||||
:loading="shareDoing"
|
||||
>
|
||||
确认分享
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
|
||||
@@ -269,41 +269,48 @@ onMounted(() => {
|
||||
>
|
||||
<DialogCloseBtn @click="emit('close')" />
|
||||
<VDivider />
|
||||
<VCardText class="d-flex">
|
||||
<VCardItem>
|
||||
<!-- 👉 Avatar -->
|
||||
<VAvatar rounded="lg" size="100" class="me-6" :image="currentAvatar" />
|
||||
<div class="flex flex-row">
|
||||
<VAvatar rounded="lg" size="100" class="me-5" :image="currentAvatar" />
|
||||
<!-- 👉 Upload Photo -->
|
||||
<div class="flex flex-col justify-center gap-5">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<VBtn color="primary" @click="refInputEl?.click()">
|
||||
<VIcon icon="mdi-cloud-upload-outline" />
|
||||
<span v-if="display.mdAndUp.value" class="ms-2">上传新头像</span>
|
||||
</VBtn>
|
||||
|
||||
<!-- 👉 Upload Photo -->
|
||||
<form class="d-flex flex-column justify-center gap-5">
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<VBtn color="primary" @click="refInputEl?.click()">
|
||||
<VIcon icon="mdi-cloud-upload-outline" />
|
||||
<span v-if="display.mdAndUp.value" class="ms-2">上传新头像</span>
|
||||
</VBtn>
|
||||
<input
|
||||
ref="refInputEl"
|
||||
type="file"
|
||||
name="file"
|
||||
accept=".jpeg,.png,.jpg,GIF"
|
||||
hidden
|
||||
@input="changeAvatar"
|
||||
/>
|
||||
|
||||
<input ref="refInputEl" type="file" name="file" accept=".jpeg,.png,.jpg,GIF" hidden @input="changeAvatar" />
|
||||
<VBtn type="reset" color="info" variant="tonal" @click="restoreCurrentAvatar" v-if="props.oper !== 'add'">
|
||||
<VIcon icon="mdi-refresh" />
|
||||
<span v-if="display.mdAndUp.value" class="ms-2">重置</span>
|
||||
</VBtn>
|
||||
|
||||
<VBtn type="reset" color="info" variant="tonal" @click="restoreCurrentAvatar" v-if="props.oper !== 'add'">
|
||||
<VIcon icon="mdi-refresh" />
|
||||
<span v-if="display.mdAndUp.value" class="ms-2">重置</span>
|
||||
</VBtn>
|
||||
|
||||
<VBtn
|
||||
type="reset"
|
||||
:color="props.oper === 'add' ? 'info' : 'error'"
|
||||
variant="tonal"
|
||||
@click="resetDefaultAvatar"
|
||||
>
|
||||
<VIcon icon="mdi-image-sync-outline" />
|
||||
<span v-if="display.mdAndUp.value" class="ms-2">默认</span>
|
||||
</VBtn>
|
||||
<VBtn
|
||||
type="reset"
|
||||
:color="props.oper === 'add' ? 'info' : 'error'"
|
||||
variant="tonal"
|
||||
@click="resetDefaultAvatar"
|
||||
>
|
||||
<VIcon icon="mdi-image-sync-outline" />
|
||||
<span v-if="display.mdAndUp.value" class="ms-2">默认</span>
|
||||
</VBtn>
|
||||
</div>
|
||||
<p class="text-body-1 mb-0">允许 JPG、PNG、GIF、WEBP 格式, 最大尺寸 800KB。</p>
|
||||
</div>
|
||||
|
||||
<p class="text-body-1 mb-0">允许 JPG、PNG、GIF、WEBP 格式, 最大尺寸 800KB。</p>
|
||||
</form>
|
||||
</VCardText>
|
||||
</div>
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<VForm @submit.prevent="() => {}" class="mt-3">
|
||||
<VForm @submit.prevent="() => {}">
|
||||
<VDivider class="my-10">
|
||||
<span>用户基础设置</span>
|
||||
</VDivider>
|
||||
|
||||
46
src/components/field/CronField.vue
Normal file
46
src/components/field/CronField.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import CronInput from '@/components/input/CronInput.vue'
|
||||
|
||||
const attrs = useAttrs()
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '* * * * *',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const innerValue = ref(props.modelValue)
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
value => {
|
||||
innerValue.value = value
|
||||
},
|
||||
)
|
||||
|
||||
const propsWithoutModelValue = computed(() => {
|
||||
const { modelValue, ...rest } = props
|
||||
return { ...rest, ...attrs }
|
||||
})
|
||||
|
||||
function updateModelValue(value: string) {
|
||||
innerValue.value = value
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CronInput v-model="innerValue" @update:modelValue="updateModelValue">
|
||||
<template #activator="{ menuprops }">
|
||||
<VTextField
|
||||
:modelValue="innerValue"
|
||||
@update:modelValue="updateModelValue"
|
||||
v-bind="{ ...menuprops, ...propsWithoutModelValue }"
|
||||
clearable
|
||||
/>
|
||||
</template>
|
||||
</CronInput>
|
||||
</template>
|
||||
41
src/components/input/CronInput.vue
Normal file
41
src/components/input/CronInput.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import api from '@/api'
|
||||
import { FileItem } from '@/api/types'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '* * * * *',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const currentCron = ref(props.modelValue)
|
||||
|
||||
watch(currentCron, newVal => {
|
||||
emit('update:modelValue', newVal)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
value => {
|
||||
currentCron.value = value
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VMenu :close-on-content-click="false" content-class="cursor-default" persistent>
|
||||
<template v-slot:activator="{ props }">
|
||||
<slot name="activator" :menuprops="props" />
|
||||
</template>
|
||||
<VList>
|
||||
<VListItem>
|
||||
<VCronVuetify v-model="currentCron" locale="zh-CN" :chip-props="{ color: 'success' }" class="mt-1" />
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</div>
|
||||
</template>
|
||||
@@ -78,25 +78,27 @@ function handleUserSelect() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VMenu :close-on-content-click="false" content-class="cursor-default">
|
||||
<template v-slot:activator="{ props }">
|
||||
<slot name="activator" :menuprops="props" />
|
||||
</template>
|
||||
<VTreeview
|
||||
v-model:activated="activedDirs"
|
||||
v-model:opened="openedDirs"
|
||||
:items="treeItems"
|
||||
:load-children="fetchDirs"
|
||||
item-key="path"
|
||||
item-title="name"
|
||||
item-value="path"
|
||||
item-type="unknown"
|
||||
activatable
|
||||
return-object
|
||||
max-height="20rem"
|
||||
expand-icon="mdi-folder"
|
||||
collapse-icon="mdi-folder-open"
|
||||
@update:activated="handleUserSelect"
|
||||
/>
|
||||
</VMenu>
|
||||
<div>
|
||||
<VMenu :close-on-content-click="false" content-class="cursor-default">
|
||||
<template v-slot:activator="{ props }">
|
||||
<slot name="activator" :menuprops="props" />
|
||||
</template>
|
||||
<VTreeview
|
||||
v-model:activated="activedDirs"
|
||||
v-model:opened="openedDirs"
|
||||
:items="treeItems"
|
||||
:load-children="fetchDirs"
|
||||
item-key="path"
|
||||
item-title="name"
|
||||
item-value="path"
|
||||
item-type="unknown"
|
||||
activatable
|
||||
return-object
|
||||
max-height="20rem"
|
||||
expand-icon="mdi-folder"
|
||||
collapse-icon="mdi-folder-open"
|
||||
@update:activated="handleUserSelect"
|
||||
/>
|
||||
</VMenu>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,57 +1,117 @@
|
||||
<script lang="ts" setup>
|
||||
<script setup lang="ts">
|
||||
import { RenderProps } from '@/api/types'
|
||||
import { type PropType, ref } from 'vue'
|
||||
import { h, resolveComponent, defineProps } from 'vue'
|
||||
|
||||
// 输入参数
|
||||
const elementProps = defineProps({
|
||||
config: Object as PropType<RenderProps>,
|
||||
form: Object as PropType<any>,
|
||||
})
|
||||
// 定义 props
|
||||
defineProps<{
|
||||
config: RenderProps // JSON 配置
|
||||
model: Record<string, any> // 数据模型
|
||||
}>()
|
||||
|
||||
// 配置元素
|
||||
const formItem = ref<RenderProps>(
|
||||
elementProps.config ?? {
|
||||
component: 'div',
|
||||
text: '',
|
||||
html: '',
|
||||
props: {},
|
||||
content: [],
|
||||
},
|
||||
)
|
||||
/**
|
||||
* 解析属性,支持 v-model 和动态绑定
|
||||
* @param rawProps 原始属性
|
||||
* @param model 数据模型
|
||||
* @returns 解析后的属性
|
||||
*/
|
||||
const parseProps = (rawProps: Record<string, any>, model: Record<string, any>) => {
|
||||
const parsedProps: Record<string, any> = {}
|
||||
|
||||
// 配置数据
|
||||
const formData = ref<any>(elementProps.form || {})
|
||||
for (const [key, value] of Object.entries(rawProps)) {
|
||||
if (key === 'modelvalue') {
|
||||
// 将 modelvalue 转换为 v-model:value 的形式
|
||||
parsedProps['value'] = model[value]
|
||||
parsedProps['onUpdate:value'] = (newValue: any) => {
|
||||
model[value] = newValue
|
||||
}
|
||||
} else if (key === 'model') {
|
||||
// 处理 v-model
|
||||
parsedProps['modelValue'] = model[value]
|
||||
parsedProps['onUpdate:modelValue'] = (newValue: any) => {
|
||||
model[value] = newValue
|
||||
}
|
||||
} else if (key.startsWith('model:')) {
|
||||
// 处理 v-model:<prop>
|
||||
const propName = key.replace('model:', '')
|
||||
parsedProps[propName] = model[value]
|
||||
parsedProps[`onUpdate:${propName}`] = (newValue: any) => {
|
||||
model[value] = newValue
|
||||
}
|
||||
} else {
|
||||
// 普通属性直接赋值
|
||||
parsedProps[key] = typeof value === 'string' && value in model ? model[value] : value
|
||||
}
|
||||
}
|
||||
|
||||
return parsedProps
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染插槽内容
|
||||
* @param slotContent 插槽配置
|
||||
* @param model 数据模型
|
||||
* @param slotScope 插槽作用域
|
||||
*/
|
||||
const renderSlotContent = (slotContent: any, model: any, slotScope: any) => {
|
||||
if (Array.isArray(slotContent)) {
|
||||
// 如果插槽内容是数组,递归渲染
|
||||
return slotContent.map(childConfig => renderComponent(childConfig, model, slotScope))
|
||||
}
|
||||
// 如果插槽内容是单个配置,递归渲染
|
||||
return renderComponent(slotContent, model, slotScope)
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染组件函数(递归支持嵌套)
|
||||
* @param config JSON 配置
|
||||
* @param model 数据模型
|
||||
* @param slotScope 插槽作用域
|
||||
* @returns 渲染的组件 VNode
|
||||
*/
|
||||
const renderComponent = (config: any, model: any, slotScope: any = {}) => {
|
||||
const { component, props: componentProps = {}, content = [], slots = {}, html, text } = config
|
||||
|
||||
// 动态解析组件
|
||||
const Component = resolveComponent(component)
|
||||
|
||||
// 解析属性
|
||||
const parsedProps = parseProps(componentProps, model)
|
||||
|
||||
// 动态插槽解析
|
||||
const slotNodes: Record<string, any> = {}
|
||||
for (const [slotName, slotContent] of Object.entries(slots)) {
|
||||
slotNodes[slotName] = (slotScopeData: any) =>
|
||||
renderSlotContent(slotContent, model, { ...slotScope, ...slotScopeData })
|
||||
}
|
||||
|
||||
// 渲染组件内容
|
||||
const renderContent = () => {
|
||||
// 如果配置了 `html`,直接渲染为 HTML 内容
|
||||
if (html) {
|
||||
return h(Component, { innerHTML: typeof html === 'string' ? html : model[html] })
|
||||
}
|
||||
|
||||
// 如果配置了 `text`,直接渲染为文本内容
|
||||
if (text) {
|
||||
return typeof text === 'string' ? text : model[text]
|
||||
}
|
||||
|
||||
// 如果配置了 `content`,递归渲染子组件
|
||||
if (Array.isArray(content)) {
|
||||
return content.map((childConfig: any) => renderComponent(childConfig, model, slotScope))
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// 渲染组件
|
||||
return h(Component, parsedProps, {
|
||||
...slotNodes,
|
||||
default: renderContent,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Component
|
||||
:is="formItem.component"
|
||||
v-if="!formItem.html && !!formItem.props?.modelvalue"
|
||||
v-bind="formItem.props"
|
||||
v-model:value="formData[formItem.props?.modelvalue]"
|
||||
>
|
||||
{{ formItem.text }}
|
||||
<template v-for="(innerItem, innerIndex) in formItem.content || []" :key="innerIndex">
|
||||
<FormRender
|
||||
v-if="!!innerItem.props?.modelvalue"
|
||||
v-model:value="formData[innerItem.props?.modelvalue]"
|
||||
:config="innerItem"
|
||||
:form="formData"
|
||||
/>
|
||||
<FormRender v-else v-model="formData[innerItem.props?.model]" :config="innerItem" :form="formData" />
|
||||
</template>
|
||||
</Component>
|
||||
<Component :is="formItem.component" v-else-if="formItem.html" v-bind="formItem.props" v-html="formItem.html" />
|
||||
<Component :is="formItem.component" v-else v-bind="formItem.props" v-model="formData[formItem.props?.model]">
|
||||
{{ formItem.text }}
|
||||
<template v-for="(innerItem, innerIndex) in formItem.content || []" :key="innerIndex">
|
||||
<FormRender
|
||||
v-if="!!innerItem.props?.modelvalue"
|
||||
v-model:value="formData[innerItem.props?.modelvalue]"
|
||||
:config="innerItem"
|
||||
:form="formData"
|
||||
/>
|
||||
<FormRender v-else v-model="formData[innerItem.props?.model]" :config="innerItem" :form="formData" />
|
||||
</template>
|
||||
</Component>
|
||||
<Component :is="renderComponent(config, model)" />
|
||||
</template>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { RenderProps } from '@/api/types'
|
||||
const emit = defineEmits(['action'])
|
||||
|
||||
// 输入参数
|
||||
const elementProps = defineProps({
|
||||
const props = defineProps({
|
||||
config: Object as PropType<RenderProps>,
|
||||
})
|
||||
|
||||
@@ -41,9 +41,9 @@ async function commonAction(api_path: string, method: string, params = {}) {
|
||||
// 组装事件
|
||||
let componentEvents = reactive<{ [key: string]: any }>({})
|
||||
watchEffect(() => {
|
||||
if (!isNullOrEmptyObject(elementProps.config?.events)) {
|
||||
for (const key in elementProps.config?.events) {
|
||||
const attr = elementProps.config?.events[key]
|
||||
if (!isNullOrEmptyObject(props.config?.events)) {
|
||||
for (const key in props.config?.events) {
|
||||
const attr = props.config?.events[key]
|
||||
const func = async () => {
|
||||
await commonAction(attr['api'], attr['method'], attr['params'])
|
||||
}
|
||||
@@ -54,35 +54,20 @@ watchEffect(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Component
|
||||
:is="elementProps.config?.component"
|
||||
v-if="!elementProps.config?.html"
|
||||
v-bind="elementProps.config?.props"
|
||||
v-on="componentEvents"
|
||||
>
|
||||
{{ elementProps.config?.text }}
|
||||
<template v-for="(content, name) in elementProps.config?.slots || []" :key="name" v-slot:[name]="{ _props }">
|
||||
<slot :name="name" v-bind="_props">
|
||||
<PageRender
|
||||
v-for="(slotItem, slotIndex) in content || []"
|
||||
:key="slotIndex"
|
||||
:config="slotItem"
|
||||
@action="emit('action')"
|
||||
/>
|
||||
</slot>
|
||||
</template>
|
||||
<Component :is="config?.component" v-if="!config?.html" v-bind="config?.props" v-on="componentEvents">
|
||||
{{ config?.text }}
|
||||
<PageRender
|
||||
v-for="(innerItem, innerIndex) in elementProps.config?.content || []"
|
||||
v-for="(innerItem, innerIndex) in config?.content || []"
|
||||
:key="innerIndex"
|
||||
:config="innerItem"
|
||||
@action="emit('action')"
|
||||
/>
|
||||
</Component>
|
||||
<Component
|
||||
:is="elementProps.config?.component"
|
||||
v-if="elementProps.config?.html"
|
||||
v-bind="elementProps.config?.props"
|
||||
v-html="elementProps.config?.html"
|
||||
:is="config?.component"
|
||||
v-if="config?.html"
|
||||
v-bind="config?.props"
|
||||
v-html="config?.html"
|
||||
v-on="componentEvents"
|
||||
/>
|
||||
<!-- 进度框 -->
|
||||
|
||||
32
src/main.ts
32
src/main.ts
@@ -1,17 +1,20 @@
|
||||
import './ace-config'
|
||||
import '@/@core/utils/compatibility'
|
||||
import '@/@iconify/icons-bundle'
|
||||
import '@/plugins/webfontloader'
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import { VAceEditor } from 'vue3-ace-editor'
|
||||
import { PerfectScrollbarPlugin } from 'vue3-perfect-scrollbar'
|
||||
import { CronVuetify } from '@vue-js-cron/vuetify'
|
||||
import { removeEl } from './@core/utils/dom'
|
||||
import { fetchGlobalSettings } from './api'
|
||||
import { isPWA } from './@core/utils/navigator'
|
||||
|
||||
import App from '@/App.vue'
|
||||
import vuetify from '@/plugins/vuetify'
|
||||
import router from '@/router'
|
||||
import store from '@/store'
|
||||
import { createApp } from 'vue'
|
||||
import { removeEl } from './@core/utils/dom'
|
||||
import { fetchGlobalSettings } from './api'
|
||||
import { isPWA } from './@core/utils/navigator'
|
||||
import './ace-config'
|
||||
import { VAceEditor } from 'vue3-ace-editor'
|
||||
import { PerfectScrollbarPlugin } from 'vue3-perfect-scrollbar'
|
||||
import ToastPlugin from 'vue-toast-notification'
|
||||
import VuetifyUseDialog from 'vuetify-use-dialog'
|
||||
import VueApexCharts from 'vue3-apexcharts'
|
||||
@@ -23,12 +26,14 @@ import PersonCard from './components/cards/PersonCard.vue'
|
||||
import MediaInfoCard from './components/cards/MediaInfoCard.vue'
|
||||
import TorrentCard from './components/cards/TorrentCard.vue'
|
||||
import MediaIdSelector from './components/misc/MediaIdSelector.vue'
|
||||
import PathField from './components/input/PathField.vue'
|
||||
import CronField from './components/field/CronField.vue'
|
||||
|
||||
import '@core/scss/template/index.scss'
|
||||
import '@layouts/styles/index.scss'
|
||||
import '@styles/styles.scss'
|
||||
import 'vue-toast-notification/dist/theme-bootstrap.css'
|
||||
import 'vue3-perfect-scrollbar/style.css'
|
||||
import '@vue-js-cron/vuetify/dist/vuetify.css'
|
||||
|
||||
// 创建Vue实例
|
||||
const app = createApp(App)
|
||||
@@ -49,13 +54,13 @@ async function initializeApp() {
|
||||
// 注册全局组件
|
||||
initializeApp().then(() => {
|
||||
// 优先注册框架
|
||||
app
|
||||
.use(vuetify)
|
||||
|
||||
app.use(vuetify)
|
||||
|
||||
// 注册全局组件
|
||||
app
|
||||
.component('VAceEditor', VAceEditor)
|
||||
.component('VApexChart', VueApexCharts)
|
||||
.component('VCronVuetify', CronVuetify)
|
||||
.component('VDialogCloseBtn', DialogCloseBtn)
|
||||
.component('VMediaCard', MediaCard)
|
||||
.component('VPosterCard', PosterCard)
|
||||
@@ -64,12 +69,13 @@ initializeApp().then(() => {
|
||||
.component('VMediaInfoCard', MediaInfoCard)
|
||||
.component('VTorrentCard', TorrentCard)
|
||||
.component('VMediaIdSelector', MediaIdSelector)
|
||||
.component('VPathField', PathField)
|
||||
.component('VCronField', CronField)
|
||||
|
||||
// 注册插件
|
||||
app
|
||||
.use(router)
|
||||
.use(store)
|
||||
.use(PerfectScrollbarPlugin)
|
||||
.use(ToastPlugin, {
|
||||
position: 'bottom-right',
|
||||
})
|
||||
@@ -93,8 +99,6 @@ initializeApp().then(() => {
|
||||
cancellationText: '取消',
|
||||
},
|
||||
})
|
||||
.use(PerfectScrollbarPlugin)
|
||||
.use(VueApexCharts)
|
||||
.mount('#app')
|
||||
.$nextTick(() => removeEl('#loading-bg'))
|
||||
})
|
||||
|
||||
@@ -130,7 +130,6 @@ function login() {
|
||||
|
||||
// 进行表单校验
|
||||
if (!form.value.username || !form.value.password || (isOTP.value && !form.value.otp_password)) {
|
||||
errorMessage.value = '请输入完整信息'
|
||||
return
|
||||
}
|
||||
// 用户名密码
|
||||
@@ -175,11 +174,11 @@ function login() {
|
||||
})
|
||||
.catch((error: any) => {
|
||||
// 登录失败,显示错误提示
|
||||
if (!error.response) errorMessage.value = '登录失败,请检查网络连接'
|
||||
else if (error.response.status === 401) errorMessage.value = '登录失败,请检查用户名、密码或双重验证是否正确'
|
||||
else if (error.response.status === 403) errorMessage.value = '登录失败,您没有权限访问'
|
||||
else if (error.response.status === 500) errorMessage.value = '登录失败,服务器错误'
|
||||
else errorMessage.value = `登录失败 ${error.response.status},请检查用户名、密码或双重验证码是否正确`
|
||||
if (!error.response) errorMessage.value = '登录失败,请检查网络连接!'
|
||||
else if (error.response.status === 401) errorMessage.value = '登录失败,请检查用户名、密码或双重验证是否正确!'
|
||||
else if (error.response.status === 403) errorMessage.value = '登录失败,您没有权限访问!'
|
||||
else if (error.response.status === 500) errorMessage.value = '登录失败,服务器错误!'
|
||||
else errorMessage.value = `登录失败 ${error.response.status},请检查用户名、密码或双重验证码是否正确!`
|
||||
})
|
||||
}
|
||||
|
||||
@@ -241,7 +240,7 @@ onUnmounted(() => {
|
||||
<VCardTitle class="font-weight-bold text-2xl text-uppercase"> MoviePilot </VCardTitle>
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<VForm ref="refForm" @submit.prevent="() => {}">
|
||||
<VForm ref="refForm" autocomplete="on" @submit.prevent="() => {}">
|
||||
<VRow>
|
||||
<!-- username -->
|
||||
<VCol cols="12">
|
||||
@@ -250,6 +249,8 @@ onUnmounted(() => {
|
||||
v-model="form.username"
|
||||
label="用户名"
|
||||
type="text"
|
||||
name="username"
|
||||
autocomplete="username"
|
||||
:rules="[requiredValidator]"
|
||||
@input="fetchOTP"
|
||||
/>
|
||||
@@ -260,6 +261,8 @@ onUnmounted(() => {
|
||||
v-model="form.password"
|
||||
label="密码"
|
||||
:type="isPasswordVisible ? 'text' : 'password'"
|
||||
name="current-password"
|
||||
autocomplete="current-password"
|
||||
:append-inner-icon="isPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
|
||||
:rules="[requiredValidator]"
|
||||
@click:append-inner="isPasswordVisible = !isPasswordVisible"
|
||||
@@ -275,9 +278,9 @@ onUnmounted(() => {
|
||||
<VCol cols="12">
|
||||
<!-- login button -->
|
||||
<VBtn block type="submit" @click="login"> 登录 </VBtn>
|
||||
<div v-if="errorMessage" class="text-error mt-2 text-shadow">
|
||||
<VAlert v-if="errorMessage" type="error" variant="tonal" class="mt-3">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
</VAlert>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
|
||||
@@ -68,6 +68,7 @@ const viewList = reactive<{ apipath: string; linkurl: string; title: string }[]>
|
||||
title: '豆瓣全球剧集榜',
|
||||
},
|
||||
])
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -189,7 +189,18 @@ const router = createRouter({
|
||||
},
|
||||
],
|
||||
})
|
||||
const abortControllers = new Set<AbortController>()
|
||||
|
||||
function registerAbortController(controller: AbortController) {
|
||||
abortControllers.add(controller)
|
||||
}
|
||||
|
||||
function abortAllControllers() {
|
||||
for (const controller of abortControllers) {
|
||||
controller.abort()
|
||||
}
|
||||
abortControllers.clear()
|
||||
}
|
||||
// 路由导航守卫
|
||||
router.beforeEach((to, from, next) => {
|
||||
// 总是记录非login路由
|
||||
@@ -198,8 +209,11 @@ router.beforeEach((to, from, next) => {
|
||||
if (to.meta.requiresAuth && !isAuthenticated) {
|
||||
next('/login')
|
||||
} else {
|
||||
abortAllControllers() // 中止所有组件的任务
|
||||
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
export default router // 导出默认对象
|
||||
export { registerAbortController } // 另行导出其他功能
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html.v-overlay-scroll-blocked {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#nprogress .bar {
|
||||
background: rgb(var(--v-theme-primary)) !important;
|
||||
inset-block-start: env(safe-area-inset-top) !important;
|
||||
@@ -24,15 +28,11 @@
|
||||
}
|
||||
|
||||
.v-dialog > .v-overlay__content {
|
||||
inline-size: calc(100% - 1rem);
|
||||
margin-block-start: calc(env(safe-area-inset-top) + 1rem);
|
||||
max-block-size: calc(100% - env(safe-area-inset-top) - 1rem);
|
||||
margin-block: env(safe-area-inset-top) env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.v-dialog--fullscreen > .v-overlay__content{
|
||||
inline-size: 100%;
|
||||
margin-block-start: env(safe-area-inset-top);
|
||||
max-block-size: calc(100% - env(safe-area-inset-top));
|
||||
.v-dialog--fullscreen > .v-overlay__content > .v-card {
|
||||
padding-block-end: calc(env(safe-area-inset-top) + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
/* router view transition fade-slide */
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import VueApexCharts from 'vue3-apexcharts'
|
||||
import { useTheme } from 'vuetify'
|
||||
import { hexToRgb } from '@layouts/utils'
|
||||
import api from '@/api'
|
||||
@@ -143,8 +142,7 @@ onUnmounted(() => {
|
||||
<VCardTitle>CPU</VCardTitle>
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<VueApexCharts type="line" :options="chartOptions" :series="series" :height="150" />
|
||||
|
||||
<VApexChart type="line" :options="chartOptions" :series="series" :height="150" />
|
||||
<p class="text-center font-weight-medium mb-0">当前:{{ current }}%</p>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import VueApexCharts from 'vue3-apexcharts'
|
||||
import { useTheme } from 'vuetify'
|
||||
import { hexToRgb } from '@layouts/utils'
|
||||
import api from '@/api'
|
||||
@@ -149,8 +148,7 @@ onUnmounted(() => {
|
||||
<VCardTitle>内存</VCardTitle>
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<VueApexCharts type="area" :options="chartOptions" :series="series" :height="150" />
|
||||
|
||||
<VApexChart type="area" :options="chartOptions" :series="series" :height="150" />
|
||||
<p class="text-center font-weight-medium mb-0">当前:{{ formatBytes(usedMemory) }}</p>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import VueApexCharts from 'vue3-apexcharts'
|
||||
import { useTheme } from 'vuetify'
|
||||
import api from '@/api'
|
||||
import { hexToRgb } from '@layouts/utils'
|
||||
@@ -127,8 +126,7 @@ onMounted(() => {
|
||||
</VCardItem>
|
||||
|
||||
<VCardText>
|
||||
<VueApexCharts type="bar" :options="options" :series="series" :height="160" />
|
||||
|
||||
<VApexChart type="bar" :options="options" :series="series" :height="160" />
|
||||
<div class="d-flex align-center mb-3">
|
||||
<h5 class="text-h5 me-4">
|
||||
{{ totalCount }}
|
||||
|
||||
@@ -27,7 +27,6 @@ const isRefreshed = ref(false)
|
||||
// 数据列表
|
||||
const dataList = ref<MediaInfo[]>([])
|
||||
const currData = ref<MediaInfo[]>([])
|
||||
|
||||
// 拼装参数
|
||||
function getParams() {
|
||||
let params = {
|
||||
@@ -78,6 +77,7 @@ async function fetchData({ done }: { done: any }) {
|
||||
} else {
|
||||
// 加载一次
|
||||
// 设置加载中
|
||||
|
||||
loading.value = true
|
||||
// 请求API
|
||||
currData.value = await api.get(props.apipath, {
|
||||
@@ -115,15 +115,7 @@ async function fetchData({ done }: { done: any }) {
|
||||
<div v-if="dataList.length > 0" class="grid gap-4 grid-media-card mx-3" tabindex="0">
|
||||
<MediaCard v-for="data in dataList" :key="data.tmdb_id || data.douban_id" :media="data" />
|
||||
</div>
|
||||
<NoDataFound
|
||||
v-if="dataList.length === 0 && isRefreshed"
|
||||
error-code="404"
|
||||
error-title="没有数据"
|
||||
error-description="无法获取到媒体信息。"
|
||||
/>
|
||||
<NoDataFound v-if="dataList.length === 0 && isRefreshed" error-code="404" error-title="没有数据"
|
||||
error-description="无法获取到媒体信息。" />
|
||||
</VInfiniteScroll>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
</style>
|
||||
|
||||
@@ -3,6 +3,7 @@ import api from '@/api'
|
||||
import type { MediaInfo } from '@/api/types'
|
||||
import MediaCard from '@/components/cards/MediaCard.vue'
|
||||
import SlideView from '@/components/slide/SlideView.vue'
|
||||
import { registerAbortController } from "@/router";
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -10,8 +11,9 @@ const props = defineProps({
|
||||
linkurl: String,
|
||||
title: String,
|
||||
})
|
||||
let abortController: AbortController | null = null;
|
||||
|
||||
provide('rankingPropsKey', reactive({...props}))
|
||||
provide('rankingPropsKey', reactive({ ...props }))
|
||||
|
||||
// 组件加载完成
|
||||
const componentLoaded = ref(false)
|
||||
@@ -24,8 +26,10 @@ async function fetchData() {
|
||||
try {
|
||||
if (!props.apipath)
|
||||
return
|
||||
|
||||
dataList.value = await api.get(props.apipath)
|
||||
abortController = new AbortController();
|
||||
registerAbortController(abortController);
|
||||
const { signal } = abortController;
|
||||
dataList.value = await api.get(props.apipath, { signal })
|
||||
if (dataList.value.length > 0)
|
||||
componentLoaded.value = true
|
||||
}
|
||||
@@ -35,23 +39,22 @@ async function fetchData() {
|
||||
}
|
||||
|
||||
// 加载时获取数据
|
||||
onMounted(fetchData)
|
||||
onMounted(() => {
|
||||
fetchData();
|
||||
});
|
||||
onActivated(() => {
|
||||
if (dataList.value.length == 0) {
|
||||
fetchData();
|
||||
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SlideView
|
||||
v-if="componentLoaded"
|
||||
>
|
||||
<SlideView v-if="componentLoaded">
|
||||
<template #content>
|
||||
<template
|
||||
v-for="data in dataList"
|
||||
:key="data.tmdb_id || data.douban_id || data.bangumi_id"
|
||||
>
|
||||
<MediaCard
|
||||
:media="data"
|
||||
height="15rem"
|
||||
width="10rem"
|
||||
/>
|
||||
<template v-for="data in dataList" :key="data.tmdb_id || data.douban_id || data.bangumi_id">
|
||||
<MediaCard :media="data" height="15rem" width="10rem" />
|
||||
</template>
|
||||
</template>
|
||||
</SlideView>
|
||||
|
||||
@@ -222,6 +222,7 @@ onMounted(() => {
|
||||
<VRadioGroup v-model="item.action" inline>
|
||||
<VRadio value="user" label="仅操作用户" />
|
||||
<VRadio value="admin" label="仅管理员" />
|
||||
<VRadio value="user,admin" label="操作用户和管理员" />
|
||||
<VRadio value="all" label="所有用户" />
|
||||
</VRadioGroup>
|
||||
</td>
|
||||
|
||||
@@ -133,6 +133,11 @@ async function saveSearchSetting() {
|
||||
selectedMediaSource.value.join(','),
|
||||
)
|
||||
|
||||
if (!result1 || !result1.success) {
|
||||
$toast.error(`媒体搜索数据源保存失败:${result1?.message}!`)
|
||||
return
|
||||
}
|
||||
|
||||
const result2: { [key: string]: any } = await api.post(
|
||||
'system/setting/SearchFilterRuleGroups',
|
||||
selectedFilterGroup.value,
|
||||
@@ -140,7 +145,7 @@ async function saveSearchSetting() {
|
||||
|
||||
const result3 = await saveSystemSetting(SystemSettings.value.Basic)
|
||||
|
||||
if (result1.success && result2.success && result3) {
|
||||
if (result2.success && result3) {
|
||||
$toast.success('搜索基础设置保存成功')
|
||||
} else {
|
||||
$toast.error('搜索基础设置保存失败!')
|
||||
|
||||
@@ -26,18 +26,16 @@ const SystemSettings = ref<any>({
|
||||
// 全局
|
||||
AUXILIARY_AUTH_ENABLE: false,
|
||||
GLOBAL_IMAGE_CACHE: false,
|
||||
SUBSCRIBE_STATISTIC_SHARE: true,
|
||||
PLUGIN_STATISTIC_SHARE: true,
|
||||
BIG_MEMORY_MODE: false,
|
||||
DB_WAL_ENABLE: false,
|
||||
ENCODING_DETECTION_PERFORMANCE_MODE: true,
|
||||
TOKENIZED_SEARCH: false,
|
||||
// 媒体
|
||||
TMDB_API_DOMAIN: null,
|
||||
TMDB_IMAGE_DOMAIN: null,
|
||||
META_CACHE_EXPIRE: 0,
|
||||
FANART_ENABLE: false,
|
||||
SCRAP_FOLLOW_TMDB: true,
|
||||
SUBSCRIBE_STATISTIC_SHARE: true,
|
||||
PLUGIN_STATISTIC_SHARE: true,
|
||||
FANART_ENABLE: false,
|
||||
// 网络
|
||||
PROXY_HOST: null,
|
||||
GITHUB_PROXY: null,
|
||||
@@ -45,9 +43,16 @@ const SystemSettings = ref<any>({
|
||||
DOH_ENABLE: false,
|
||||
DOH_RESOLVERS: null,
|
||||
DOH_DOMAINS: null,
|
||||
// 开发
|
||||
// 日志
|
||||
DEBUG: false,
|
||||
LOG_LEVEL: 'INFO',
|
||||
LOG_MAX_FILE_SIZE: '5',
|
||||
LOG_BACKUP_COUNT: '3',
|
||||
LOG_FILE_FORMAT: '【%(levelname)s】%(asctime)s - %(message)s',
|
||||
// 实验室
|
||||
PLUGIN_AUTO_RELOAD: false,
|
||||
ENCODING_DETECTION_PERFORMANCE_MODE: true,
|
||||
TOKENIZED_SEARCH: false,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -172,6 +177,9 @@ async function saveSystemSetting(value: { [key: string]: any }) {
|
||||
const result: { [key: string]: any } = await api.post('system/env', value)
|
||||
if (result.success) {
|
||||
return true
|
||||
} else {
|
||||
$toast.error(`设置保存失败:${result?.message}!`)
|
||||
return false
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
@@ -184,22 +192,29 @@ async function saveBasicSettings() {
|
||||
if (await saveSystemSetting(SystemSettings.value.Basic)) {
|
||||
$toast.success('基础设置保存成功')
|
||||
await reloadSystem()
|
||||
} else {
|
||||
$toast.error('基础设置保存失败!')
|
||||
}
|
||||
}
|
||||
|
||||
// 高级设置变化,等待保存
|
||||
// 保存高级设置
|
||||
async function saveAdvancedSettings() {
|
||||
cleanEmptyFields(SystemSettings.value.Advanced, ['LOG_FILE_FORMAT'])
|
||||
|
||||
if (await saveSystemSetting(SystemSettings.value.Advanced)) {
|
||||
advancedDialog.value = false
|
||||
$toast.success('高级设置保存成功')
|
||||
await reloadSystem()
|
||||
} else {
|
||||
$toast.error('高级设置保存失败!')
|
||||
}
|
||||
}
|
||||
|
||||
// 当字段为空时,将其设置为 null 提交,以便后端恢复为默认值
|
||||
function cleanEmptyFields(settings: any, fields: string[]) {
|
||||
fields.forEach(field => {
|
||||
if (settings[field]?.trim?.() === '') {
|
||||
settings[field] = null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 快捷复制到剪贴板
|
||||
async function copyValue(value: string) {
|
||||
try {
|
||||
@@ -239,6 +254,15 @@ const pipMirrorsItems = [
|
||||
'https://mirrors.bfsu.edu.cn/pypi/web/simple', // 北京外国语大学
|
||||
]
|
||||
|
||||
// 日志等级
|
||||
const logLevelItems = [
|
||||
{ title: 'DEBUG - 调试 ', value: 'DEBUG' },
|
||||
{ title: 'INFO - 信息 ', value: 'INFO' },
|
||||
{ title: 'WARNING - 警告 ', value: 'WARNING' },
|
||||
{ title: 'ERROR - 错误 ', value: 'ERROR' },
|
||||
{ title: 'CRITICAL - 严重 ', value: 'CRITICAL' },
|
||||
]
|
||||
|
||||
// 创建随机字符串
|
||||
function createRandomString() {
|
||||
const charset = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_'
|
||||
@@ -536,6 +560,9 @@ onDeactivated(() => {
|
||||
<VTab value="network">
|
||||
<div>网络</div>
|
||||
</VTab>
|
||||
<VTab value="log">
|
||||
<div>日志</div>
|
||||
</VTab>
|
||||
<VTab value="dev">
|
||||
<div>实验室</div>
|
||||
</VTab>
|
||||
@@ -605,7 +632,7 @@ onDeactivated(() => {
|
||||
placeholder="api.themoviedb.org"
|
||||
hint="自定义themoviedb API域名或代理地址"
|
||||
persistent-hint
|
||||
:items="['api.themoviedb.org']"
|
||||
:items="['api.themoviedb.org', 'api.tmdb.org']"
|
||||
:rules="[(v: string) => !!v || '请输入TMDB API域名']"
|
||||
/>
|
||||
</VCol>
|
||||
@@ -707,18 +734,64 @@ onDeactivated(() => {
|
||||
</VRow>
|
||||
</div>
|
||||
</VWindowItem>
|
||||
|
||||
<VWindowItem value="dev">
|
||||
<VWindowItem value="log">
|
||||
<div>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="SystemSettings.Advanced.DEBUG"
|
||||
label="DEBUG日志"
|
||||
hint="显示DEBUG级别日志,方便排查问题"
|
||||
label="调试模式"
|
||||
hint="启用调试模式后,日志将以DEBUG级别记录,以便排查问题"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSelect
|
||||
v-if="!SystemSettings.Advanced.DEBUG"
|
||||
v-model="SystemSettings.Advanced.LOG_LEVEL"
|
||||
label="日志等级"
|
||||
hint="设置日志记录的级别,用于控制日志输出量"
|
||||
persistent-hint
|
||||
:items="logLevelItems"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="SystemSettings.Advanced.LOG_MAX_FILE_SIZE"
|
||||
label="日志文件最大容量(MB)"
|
||||
hint="限制单个日志文件的最大容量,超出后将自动分割日志"
|
||||
persistent-hint
|
||||
min="1"
|
||||
type="number"
|
||||
suffix="MB"
|
||||
:rules="[(v: any) => v === 0 || !!v || '日志文件最大大小', (v: any) => v >= 1 || '日志文件最大容量必须大于等于1']"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="SystemSettings.Advanced.LOG_BACKUP_COUNT"
|
||||
label="日志文件最大备份数量"
|
||||
hint="设置每个模块日志文件的最大备份数量,超过后将覆盖旧日志"
|
||||
persistent-hint
|
||||
min="1"
|
||||
type="number"
|
||||
:rules="[(v: any) => v === 0 || !!v || '请输入日志文件最大备份数量', (v: any) => v >= 1 || '日志文件最大备份数量必须大于等于1']"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="SystemSettings.Advanced.LOG_FILE_FORMAT"
|
||||
label="日志文件格式"
|
||||
hint="设置日志文件的输出格式,用于自定义日志的显示内容"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</div>
|
||||
</VWindowItem>
|
||||
<VWindowItem value="dev">
|
||||
<div>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="SystemSettings.Advanced.PLUGIN_AUTO_RELOAD"
|
||||
|
||||
@@ -250,13 +250,13 @@ watch(
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VCard title="个人信息">
|
||||
<VCardText class="d-flex">
|
||||
<VCardText class="flex">
|
||||
<!-- 👉 Avatar -->
|
||||
<VAvatar rounded="lg" size="100" class="me-6" :image="currentAvatar" />
|
||||
|
||||
<!-- 👉 Upload Photo -->
|
||||
<form class="d-flex flex-column justify-center gap-5">
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<form class="flex flex-col justify-center gap-5">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<VBtn color="primary" @click="refInputEl?.click()">
|
||||
<VIcon icon="mdi-cloud-upload-outline" />
|
||||
<span v-if="display.mdAndUp.value" class="ms-2">上传新头像</span>
|
||||
|
||||
27
yarn.lock
27
yarn.lock
@@ -2269,6 +2269,21 @@
|
||||
"@volar/language-core" "2.2.0-alpha.5"
|
||||
path-browserify "^1.0.1"
|
||||
|
||||
"@vue-js-cron/core@5.4.1":
|
||||
version "5.4.1"
|
||||
resolved "https://registry.yarnpkg.com/@vue-js-cron/core/-/core-5.4.1.tgz#5c9b4a3b65f215f5f6767b56f2bc7e7d87e32834"
|
||||
integrity sha512-Z2dPQWyBlsCvoFAZMSx+CWqWRXyf09AabvG/jkEvHx9hbydhHDQApNhQHKVjbMMbFzu4HWRmjRjrx3Cq6bCOOw==
|
||||
dependencies:
|
||||
mustache "^4.2.0"
|
||||
|
||||
"@vue-js-cron/vuetify@^5.0.9":
|
||||
version "5.0.9"
|
||||
resolved "https://registry.yarnpkg.com/@vue-js-cron/vuetify/-/vuetify-5.0.9.tgz#e78bf266fe958332aae931081a59423712d1e164"
|
||||
integrity sha512-/j/fBPiWAnc8ZUWILBCGlUwowhksOZGqID8gN1KyCUCUnovwR3aNE5YWbYAuV3P8RxelJstCdgvlBFl5hleu9A==
|
||||
dependencies:
|
||||
"@vue-js-cron/core" "5.4.1"
|
||||
vuetify "^3.4.0"
|
||||
|
||||
"@vue-macros/common@1.10.2":
|
||||
version "1.10.2"
|
||||
resolved "https://registry.yarnpkg.com/@vue-macros/common/-/common-1.10.2.tgz#4c886082cfd94de2fb16e8e1df99d141873450e7"
|
||||
@@ -5538,6 +5553,11 @@ muggle-string@^0.4.0:
|
||||
resolved "https://registry.yarnpkg.com/muggle-string/-/muggle-string-0.4.1.tgz#3b366bd43b32f809dc20659534dd30e7c8a0d328"
|
||||
integrity sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==
|
||||
|
||||
mustache@^4.2.0:
|
||||
version "4.2.0"
|
||||
resolved "https://registry.yarnpkg.com/mustache/-/mustache-4.2.0.tgz#e5892324d60a12ec9c2a73359edca52972bf6f64"
|
||||
integrity sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==
|
||||
|
||||
mz@^2.7.0:
|
||||
version "2.7.0"
|
||||
resolved "https://registry.yarnpkg.com/mz/-/mz-2.7.0.tgz#95008057a56cafadc2bc63dde7f9ff6955948e32"
|
||||
@@ -6619,7 +6639,6 @@ stop-iteration-iterator@^1.0.0:
|
||||
internal-slot "^1.0.4"
|
||||
|
||||
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.3:
|
||||
name string-width-cjs
|
||||
version "4.2.3"
|
||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||
@@ -6693,7 +6712,6 @@ stringify-object@^3.3.0:
|
||||
is-regexp "^1.0.0"
|
||||
|
||||
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||
name strip-ansi-cjs
|
||||
version "6.0.1"
|
||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||
@@ -7615,6 +7633,11 @@ vuetify@3.6.8:
|
||||
resolved "https://registry.yarnpkg.com/vuetify/-/vuetify-3.6.8.tgz#89ab0b68aa5488c7b54a04fa4a02a1b802892aaa"
|
||||
integrity sha512-j0v0iTeSVRj2ZEM9Q8HxejHxmxrQLYQSalhH82hfcraORaiDoqf1XV05N3P5ERXkKiJjJc/LfxFAUUvYSldxeg==
|
||||
|
||||
vuetify@^3.4.0:
|
||||
version "3.7.6"
|
||||
resolved "https://registry.yarnpkg.com/vuetify/-/vuetify-3.7.6.tgz#f966dbe74666cb847d67b3e3d8c6fee75cf972d3"
|
||||
integrity sha512-lol0Va5HtMIqZfjccSD5DLv5v31R/asJXzc6s7ULy51PHr1DjXxWylZejhq0kVpMGW64MiV1FmA/p8eYQfOWfQ==
|
||||
|
||||
vuex-persistedstate@^4.1.0:
|
||||
version "4.1.0"
|
||||
resolved "https://registry.yarnpkg.com/vuex-persistedstate/-/vuex-persistedstate-4.1.0.tgz#127165f85f5b4534fb3170a5d3a8be9811bd2a53"
|
||||
|
||||
Reference in New Issue
Block a user