Compare commits

...

100 Commits

Author SHA1 Message Date
jxxghp
41ce095505 更新 SubscribeShareCard 和 ForkSubscribeDialog 组件,调整图标颜色,添加搜索词显示,优化识别词的显示行数,并在 ForkSubscribeDialog 中添加复用次数提示 2025-01-13 08:56:51 +08:00
jxxghp
0e2290ce8a 更新 SubscribeShareDialog 组件,添加 share_title 字段的格式化,设置标题为只读,并优化说明提示文本;在确认分享按钮中添加加载状态 2025-01-13 08:16:27 +08:00
jxxghp
1b8db5b7f1 优化 FormRender 组件的渲染逻辑,增强对插槽和内容的支持,简化模板结构 2025-01-12 18:11:39 +08:00
jxxghp
0cb42c1117 更新 FormRender 组件,使用 RenderProps 类型替代原有的 config 类型,并增强渲染逻辑以支持 html 和 text 属性 2025-01-12 18:05:37 +08:00
jxxghp
a289fe3da5 更新 package.json,版本号从 2.2.0 升级至 2.2.1 2025-01-12 17:03:04 +08:00
jxxghp
f53192cfa2 更新 MessageCard 组件,添加对 props.message.action 的检查以优化条件渲染逻辑 2025-01-12 17:02:29 +08:00
jxxghp
235e014542 重构 PluginCard 组件,替换 DynamicRender 为 FormRender;在 ForkSubscribeDialog 组件中添加处理状态以优化用户体验;删除不再使用的 DynamicRender 组件 2025-01-12 16:40:33 +08:00
jxxghp
211b05c643 更新 DynamicRender 组件,添加对 config.text 的支持以增强渲染功能 2025-01-12 16:24:05 +08:00
jxxghp
3e1bd687f1 Merge pull request #286 from InfinityPacer/v2 2025-01-11 21:31:51 +08:00
jxxghp
072fb01a04 更新 CronInput 组件,添加 persistent 属性以优化 VMenu 行为 2025-01-11 20:48:45 +08:00
jxxghp
81fbf4f5ba 更新 CronInput 组件,修改当前 CRON 值的绑定方式,以支持 v-model 绑定 2025-01-11 20:24:06 +08:00
jxxghp
88c86f49bf 重构 DirectoryCard 组件,替换 VPathField 为 PathInput;删除不再使用的 PathField 组件;更新 CronInput 组件以支持 v-model 绑定;添加 CronField 组件以简化 CRON 表达式输入 2025-01-11 20:20:05 +08:00
jxxghp
3023214072 重构 PluginCard 组件,替换 FormRender 为 DynamicRender,优化动态渲染逻辑;删除不再使用的 FormRender 组件 2025-01-11 16:24:16 +08:00
jxxghp
6ea6f89ab2 FIXME 2025-01-11 15:00:23 +08:00
jxxghp
43c6672ab1 fixme 2025-01-11 14:15:52 +08:00
InfinityPacer
5cb56127d5 feat(login): add autocomplete attributes for browser auto-fill 2025-01-11 13:56:01 +08:00
jxxghp
afa333243f 添加 VCronInput 公共组件,用于快速录入CRON表达式 2025-01-11 13:28:46 +08:00
jxxghp
047e99e27c 更新 SubscribeShareDialog.vue:添加分享处理状态,禁用分享按钮以防止重复提交 2025-01-09 16:19:28 +08:00
jxxghp
eef6f37ace 更新 MessageCard.vue:调整卡片宽度,优化文本处理逻辑以支持换行 2025-01-09 12:44:07 +08:00
jxxghp
e8ede6e606 更新设置相关组件:优化错误提示信息,增强用户反馈 2025-01-09 12:29:45 +08:00
jxxghp
bfb4ea4123 更新 package.json 2025-01-09 08:24:57 +08:00
jxxghp
51b0403f64 更新 MessageCard.vue:优化图片显示和文本处理逻辑 2025-01-09 08:22:12 +08:00
jxxghp
a5cd396de6 更新 DirectoryCard.vue 2025-01-07 20:50:12 +08:00
jxxghp
754bc3d3c9 fix(login): improve error messages and update error display component 2025-01-07 09:56:14 +08:00
jxxghp
07a2bcfb97 更新 package.json 2025-01-06 18:01:02 +08:00
jxxghp
20222201ae Merge pull request #284 from InfinityPacer/v2 2025-01-06 17:59:21 +08:00
jxxghp
a2a5ddd66c 升级版本号至 2.1.9 2025-01-06 11:55:29 +08:00
InfinityPacer
7bfc7602a7 fix(log): add LOG_FILE_FORMAT 2025-01-06 02:38:58 +08:00
InfinityPacer
b52b2cedad fix(log): update hint 2025-01-06 02:37:40 +08:00
jxxghp
e93df6ba2c 为通知设置添加“操作用户和管理员”选项 2025-01-05 13:17:46 +08:00
jxxghp
f9f29ccc3c 调整样式以考虑安全区域的上下内边距,优化布局和溢出处理 2025-01-04 14:12:07 +08:00
jxxghp
3bd63ab7c8 为遮罩层添加最小高度并调整溢出样式 2025-01-04 12:23:03 +08:00
jxxghp
301ea445bb 优化样式,合并对话框的边距设置,并为遮罩层添加最小高度 2025-01-04 12:13:03 +08:00
jxxghp
475bee28c6 优化用户头像上传界面,调整布局和样式 2025-01-04 11:52:45 +08:00
jxxghp
cd69920b41 更新 UserAddEditDialog.vue 2025-01-04 11:08:11 +08:00
jxxghp
83aab4e47d 更新 styles.scss 2025-01-04 10:58:45 +08:00
jxxghp
e12093c966 更新 UserAddEditDialog.vue 2025-01-04 10:52:42 +08:00
jxxghp
f21d546d18 更新 UserAddEditDialog.vue 2025-01-04 10:39:34 +08:00
jxxghp
26c8a6ba43 Merge branch 'v2' of https://github.com/jxxghp/MoviePilot-Frontend into v2 2025-01-04 10:23:20 +08:00
jxxghp
827bb8ba69 feat(AccountSettingSystem): 添加备用TMDB API域名选项 2025-01-04 10:23:16 +08:00
jxxghp
d1d2ef37d2 更新 package.json 2025-01-04 10:16:20 +08:00
jxxghp
659594898b refactor(SiteCard): Rename test button text and simplify action button structure 2025-01-04 10:07:01 +08:00
jxxghp
7569401fe0 style(SiteCard): Update card layout and remove inline styles 2025-01-04 09:43:24 +08:00
jxxghp
dc9c86273d Merge pull request #281 from wintsa123/v2 2025-01-03 22:19:26 +08:00
jxxghp
0e816e678a Merge pull request #283 from Aqr-K/feature/log 2025-01-03 13:38:52 +08:00
wintsa
ff1c2a890c remove console 2025-01-03 09:56:06 +08:00
Aqr-K
b802ad8a75 feat(SystemSettings): Add the log setting UI 2025-01-03 06:23:44 +08:00
wintsa
c11fb54b0b remove console 2025-01-03 00:03:45 +08:00
wintsa
856dec3991 忘记把注释去掉了 2025-01-02 15:42:25 +08:00
wintsa
1d8c71da3f 添加取消请求 2025-01-02 15:39:36 +08:00
jxxghp
4152d0f715 更新 package.json 2024-12-31 07:06:18 +08:00
jxxghp
0ead8cc052 fix(PluginCard): 调整插件卡片的 CSS 类以确保高度填充 2024-12-30 19:18:26 +08:00
jxxghp
2b5ecf3f8a fix(PluginCard): 移除悬停显示插件版本和描述的过渡效果以简化组件结构 2024-12-30 19:10:49 +08:00
jxxghp
de7aeeaeb3 fix(UserAuthDialog): 移除 VDialog 的可滚动属性以优化用户体验 2024-12-30 18:21:29 +08:00
jxxghp
994c52f6aa feat(PluginCard): 在插件卡片中添加悬停显示插件版本信息 2024-12-29 20:21:13 +08:00
jxxghp
c6eb744257 feat(PluginAppCard, PluginCard): 添加 VSlideYTransition 以增强插件描述的显示效果 2024-12-29 20:18:30 +08:00
jxxghp
4f462c5cfd feat(PluginAppCard, PluginCard): 添加插件详情弹窗并优化样式 2024-12-29 19:59:14 +08:00
jxxghp
60850970a8 fix(ForkSubscribeDialog): 更新样式,优化文本对齐和可读性 2024-12-29 14:34:17 +08:00
jxxghp
3b2d5e45bb feat: 添加 MediaInfoDialog 组件并更新相关引用 2024-12-29 14:22:27 +08:00
jxxghp
a604d3223a Merge pull request #279 from Aqr-K/fix/search 2024-12-29 07:54:40 +08:00
Aqr-K
00bd1c45a1 fix: bug
- 增加防抖,解决输入打断
2024-12-28 22:34:32 +08:00
jxxghp
4bc6dc7af7 Merge pull request #278 from Aqr-K/fix/search
fix(search): Input method optimization
2024-12-28 14:38:59 +08:00
jxxghp
3a8effd01f fix(FilterRuleGroupCard, TransferQueueDialog): 更新组件结构,替换 VCardText 为 VCardItem,优化显示效果 2024-12-28 12:16:31 +08:00
Aqr-K
da67088e9c 移除无用值 2024-12-28 11:54:50 +08:00
jxxghp
bacd4d23a3 fix(TransferQueueDialog): 更新加载进度逻辑 2024-12-27 18:42:57 +08:00
jxxghp
020f667749 fix(TransferQueueDialog): 更新加载进度处理逻辑 2024-12-27 17:11:00 +08:00
Aqr-K
84652e8c82 更新 SearchBarView.vue 2024-12-27 16:01:30 +08:00
Aqr-K
565ebd936e 更新 TransferHistoryView.vue 2024-12-27 14:30:37 +08:00
Aqr-K
3af127c66f fix(search): Input method optimization
- 合成文字输入法的支持,允许输入到中途进行暂停等操作,而不打断输入触发高频事件
2024-12-27 14:29:21 +08:00
jxxghp
849bb04249 fix(styles): 调整 grid-plugin-card 的列宽,从 18rem 更新为 20rem 2024-12-27 08:01:12 +08:00
jxxghp
09d647877f fix(ReorganizeDialog, TransferHistoryView, AccountSettingSystem, SearchBarView): 更新提示信息,将“历史记录”替换为“整理记录” 2024-12-27 07:56:20 +08:00
jxxghp
c868afbcbf feat(ReorganizeDialog, TransferQueueDialog): 优化错误提示,添加媒体状态颜色和任务计数功能 2024-12-27 07:53:51 +08:00
jxxghp
3f033bfdec 更新 menu.ts 2024-12-26 22:35:24 +08:00
jxxghp
eca2f43e0e 更新 TransferQueueDialog.vue 2024-12-26 22:30:47 +08:00
jxxghp
eeb17040f7 更新 menu.ts 2024-12-26 22:30:00 +08:00
jxxghp
11dee1ed62 更新 package.json 2024-12-26 21:26:28 +08:00
jxxghp
11a6232f83 Merge pull request #277 from Aqr-K/style/site 2024-12-26 17:32:44 +08:00
jxxghp
9eded24e0e Merge pull request #276 from InfinityPacer/v2 2024-12-26 17:31:55 +08:00
Aqr-K
7548882148 style(site): 样式调整 2024-12-26 16:25:32 +08:00
InfinityPacer
4ad89955d4 feat(config): add TOKENIZED_SEARCH 2024-12-26 13:56:27 +08:00
jxxghp
a53553d658 feat(TransferQueueDialog): 添加状态标签以显示任务状态,优化进度条显示逻辑 2024-12-26 13:42:59 +08:00
jxxghp
2602cb0998 feat(TransferQueueDialog): 优化任务队列显示,添加媒体信息和任务管理功能 2024-12-26 13:30:22 +08:00
jxxghp
e402de29d5 Merge pull request #275 from wikrin/v2 2024-12-26 09:28:46 +08:00
Attente
a4cc1cc615 feat(dialog): 仅在指定条件下启用指定集数 2024-12-26 08:24:11 +08:00
jxxghp
2d900baad1 feat(ReorganizeDialog): 添加SSE支持以监听文件整理进度 2024-12-25 21:55:20 +08:00
jxxghp
17d6f6db05 feat(ReorganizeDialog): 重构整理功能,支持后台处理和日志整理 2024-12-25 20:39:53 +08:00
jxxghp
7f3ba543b7 feat(TransferQueue): 添加整理队列对话框及相关功能 2024-12-25 18:12:50 +08:00
jxxghp
b33cb8a12c refactor(Menu): update menu titles for clarity and consistency 2024-12-25 13:25:03 +08:00
jxxghp
cfa8b78c2e Merge pull request #274 from Aqr-K/style/site 2024-12-24 22:05:40 +08:00
Aqr-K
4024daf189 style(site): 统一对齐高度 2024-12-24 21:49:03 +08:00
jxxghp
77d7c3bb61 refactor(ReorganizeDialog): update progress text and remove success messages 2024-12-24 14:17:56 +08:00
jxxghp
868ad57e12 refactor(ReorganizeDialog): remove SSE progress handling and improve toast messages 2024-12-24 13:49:47 +08:00
jxxghp
eaf9724295 Merge pull request #272 from InfinityPacer/v2 2024-12-23 12:11:21 +08:00
InfinityPacer
30e98de38a Merge branch 'v2' of https://github.com/jxxghp/MoviePilot-Frontend into v2 2024-12-23 02:47:52 +08:00
InfinityPacer
79c606370c feat(MediaCard): implement lazy loading for API calls 2024-12-23 02:47:37 +08:00
jxxghp
b70597b5f5 Merge pull request #271 from InfinityPacer/v2 2024-12-21 07:59:41 +08:00
InfinityPacer
a469282730 fix(subscribe): reactive update for subscribeState and lastUpdateText 2024-12-20 14:14:23 +08:00
jxxghp
c3708360fa Merge pull request #270 from libashanxi/v2 2024-12-20 12:05:40 +08:00
libashanxi
80f0560e0f Update AccountSettingAbout.vue
修改文档链接
2024-12-20 09:23:10 +08:00
jxxghp
84951cdc44 feat(subscribe): update display list based on user role and improve sorting logic 2024-12-20 08:04:18 +08:00
53 changed files with 1384 additions and 655 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "moviepilot",
"version": "2.1.4",
"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"
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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()

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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))
}

View File

@@ -1100,6 +1100,7 @@ export interface FilterRuleGroup {
category?: string
}
// 订阅下载文件详情
export interface SubscribeDownloadFileInfo {
// 种子名称
torrent_title?: string
@@ -1113,6 +1114,7 @@ export interface SubscribeDownloadFileInfo {
file_path?: string
}
// 订阅媒体库文件详情
export interface SubscribeLibraryFileInfo {
// 存储
storage?: string
@@ -1120,6 +1122,7 @@ export interface SubscribeLibraryFileInfo {
file_path?: string
}
// 订阅集详情
export interface SubscribeEpisodeInfo {
// 标题
title?: string
@@ -1133,6 +1136,7 @@ export interface SubscribeEpisodeInfo {
library?: SubscribeLibraryFileInfo[]
}
// 订阅详情
export interface SubscrbieInfo {
// 订阅信息
subscribe: Subscribe
@@ -1140,6 +1144,7 @@ export interface SubscrbieInfo {
episodes: Record<number, SubscribeEpisodeInfo>
}
// 整理表单
export interface TransferForm {
// 文件项
fileitem: FileItem
@@ -1178,3 +1183,20 @@ export interface TransferForm {
// 媒体库类别子目录
library_category_folder?: boolean
}
// 整理队列
export interface TransferQueue {
// 媒体信息
media: MediaInfo
// 季
season?: number
// 任务列表
tasks: {
// 文件项
fileitem: FileItem
// 元数据
meta: MetaInfo
// 状态
state: string
}[]
}

View File

@@ -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

View File

@@ -224,8 +224,8 @@ function onClose() {
<VCard :title="`${props.group.name} - 配置`" class="rounded-t">
<DialogCloseBtn v-model="groupInfoDialog" />
<VDivider />
<VCardText>
<VRow>
<VCardItem class="pt-1">
<VRow class="mt-1">
<VCol cols="12" md="6">
<VTextField
v-model="groupInfo.name"
@@ -257,7 +257,7 @@ function onClose() {
/>
</VCol>
</VRow>
</VCardText>
</VCardItem>
<VCardText>
<draggable
v-model="filterRuleCards"

View File

@@ -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,
@@ -67,6 +71,12 @@ const sourceIconDict: { [key: string]: any } = {
bangumi: bangumiImage,
}
// 绑定MediaCard元素
const mediaCardRef = ref<HTMLElement | null>(null)
// 创建Intersection Observer实例
const observer = ref<IntersectionObserver | null>(null)
// 获得mediaid
function getMediaId() {
if (props.media?.tmdb_id) return `tmdb:${props.media?.tmdb_id}`
@@ -209,6 +219,7 @@ async function removeSubscribe() {
// 查询当前媒体是否已订阅
async function handleCheckSubscribe() {
try {
const result = await checkSubscribe(props.media?.season)
if (result) isSubscribed.value = true
} catch (error) {
@@ -219,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,
@@ -227,6 +239,7 @@ async function handleCheckExists() {
season: props.media?.season,
mtype: props.media?.type,
},
signal
})
if (result.success) isExists.value = true
@@ -238,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}`, {
@@ -245,6 +259,7 @@ async function checkSubscribe(season = 0) {
season,
title: props.media?.title,
},
signal
})
return result.id || null
@@ -359,10 +374,40 @@ function handleSearch() {
})
}
// 装载时检查是否已订阅
onBeforeMount(() => {
// 懒加载检查
function handleCheckLazy() {
handleCheckSubscribe()
handleCheckExists()
}
// 在元素进入视窗时触发懒加载函数
function setupIntersectionObserver() {
if (mediaCardRef.value) {
observer.value = new IntersectionObserver(
entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// 只要MediaCard进入视窗就调用懒加载的操作
handleCheckLazy()
// 加载后销毁观察者实例
observer.value?.disconnect()
observer.value = null
}
})
},
{ threshold: 0.1 },
)
observer.value.observe(mediaCardRef.value)
}
}
onMounted(() => {
setupIntersectionObserver()
})
onBeforeUnmount(() => {
observer.value?.disconnect()
observer.value = null
})
// 计算图片地址
@@ -407,82 +452,56 @@ function onRemoveSubscribe() {
<template>
<VHover>
<template #default="hover">
<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"
>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
<div ref="mediaCardRef">
<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">
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
</VImg>
<!-- 详情 -->
<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%)">
<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 }}
</h1>
<p class="leading-4 line-clamp-4 overflow-hidden text-ellipsis ...">
{{ props.media?.overview }}
</p>
<div class="flex align-center justify-between">
<IconBtn icon="mdi-magnify" color="white" @click.stop="handleSearch" />
<IconBtn icon="mdi-heart" :color="isSubscribed ? 'error' : 'white'" @click.stop="handleSubscribe" />
</div>
</template>
</VImg>
<!-- 详情 -->
<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%)"
>
<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 }}
</h1>
<p class="leading-4 line-clamp-4 overflow-hidden text-ellipsis ...">
{{ props.media?.overview }}
</p>
<div class="flex align-center justify-between">
<IconBtn icon="mdi-magnify" color="white" @click.stop="handleSearch" />
<IconBtn icon="mdi-heart" :color="isSubscribed ? 'error' : 'white'" @click.stop="handleSubscribe" />
</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"
>
{{ 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"
>
{{ 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"
>
<VImg cover :src="sourceIconDict[props.media?.source]" class="shadow-lg" />
</VAvatar>
</VCard>
</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">
{{ 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">
{{ 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">
<VImg cover :src="sourceIconDict[props.media?.source]" class="shadow-lg" />
</VAvatar>
</VCard>
</div>
</template>
</VHover>
<!-- 订阅季弹窗 -->
@@ -497,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" />
@@ -543,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>

View File

@@ -4,7 +4,7 @@ import type { Context } from '@/api/types'
import { isNullOrEmptyObject } from '@/@core/utils'
// 输入参数
const props = defineProps({
defineProps({
context: Object as PropType<Context>,
})
@@ -45,8 +45,7 @@ function openTmdbPage(type: string, tmdbId: number) {
</template>
</VImg>
</div>
<div>
<div class="flex-grow">
<VCardItem class="pb-1">
<VCardTitle class="text-center text-md-left">
{{ context?.media_info?.title || context?.meta_info?.name }}

View File

@@ -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">

View File

@@ -43,11 +43,8 @@ const imageLoadError = ref(false)
// 更新日志弹窗
const releaseDialog = ref(false)
// 计算插件标签
const pluginLabels = computed(() => {
if (!props.plugin?.plugin_label) return []
return props.plugin.plugin_label.split(',')
})
// 插件详情弹窗
const detailDialog = ref(false)
// 图片加载完成
async function imageLoaded() {
@@ -76,7 +73,7 @@ async function installPlugin() {
if (result.success) {
$toast.success(`插件 ${props.plugin?.plugin_name} 安装成功!`)
detailDialog.value = false
// 通知父组件刷新
emit('install')
} else {
@@ -150,7 +147,7 @@ const dropdownItems = ref([
<template>
<div>
<VCard :width="props.width" :height="props.height" @click="installPlugin" class="flex flex-col h-full">
<VCard :width="props.width" :height="props.height" @click="detailDialog = true" class="flex flex-col h-full">
<div
class="relative flex flex-row items-start pa-3 justify-between grow"
:style="{ background: `${backgroundColor}` }"
@@ -160,11 +157,11 @@ const dropdownItems = ref([
:style="{ background: `${backgroundColor}`, filter: 'brightness(0.5)' }"
></div>
<div class="relative flex-1 min-w-0">
<VCardTitle class="text-white px-2 text-shadow whitespace-nowrap overflow-hidden text-ellipsis">
<VCardTitle class="text-white text-lg px-2 text-shadow whitespace-nowrap overflow-hidden text-ellipsis ...">
{{ props.plugin?.plugin_name }}
<span class="text-sm text-gray-200">v{{ props.plugin?.plugin_version }}</span>
</VCardTitle>
<VCardText class="text-white px-2 py-1 text-shadow line-clamp-3">
<VCardText class="text-white text-sm px-2 py-0 text-shadow overflow-hidden line-clamp-3 ...">
{{ props.plugin?.plugin_desc }}
</VCardText>
</div>
@@ -226,5 +223,64 @@ const dropdownItems = ref([
<VersionHistory :history="props.plugin?.history" />
</VCard>
</VDialog>
<!-- 插件详情-->
<VDialog v-if="detailDialog" v-model="detailDialog" max-width="30rem">
<VCard>
<DialogCloseBtn @click="detailDialog = false" />
<VCardText>
<VCol>
<div class="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row">
<div class="mx-auto mt-5">
<VAvatar size="64">
<VImg
ref="imageRef"
:src="iconPath"
aspect-ratio="4/3"
cover
:class="{ shadow: isImageLoaded }"
@load="imageLoaded"
@error="imageLoadError = true"
/>
</VAvatar>
</div>
<div class="flex-grow">
<VCardItem>
<VCardTitle class="text-center text-md-left">
{{ props.plugin?.plugin_name }}
</VCardTitle>
<VCardSubtitle
class="text-center text-md-left break-words whitespace-break-spaces line-clamp-4 overflow-hidden text-ellipsis ..."
>
{{ props.plugin?.plugin_desc }}
</VCardSubtitle>
<VList lines="one">
<VListItem class="ps-0">
<VListItemTitle class="text-center text-md-left">
<span class="font-weight-medium">版本</span>
<span class="text-body-1"> v{{ props.plugin?.plugin_version }}</span>
</VListItemTitle>
</VListItem>
<VListItem class="ps-0">
<VListItemTitle class="text-center text-md-left">
<span class="font-weight-medium">作者</span>
<span class="text-body-1 cursor-pointer" @click="visitPluginPage">
{{ props.plugin?.plugin_author }}
</span>
</VListItemTitle>
</VListItem>
</VList>
<div class="text-center text-md-left">
<VBtn color="primary" @click="installPlugin" prepend-icon="mdi-download"> 安装到本地 </VBtn>
<div class="text-xs mt-2" v-if="props.count">
<VIcon icon="mdi-fire" /> {{ props.count?.toLocaleString() }} 次下载
</div>
</div>
</VCardItem>
</div>
</div>
</VCol>
</VCardText>
</VCard>
</VDialog>
</div>
</template>

View File

@@ -1,7 +1,6 @@
<script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import { useConfirm } from 'vuetify-use-dialog'
import { VIcon } from 'vuetify/lib/components/index.mjs'
import api from '@/api'
import type { Plugin } from '@/api/types'
import FormRender from '@/components/render/FormRender.vue'
@@ -15,6 +14,8 @@ import ProgressDialog from '../dialog/ProgressDialog.vue'
// 显示器宽度
const display = useDisplay()
// APP
const appMode = inject('pwaMode') && display.mdAndDown.value
// 输入参数
const props = defineProps({
@@ -49,6 +50,9 @@ const pluginConfigDialog = ref(false)
// 插件配置表单数据
const pluginConfigForm = ref({})
// 菜单显示状态
const menuVisible = ref(false)
// 进度框
const progressDialog = ref(false)
@@ -411,12 +415,12 @@ watch(
:style="{ background: `${backgroundColor}`, filter: 'brightness(0.5)' }"
/>
<div class="relative flex-1 min-w-0">
<VCardTitle class="text-white px-2 text-shadow whitespace-nowrap overflow-hidden text-ellipsis">
<VCardTitle class="text-white text-lg px-2 text-shadow whitespace-nowrap overflow-hidden text-ellipsis">
<VBadge v-if="props.plugin?.state" dot inline color="success" />
{{ props.plugin?.plugin_name }}
<span class="text-sm mt-1 text-gray-200">v{{ props.plugin?.plugin_version }}</span>
<span class="text-sm mt-1 text-gray-200"> v{{ props.plugin?.plugin_version }} </span>
</VCardTitle>
<VCardText class="px-2 py-1 text-white text-shadow line-clamp-3">
<VCardText class="px-2 py-0 text-white text-sm text-shadow overflow-hidden line-clamp-3 ...">
{{ props.plugin?.plugin_desc }}
</VCardText>
</div>
@@ -450,7 +454,7 @@ watch(
<div class="me-n3 absolute bottom-1 right-3">
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VMenu v-model="menuVisible" activator="parent" close-on-content-click>
<VList>
<VListItem
v-for="(item, i) in dropdownItems"
@@ -486,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">
@@ -505,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>

View File

@@ -8,7 +8,6 @@ import SiteCookieUpdateDialog from '../dialog/SiteCookieUpdateDialog.vue'
import api from '@/api'
import type { Site, SiteStatistic, SiteUserData } from '@/api/types'
import { isNullOrEmptyObject } from '@/@core/utils'
import { VCardActions, VExpandTransition, VProgressLinear, VSpacer } from 'vuetify/lib/components/index.mjs'
import { formatFileSize } from '@/@core/utils/formatters'
// 输入参数
@@ -27,7 +26,7 @@ const siteIcon = ref<string>('')
const $toast = useToast()
// 测试按钮文字
const testButtonText = ref('测试')
const testButtonText = ref('连通性测试')
// 测试按钮可用性
const testButtonDisable = ref(false)
@@ -44,9 +43,6 @@ const resourceDialog = ref(false)
// 用户数据弹窗
const siteUserDataDialog = ref(false)
// 站点操作显示
const siteActionShow = ref(false)
// 站点使用统计
const siteStats = ref<SiteStatistic>({})
@@ -69,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()
@@ -156,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>
@@ -195,44 +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>
<VDivider class="mb-1" v-if="siteActionShow" />
<VExpandTransition>
<div v-show="siteActionShow" 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>
</VExpandTransition>
<StatIcon v-if="cardProps.site?.is_active" :color="statColor" />
<span class="absolute top-1 right-8">
<VIcon class="cursor-move">mdi-drag</VIcon>

View File

@@ -38,11 +38,11 @@ const subscribeFilesDialog = ref(false)
// 分享订阅弹窗
const subscribeShareDialog = ref(false)
// 定义一个变量来保存当前的订阅状态
// 当前的订阅状态
const subscribeState = ref<string>(props.media?.state ?? 'P')
// 上一次更新时间
const lastUpdateText = ref(props.media && props.media.last_update ? formatDateDifference(props.media.last_update) : '')
const lastUpdateText = computed(() => (props.media?.last_update ? formatDateDifference(props.media.last_update) : ''))
// 图片加载完成响应
function imageLoadHandler() {
@@ -239,6 +239,14 @@ watch(
},
)
// 监听订阅状态
watch(
() => props.media?.state,
newState => {
subscribeState.value = newState ?? 'P'
},
)
// 计算backdrop图片地址
const backdropUrl = computed(() => {
const url = props.media?.backdrop || props.media?.poster

View File

@@ -1,24 +1,15 @@
<script lang="ts" setup>
import { formatDateDifference } from '@/@core/utils/formatters'
import api from '@/api'
import { doneNProgress, startNProgress } from '@/api/nprogress'
import type { SubscribeShare } from '@/api/types'
import router from '@/router'
import { useToast } from 'vue-toast-notification'
import { useConfirm } from 'vuetify-use-dialog'
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
import ForkSubscribeDialog from '../dialog/ForkSubscribeDialog.vue'
// 输入参数
const props = defineProps({
media: Object as PropType<SubscribeShare>,
})
// 提示框
const $toast = useToast()
// 确认框
const createConfirm = useConfirm()
// 从 provide 中获取全局设置
const globalSettings: any = inject('globalSettings')
@@ -28,6 +19,9 @@ const imageLoaded = ref(false)
// 订阅编辑弹窗
const subscribeEditDialog = ref(false)
// 复用订阅弹窗
const forkSubscribeDialog = ref(false)
// 订阅ID
const subscribeId = ref<number>()
@@ -69,109 +63,99 @@ async function viewMediaDetail() {
}
// 复用订阅
async function forkSubscribe() {
// 开始处理
startNProgress()
try {
// 确认
const isConfirmed = await createConfirm({
title: '确认',
content: `是否确认添加来自 ${props.media?.share_user} 分享的订阅:${props.media?.share_title}`,
})
if (!isConfirmed) return
function showForkSubscribe() {
forkSubscribeDialog.value = true
}
// 请求API
const result: { [key: string]: any } = await api.post('subscribe/fork', props.media)
// 订阅状态
if (result.success) {
$toast.success(`${props.media?.share_title} 添加订阅成功!`)
// 弹出订阅编辑弹窗
subscribeId.value = result.data.id
subscribeEditDialog.value = true
} else {
$toast.error(`${props.media?.share_title} 添加订阅失败:${result.message}`)
}
} catch (error) {
console.error(error)
} finally {
doneNProgress()
}
// 完成复用订阅
function finishForkSubscribe(subid: number) {
subscribeId.value = subid
subscribeEditDialog.value = true
}
</script>
<template>
<VHover>
<template #default="hover">
<VCard
v-bind="hover.props"
:key="props.media?.id"
class="flex flex-col rounded-lg"
:class="{
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
}"
min-height="170"
@click="forkSubscribe"
>
<template #image>
<VImg :src="backdropUrl || posterUrl" aspect-ratio="3/2" cover @load="imageLoadHandler" position="top">
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
<div>
<VHover>
<template #default="hover">
<VCard
v-bind="hover.props"
:key="props.media?.id"
class="flex flex-col rounded-lg"
:class="{
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
}"
min-height="170"
@click="showForkSubscribe"
>
<template #image>
<VImg :src="backdropUrl || posterUrl" aspect-ratio="3/2" cover @load="imageLoadHandler" position="top">
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
</div>
</template>
<div class="absolute inset-0 subscribe-card-background"></div>
</VImg>
</template>
<div>
<VCardText class="flex items-center pb-1">
<div class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md shadow-lg" v-if="imageLoaded">
<VImg :src="posterUrl" aspect-ratio="2/3" cover @click.stop="viewMediaDetail">
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
</VImg>
</div>
</template>
<div class="absolute inset-0 subscribe-card-background"></div>
</VImg>
</template>
<div>
<VCardText class="flex items-center pb-1">
<div class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md shadow-lg" v-if="imageLoaded">
<VImg :src="posterUrl" aspect-ratio="2/3" cover @click.stop="viewMediaDetail">
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
</VImg>
</div>
<div class="flex flex-col justify-center pl-2 xl:pl-4">
<div class="mr-2 min-w-0 text-lg font-bold text-white line-clamp-2 overflow-hidden text-ellipsis ...">
{{ props.media?.share_title }}
<div class="flex flex-col justify-center pl-2 xl:pl-4">
<div class="mr-2 min-w-0 text-lg font-bold text-white line-clamp-2 overflow-hidden text-ellipsis ...">
{{ props.media?.share_title }}
</div>
<div class="text-sm font-medium text-gray-200 sm:pt-1 line-clamp-3 overflow-hidden text-ellipsis ...">
{{ props.media?.share_comment }}
</div>
</div>
<div class="text-sm font-medium text-gray-200 sm:pt-1 line-clamp-3 overflow-hidden text-ellipsis ...">
{{ props.media?.share_comment }}
</VCardText>
<VCardText class="flex justify-space-between align-center flex-wrap">
<div class="flex align-center">
<IconBtn v-bind="props" icon="mdi-account" color="white" class="me-1" />
<div class="text-subtitle-2 me-4 text-white">
{{ props.media?.share_user }}
</div>
<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>
</div>
</div>
</VCardText>
<VCardText class="flex justify-space-between align-center flex-wrap">
<div class="flex align-center">
<IconBtn v-bind="props" icon="mdi-account" color="white" class="me-1" />
<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" />
<span v-if="props.media?.count" class="text-subtitle-2 me-4 text-white">
{{ props.media?.count.toLocaleString() }}
</span>
</div>
</VCardText>
<VCardText class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300">
<VIcon icon="mdi-calcdar" class="me-1" />
{{ dateText }}
</VCardText>
</div>
</VCard>
</template>
</VHover>
<!-- 订阅编辑弹窗 -->
<SubscribeEditDialog
v-if="subscribeEditDialog"
v-model="subscribeEditDialog"
:subid="subscribeId"
@close="subscribeEditDialog = false"
@save="subscribeEditDialog = false"
@remove="subscribeEditDialog = false"
/>
</VCardText>
<VCardText class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300">
<VIcon icon="mdi-calcdar" class="me-1" />
{{ dateText }}
</VCardText>
</div>
</VCard>
</template>
</VHover>
<!-- 订阅编辑弹窗 -->
<SubscribeEditDialog
v-if="subscribeEditDialog"
v-model="subscribeEditDialog"
:subid="subscribeId"
@close="subscribeEditDialog = false"
@save="subscribeEditDialog = false"
@remove="subscribeEditDialog = false"
/>
<!-- 复用订阅弹窗 -->
<ForkSubscribeDialog
v-if="forkSubscribeDialog"
v-model="forkSubscribeDialog"
:media="props.media"
@close="forkSubscribeDialog = false"
@done="finishForkSubscribe"
/>
</div>
</template>
<style lang="scss">
.subscribe-card-background {

View File

@@ -0,0 +1,146 @@
<script setup lang="ts">
import api from '@/api'
import { doneNProgress, startNProgress } from '@/api/nprogress'
import { SubscribeShare } from '@/api/types'
import router from '@/router'
import { useToast } from 'vue-toast-notification'
import { VBtn } from 'vuetify/lib/components/index.mjs'
// 输入参数
const props = defineProps({
media: Object as PropType<SubscribeShare>,
})
// 定义事件
const emit = defineEmits(['close', 'done'])
// 从 provide 中获取全局设置
const globalSettings: any = inject('globalSettings')
// 提示框
const $toast = useToast()
// 处理中
const processing = ref(false)
// 计算海报图片地址
const posterUrl = computed(() => {
const url = props.media?.poster
// 使用图片缓存
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
return url
})
// 查看媒体详情
async function viewMediaDetail() {
router.push({
path: '/media',
query: {
mediaid: `${props.media?.tmdbid ? `tmdb:${props.media?.tmdbid}` : `douban:${props.media?.doubanid}`}`,
type: props.media?.type,
},
})
}
// 复用订阅
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} 添加订阅成功!`)
// 完成
emit('done', result.data.id)
} else {
$toast.error(`${props.media?.share_title} 添加订阅失败:${result.message}`)
}
} catch (error) {
console.error(error)
} finally {
processing.value = false
doneNProgress()
}
}
</script>
<template>
<VDialog max-width="40rem">
<VCard>
<DialogCloseBtn @click="emit('close')" />
<VCardText>
<VCol>
<div class="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row">
<div class="ma-auto">
<VImg
width="10rem"
aspect-ratio="2/3"
class="object-cover aspect-w-2 aspect-h-3 rounded-lg ring-1 ring-gray-500"
:src="posterUrl"
@click="viewMediaDetail"
cover
>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
</VImg>
</div>
<div class="flex-grow">
<VCardItem>
<VCardTitle class="text-center text-md-left">
{{ props.media?.share_title }}
</VCardTitle>
<VCardSubtitle
class="text-center text-md-left break-words whitespace-break-spaces line-clamp-2 overflow-hidden text-ellipsis ..."
>
{{ props.media?.share_comment }}
</VCardSubtitle>
<VList lines="one">
<VListItem class="ps-0">
<VListItemTitle class="text-center text-md-left">
<span class="font-weight-medium">分享人</span>
<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-10 overflow-hidden text-ellipsis ..."
>
<span class="font-weight-medium">识别词</span>
<span class="text-body-1"> {{ media?.custom_words }}</span>
</VListItemTitle>
</VListItem>
</VList>
<div class="text-center text-md-left">
<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>
</div>
</VCol>
</VCardText>
</VCard>
</VDialog>
</template>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import { Context } from '@/api/types'
import MediaInfoCard from '../cards/MediaInfoCard.vue'
// 输入参数
defineProps({
context: Object as PropType<Context>,
})
// 定义事件
const emit = defineEmits(['close'])
</script>
<template>
<VDialog max-width="50rem">
<VCard>
<DialogCloseBtn @click="emit('close')" />
<VCardItem>
<MediaInfoCard :context="context" />
</VCardItem>
</VCard>
</VDialog>
</template>

View File

@@ -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)
}

View File

@@ -49,7 +49,7 @@ const progressEventSource = ref<EventSource>()
const progressDialog = ref(false)
// 整理进度文本
const progressText = ref('请稍候 ...')
const progressText = ref('正在处理 ...')
// 整理进度
const progressValue = ref(0)
@@ -65,6 +65,13 @@ const dialogTitle = computed(() => {
return '手动整理'
})
// 禁用指定集数
const disableEpisodeDetail = computed(() => {
if (props.items) {
return !(props.items.length === 1 && props.items[0].type !== 'dir')
}
})
// 表单
const transferForm = reactive<TransferForm>({
fileitem: {} as FileItem,
@@ -119,9 +126,35 @@ watch(
transferForm.library_type_folder = undefined
transferForm.library_category_folder = undefined
}
}
},
)
// 整理文件
async function handleTransfer(item: FileItem, background: boolean = false) {
transferForm.fileitem = item
transferForm.logid = 0
try {
const result: { [key: string]: any } = await api.post(`transfer/manual?background=${background}`, transferForm)
if (!result.success) $toast.error(result.message)
else if (background) $toast.success(`文件 ${item.name} 已加入整理队列!`)
} catch (e) {
console.log(e)
}
}
// 整理日志
async function handleTransferLog(logid: number, background: boolean = false) {
transferForm.logid = logid
transferForm.fileitem = {} as FileItem
try {
const result: { [key: string]: any } = await api.post(`transfer/manual?background=${background}`, transferForm)
if (!result.success) $toast.error(result.message)
else if (background) $toast.success(`历史记录 ${logid} 已加入整理队列!`)
} catch (e) {
console.log(e)
}
}
// 使用SSE监听加载进度
function startLoadingProgress() {
progressText.value = '请稍候 ...'
@@ -141,63 +174,49 @@ function stopLoadingProgress() {
}
// 整理文件
async function transfer() {
async function transfer(background: boolean = false) {
if (!props.logids && !props.items) return
// 显示进度条
progressDialog.value = true
// 开始监听进度
startLoadingProgress()
if (!background) {
// 开始监听进度
startLoadingProgress()
}
// 文件整理
if (props.items) {
for (const item of props.items) {
await handleTransfer(item)
await handleTransfer(item, background)
}
}
// 日志整理
if (props.logids) {
for (const logid of props.logids) {
await handleTransferLog(logid)
await handleTransferLog(logid, background)
}
}
// 停止监听进度
stopLoadingProgress()
if (!background) {
// 停止监听进度
stopLoadingProgress()
}
// 关闭进度条
progressDialog.value = false
// 重新加载
emit('done')
}
// 整理文件
async function handleTransfer(item: FileItem) {
transferForm.fileitem = item
transferForm.logid = 0
try {
const result: { [key: string]: any } = await api.post('transfer/manual', transferForm)
if (!result.success) $toast.error(`文件 ${item.path} 整理失败:${result.message}`)
} catch (e) {
console.log(e)
}
}
// 整理日志
async function handleTransferLog(logid: number) {
transferForm.logid = logid
transferForm.fileitem = {} as FileItem
try {
const result: { [key: string]: any } = await api.post('transfer/manual', transferForm)
if (!result.success) $toast.error(`历史记录 ${logid} 重新整理失败:${result.message}`)
} catch (e) {
console.log(e)
}
}
onMounted(() => {
loadDirectories()
})
onUnmounted(() => {
stopLoadingProgress()
})
</script>
<template>
@@ -224,7 +243,8 @@ onMounted(() => {
label="整理方式"
:items="transferTypeOptions"
hint="文件操作整理方式"
persistent-hint>
persistent-hint
>
<template v-slot:selection="{ item }">
{{ transferForm.transfer_type === '' ? '自动' : item.title }}
</template>
@@ -305,6 +325,7 @@ onMounted(() => {
<VCol cols="12" md="4">
<VTextField
v-model="transferForm.episode_detail"
:disabled="disableEpisodeDetail"
label="指定集数"
placeholder="起始集,终止集如1或1,2"
hint="指定集数或范围如1或1,2"
@@ -369,7 +390,7 @@ onMounted(() => {
<VSwitch
v-model="transferForm.from_history"
label="复用历史识别信息"
hint="使用历史记录中已识别的媒体信息"
hint="使用历史整理记录中已识别的媒体信息"
persistent-hint
/>
</VCol>
@@ -378,7 +399,12 @@ onMounted(() => {
</VCardText>
<VCardActions class="pt-3">
<VSpacer />
<VBtn variant="elevated" @click="transfer" prepend-icon="mdi-arrow-right-bold" class="px-5"> 开始整理 </VBtn>
<VBtn variant="elevated" color="success" @click="transfer(true)" prepend-icon="mdi-plus" class="px-5">
加入整理队列
</VBtn>
<VBtn variant="elevated" @click="transfer(false)" prepend-icon="mdi-arrow-right-bold" class="px-5">
立即整理
</VBtn>
</VCardActions>
</VCard>
<!-- 手动整理进度框 -->

View File

@@ -2,9 +2,7 @@
import type { Site, SiteUserData } from '@/api/types'
import api from '@/api'
import { useDisplay, useTheme } from 'vuetify'
import { VAvatar, VCardText, VIcon } from 'vuetify/lib/components/index.mjs'
import { formatFileSize } from '@/@core/utils/formatters'
import VueApexCharts from 'vue3-apexcharts'
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
// 显示器宽度
@@ -34,7 +32,6 @@ const siteDatas = ref<SiteUserData[]>([])
// 最新一天的数据
const siteData = computed(() => siteDatas.value[siteDatas.value.length - 1])
// 站点数据列表中的上传量、下载量数据生成图形使用的数据
const historySeries = computed(() => {
return [
@@ -243,13 +240,12 @@ async function fetchSiteUserData() {
}
}
// 刷新站点数据
async function refreshSiteData(){
async function refreshSiteData() {
progressDialog.value = true
try {
const result: { [key: string]: any } = await api.post(`site/userdata/${props.site?.id}`)
if (result.success){
if (result.success) {
await fetchSiteUserData()
}
} catch (error) {
@@ -267,12 +263,14 @@ onBeforeMount(async () => {
<VDialog scrollable eager max-width="80rem" :fullscreen="!display.mdAndUp.value">
<VCard class="rounded-t">
<VCardItem>
<VCardTitle>{{ `数据 - ${props.site?.name}` }}
<IconBtn @click.stop="refreshSiteData" color="info"><VIcon icon="mdi-refresh"</VIcon></IconBtn>
<VCardTitle
>{{ `数据 - ${props.site?.name}` }}
<IconBtn @click.stop="refreshSiteData" color="info"><VIcon icon="mdi-refresh" /></IconBtn>
</VCardTitle>
<DialogCloseBtn @click="emit('close')" />
</VCardItem>
<VCardText>
<VDivider />
<VCardText class="pt-5">
<VRow class="match-height">
<!-- 用户信息 -->
<VCol cols="12" md="3">
@@ -441,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>
@@ -450,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>

View File

@@ -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>

View File

@@ -0,0 +1,187 @@
<script lang="ts" setup>
import { formatFileSize } from '@/@core/utils/formatters'
import api from '@/api'
import { FileItem, TransferQueue } from '@/api/types'
import { useDisplay } from 'vuetify'
// 显示器宽度
const display = useDisplay()
// 定义触发的自定义事件
const emit = defineEmits(['close'])
// 数据列表
const dataList = ref<TransferQueue[]>([])
// 加载进度SSE
const progressEventSource = ref<EventSource>()
// 整理进度文本
const progressText = ref('请稍候 ...')
// 整理进度
const progressValue = ref(0)
// 数据可刷新标志
const refreshFlag = ref(false)
// 活动标签
const activeTab = ref('')
// 状态标签
const stateDict: { [key: string]: string } = {
'waiting': '等待中',
'running': '正在整理',
'completed': '完成',
'failed': '失败',
}
// 获取状态颜色
function getStateColor(state: string) {
if (state === 'waiting') return 'gray'
else if (state === 'running') return 'primary'
else if (state === 'completed') return 'success'
else return 'error'
}
// 从dataList中提取所有的媒体信息
const mediaList = computed(() => {
return dataList.value.map(item => item.media)
})
// 按media计算总数和完成数返回 x/x
function getMediaCount(title_year: string) {
// 按title_year查询出所有media列表
const medias = dataList.value.filter(item => item.media.title_year === title_year)
// 计算media下任务的总数
const total = medias.reduce((acc, cur) => acc + cur.tasks.length, 0)
// 计算media下任务的完成数
const completed = medias.reduce((acc, cur) => acc + cur.tasks.filter(task => task.state === 'completed').length, 0)
return `${completed} / ${total}`
}
// 根据媒体信息获取对应的整理任务
const activeTasks = computed(() => {
return dataList.value.find(item => item.media.title_year === activeTab.value)?.tasks
})
// 调用API获取队列信息
async function get_transfer_queue() {
try {
dataList.value = await api.get('transfer/queue')
if (dataList.value.length > 0) {
if (!activeTab.value || activeTasks.value?.length == 0) activeTab.value = dataList.value[0].media.title_year || ''
}
} catch (error) {
console.error(error)
}
}
// 移除队列任务
async function remove_queue_task(fileitem: FileItem) {
try {
await api.delete(`transfer/queue`, { data: fileitem })
get_transfer_queue()
} catch (error) {
console.error(error)
}
}
// 使用SSE监听加载进度
function startLoadingProgress() {
progressText.value = '请稍候 ...'
progressEventSource.value = new EventSource(`${import.meta.env.VITE_API_BASE_URL}system/progress/filetransfer`)
progressEventSource.value.onmessage = event => {
const progress = JSON.parse(event.data)
if (progress) {
if (!progress.enable) {
progressText.value = '请稍候 ...'
progressValue.value = 0
if (refreshFlag.value) {
refreshFlag.value = false
get_transfer_queue()
}
return
}
progressText.value = progress.text
progressValue.value = progress.value
if (progress.value >= 100 && refreshFlag.value) {
refreshFlag.value = false
get_transfer_queue()
} else {
if (progress.value > 0 && refreshFlag.value && progress.text?.includes('整理完成')) {
refreshFlag.value = false
get_transfer_queue()
} else {
refreshFlag.value = true
}
}
}
}
}
// 停止监听加载进度
function stopLoadingProgress() {
progressEventSource.value?.close()
}
onMounted(() => {
get_transfer_queue()
startLoadingProgress()
})
onUnmounted(() => {
stopLoadingProgress()
})
</script>
<template>
<VDialog scrollable max-width="50rem" :fullscreen="!display.mdAndUp.value">
<VCard class="mx-auto" width="100%">
<VCardItem>
<VCardTitle>整理队列</VCardTitle>
</VCardItem>
<DialogCloseBtn @click="emit('close')" />
<VDivider />
<VProgressLinear
v-if="dataList.length > 0 && progressValue > 0"
:value="progressValue"
color="primary"
indeterminate
/>
<VCardItem v-if="dataList.length > 0 && progressValue > 0" class="text-center pt-2">
<span class="text-sm">{{ progressText }}</span>
</VCardItem>
<VCardText v-if="dataList.length === 0" class="text-center"> 没有正在整理的任务 </VCardText>
<VCardText>
<VTabs v-model="activeTab" show-arrows class="v-tabs-pill" stacked>
<VTab
v-for="media in mediaList"
:value="media.title_year"
selected-class="v-slide-group-item--active v-tab--selected"
>
<div class="font-bold text-lg">{{ media.title }}</div>
<div>({{ getMediaCount(media.title_year || '') }})</div>
</VTab>
</VTabs>
<VWindow v-model="activeTab" class="mt-5 disable-tab-transition" :touch="false">
<VWindowItem v-for="media in mediaList" :value="media.title_year">
<VList>
<VListItem v-for="task in activeTasks">
<VListItemTitle>{{ task.fileitem.name }}</VListItemTitle>
<VListItemSubtitle>
大小{{ formatFileSize(task.fileitem.size || 0) }}
<VChip size="small" :color="getStateColor(task.state)" class="ms-2">
{{ stateDict[task.state] }}
</VChip>
</VListItemSubtitle>
<template #append>
<IconBtn size="small" icon="mdi-cancel" @click="remove_queue_task(task.fileitem)" />
</template>
</VListItem>
</VList>
</VWindowItem>
</VWindow>
</VCardText>
</VCard>
</VDialog>
</template>

View File

@@ -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">允许 JPGPNGGIFWEBP 格式 最大尺寸 800KB</p>
</div>
<p class="text-body-1 mb-0">允许 JPGPNGGIFWEBP 格式 最大尺寸 800KB</p>
</form>
</VCardText>
</div>
</VCardItem>
<VCardText>
<VForm @submit.prevent="() => {}" class="mt-3">
<VForm @submit.prevent="() => {}">
<VDivider class="my-10">
<span>用户基础设置</span>
</VDivider>

View File

@@ -129,7 +129,7 @@ onMounted(async () => {
</script>
<template>
<VDialog width="40rem" scrollable max-height="85vh">
<VDialog width="40rem" max-height="85vh">
<VCard title="用户认证" class="rounded-t">
<DialogCloseBtn @click="emit('close')" />
<VCardText>

View 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>

View File

@@ -7,9 +7,9 @@ import ReorganizeDialog from '../dialog/ReorganizeDialog.vue'
import { formatBytes } from '@core/utils/formatters'
import type { Context, EndPoints, FileItem } from '@/api/types'
import api from '@/api'
import MediaInfoCard from '@/components/cards/MediaInfoCard.vue'
import ProgressDialog from '../dialog/ProgressDialog.vue'
import { useDisplay } from 'vuetify'
import MediaInfoDialog from '../dialog/MediaInfoDialog.vue'
// 显示器宽度
const display = useDisplay()
@@ -735,14 +735,12 @@ onMounted(() => {
<!-- 进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" :value="progressValue" />
<!-- 识别结果对话框 -->
<VDialog v-if="nameTestDialog" v-model="nameTestDialog" width="50rem">
<VCard>
<DialogCloseBtn @click="nameTestDialog = false" />
<VCardItem>
<MediaInfoCard :context="nameTestResult" />
</VCardItem>
</VCard>
</VDialog>
<MediaInfoDialog
v-if="nameTestDialog"
v-model="nameTestDialog"
:context="nameTestResult"
@close="nameTestDialog = false"
/>
</template>
<style lang="scss" scoped>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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"
/>
<!-- 进度框 -->

View File

@@ -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'))
})

View File

@@ -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>

View File

@@ -68,6 +68,7 @@ const viewList = reactive<{ apipath: string; linkurl: string; title: string }[]>
title: '豆瓣全球剧集榜',
},
])
</script>
<template>

View File

@@ -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 } // 另行导出其他功能

View File

@@ -50,15 +50,15 @@ export const SystemNavMenus = [
admin: false,
},
{
title: '正在下载',
title: '下载管理',
icon: 'mdi-download-outline',
to: '/downloading',
header: '整理',
admin: false,
},
{
title: '历史记录',
icon: 'mdi-history',
title: '媒体整理',
icon: 'mdi-folder-play-outline',
to: '/history',
header: '整理',
admin: true,
@@ -100,35 +100,6 @@ export const SystemNavMenus = [
},
]
// 常用菜单功能
export const UserfulMenus = [
{
title: '搜索设置',
icon: 'mdi-magnify',
to: 'setting?tab=search',
},
{
title: '订阅设置',
icon: 'mdi-rss',
to: 'setting?tab=subscribe',
},
{
title: '服务',
icon: 'mdi-list-box',
to: 'setting?tab=service',
},
{
title: '词表',
icon: 'mdi-file-word-box',
to: 'setting?tab=words',
},
{
title: '历史记录',
icon: 'mdi-history',
to: 'history',
},
]
// 设定标签页
export const SettingTabs = [
{

View File

@@ -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 */

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 }}

View File

@@ -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>

View File

@@ -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>

View File

@@ -4,6 +4,7 @@ import { useToast } from 'vue-toast-notification'
import api from '@/api'
import type { TransferHistory } from '@/api/types'
import ReorganizeDialog from '@/components/dialog/ReorganizeDialog.vue'
import TransferQueueDialog from '@/components/dialog/TransferQueueDialog.vue'
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
import { useRoute } from 'vue-router'
import router from '@/router'
@@ -20,9 +21,15 @@ const $toast = useToast()
// 路由
const route = useRoute()
// 组合式输入法状态
const isComposing = ref(false)
// 重新整理对话框
const redoDialog = ref(false)
// 整理队列对话框
const transferQueueDialog = ref(false)
// 当前操作记录
const currentHistory = ref<TransferHistory>()
@@ -144,7 +151,7 @@ const totalPage = computed(() => {
return total
})
// 切换页签和搜索词
// 切换页签
watch(
[() => currentPage.value, () => itemsPerPage.value],
debounce(async () => {
@@ -152,10 +159,13 @@ watch(
}, 1000),
)
watch(
[() => search.value],
// 搜索监听
watch([() => search.value, () => isComposing.value],
debounce(async () => {
reloadPage(true)
if (!isComposing.value) {
console.log('search: ' + search.value)
reloadPage(true)
}
}, 1000),
)
@@ -361,15 +371,16 @@ onMounted(fetchData)
<VCardItem>
<VCardTitle>
<VRow>
<VCol cols="4" md="6"> 历史记录 </VCol>
<VCol cols="8" md="6" class="flex">
<VCombobox
key="search_navbar"
v-model="search"
:items="searchHintList"
@compositionstart="isComposing = true"
@compositionend="isComposing = false"
class="text-disabled"
density="compact"
label="搜索目录、状态"
label="搜索整理记录"
prepend-inner-icon="mdi-magnify"
variant="solo-filled"
single-line
@@ -379,6 +390,16 @@ onMounted(fetchData)
clearable
/>
</VCol>
<VCol cols="4" md="6" class="text-end">
<VBtn
color="primary"
prepend-icon="mdi-tray-full"
append-icon="mdi-dots-horizontal"
@click="transferQueueDialog = true"
>
<span v-if="display.mdAndUp.value" class="ms-2">整理队列</span>
</VBtn>
</VCol>
</VRow>
</VCardTitle>
</VCardItem>
@@ -514,13 +535,13 @@ onMounted(fetchData)
{{ confirmTitle }}
</VCardTitle>
<div class="d-flex flex-column flex-lg-row justify-center my-3">
<VBtn color="primary" class="mb-2 mx-2" @click="deleteConfirmHandler(false, false)"> 仅删除历史记录 </VBtn>
<VBtn color="warning" class="mb-2 mx-2" @click="deleteConfirmHandler(true, false)"> 删除历史记录和源文件 </VBtn>
<VBtn color="primary" class="mb-2 mx-2" @click="deleteConfirmHandler(false, false)"> 仅删除整理记录 </VBtn>
<VBtn color="warning" class="mb-2 mx-2" @click="deleteConfirmHandler(true, false)"> 删除整理记录和源文件 </VBtn>
<VBtn color="info" class="mb-2 mx-2" @click="deleteConfirmHandler(false, true)">
删除历史记录和媒体库文件
删除整理记录和媒体库文件
</VBtn>
<VBtn color="error" class="mb-2 mx-2" @click="deleteConfirmHandler(true, true)">
删除历史记录源文件和媒体库文件
删除整理记录源文件和媒体库文件
</VBtn>
</div>
</VCard>
@@ -536,6 +557,8 @@ onMounted(fetchData)
@done="transferDone"
@close="redoDialog = false"
/>
<!-- 整理队列进度弹窗 -->
<TransferQueueDialog v-if="transferQueueDialog" v-model="transferQueueDialog" @close="transferQueueDialog = false" />
</template>
<style lang="scss">

View File

@@ -165,7 +165,7 @@ onMounted(() => {
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
<span class="flex-grow undefined">
<a
href="https://github.com/jxxghp/MoviePilot/blob/main/README.md"
href="https://wiki.movie-pilot.org"
target="_blank"
rel="noreferrer"
class="text-indigo-500 transition duration-300 hover:underline"

View File

@@ -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>

View File

@@ -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('搜索基础设置保存失败!')

View File

@@ -26,17 +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,
// 媒体
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,
@@ -44,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,
},
})
@@ -171,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)
@@ -183,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 {
@@ -238,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-_'
@@ -402,7 +427,14 @@ onDeactivated(() => {
<div class="d-flex flex-wrap gap-4 mt-4">
<VBtn type="submit" @click="saveBasicSettings"> 保存 </VBtn>
<VSpacer />
<VBtn color="warning" @click="advancedDialog = true" append-icon="mdi-dots-horizontal"> 高级设置 </VBtn>
<VBtn
color="error"
@click="advancedDialog = true"
prepend-icon="mdi-cog"
append-icon="mdi-dots-horizontal"
>
高级设置
</VBtn>
</div>
</VForm>
</VCardText>
@@ -528,6 +560,9 @@ onDeactivated(() => {
<VTab value="network">
<div>网络</div>
</VTab>
<VTab value="log">
<div>日志</div>
</VTab>
<VTab value="dev">
<div>实验室</div>
</VTab>
@@ -597,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>
@@ -699,19 +734,65 @@ onDeactivated(() => {
</VRow>
</div>
</VWindowItem>
<VWindowItem value="dev">
<VWindowItem value="log">
<div>
<VRow>
<VCol cols="12" md="4">
<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="4">
<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"
label="插件热加载"
@@ -719,7 +800,7 @@ onDeactivated(() => {
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VCol cols="12" md="6">
<VSwitch
v-model="SystemSettings.Advanced.ENCODING_DETECTION_PERFORMANCE_MODE"
label="编码探测性能模式"
@@ -727,6 +808,14 @@ onDeactivated(() => {
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="SystemSettings.Advanced.TOKENIZED_SEARCH"
label="分词搜索"
hint="提升整理历史记录搜索精度,但可能增加性能开销和意外结果"
persistent-hint
/>
</VCol>
</VRow>
</div>
</VWindowItem>

View File

@@ -35,6 +35,18 @@ const historyDialog = ref(false)
// 订阅顺序配置
const orderConfig = ref<{ id: number }[]>([])
// 显示的订阅列表
const displayList = ref<Subscribe[]>([])
// 监听dataList变化同步更新displayList
watch(dataList, () => {
// 从Vuex Store中获取用户信息
const superUser = store.state.auth.superUser
const userName = store.state.auth.userName
if (superUser) displayList.value = dataList.value.filter(data => data.type === props.type)
else displayList.value = dataList.value.filter(data => data.type === props.type && data.username === userName)
})
// 加载顺序
async function loadSubscribeOrderConfig() {
// 顺序配置
@@ -48,15 +60,17 @@ async function loadSubscribeOrderConfig() {
localStorage.setItem('MP_SUBSCRIBE_ORDER', JSON.stringify(orderConfig.value))
}
}
// 排序
if (orderConfig.value) {
sortSubscribeOrder()
}
}
// 按order的顺序排序
function sortSubscribeOrder() {
displayList.value.sort((a, b) => {
if (!orderConfig.value) {
return
}
if (dataList.value.length === 0) {
return
}
dataList.value.sort((a, b) => {
const aIndex = orderConfig.value.findIndex((item: { id: number }) => item.id === a.id)
const bIndex = orderConfig.value.findIndex((item: { id: number }) => item.id === b.id)
return (aIndex === -1 ? 999 : aIndex) - (bIndex === -1 ? 999 : bIndex)
@@ -67,6 +81,7 @@ function sortSubscribeOrder() {
async function saveSubscribeOrder() {
// 顺序配置
const orderObj = displayList.value.map(item => ({ id: item.id }))
orderConfig.value = orderObj
const orderString = JSON.stringify(orderObj)
localStorage.setItem('MP_SUBSCRIBE_ORDER', orderString)
@@ -83,6 +98,8 @@ async function fetchData() {
try {
loading.value = true
dataList.value = await api.get('subscribe/')
// 排序
sortSubscribeOrder()
loading.value = false
isRefreshed.value = true
} catch (error) {
@@ -96,28 +113,12 @@ const loading = ref(false)
// 下拉刷新
async function onRefresh({ done }: { done: any }) {
await fetchData()
await loadSubscribeOrderConfig()
done('ok')
}
// 显示的订阅列表
const displayList = ref<Subscribe[]>([])
// 监听dataList变化同步更新displayList
watch(dataList, () => {
// 从Vuex Store中获取用户信息
const superUser = store.state.auth.superUser
const userName = store.state.auth.userName
if (superUser) displayList.value = dataList.value.filter(data => data.type === props.type)
else displayList.value = dataList.value.filter(data => data.type === props.type && data.username === userName)
})
// 过滤数据,管理员用户显示全部,非管理员只显示自己的订阅
const filteredDataList = computed(() => {})
onMounted(async () => {
await fetchData()
await loadSubscribeOrderConfig()
await fetchData()
if (props.subid) {
// 找到这个订阅
const sub = dataList.value.find(sub => sub.id.toString() == props.subid?.toString())
@@ -131,7 +132,6 @@ onMounted(async () => {
onActivated(async () => {
if (!loading.value) {
await fetchData()
await loadSubscribeOrderConfig()
}
})
</script>

View File

@@ -300,7 +300,7 @@ onMounted(() => {
<template #default="hover">
<VListItem prepend-icon="mdi-history" link v-bind="hover.props" @click="searchHistory">
<VListItemTitle class="break-words whitespace-break-spaces">
搜索 <span class="font-bold">{{ searchWord }}</span> 相关的历史记录 ...
搜索 <span class="font-bold">{{ searchWord }}</span> 相关的整理记录 ...
</VListItemTitle>
<template #append>
<VIcon v-if="hover.isHovering" icon="ri-corner-down-left-line" />

View File

@@ -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>

View File

@@ -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"