mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-28 11:37:29 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afb617724c | ||
|
|
05bbecba53 | ||
|
|
deb9b103da | ||
|
|
2d92315e17 | ||
|
|
077f0c1366 | ||
|
|
eab4caa279 | ||
|
|
3bba73abf1 | ||
|
|
8edf1f686a | ||
|
|
3440b377ee | ||
|
|
9161e5e300 | ||
|
|
25fdb09c61 | ||
|
|
25119b224d | ||
|
|
76f79e1656 | ||
|
|
b5230cef79 | ||
|
|
f871746efb | ||
|
|
d17ca69a28 | ||
|
|
e445437129 | ||
|
|
090c5476c6 | ||
|
|
646c7b33dd | ||
|
|
0902f16f84 | ||
|
|
ec4eaed1df | ||
|
|
175f610524 | ||
|
|
530fe9d35b | ||
|
|
48ed396a19 | ||
|
|
b0356c217d | ||
|
|
55eed1ecb5 | ||
|
|
50ae739a4d | ||
|
|
d9cbcc2991 | ||
|
|
ad12701fe2 | ||
|
|
2b426a47c6 | ||
|
|
7f0f12ac41 | ||
|
|
6789d63ca1 | ||
|
|
3202251f55 | ||
|
|
8e99ad9cf9 | ||
|
|
83dde400e7 | ||
|
|
1b57f925ee | ||
|
|
16428066b9 | ||
|
|
e211a80cf4 | ||
|
|
ea0b5b62d9 | ||
|
|
62dc2c4f66 | ||
|
|
b2a2c7080e | ||
|
|
05c2e7855a | ||
|
|
8d9c622dc5 | ||
|
|
bf0b17c314 | ||
|
|
37f31f6554 | ||
|
|
3de409fb07 | ||
|
|
7e9c0fd206 | ||
|
|
fb4f5658a8 |
@@ -0,0 +1,123 @@
|
||||
name: PR Agent
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
# PR-Agent 通过 base repo 上下文读取 PR diff 并发布 Review,不 checkout 或执行 PR 分支代码。
|
||||
# pull_request_target 允许 fork PR 使用仓库 secrets,因此 workflow 只运行固定 digest 的 PR-Agent 容器。
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- ready_for_review
|
||||
- review_requested
|
||||
- synchronize
|
||||
issue_comment:
|
||||
# 手动命令如 "/review"、"/describe"、"/improve" 和 "/ask ..." 只在 PR 评论中有意义。
|
||||
# issue_comment 同时覆盖普通 issue,因此 job 里还会再判断是否属于 PR。
|
||||
types:
|
||||
- created
|
||||
- edited
|
||||
|
||||
permissions:
|
||||
# 读取仓库内容和 PR diff。
|
||||
contents: read
|
||||
# 更新 PR 描述、发布 PR Review 或修改 PR 相关元数据。
|
||||
pull-requests: write
|
||||
# PR 评论在 GitHub API 中属于 issue comments,手动命令和总结评论需要该权限。
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
pr-agent:
|
||||
name: PR-Agent review and describe
|
||||
# PR 事件自动处理;评论命令仅允许指定身份在 PR 下触发,避免任意评论消耗模型配额。
|
||||
if: >-
|
||||
github.event.sender.type != 'Bot' &&
|
||||
(
|
||||
github.event_name == 'pull_request_target' ||
|
||||
(
|
||||
github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request != null &&
|
||||
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIME_CONTRIBUTOR"]'), github.event.comment.author_association) &&
|
||||
(
|
||||
github.event.comment.body == '/review' ||
|
||||
startsWith(github.event.comment.body, '/review ') ||
|
||||
github.event.comment.body == '/describe' ||
|
||||
startsWith(github.event.comment.body, '/describe ') ||
|
||||
github.event.comment.body == '/improve' ||
|
||||
startsWith(github.event.comment.body, '/improve ') ||
|
||||
github.event.comment.body == '/ask' ||
|
||||
startsWith(github.event.comment.body, '/ask ')
|
||||
)
|
||||
)
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Run PR-Agent
|
||||
id: pragent
|
||||
# 使用版本号加 digest 固定容器构建,避免 tag 被重推后改变运行内容。
|
||||
uses: docker://pragent/pr-agent:0.37.0-github_action@sha256:4ec7bac814050a1bc8c96ab2fab6b7b0f65df0049a5ec43f3fee1a0b551c28ca
|
||||
env:
|
||||
# PR-Agent 使用该 token 读取 PR 元数据并发布评论。
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
# 仓库设置中添加的 Secret:Settings -> Secrets and variables -> Actions。
|
||||
# 该 key 只传给 PR-Agent 运行时,不写入仓库。
|
||||
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
|
||||
|
||||
# 仓库设置中添加的 Secret。OpenAI 兼容服务通常需要填写以 "/v1" 结尾的 API 根地址。
|
||||
OPENAI.API_BASE: ${{ secrets.OPENAI_API_BASE }}
|
||||
|
||||
# 模型、输出语言和大 diff 处理策略。
|
||||
config.model: "gpt-5.5"
|
||||
config.fallback_models: '["gpt-5.4"]'
|
||||
config.reasoning_effort: "xhigh"
|
||||
config.ai_timeout: "900"
|
||||
config.response_language: "zh-CN"
|
||||
config.large_patch_policy: "clip"
|
||||
config.ignore_pr_title: '["^\\[Auto\\]", "^Auto"]'
|
||||
config.ignore_pr_labels: '["skip pr-agent"]'
|
||||
|
||||
# pull_request_target 事件默认自动执行 /review 和 /describe;/improve 保持手动触发。
|
||||
github_action_config.auto_review: "true"
|
||||
github_action_config.auto_describe: "true"
|
||||
github_action_config.auto_improve: "false"
|
||||
|
||||
# 允许触发自动工具的 PR 动作。包含 synchronize,便于新 commit 推送后刷新结果。
|
||||
github_action_config.pr_actions: '["opened", "reopened", "ready_for_review", "review_requested", "synchronize"]'
|
||||
|
||||
# 保留 action outputs,便于后续 workflow 编排或排查。
|
||||
github_action_config.enable_output: "true"
|
||||
|
||||
# /describe 行为控制;与自动触发配置放在同一层,避免使用默认图表和标签策略。
|
||||
pr_description.generate_ai_title: "false"
|
||||
pr_description.publish_labels: "false"
|
||||
pr_description.enable_pr_diagram: "false"
|
||||
pr_description.collapsible_file_list: "adaptive"
|
||||
pr_description.add_original_user_description: "true"
|
||||
|
||||
# /review 输出策略,聚焦维护者需要处理的风险和缺口。
|
||||
pr_reviewer.extra_instructions: |
|
||||
请用中文输出。
|
||||
优先指出 P0/P1 风险,避免纠结纯格式问题。
|
||||
重点检查安全、权限、状态一致性、异步/缓存、副作用和测试缺口。
|
||||
pr_reviewer.num_max_findings: "5"
|
||||
pr_reviewer.persistent_comment: "true"
|
||||
pr_reviewer.publish_output_no_suggestions: "true"
|
||||
pr_reviewer.require_tests_review: "true"
|
||||
pr_reviewer.require_security_review: "true"
|
||||
pr_reviewer.require_estimate_effort_to_review: "true"
|
||||
pr_reviewer.require_can_be_split_review: "true"
|
||||
pr_reviewer.require_todo_scan: "false"
|
||||
pr_reviewer.enable_review_labels_effort: "false"
|
||||
pr_reviewer.enable_review_labels_security: "true"
|
||||
|
||||
# /improve 和 /ask 的手动命令策略。
|
||||
pr_code_suggestions.focus_only_on_problems: "true"
|
||||
pr_code_suggestions.suggestions_score_threshold: "7"
|
||||
pr_code_suggestions.commitable_code_suggestions: "false"
|
||||
pr_questions.use_conversation_history: "true"
|
||||
|
||||
# 可选成本和噪音控制:
|
||||
# github_action_config.auto_improve: "true"
|
||||
# config.verbosity_level: "1"
|
||||
# pr_reviewer.num_max_findings: "3"
|
||||
@@ -37,3 +37,4 @@ src/@iconify/*.js
|
||||
public/plugin_icon/**
|
||||
docs-lock/
|
||||
.trae/
|
||||
output/
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "moviepilot",
|
||||
"version": "2.13.13",
|
||||
"version": "2.13.15",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": "dist/service.js",
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ import { checkAndEmitUnreadMessages } from '@/utils/badge'
|
||||
import { preloadImage } from './@core/utils/image'
|
||||
import { globalLoadingStateManager } from '@/utils/loadingStateManager'
|
||||
import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager'
|
||||
import PWAInstallPrompt from '@/components/PWAInstallPrompt.vue'
|
||||
import PWAInstallPrompt from '@/components/pwa/PWAInstallPrompt.vue'
|
||||
import SharedDialogHost from '@/components/dialog/SharedDialogHost.vue'
|
||||
import { applyStoredThemeCustomizerAppearance } from '@/composables/useThemeCustomizer'
|
||||
import { completeLaunchLoading } from '@/composables/useLaunchLoading'
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+1160
-270
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import AgentAssistantEntry from './AgentAssistantEntry.vue'
|
||||
import AgentAssistantPanel from './AgentAssistantPanel.vue'
|
||||
|
||||
type AgentAssistantEntryRef = InstanceType<typeof AgentAssistantEntry>
|
||||
|
||||
const panelOpen = ref(false)
|
||||
const thinking = ref(false)
|
||||
const entryRef = ref<AgentAssistantEntryRef | null>(null)
|
||||
|
||||
function openPanel() {
|
||||
panelOpen.value = true
|
||||
entryRef.value?.clearBubbles()
|
||||
}
|
||||
|
||||
function handleAssistantPreview(value: string) {
|
||||
if (panelOpen.value) return
|
||||
|
||||
entryRef.value?.showAssistantReplyPreview(value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AgentAssistantEntry ref="entryRef" :active="!panelOpen" :thinking="thinking" @open="openPanel" />
|
||||
<AgentAssistantPanel v-model="panelOpen" @assistant-preview="handleAssistantPreview" @thinking-change="thinking = $event" />
|
||||
</template>
|
||||
@@ -13,6 +13,12 @@ const props = defineProps({
|
||||
const imageLoaded = ref(false)
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
const cardStyle = computed(() => ({
|
||||
aspectRatio: props.height ? undefined : '3 / 2',
|
||||
blockSize: props.height,
|
||||
inlineSize: props.width || '100%',
|
||||
}))
|
||||
|
||||
// 图片加载完成响应
|
||||
function imageLoadHandler() {
|
||||
imageLoaded.value = true
|
||||
@@ -46,22 +52,30 @@ const getImgUrl = computed(() => {
|
||||
<template>
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:height="props.height"
|
||||
:width="props.width"
|
||||
class="ring-gray-500"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
'ring-1': imageLoaded,
|
||||
}"
|
||||
@click="goPlay"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="backdrop-card-hover-area">
|
||||
<VCard
|
||||
:style="cardStyle"
|
||||
class="app-hover-lift-card ring-gray-500"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
'ring-1': imageLoaded,
|
||||
}"
|
||||
@click="goPlay"
|
||||
>
|
||||
<template #image>
|
||||
<VImg :src="getImgUrl" aspect-ratio="2/3" cover @load="imageLoadHandler" @error="imageErrorHandler">
|
||||
<VImg
|
||||
:src="getImgUrl"
|
||||
aspect-ratio="2/3"
|
||||
class="backdrop-card-image"
|
||||
:class="{ 'backdrop-card-image--loaded': imageLoaded }"
|
||||
cover
|
||||
@load="imageLoadHandler"
|
||||
@error="imageErrorHandler"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
|
||||
<div class="backdrop-card-placeholder">
|
||||
<VSkeletonLoader class="backdrop-card-skeleton" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
@@ -86,7 +100,43 @@ const getImgUrl = computed(() => {
|
||||
color="success"
|
||||
/>
|
||||
</div>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* stylelint-disable selector-pseudo-class-no-unknown */
|
||||
|
||||
.backdrop-card-hover-area {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.backdrop-card-image {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.backdrop-card-placeholder,
|
||||
.backdrop-card-skeleton {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.backdrop-card-image :deep(.v-img__img) {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.backdrop-card-image--loaded :deep(.v-img__img) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.backdrop-card-image :deep(.v-img__img) {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -73,16 +73,16 @@ async function deleteDownload() {
|
||||
<template>
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-if="cardState"
|
||||
v-bind="hover.props"
|
||||
:key="props.info?.hash"
|
||||
class="downloading-card app-surface flex flex-col h-full overflow-hidden"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
}"
|
||||
min-height="150"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-if="cardState" v-bind="hover.props" class="downloading-card-hover-area h-full">
|
||||
<VCard
|
||||
:key="props.info?.hash"
|
||||
class="downloading-card app-hover-lift-card app-surface flex flex-col h-full overflow-hidden"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
}"
|
||||
min-height="150"
|
||||
>
|
||||
<template #image>
|
||||
<VImg
|
||||
:src="props.info?.media.image"
|
||||
@@ -130,7 +130,8 @@ async function deleteDownload() {
|
||||
<VBtn color="error" icon="mdi-trash-can-outline" @click="deleteDownload" />
|
||||
</VCardActions>
|
||||
</div>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</template>
|
||||
@@ -138,6 +139,10 @@ async function deleteDownload() {
|
||||
<style lang="scss" scoped>
|
||||
/* stylelint-disable selector-pseudo-class-no-unknown */
|
||||
|
||||
.downloading-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.downloading-card-image {
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,12 @@ const imageLoaded = ref(false)
|
||||
// 图片是否加载错误
|
||||
const imageError = ref(false)
|
||||
|
||||
const cardStyle = computed(() => ({
|
||||
aspectRatio: props.height ? undefined : '3 / 2',
|
||||
blockSize: props.height,
|
||||
inlineSize: props.width || '100%',
|
||||
}))
|
||||
|
||||
// 图片加载完成响应
|
||||
function imageLoadHandler() {
|
||||
imageLoaded.value = true
|
||||
@@ -156,21 +162,30 @@ onMounted(async () => {
|
||||
<template>
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:height="props.height"
|
||||
:width="props.width"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
}"
|
||||
@click="goPlay"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="library-card-hover-area">
|
||||
<VCard
|
||||
:style="cardStyle"
|
||||
class="app-hover-lift-card"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
}"
|
||||
@click="goPlay"
|
||||
>
|
||||
<template #image>
|
||||
<canvas ref="canvasRef" width="640" height="360" class="w-full h-full hidden" />
|
||||
<VImg :src="imgUrl" aspect-ratio="2/3" cover @load="imageLoadHandler" @error="imageErrorHandler">
|
||||
<VImg
|
||||
:src="imgUrl"
|
||||
aspect-ratio="2/3"
|
||||
class="library-card-image"
|
||||
:class="{ 'library-card-image--loaded': imageLoaded }"
|
||||
cover
|
||||
@load="imageLoadHandler"
|
||||
@error="imageErrorHandler"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
|
||||
<div class="library-card-placeholder">
|
||||
<VSkeletonLoader class="library-card-skeleton" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
@@ -184,7 +199,43 @@ onMounted(async () => {
|
||||
</template>
|
||||
</VImg>
|
||||
</template>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* stylelint-disable selector-pseudo-class-no-unknown */
|
||||
|
||||
.library-card-hover-area {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.library-card-image {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.library-card-placeholder,
|
||||
.library-card-skeleton {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.library-card-image :deep(.v-img__img) {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.library-card-image--loaded :deep(.v-img__img) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.library-card-image :deep(.v-img__img) {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+178
-210
@@ -1,27 +1,34 @@
|
||||
<script lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const activeTouchMediaCardId = ref<number | null>(null)
|
||||
let mediaCardIdSeed = 0
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import noImage from '@images/no-image.jpeg'
|
||||
import { getDisplayImageUrl, getLogoUrl } from '@/utils/imageUtils'
|
||||
import api from '@/api'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { formatSeason, formatRating } from '@/@core/utils/formatters'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import type { MediaInfo, Subscribe, MediaSeason, Site } from '@/api/types'
|
||||
import { formatRating } from '@/@core/utils/formatters'
|
||||
import type { MediaInfo, Site, Subscribe } from '@/api/types'
|
||||
import router from '@/router'
|
||||
import { useUserStore, useGlobalSettingsStore } from '@/stores'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { mediaTypeDict } from '@/api/constants'
|
||||
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import {
|
||||
getMediaSubscribeId,
|
||||
getSubscribeMode,
|
||||
useMediaSubscribe,
|
||||
type SeasonSubscribeModes,
|
||||
} from '@/composables/useMediaSubscribe'
|
||||
import {
|
||||
getCachedMediaExistsStatus,
|
||||
getCachedMediaSubscribeStatus,
|
||||
setCachedMediaExistsStatus,
|
||||
setCachedMediaSubscribeStatus,
|
||||
} from '@/utils/mediaStatusCache'
|
||||
|
||||
const SearchSiteDialog = defineAsyncComponent(() => import('@/components/dialog/SearchSiteDialog.vue'))
|
||||
const SubscribeEditDialog = defineAsyncComponent(() => import('../dialog/SubscribeEditDialog.vue'))
|
||||
const SubscribeSeasonDialog = defineAsyncComponent(() => import('../dialog/SubscribeSeasonDialog.vue'))
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -49,9 +56,6 @@ const userPermissions = computed(() => buildUserPermissionContext(userStore.supe
|
||||
const canSearch = computed(() => hasPermission(userPermissions.value, 'search'))
|
||||
const canSubscribe = computed(() => hasPermission(userPermissions.value, 'subscribe'))
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 图片加载状态
|
||||
const isImageLoaded = ref(false)
|
||||
|
||||
@@ -64,8 +68,15 @@ const isSubscribed = ref(false)
|
||||
// 本地存在状态
|
||||
const isExists = ref(false)
|
||||
|
||||
// 选中的订阅季
|
||||
const seasonsSelected = ref<MediaSeason[]>([])
|
||||
// 当前媒体已订阅的季号
|
||||
const subscribedSeasons = ref<number[]>([])
|
||||
|
||||
// 当前媒体已订阅季的订阅模式
|
||||
const subscribedSeasonModes = ref<SeasonSubscribeModes>({})
|
||||
|
||||
const subscribedSeasonsLoaded = ref(false)
|
||||
|
||||
const subscribedSeasonsLoading = ref(false)
|
||||
|
||||
// 来源角标字典
|
||||
const sourceIconDict: { [key: string]: any } = {
|
||||
@@ -89,32 +100,12 @@ const selectedSites = ref<number[]>([])
|
||||
// 搜索菜单显示状态
|
||||
const searchMenuShow = ref(false)
|
||||
|
||||
// 选择的剧集组
|
||||
const episodeGroup = ref('')
|
||||
const mediaCardId = ++mediaCardIdSeed
|
||||
|
||||
// 打开订阅季选择弹窗,避免每个媒体卡片都持有弹窗实例。
|
||||
function openSubscribeSeasonDialog() {
|
||||
openSharedDialog(
|
||||
SubscribeSeasonDialog,
|
||||
{ media: props.media },
|
||||
{
|
||||
subscribe: subscribeSeasons,
|
||||
},
|
||||
{ closeOn: ['close', 'subscribe'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 打开订阅编辑弹窗,保存、关闭或删除时释放共享弹窗实例。
|
||||
function openSubscribeEditDialog(subid: number) {
|
||||
openSharedDialog(
|
||||
SubscribeEditDialog,
|
||||
{ subid },
|
||||
{
|
||||
remove: onRemoveSubscribe,
|
||||
},
|
||||
{ closeOn: ['close', 'save', 'remove'] },
|
||||
)
|
||||
}
|
||||
// 粗指针设备使用点击展开详情,避免 iOS 返回后沿用 VHover 的触摸态。
|
||||
const isTouchLikePointer = ref(
|
||||
typeof window !== 'undefined' && Boolean(window.matchMedia?.('(hover: none), (pointer: coarse)').matches),
|
||||
)
|
||||
|
||||
// 打开站点选择弹窗,并把选择结果交回当前媒体卡片继续搜索。
|
||||
function openSearchSiteDialog() {
|
||||
@@ -155,10 +146,7 @@ async function querySelectedSites() {
|
||||
|
||||
// 获得mediaid
|
||||
function getMediaId() {
|
||||
if (props.media?.tmdb_id) return `tmdb:${props.media?.tmdb_id}`
|
||||
else if (props.media?.douban_id) return `douban:${props.media?.douban_id}`
|
||||
else if (props.media?.bangumi_id) return `bangumi:${props.media?.bangumi_id}`
|
||||
else return `${props.media?.mediaid_prefix}:${props.media?.media_id}`
|
||||
return getMediaSubscribeId(props.media)
|
||||
}
|
||||
|
||||
function getSubscribeStatusKey(season: number | null = props.media?.season ?? null) {
|
||||
@@ -177,6 +165,15 @@ function getExistsStatusKey() {
|
||||
].join('::')
|
||||
}
|
||||
|
||||
function isSameSubscribeMedia(subscribe: Subscribe) {
|
||||
if (props.media?.tmdb_id && subscribe.tmdbid) return props.media.tmdb_id === subscribe.tmdbid
|
||||
if (props.media?.douban_id && subscribe.doubanid) return props.media.douban_id === subscribe.doubanid
|
||||
if (props.media?.bangumi_id && subscribe.bangumiid) return props.media.bangumi_id === subscribe.bangumiid
|
||||
|
||||
const mediaId = props.media?.media_id ? `${props.media.mediaid_prefix}:${props.media.media_id}` : ''
|
||||
return Boolean(mediaId && subscribe.mediaid === mediaId)
|
||||
}
|
||||
|
||||
// 角标颜色
|
||||
function getChipColor(type: string) {
|
||||
if (type === '电影') return 'border-blue-500 bg-blue-600'
|
||||
@@ -184,101 +181,6 @@ function getChipColor(type: string) {
|
||||
else return 'border-purple-600 bg-purple-600'
|
||||
}
|
||||
|
||||
// 添加订阅处理
|
||||
async function handleAddSubscribe() {
|
||||
if (props.media?.type === '电视剧') {
|
||||
// 弹出季选择列表,支持多选
|
||||
seasonsSelected.value = []
|
||||
openSubscribeSeasonDialog()
|
||||
} else {
|
||||
// 电影
|
||||
addSubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
// 调用API添加订阅,电视剧的话需要指定季
|
||||
async function addSubscribe(season: number | null = null, best_version: number = 0) {
|
||||
// 开始处理
|
||||
startNProgress()
|
||||
try {
|
||||
// 是否洗版
|
||||
if (!best_version && props.media?.type == '电影') best_version = isExists.value ? 1 : 0
|
||||
// 请求API
|
||||
const result: { [key: string]: any } = await api.post('subscribe/', {
|
||||
name: props.media?.title,
|
||||
type: props.media?.type,
|
||||
year: props.media?.year,
|
||||
tmdbid: props.media?.tmdb_id,
|
||||
doubanid: props.media?.douban_id,
|
||||
bangumiid: props.media?.bangumi_id,
|
||||
mediaid: props.media?.media_id ? `${props.media?.mediaid_prefix}:${props.media?.media_id}` : '',
|
||||
season: props.media?.type === '电影' ? null : season,
|
||||
best_version,
|
||||
episode_group: episodeGroup.value,
|
||||
})
|
||||
|
||||
// 订阅状态
|
||||
if (result.success) {
|
||||
// 订阅成功
|
||||
isSubscribed.value = true
|
||||
setCachedMediaSubscribeStatus(getSubscribeStatusKey(season), true)
|
||||
}
|
||||
|
||||
// 提示
|
||||
showSubscribeAddToast(result.success, props.media?.title ?? '', season, result.message, best_version)
|
||||
|
||||
// 弹出订阅编辑弹窗
|
||||
if (result.success && seasonsSelected.value.length <= 1) {
|
||||
const show_edit_dialog = await queryDefaultSubscribeConfig()
|
||||
if (show_edit_dialog) {
|
||||
openSubscribeEditDialog(result.data.id)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
doneNProgress()
|
||||
}
|
||||
}
|
||||
|
||||
// 弹出添加订阅提示
|
||||
function showSubscribeAddToast(result: boolean, title: string, season: number | null, message: string, best_version: number) {
|
||||
if (season !== null) title = `${title} ${formatSeason(season.toString())}`
|
||||
|
||||
let subname = t('subscribe.normalSub')
|
||||
if (best_version > 0) subname = t('subscribe.versionSub')
|
||||
|
||||
if (result) $toast.success(`${title} ${t('subscribe.addSuccess', { name: subname })}`)
|
||||
else if (!result) $toast.error(`${title} ${t('subscribe.addFailed', { name: subname, message: message })}`)
|
||||
}
|
||||
|
||||
// 调用API取消订阅
|
||||
async function removeSubscribe() {
|
||||
// 开始处理
|
||||
startNProgress()
|
||||
try {
|
||||
const mediaid = getMediaId()
|
||||
|
||||
const result: { [key: string]: any } = await api.delete(`subscribe/media/${mediaid}`, {
|
||||
params: {
|
||||
season: props.media?.season,
|
||||
},
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
isSubscribed.value = false
|
||||
setCachedMediaSubscribeStatus(getSubscribeStatusKey(props.media?.season ?? null), false)
|
||||
$toast.success(`${props.media?.title} ${t('subscribe.cancelSuccess')}`)
|
||||
} else {
|
||||
$toast.error(`${props.media?.title} ${t('subscribe.cancelFailed', { message: result.message })}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
doneNProgress()
|
||||
}
|
||||
}
|
||||
|
||||
// 查询当前媒体是否已订阅
|
||||
async function handleCheckSubscribe() {
|
||||
try {
|
||||
@@ -291,6 +193,36 @@ async function handleCheckSubscribe() {
|
||||
}
|
||||
}
|
||||
|
||||
async function querySubscribedSeasons() {
|
||||
if (
|
||||
props.media?.type !== '电视剧' ||
|
||||
!isSubscribed.value ||
|
||||
subscribedSeasonsLoaded.value ||
|
||||
subscribedSeasonsLoading.value
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
subscribedSeasonsLoading.value = true
|
||||
try {
|
||||
const subscribes: Subscribe[] = await api.get('subscribe/')
|
||||
const mediaSubscribes = subscribes.filter(
|
||||
item => item.type === '电视剧' && item.season !== undefined && isSameSubscribeMedia(item),
|
||||
)
|
||||
|
||||
subscribedSeasons.value = mediaSubscribes.map(item => item.season as number).sort((a, b) => a - b)
|
||||
subscribedSeasonModes.value = mediaSubscribes.reduce<SeasonSubscribeModes>((modes, item) => {
|
||||
if (item.season !== undefined) modes[item.season] = getSubscribeMode(item)
|
||||
return modes
|
||||
}, {})
|
||||
subscribedSeasonsLoaded.value = true
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
subscribedSeasonsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 查询当前媒体是否已入库
|
||||
async function handleCheckExists() {
|
||||
try {
|
||||
@@ -317,63 +249,20 @@ async function handleCheckExists() {
|
||||
|
||||
// 调用API检查是否已订阅,电视剧需要指定季
|
||||
async function checkSubscribe(season: number | null) {
|
||||
try {
|
||||
// AbortController 现在由全局请求优化器自动管理
|
||||
const mediaid = getMediaId()
|
||||
const result: Subscribe = await api.get(`subscribe/media/${mediaid}`, {
|
||||
params: {
|
||||
season,
|
||||
title: props.media?.title,
|
||||
},
|
||||
})
|
||||
|
||||
return Boolean(result.id)
|
||||
} catch (error: any) {
|
||||
if (error?.response?.status === 404) {
|
||||
return false
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 查询订阅弹窗规则
|
||||
async function queryDefaultSubscribeConfig() {
|
||||
if (!canSubscribe.value) return false
|
||||
try {
|
||||
let subscribe_config_url = ''
|
||||
if (props.media?.type === '电影') subscribe_config_url = 'system/setting/public/DefaultMovieSubscribeConfig'
|
||||
else subscribe_config_url = 'system/setting/public/DefaultTvSubscribeConfig'
|
||||
const result: { [key: string]: any } = await api.get(subscribe_config_url)
|
||||
if (result.data?.value) return result.data.value.show_edit_dialog
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
return false
|
||||
return subscribeActions.checkSubscribe(season)
|
||||
}
|
||||
|
||||
// 爱心订阅按钮响应
|
||||
function handleSubscribe() {
|
||||
if (isSubscribed.value) removeSubscribe()
|
||||
else handleAddSubscribe()
|
||||
}
|
||||
|
||||
// 订阅多季
|
||||
function subscribeSeasons(seasons: MediaSeason[], seasonNoExists: { [key: number]: number }, groudId: string) {
|
||||
episodeGroup.value = groudId
|
||||
seasonsSelected.value = seasons || []
|
||||
seasonsSelected.value.forEach(season => {
|
||||
let best_version = 0
|
||||
if (season && props.media?.tmdb_id)
|
||||
// 全部存在时洗版
|
||||
best_version = !seasonNoExists[season.season_number || 0] ? 1 : 0
|
||||
addSubscribe(season.season_number ?? null, best_version)
|
||||
})
|
||||
async function handleSubscribe() {
|
||||
await querySubscribedSeasons()
|
||||
subscribeActions.handleSubscribe()
|
||||
}
|
||||
|
||||
// 打开详情页
|
||||
function goMediaDetail(isHovering = false) {
|
||||
if (isHovering) {
|
||||
resetMediaCardDetailState()
|
||||
|
||||
if (props.media?.collection_id) {
|
||||
// 跳转到合集列表
|
||||
router.push({
|
||||
@@ -397,6 +286,42 @@ function goMediaDetail(isHovering = false) {
|
||||
}
|
||||
}
|
||||
|
||||
// 当前卡片是否进入可操作详情态,桌面使用 hover,触摸端使用显式点击态。
|
||||
function isMediaCardActive(isHovering: boolean | null | undefined) {
|
||||
return activeTouchMediaCardId.value === mediaCardId || (!isTouchLikePointer.value && Boolean(isHovering))
|
||||
}
|
||||
|
||||
// 当前卡片详情层是否显示,保留图片错误和站点选择时的强制显示。
|
||||
function isMediaCardDetailVisible(isHovering: boolean | null | undefined) {
|
||||
return isMediaCardActive(isHovering) || imageLoadError.value || searchMenuShow.value
|
||||
}
|
||||
|
||||
// 清理移动端详情层展开态,避免路由返回后继续显示上一次的详情层。
|
||||
function resetMediaCardDetailState() {
|
||||
if (activeTouchMediaCardId.value === mediaCardId) {
|
||||
activeTouchMediaCardId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 处理媒体卡片点击:触摸端第一次点击展开,展开后再次点击进入详情。
|
||||
function handleMediaCardClick(isHovering: boolean | null | undefined) {
|
||||
if (isTouchLikePointer.value && !isMediaCardDetailVisible(isHovering)) {
|
||||
activeTouchMediaCardId.value = mediaCardId
|
||||
querySubscribedSeasons()
|
||||
return
|
||||
}
|
||||
|
||||
goMediaDetail(isMediaCardActive(isHovering) || isMediaCardDetailVisible(isHovering))
|
||||
}
|
||||
|
||||
function handleDocumentPointerDown(event: PointerEvent) {
|
||||
if (!isTouchLikePointer.value || activeTouchMediaCardId.value !== mediaCardId) return
|
||||
if (!(event.target instanceof Node)) return
|
||||
if (mediaCardRef.value?.contains(event.target)) return
|
||||
|
||||
resetMediaCardDetailState()
|
||||
}
|
||||
|
||||
// 点击搜索
|
||||
async function clickSearch() {
|
||||
if (allSites.value?.length == 0) {
|
||||
@@ -469,22 +394,54 @@ const getImgUrl: Ref<string> = computed(() => {
|
||||
return getDisplayImageUrl(url, globalSettings.GLOBAL_IMAGE_CACHE)
|
||||
})
|
||||
|
||||
// 移除订阅
|
||||
function onRemoveSubscribe() {
|
||||
isSubscribed.value = false
|
||||
}
|
||||
|
||||
// 获取媒体类型文本
|
||||
function getMediaTypeText(type: string | undefined) {
|
||||
if (!type) return ''
|
||||
return mediaTypeDict[type]
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setupIntersectionObserver()
|
||||
const subscribeActions = useMediaSubscribe({
|
||||
media: () => props.media,
|
||||
canSubscribe: () => canSubscribe.value,
|
||||
isSubscribed,
|
||||
isExists: () => isExists.value,
|
||||
subscribedSeasons,
|
||||
subscribedSeasonModes,
|
||||
primarySeason: () => props.media?.season ?? null,
|
||||
getSubscribeStatusKey,
|
||||
})
|
||||
|
||||
watch(isSubscribed, subscribed => {
|
||||
subscribedSeasonsLoaded.value = false
|
||||
if (!subscribed) {
|
||||
subscribedSeasons.value = []
|
||||
subscribedSeasonModes.value = {}
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.media,
|
||||
() => {
|
||||
resetMediaCardDetailState()
|
||||
subscribedSeasons.value = []
|
||||
subscribedSeasonModes.value = {}
|
||||
subscribedSeasonsLoaded.value = false
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
setupIntersectionObserver()
|
||||
if (isTouchLikePointer.value) {
|
||||
document.addEventListener('pointerdown', handleDocumentPointerDown)
|
||||
}
|
||||
})
|
||||
|
||||
onActivated(resetMediaCardDetailState)
|
||||
onDeactivated(resetMediaCardDetailState)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resetMediaCardDetailState()
|
||||
document.removeEventListener('pointerdown', handleDocumentPointerDown)
|
||||
observer.value?.disconnect()
|
||||
observer.value = null
|
||||
})
|
||||
@@ -494,16 +451,17 @@ onBeforeUnmount(() => {
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div ref="mediaCardRef" v-bind="hover.props" class="media-card-hover-area">
|
||||
<div ref="mediaCardRef" v-bind="hover.props" class="media-card-hover-area" @mouseenter="querySubscribedSeasons">
|
||||
<VCard
|
||||
:height="props.height"
|
||||
:width="props.width"
|
||||
class="outline-none ring-gray-500 media-card"
|
||||
:ripple="false"
|
||||
class="app-hover-lift-card outline-none ring-gray-500 media-card"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
'app-hover-lift-card--hovering': isMediaCardActive(hover.isHovering),
|
||||
'ring-1': isImageLoaded,
|
||||
}"
|
||||
@click.stop="goMediaDetail(hover.isHovering ?? false)"
|
||||
@click.stop="handleMediaCardClick(hover.isHovering)"
|
||||
>
|
||||
<VImg
|
||||
aspect-ratio="2/3"
|
||||
@@ -522,7 +480,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<!-- 详情 -->
|
||||
<VCardText
|
||||
v-show="hover.isHovering || imageLoadError || searchMenuShow"
|
||||
v-show="isMediaCardDetailVisible(hover.isHovering)"
|
||||
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%)"
|
||||
>
|
||||
@@ -535,17 +493,11 @@ onBeforeUnmount(() => {
|
||||
</p>
|
||||
<div v-if="props.media?.collection_id" class="mb-3" @click.stop=""></div>
|
||||
<div v-else class="flex align-center justify-between">
|
||||
<IconBtn
|
||||
v-if="canSearch"
|
||||
icon="mdi-magnify"
|
||||
color="white"
|
||||
size="small"
|
||||
@click.stop="clickSearch"
|
||||
/>
|
||||
<IconBtn v-if="canSearch" icon="mdi-magnify" color="white" size="small" @click.stop="clickSearch" />
|
||||
<VSpacer />
|
||||
<IconBtn
|
||||
v-if="canSubscribe"
|
||||
icon="mdi-heart"
|
||||
:icon="isSubscribed ? 'mdi-heart' : 'mdi-heart-outline'"
|
||||
:color="isSubscribed ? 'error' : 'white'"
|
||||
size="small"
|
||||
@click.stop="handleSubscribe"
|
||||
@@ -563,10 +515,10 @@ onBeforeUnmount(() => {
|
||||
{{ getMediaTypeText(props.media?.type) }}
|
||||
</VChip>
|
||||
<!-- 本地存在标识 -->
|
||||
<ExistIcon v-if="isExists && !hover.isHovering" />
|
||||
<ExistIcon v-if="isExists && !isMediaCardActive(hover.isHovering)" />
|
||||
<!-- 评分角标 -->
|
||||
<VChip
|
||||
v-if="isImageLoaded && props.media?.vote_average && !(isExists && !hover.isHovering)"
|
||||
v-if="isImageLoaded && props.media?.vote_average && !(isExists && !isMediaCardActive(hover.isHovering))"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
:class="getChipColor('rating')"
|
||||
@@ -581,7 +533,7 @@ onBeforeUnmount(() => {
|
||||
density="compact"
|
||||
class="absolute bottom-1 right-1"
|
||||
tile
|
||||
v-if="!hover.isHovering && isImageLoaded && props.media?.source && !imageLoadError"
|
||||
v-if="!isMediaCardActive(hover.isHovering) && isImageLoaded && props.media?.source && !imageLoadError"
|
||||
>
|
||||
<VImg cover :src="sourceIconDict[props.media?.source]" class="shadow-lg" />
|
||||
</VAvatar>
|
||||
@@ -592,7 +544,7 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
<style scoped>
|
||||
.media-card-hover-area {
|
||||
width: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.media-card-title {
|
||||
@@ -604,4 +556,20 @@ onBeforeUnmount(() => {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1rem;
|
||||
}
|
||||
|
||||
.media-card-subscribe-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: white;
|
||||
font-size: 0.75rem;
|
||||
gap: 0.25rem;
|
||||
line-height: 1rem;
|
||||
min-block-size: 1.25rem;
|
||||
}
|
||||
|
||||
.media-card-subscribe-summary span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -75,15 +75,17 @@ function goPersonDetail() {
|
||||
<template>
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:height="personProps.height"
|
||||
:width="personProps.width"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
}"
|
||||
@click.stop="goPersonDetail"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="person-card-hover-area">
|
||||
<VCard
|
||||
:height="personProps.height"
|
||||
:width="personProps.width"
|
||||
class="app-hover-lift-card"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
}"
|
||||
@click.stop="goPersonDetail"
|
||||
>
|
||||
<div class="person-card relative cursor-pointer ring-gray-700">
|
||||
<div style="padding-block-end: 150%">
|
||||
<div class="absolute inset-0 flex h-full w-full flex-col items-center p-2">
|
||||
@@ -107,12 +109,17 @@ function goPersonDetail() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.person-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.person-card {
|
||||
background-image: linear-gradient(
|
||||
45deg,
|
||||
|
||||
@@ -230,16 +230,17 @@ onUnmounted(() => {
|
||||
<div>
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
@click="showPluginDetail"
|
||||
class="flex flex-col h-full"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
}"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="plugin-app-card-hover-area h-full">
|
||||
<VCard
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
@click="showPluginDetail"
|
||||
class="app-hover-lift-card flex flex-col h-full"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="flex-grow"
|
||||
:style="`background: linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.5) 100%), linear-gradient(${backgroundColor} 0%, ${backgroundColor} 100%)`"
|
||||
@@ -325,13 +326,18 @@ onUnmounted(() => {
|
||||
</IconBtn>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.plugin-app-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.plugin-app-card__tags-section {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -567,19 +567,19 @@ watch(
|
||||
<!-- 插件卡片 -->
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-if="isVisible"
|
||||
v-bind="hover.props"
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
@click="handleCardClick"
|
||||
class="flex flex-col h-full"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering && !props.sortable,
|
||||
'cursor-move': props.sortable,
|
||||
}"
|
||||
:ripple="!props.sortable"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-if="isVisible" v-bind="hover.props" class="plugin-card-hover-area h-full">
|
||||
<VCard
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
@click="handleCardClick"
|
||||
class="app-hover-lift-card flex flex-col h-full"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering && !props.sortable,
|
||||
'cursor-move': props.sortable,
|
||||
}"
|
||||
:ripple="!props.sortable"
|
||||
>
|
||||
<div
|
||||
class="flex-grow"
|
||||
:style="`background: linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.5) 100%), linear-gradient(${backgroundColor} 0%, ${backgroundColor} 100%)`"
|
||||
@@ -669,7 +669,8 @@ watch(
|
||||
<div v-if="props.plugin?.has_update" class="me-n3 absolute top-0 right-5">
|
||||
<VIcon icon="mdi-new-box" class="text-white" />
|
||||
</div>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
|
||||
@@ -677,6 +678,10 @@ watch(
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.plugin-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.card-cover-blurred::before {
|
||||
position: absolute;
|
||||
/* stylelint-disable-next-line property-no-vendor-prefix */
|
||||
|
||||
@@ -211,20 +211,21 @@ const dropdownItems = ref([
|
||||
<!-- 文件夹卡片 -->
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:ripple="false"
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
min-height="8.5rem"
|
||||
@click="handleCardClick"
|
||||
class="plugin-folder-card h-full"
|
||||
:class="{
|
||||
'plugin-folder-card--mobile': display.mobile,
|
||||
'plugin-folder-card--hover': hover.isHovering && !props.sortable,
|
||||
'plugin-folder-card--sortable': props.sortable,
|
||||
}"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="plugin-folder-card-hover-area h-full">
|
||||
<VCard
|
||||
:ripple="false"
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
min-height="8.5rem"
|
||||
@click="handleCardClick"
|
||||
class="plugin-folder-card app-hover-lift-card h-full"
|
||||
:class="{
|
||||
'plugin-folder-card--mobile': display.mobile,
|
||||
'plugin-folder-card--hover': hover.isHovering && !props.sortable,
|
||||
'plugin-folder-card--sortable': props.sortable,
|
||||
}"
|
||||
>
|
||||
<template v-if="backgroundImage" #image>
|
||||
<VImg :src="backgroundImage" cover position="top"> </VImg>
|
||||
</template>
|
||||
@@ -288,25 +289,29 @@ const dropdownItems = ref([
|
||||
</VMenu>
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.plugin-folder-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.plugin-folder-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&--sortable {
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
&--hover {
|
||||
transform: translateY(-4px);
|
||||
transform: translate3d(0, -0.25rem, 0);
|
||||
}
|
||||
|
||||
&__bg {
|
||||
|
||||
@@ -17,6 +17,12 @@ const isImageLoaded = ref(false)
|
||||
// 图片加载失败
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
const cardStyle = computed(() => ({
|
||||
aspectRatio: props.height ? undefined : '2 / 3',
|
||||
blockSize: props.height,
|
||||
inlineSize: props.width || '100%',
|
||||
}))
|
||||
|
||||
// 角标颜色
|
||||
function getChipColor(type: string) {
|
||||
if (type === '电影') return 'border-blue-500 bg-blue-600'
|
||||
@@ -47,20 +53,21 @@ async function goPlay(isHovering: boolean | null = false) {
|
||||
<template>
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:height="props.height"
|
||||
:width="props.width"
|
||||
class="outline-none ring-gray-500"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
'ring-1': isImageLoaded,
|
||||
}"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="poster-card-hover-area">
|
||||
<VCard
|
||||
:style="cardStyle"
|
||||
class="app-hover-lift-card outline-none ring-gray-500"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
'ring-1': isImageLoaded,
|
||||
}"
|
||||
>
|
||||
<VImg
|
||||
aspect-ratio="2/3"
|
||||
:src="getImgUrl"
|
||||
class="object-cover aspect-w-2 aspect-h-3"
|
||||
class="poster-card-image object-cover aspect-w-2 aspect-h-3"
|
||||
:class="{ 'poster-card-image--loaded': isImageLoaded }"
|
||||
cover
|
||||
@load="isImageLoaded = true"
|
||||
@error="imageLoadError = true"
|
||||
@@ -77,7 +84,7 @@ async function goPlay(isHovering: boolean | null = false) {
|
||||
variant="elevated"
|
||||
size="small"
|
||||
:class="getChipColor(props.media?.type || '')"
|
||||
class="absolute left-2 top-2 bg-opacity-80 text-white font-bold"
|
||||
class="poster-card-chip absolute left-2 top-2 bg-opacity-80 text-white font-bold"
|
||||
>
|
||||
{{ props.media?.type }}
|
||||
</VChip>
|
||||
@@ -93,7 +100,41 @@ async function goPlay(isHovering: boolean | null = false) {
|
||||
{{ props.media?.title }}
|
||||
</h1>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* stylelint-disable selector-pseudo-class-no-unknown */
|
||||
|
||||
.poster-card-hover-area {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.poster-card-image {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.poster-card-image :deep(.v-img__img) {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.poster-card-image--loaded :deep(.v-img__img) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.poster-card-image :deep(.v-responsive__sizer) {
|
||||
padding-bottom: 150%;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.poster-card-image :deep(.v-img__img) {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -239,25 +239,27 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VCard
|
||||
class="site-card relative h-full flex flex-col overflow-hidden group transition-all duration-300"
|
||||
:class="[
|
||||
cardProps.site?.is_active ? '' : 'opacity-70',
|
||||
{
|
||||
'border-error': statColor === 'error',
|
||||
'border-warning': statColor === 'warning',
|
||||
'border-success': statColor === 'success',
|
||||
'cursor-pointer hover:-translate-y-1': !cardProps.sortable,
|
||||
'cursor-move': cardProps.sortable,
|
||||
'site-card--sortable': cardProps.sortable,
|
||||
},
|
||||
]"
|
||||
:ripple="false"
|
||||
variant="flat"
|
||||
elevation="0"
|
||||
:hover="!cardProps.sortable"
|
||||
@click="handleCardClick"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div class="site-card-hover-area h-full">
|
||||
<VCard
|
||||
class="site-card app-hover-lift-card relative h-full flex flex-col overflow-hidden group"
|
||||
:class="[
|
||||
cardProps.site?.is_active ? '' : 'opacity-70',
|
||||
{
|
||||
'border-error': statColor === 'error',
|
||||
'border-warning': statColor === 'warning',
|
||||
'border-success': statColor === 'success',
|
||||
'cursor-pointer site-card--hoverable': !cardProps.sortable,
|
||||
'cursor-move': cardProps.sortable,
|
||||
'site-card--sortable': cardProps.sortable,
|
||||
},
|
||||
]"
|
||||
:ripple="false"
|
||||
variant="flat"
|
||||
elevation="0"
|
||||
:hover="!cardProps.sortable"
|
||||
@click="handleCardClick"
|
||||
>
|
||||
<!-- 装饰性状态指示器 -->
|
||||
<div v-if="cardProps.site?.is_active" class="site-status-indicator" :class="statColor"></div>
|
||||
|
||||
@@ -419,11 +421,20 @@ onMounted(() => {
|
||||
</VMenu>
|
||||
</VBtn>
|
||||
</VSheet>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.site-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.site-card-hover-area:hover .site-card--hoverable {
|
||||
transform: translate3d(0, -0.25rem, 0);
|
||||
}
|
||||
|
||||
.site-status-indicator {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
@@ -455,7 +466,7 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
/* 站点卡片悬停时状态指示器变化 */
|
||||
.site-card:not(.site-card--sortable):hover .site-status-indicator {
|
||||
.site-card-hover-area:hover .site-card:not(.site-card--sortable) .site-status-indicator {
|
||||
block-size: 2px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
@@ -644,7 +655,7 @@ onMounted(() => {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.site-card:hover .site-card-actions {
|
||||
.site-card-hover-area:hover .site-card-actions {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
visibility: visible;
|
||||
|
||||
@@ -404,26 +404,27 @@ function handleCardClick() {
|
||||
<div>
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<div
|
||||
class="subscribe-card-shell w-full h-full relative"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering && !props.sortable,
|
||||
'outline-dotted outline-pink-500 outline-2': props.batchMode && props.selected,
|
||||
}"
|
||||
>
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:key="props.media?.id"
|
||||
class="flex flex-col h-full overflow-hidden"
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="subscribe-card-hover-area w-full h-full">
|
||||
<div
|
||||
class="subscribe-card-shell app-hover-lift-card w-full h-full relative"
|
||||
:class="{
|
||||
'subscribe-card-paused': subscribeState === 'S',
|
||||
'subscribe-card-pending-tint': subscribeState === 'P',
|
||||
'cursor-move': props.sortable,
|
||||
'app-hover-lift-card--hovering': hover.isHovering && !props.sortable,
|
||||
'outline-dotted outline-pink-500 outline-2': props.batchMode && props.selected,
|
||||
}"
|
||||
min-height="150"
|
||||
@click="handleCardClick"
|
||||
:ripple="!props.batchMode && !props.sortable"
|
||||
>
|
||||
<VCard
|
||||
:key="props.media?.id"
|
||||
class="flex flex-col h-full overflow-hidden"
|
||||
:class="{
|
||||
'subscribe-card-paused': subscribeState === 'S',
|
||||
'subscribe-card-pending-tint': subscribeState === 'P',
|
||||
'cursor-move': props.sortable,
|
||||
}"
|
||||
min-height="150"
|
||||
@click="handleCardClick"
|
||||
:ripple="!props.batchMode && !props.sortable"
|
||||
>
|
||||
<div
|
||||
v-if="bestVersionBadge && imageLoaded"
|
||||
class="best-version-badge"
|
||||
@@ -568,13 +569,18 @@ function handleCardClick() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</div>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
.subscribe-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.subscribe-card-background {
|
||||
background-image: linear-gradient(180deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%);
|
||||
}
|
||||
|
||||
@@ -93,16 +93,17 @@ function doDelete() {
|
||||
<div class="h-full">
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<div
|
||||
class="w-full h-full overflow-hidden"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
}"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="subscribe-share-card-hover-area w-full h-full">
|
||||
<div
|
||||
class="app-hover-lift-card w-full h-full overflow-hidden"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
}"
|
||||
>
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:key="props.media?.id"
|
||||
class="flex flex-col h-full"
|
||||
class="app-hover-lift-card flex flex-col h-full"
|
||||
min-height="150"
|
||||
@click="showForkSubscribe"
|
||||
>
|
||||
@@ -155,13 +156,18 @@ function doDelete() {
|
||||
{{ dateText }}
|
||||
</VCardText>
|
||||
</div>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</div>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
.subscribe-share-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.subscribe-card-background {
|
||||
background-image: linear-gradient(180deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%);
|
||||
}
|
||||
|
||||
@@ -100,12 +100,13 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full">
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div class="subtitle-card-hover-area h-full">
|
||||
<VCard
|
||||
:width="props.width || '100%'"
|
||||
:variant="isDownloaded ? 'outlined' : 'flat'"
|
||||
@click="handleAddDownload"
|
||||
class="h-full cursor-pointer transition-transform hover:-translate-y-1 duration-300 d-flex flex-column overflow-hidden subtitle-card"
|
||||
class="app-hover-lift-card h-full cursor-pointer d-flex flex-column overflow-hidden subtitle-card"
|
||||
:class="{ 'border-success border-2 opacity-85': isDownloaded }"
|
||||
hover
|
||||
>
|
||||
@@ -203,11 +204,19 @@ watch(
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.subtitle-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.subtitle-card-hover-area:hover .subtitle-card {
|
||||
transform: translate3d(0, -0.25rem, 0);
|
||||
}
|
||||
|
||||
.subtitle-card {
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.subtitle-card:hover {
|
||||
.subtitle-card-hover-area:hover .subtitle-card {
|
||||
border-color: rgba(var(--v-theme-primary), 0.3);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -99,10 +99,11 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-100">
|
||||
<!-- Hover 命中区域保持静止,避免列表项上浮后底边反复触发 mouseleave。 -->
|
||||
<div class="subtitle-item-hover-area w-100">
|
||||
<VListItem
|
||||
:value="subtitle?.enclosure"
|
||||
class="pa-3 mb-2 rounded subtitle-item transition-all duration-300 hover:-translate-y-1 overflow-hidden"
|
||||
class="app-hover-lift-card pa-3 mb-2 rounded subtitle-item overflow-hidden"
|
||||
:class="{ 'border-start border-success border-3 opacity-85': isDownloaded }"
|
||||
@click="handleAddDownload"
|
||||
>
|
||||
@@ -206,11 +207,19 @@ watch(
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.subtitle-item-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.subtitle-item-hover-area:hover .subtitle-item {
|
||||
transform: translate3d(0, -0.25rem, 0);
|
||||
}
|
||||
|
||||
.subtitle-item {
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.subtitle-item:hover {
|
||||
.subtitle-item-hover-area:hover .subtitle-item {
|
||||
border-color: rgba(var(--v-theme-primary), 0.3);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -146,12 +146,13 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full">
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div class="torrent-card-hover-area h-full">
|
||||
<VCard
|
||||
:width="props.width || '100%'"
|
||||
:variant="isDownloaded ? 'outlined' : 'flat'"
|
||||
@click="handleAddDownload(props.torrent)"
|
||||
class="h-full cursor-pointer transition-transform hover:-translate-y-1 duration-300 d-flex flex-column overflow-hidden torrent-card"
|
||||
class="app-hover-lift-card h-full cursor-pointer d-flex flex-column overflow-hidden torrent-card"
|
||||
:class="{ 'border-success border-2 opacity-85': isDownloaded }"
|
||||
hover
|
||||
>
|
||||
@@ -316,12 +317,20 @@ watch(
|
||||
inset-inline-end: 0;
|
||||
}
|
||||
|
||||
.torrent-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.torrent-card-hover-area:hover .torrent-card {
|
||||
transform: translate3d(0, -0.25rem, 0);
|
||||
}
|
||||
|
||||
/* 卡片悬停效果 */
|
||||
.torrent-card {
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.torrent-card:hover {
|
||||
.torrent-card-hover-area:hover .torrent-card {
|
||||
border-color: rgba(var(--v-theme-primary), 0.3);
|
||||
}
|
||||
|
||||
|
||||
@@ -115,10 +115,11 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-100">
|
||||
<!-- Hover 命中区域保持静止,避免列表项上浮后底边反复触发 mouseleave。 -->
|
||||
<div class="torrent-item-hover-area w-100">
|
||||
<VListItem
|
||||
:value="props.torrent?.torrent_info?.enclosure"
|
||||
class="pa-3 mb-2 rounded torrent-item transition-all duration-300 hover:-translate-y-1 overflow-hidden"
|
||||
class="app-hover-lift-card pa-3 mb-2 rounded torrent-item overflow-hidden"
|
||||
:class="{ 'border-start border-success border-3 opacity-85': isDownloaded }"
|
||||
@click="handleAddDownload"
|
||||
>
|
||||
@@ -262,11 +263,19 @@ watch(
|
||||
inset-inline-end: 0;
|
||||
}
|
||||
|
||||
.torrent-item-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.torrent-item-hover-area:hover .torrent-item {
|
||||
transform: translate3d(0, -0.25rem, 0);
|
||||
}
|
||||
|
||||
.torrent-item {
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.torrent-item:hover {
|
||||
.torrent-item-hover-area:hover .torrent-item {
|
||||
border-color: rgba(var(--v-theme-primary), 0.3);
|
||||
}
|
||||
|
||||
|
||||
@@ -127,14 +127,16 @@ onMounted(() => {
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<VCard
|
||||
:class="[
|
||||
'transition-transform duration-300 hover:-translate-y-1',
|
||||
!props.user.is_active ? 'opacity-85 bg-surface-lighten-1' : '',
|
||||
]"
|
||||
class="user-card flex flex-column h-full"
|
||||
@click="editUser"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div class="user-card-hover-area h-full">
|
||||
<VCard
|
||||
:class="[
|
||||
'app-hover-lift-card',
|
||||
!props.user.is_active ? 'opacity-85 bg-surface-lighten-1' : '',
|
||||
]"
|
||||
class="user-card flex flex-column h-full"
|
||||
@click="editUser"
|
||||
>
|
||||
<div class="user-card__body flex-grow flex-grow-1">
|
||||
<!-- 用户头像和基本信息 -->
|
||||
<VCardItem :class="[user.is_superuser ? 'admin-header' : '']">
|
||||
@@ -302,10 +304,19 @@ onMounted(() => {
|
||||
</div>
|
||||
</VCardText>
|
||||
</div>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.user-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.user-card-hover-area:hover .user-card {
|
||||
transform: translate3d(0, -0.25rem, 0);
|
||||
}
|
||||
|
||||
.user-card {
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
@@ -95,17 +95,18 @@ function doDelete() {
|
||||
<div class="h-full">
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:key="props.workflow?.id"
|
||||
class="workflow-share-card flex flex-col h-full cursor-pointer overflow-hidden"
|
||||
:class="{
|
||||
'workflow-share-card--hovering': hover.isHovering,
|
||||
}"
|
||||
min-height="150"
|
||||
:style="{ background: gradientStyle }"
|
||||
@click="showForkWorkflow"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="workflow-share-card-hover-area h-full">
|
||||
<VCard
|
||||
:key="props.workflow?.id"
|
||||
class="workflow-share-card app-hover-lift-card flex flex-col h-full cursor-pointer overflow-hidden"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
}"
|
||||
min-height="150"
|
||||
:style="{ background: gradientStyle }"
|
||||
@click="showForkWorkflow"
|
||||
>
|
||||
<div class="h-full flex flex-col">
|
||||
<VCardText class="flex items-center pa-3 pb-1 grow">
|
||||
<div class="flex flex-col justify-center w-full">
|
||||
@@ -134,20 +135,16 @@ function doDelete() {
|
||||
{{ dateText }}
|
||||
</VCardText>
|
||||
</div>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// 阴影需要落在实际卡片上,不能被额外的 overflow 容器裁掉。
|
||||
.workflow-share-card {
|
||||
transition: transform 0.3s ease, box-shadow 0.2s ease;
|
||||
transform: translateZ(0);
|
||||
.workflow-share-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.workflow-share-card--hovering {
|
||||
transform: translate3d(0, -0.25rem, 0);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -220,14 +220,15 @@ const resolveProgress = (item: Workflow) => {
|
||||
<template>
|
||||
<div class="h-full">
|
||||
<VHover v-slot="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
class="mx-auto h-full"
|
||||
@click="handleFlow(workflow)"
|
||||
:ripple="false"
|
||||
:loading="loading"
|
||||
:class="{ 'transition transform-cpu duration-300 -translate-y-1': hover.isHovering }"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="workflow-task-card-hover-area h-full">
|
||||
<VCard
|
||||
class="app-hover-lift-card mx-auto h-full"
|
||||
@click="handleFlow(workflow)"
|
||||
:ripple="false"
|
||||
:loading="loading"
|
||||
:class="{ 'app-hover-lift-card--hovering': hover.isHovering }"
|
||||
>
|
||||
<VCardItem
|
||||
class="px-2 py-2"
|
||||
:style="{
|
||||
@@ -367,7 +368,14 @@ const resolveProgress = (item: Workflow) => {
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</VHover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.workflow-task-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { qualityOptions, resolutionOptions, effectOptions } from '@/api/constants'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||
import { formatSeason } from '@/@core/utils/formatters'
|
||||
// i18n
|
||||
const { t } = useI18n()
|
||||
const userStore = useUserStore()
|
||||
@@ -96,6 +97,13 @@ const seasonItems = ref(
|
||||
})),
|
||||
)
|
||||
|
||||
function getSubscribeDisplayName() {
|
||||
const name = subscribeForm.value.name || ''
|
||||
const season = subscribeForm.value.season
|
||||
if (season === null || season === undefined) return name
|
||||
return `${name} ${formatSeason(season.toString())}`
|
||||
}
|
||||
|
||||
// 剧集组选项属性
|
||||
function episodeGroupItemProps(item: { title: string; subtitle: string }) {
|
||||
return {
|
||||
@@ -158,11 +166,11 @@ async function updateSubscribeInfo() {
|
||||
const result: { [key: string]: any } = await api.put('subscribe/', subscribeForm.value)
|
||||
// 提示
|
||||
if (result.success) {
|
||||
$toast.success(`${subscribeForm.value.name} 更新成功!`)
|
||||
$toast.success(`${getSubscribeDisplayName()} 更新成功!`)
|
||||
// 通知父组件刷新
|
||||
emit('save')
|
||||
} else {
|
||||
$toast.error(`${subscribeForm.value.name} 更新失败:${result.message}!`)
|
||||
$toast.error(`${getSubscribeDisplayName()} 更新失败:${result.message}!`)
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
@@ -258,7 +266,7 @@ async function removeSubscribe() {
|
||||
const result: { [key: string]: any } = await api.delete(`subscribe/${props.subid}`)
|
||||
|
||||
if (result.success) {
|
||||
$toast.success(`订阅 ${subscribeForm.value.name} 已取消!`)
|
||||
$toast.success(`订阅 ${getSubscribeDisplayName()} 已取消!`)
|
||||
// 通知父组件刷新
|
||||
emit('remove')
|
||||
}
|
||||
@@ -317,10 +325,7 @@ onMounted(() => {
|
||||
{{ props.default ? t('dialog.subscribeEdit.titleDefault') : t('dialog.subscribeEdit.titleEdit') }}
|
||||
</VCardTitle>
|
||||
<VCardSubtitle v-if="!props.default">
|
||||
{{ subscribeForm.name }}
|
||||
<span v-if="subscribeForm.season">
|
||||
{{ t('dialog.subscribeEdit.seasonFormat', { number: subscribeForm.season }) }}
|
||||
</span>
|
||||
{{ getSubscribeDisplayName() }}
|
||||
</VCardSubtitle>
|
||||
<VCardSubtitle v-else>
|
||||
{{ props.type }}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
type SubscribeMode = 'normal' | 'best_version' | 'best_version_full'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: boolean
|
||||
type?: string
|
||||
modes?: SubscribeMode[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'choose', mode: SubscribeMode): void
|
||||
(e: 'close'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const modeItems = computed<SubscribeMode[]>(() =>
|
||||
props.modes?.length
|
||||
? props.modes
|
||||
: props.type === '电视剧'
|
||||
? ['normal', 'best_version', 'best_version_full']
|
||||
: ['normal', 'best_version'],
|
||||
)
|
||||
|
||||
const optionMeta: Record<SubscribeMode, { icon: string; title: string }> = {
|
||||
normal: {
|
||||
icon: 'mdi-plus-circle-outline',
|
||||
title: t('dialog.subscribeMode.normal'),
|
||||
},
|
||||
best_version: {
|
||||
icon: 'mdi-refresh',
|
||||
title:
|
||||
props.type === '电视剧' ? t('dialog.subscribeMode.bestVersionEpisode') : t('dialog.subscribeMode.bestVersion'),
|
||||
},
|
||||
best_version_full: {
|
||||
icon: 'mdi-shimmer',
|
||||
title: t('dialog.subscribeMode.bestVersionFull'),
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog :model-value="modelValue" max-width="28rem" @update:model-value="emit('update:modelValue', $event)">
|
||||
<VCard>
|
||||
<VCardTitle class="text-lg font-weight-bold px-5 pt-5">
|
||||
{{ t('dialog.subscribeMode.title') }}
|
||||
</VCardTitle>
|
||||
<VDivider />
|
||||
<VList class="py-2">
|
||||
<VListItem
|
||||
v-for="mode in modeItems"
|
||||
:key="mode"
|
||||
:prepend-icon="optionMeta[mode].icon"
|
||||
:title="optionMeta[mode].title"
|
||||
@click="emit('choose', mode)"
|
||||
/>
|
||||
</VList>
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
@@ -2,12 +2,28 @@
|
||||
import api from '@/api'
|
||||
import { MediaInfo, MediaSeason, NotExistMediaInfo } from '@/api/types'
|
||||
import { PropType } from 'vue'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import NoDataFound from '@/components/states/NoDataFound.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import type { SeasonSubscribeModes, SubscribeMode } from '@/composables/useMediaSubscribe'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
type SubscribeModeOption = {
|
||||
icon: string
|
||||
title: string
|
||||
value: SubscribeMode
|
||||
}
|
||||
|
||||
type EpisodeGroupOption = {
|
||||
title: string
|
||||
subtitle: string
|
||||
value: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
const { mdAndUp } = useDisplay()
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['subscribe', 'close'])
|
||||
@@ -15,6 +31,10 @@ const emit = defineEmits(['subscribe', 'close'])
|
||||
// 定义输入
|
||||
const props = defineProps({
|
||||
media: Object as PropType<MediaInfo>,
|
||||
selectedSeason: Number,
|
||||
subscribedSeasons: Array as PropType<number[]>,
|
||||
subscribedSeasonModes: Object as PropType<SeasonSubscribeModes>,
|
||||
defaultSubscribeMode: String as PropType<SubscribeMode>,
|
||||
})
|
||||
|
||||
// 从 provide 中获取全局设置
|
||||
@@ -25,8 +45,14 @@ const globalSettings = globalSettingsStore.globalSettings
|
||||
// 季详情
|
||||
const seasonInfos = ref<MediaSeason[]>([])
|
||||
|
||||
// 选中的订阅季
|
||||
const seasonsSelected = ref<MediaSeason[]>([])
|
||||
// 选中的订阅季号
|
||||
const seasonsSelected = ref<number[]>([])
|
||||
|
||||
// 各季订阅方式
|
||||
const seasonModes = ref<Record<number, SubscribeMode>>({})
|
||||
|
||||
// 用户已手动选择过模式的季号,入库状态异步刷新时不再覆盖。
|
||||
const manuallySelectedModeSeasons = ref(new Set<number>())
|
||||
|
||||
// 各季缺失状态:0-已入库 1-部分缺失 2-全部缺失,没有数据也是已入库
|
||||
const seasonsNotExisted = ref<{ [key: number]: number }>({})
|
||||
@@ -40,33 +66,88 @@ const episodeGroups = ref<{ [key: string]: any }[]>([])
|
||||
// 当前选择剧集组
|
||||
const episodeGroup = ref('')
|
||||
|
||||
// 剧集组选项属性
|
||||
function episodeGroupItemProps(item: { title: string; subtitle: string }) {
|
||||
return {
|
||||
title: item.title,
|
||||
subtitle: item.subtitle,
|
||||
}
|
||||
const subscribeModeOptions = computed<SubscribeModeOption[]>(() => [
|
||||
{
|
||||
title: t('dialog.subscribeMode.normal'),
|
||||
value: 'normal',
|
||||
icon: 'mdi-plus-circle-outline',
|
||||
},
|
||||
{
|
||||
title: t('dialog.subscribeMode.bestVersionEpisode'),
|
||||
value: 'best_version',
|
||||
icon: 'mdi-refresh',
|
||||
},
|
||||
{
|
||||
title: t('dialog.subscribeMode.bestVersionFull'),
|
||||
value: 'best_version_full',
|
||||
icon: 'mdi-shimmer',
|
||||
},
|
||||
])
|
||||
|
||||
function getSubscribeModeColor(mode: SubscribeMode) {
|
||||
if (mode === 'normal') return 'primary'
|
||||
if (mode === 'best_version') return 'warning'
|
||||
return 'success'
|
||||
}
|
||||
|
||||
function isSubscribeMode(value: unknown): value is SubscribeMode {
|
||||
return value === 'normal' || value === 'best_version' || value === 'best_version_full'
|
||||
}
|
||||
|
||||
const subscribedSeasonSet = computed(() => new Set(props.subscribedSeasons ?? []))
|
||||
|
||||
const selectedSeasonSet = computed(() => new Set(seasonsSelected.value))
|
||||
|
||||
const visibleSeasonNumbers = computed(() =>
|
||||
seasonInfos.value
|
||||
.map(item => item.season_number)
|
||||
.filter((season): season is number => season !== null && season !== undefined),
|
||||
)
|
||||
|
||||
const hasSelectionChanges = computed(() => {
|
||||
const visibleSeasons = new Set(visibleSeasonNumbers.value)
|
||||
|
||||
for (const season of visibleSeasons) {
|
||||
if (subscribedSeasonSet.value.has(season) !== selectedSeasonSet.value.has(season)) return true
|
||||
if (
|
||||
subscribedSeasonSet.value.has(season) &&
|
||||
selectedSeasonSet.value.has(season) &&
|
||||
(props.subscribedSeasonModes?.[season] ?? 'normal') !== (seasonModes.value[season] ?? 'normal')
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
const submitButtonText = computed(() => {
|
||||
if (!hasSelectionChanges.value && seasonsSelected.value.length === 0) return t('dialog.subscribeSeason.selectSeasons')
|
||||
|
||||
return t('dialog.subscribeSeason.submit')
|
||||
})
|
||||
|
||||
// 剧集组选项
|
||||
const episodeGroupOptions = computed(() => {
|
||||
let options = (episodeGroups.value as { id: string; name: string; group_count: number; episode_count: number }[]).map(
|
||||
item => {
|
||||
return {
|
||||
title: item.name,
|
||||
subtitle: `${t('dialog.subscribeSeason.seasonCount', { count: item.group_count })} • ${t(
|
||||
'dialog.subscribeSeason.episodeCount',
|
||||
{ count: item.episode_count },
|
||||
)}`,
|
||||
value: item.id,
|
||||
}
|
||||
},
|
||||
)
|
||||
const episodeGroupOptions = computed<EpisodeGroupOption[]>(() => {
|
||||
const options = (
|
||||
episodeGroups.value as { id: string; name: string; group_count: number; episode_count: number }[]
|
||||
).map(item => {
|
||||
return {
|
||||
title: item.name,
|
||||
subtitle: `${t('dialog.subscribeSeason.seasonCount', { count: item.group_count })} • ${t(
|
||||
'dialog.subscribeSeason.episodeCount',
|
||||
{ count: item.episode_count },
|
||||
)}`,
|
||||
value: item.id,
|
||||
icon: 'mdi-folder-play-outline',
|
||||
}
|
||||
})
|
||||
// 添加不使用选项
|
||||
options.unshift({
|
||||
title: t('dialog.subscribeSeason.defaultGroup'),
|
||||
subtitle: t('dialog.subscribeSeason.seasonCount', { count: seasonInfos.value.length }),
|
||||
value: '',
|
||||
icon: 'mdi-layers-outline',
|
||||
})
|
||||
return options
|
||||
})
|
||||
@@ -125,9 +206,10 @@ async function getGroupSeasons() {
|
||||
async function checkSeasonsNotExists() {
|
||||
// 开始处理
|
||||
try {
|
||||
let tmpMedia = props.media ?? { episode_group: '' }
|
||||
if (episodeGroup.value) tmpMedia.episode_group = episodeGroup.value
|
||||
else tmpMedia.episode_group = ''
|
||||
const tmpMedia = {
|
||||
...(props.media ?? {}),
|
||||
episode_group: episodeGroup.value || '',
|
||||
}
|
||||
const result: NotExistMediaInfo[] = await api.post('mediaserver/notexists', tmpMedia)
|
||||
if (result) {
|
||||
result.forEach(item => {
|
||||
@@ -183,8 +265,103 @@ function getYear(airDate: string) {
|
||||
return date.getFullYear()
|
||||
}
|
||||
|
||||
function setEpisodeGroup(value: string) {
|
||||
if (episodeGroup.value === value) return
|
||||
|
||||
seasonsNotExisted.value = {}
|
||||
seasonInfos.value = []
|
||||
episodeGroup.value = value
|
||||
}
|
||||
|
||||
function subscribeSeasons() {
|
||||
emit('subscribe', seasonsSelected.value, seasonsNotExisted.value, episodeGroup.value)
|
||||
const selectedSeasons = seasonInfos.value.filter(item => {
|
||||
const seasonNumber = item.season_number ?? null
|
||||
return seasonNumber !== null && selectedSeasonSet.value.has(seasonNumber)
|
||||
})
|
||||
|
||||
emit(
|
||||
'subscribe',
|
||||
selectedSeasons,
|
||||
seasonsNotExisted.value,
|
||||
episodeGroup.value,
|
||||
{ ...seasonModes.value },
|
||||
visibleSeasonNumbers.value,
|
||||
)
|
||||
}
|
||||
|
||||
function setSeasonMode(season: number, mode: SubscribeMode) {
|
||||
seasonModes.value = {
|
||||
...seasonModes.value,
|
||||
[season]: mode,
|
||||
}
|
||||
}
|
||||
|
||||
function updateSeasonMode(season: number, mode: unknown) {
|
||||
if (!isSubscribeMode(mode)) return
|
||||
manuallySelectedModeSeasons.value.add(season)
|
||||
setSeasonMode(season, mode)
|
||||
}
|
||||
|
||||
function getDefaultSeasonMode(season: number) {
|
||||
if (!seasonsNotExisted.value[season]) return 'best_version_full'
|
||||
|
||||
return props.defaultSubscribeMode ?? 'normal'
|
||||
}
|
||||
|
||||
function ensureSeasonMode(season: number) {
|
||||
if (!seasonModes.value[season]) setSeasonMode(season, props.subscribedSeasonModes?.[season] ?? getDefaultSeasonMode(season))
|
||||
}
|
||||
|
||||
function syncDefaultSeasonModes() {
|
||||
seasonsSelected.value.forEach(season => {
|
||||
if (subscribedSeasonSet.value.has(season) || manuallySelectedModeSeasons.value.has(season)) return
|
||||
setSeasonMode(season, getDefaultSeasonMode(season))
|
||||
})
|
||||
}
|
||||
|
||||
function isSeasonSubscribed(season: number) {
|
||||
return subscribedSeasonSet.value.has(season)
|
||||
}
|
||||
|
||||
function isSeasonSelected(season: number) {
|
||||
return selectedSeasonSet.value.has(season)
|
||||
}
|
||||
|
||||
function setSeasonSelected(season: number, selected: boolean | null) {
|
||||
const nextSeasons = new Set(seasonsSelected.value)
|
||||
if (selected) {
|
||||
nextSeasons.add(season)
|
||||
ensureSeasonMode(season)
|
||||
} else {
|
||||
nextSeasons.delete(season)
|
||||
}
|
||||
seasonsSelected.value = [...nextSeasons].sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
function toggleSeasonSelected(season: number) {
|
||||
setSeasonSelected(season, !isSeasonSelected(season))
|
||||
}
|
||||
|
||||
function syncSelectedSeason() {
|
||||
if (!seasonInfos.value.length) return
|
||||
|
||||
const seasonNumbers = new Set<number>()
|
||||
props.subscribedSeasons?.forEach(season => seasonNumbers.add(season))
|
||||
if (props.selectedSeason !== undefined) seasonNumbers.add(props.selectedSeason)
|
||||
|
||||
const validSeasonNumbers = new Set(
|
||||
seasonInfos.value
|
||||
.map(item => item.season_number)
|
||||
.filter((season): season is number => season !== null && season !== undefined),
|
||||
)
|
||||
|
||||
seasonsSelected.value = [...seasonNumbers].filter(season => validSeasonNumbers.has(season)).sort((a, b) => a - b)
|
||||
seasonsSelected.value.forEach(ensureSeasonMode)
|
||||
Object.entries(props.subscribedSeasonModes ?? {}).forEach(([season, mode]) => {
|
||||
const seasonNumber = Number(season)
|
||||
if (!validSeasonNumbers.has(seasonNumber)) return
|
||||
setSeasonMode(seasonNumber, mode)
|
||||
})
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
@@ -193,6 +370,16 @@ watchEffect(() => {
|
||||
checkSeasonsNotExists()
|
||||
})
|
||||
|
||||
watch(seasonInfos, syncSelectedSeason)
|
||||
|
||||
watch(seasonsNotExisted, syncDefaultSeasonModes, { deep: true })
|
||||
|
||||
watch(() => props.selectedSeason, syncSelectedSeason)
|
||||
|
||||
watch(() => props.subscribedSeasons, syncSelectedSeason)
|
||||
|
||||
watch(() => props.subscribedSeasonModes, syncSelectedSeason)
|
||||
|
||||
onMounted(async () => {
|
||||
getMediaSeasons()
|
||||
getEpisodeGroups()
|
||||
@@ -201,25 +388,54 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VBottomSheet inset scrollable>
|
||||
<VCard>
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
<VCardItem>
|
||||
<VCardTitle class="pe-10"> {{ t('dialog.subscribeSeason.title', { title: props.media?.title }) }} </VCardTitle>
|
||||
<VDialog max-width="45rem" scrollable :fullscreen="!mdAndUp">
|
||||
<VCard class="subscribe-season-dialog">
|
||||
<VCardItem class="py-2">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-clipboard-list-outline" class="me-2" />
|
||||
</template>
|
||||
<VCardTitle class="pe-10">
|
||||
{{ t('dialog.subscribeSeason.selectSeasons') }}
|
||||
</VCardTitle>
|
||||
<VCardSubtitle>
|
||||
{{ props.media?.title }}
|
||||
</VCardSubtitle>
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VSelect
|
||||
v-model="episodeGroup"
|
||||
:items="episodeGroupOptions"
|
||||
:item-props="episodeGroupItemProps"
|
||||
:label="t('dialog.subscribeSeason.selectGroup')"
|
||||
persistent-hint
|
||||
/>
|
||||
<div class="subscribe-season-group-selector">
|
||||
<div class="subscribe-season-group-label">
|
||||
{{ t('dialog.subscribeSeason.selectGroup') }}
|
||||
</div>
|
||||
<div class="subscribe-season-group-options">
|
||||
<button
|
||||
v-for="group in episodeGroupOptions"
|
||||
:key="group.value || 'default'"
|
||||
type="button"
|
||||
class="subscribe-season-group-option"
|
||||
:class="{ 'subscribe-season-group-option--active': episodeGroup === group.value }"
|
||||
@click="setEpisodeGroup(group.value)"
|
||||
>
|
||||
<VIcon :icon="group.icon" size="small" />
|
||||
<span class="subscribe-season-group-text">
|
||||
<span class="subscribe-season-group-title">{{ group.title }}</span>
|
||||
<span class="subscribe-season-group-subtitle">{{ group.subtitle }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<LoadingBanner v-if="!isRefreshed" class="mt-5" />
|
||||
<div v-else-if="seasonInfos.length > 0">
|
||||
<VList v-model:selected="seasonsSelected" lines="three" select-strategy="classic">
|
||||
<VListItem v-for="(item, i) in seasonInfos" :key="i" :value="item">
|
||||
<VList lines="three" class="subscribe-season-list">
|
||||
<VListItem
|
||||
v-for="(item, i) in seasonInfos"
|
||||
:key="i"
|
||||
:active="isSeasonSelected(item.season_number || 0)"
|
||||
rounded="lg"
|
||||
class="subscribe-season-list-item"
|
||||
@click="toggleSeasonSelected(item.season_number || 0)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VImg
|
||||
height="90"
|
||||
@@ -258,10 +474,41 @@ onMounted(async () => {
|
||||
>
|
||||
{{ getExistText(item.season_number || 0) }}
|
||||
</VChip>
|
||||
<VChip v-if="isSeasonSubscribed(item.season_number || 0)" class="mt-2 ms-2" size="small" color="error">
|
||||
{{ t('media.status.subscribed') }}
|
||||
</VChip>
|
||||
</VListItemSubtitle>
|
||||
<template #append="{ isSelected }">
|
||||
<VListItemAction start>
|
||||
<VSwitch :model-value="isSelected" />
|
||||
<template #append>
|
||||
<VListItemAction start class="subscribe-season-actions">
|
||||
<VSwitch
|
||||
:model-value="isSeasonSelected(item.season_number || 0)"
|
||||
hide-details
|
||||
@click.stop
|
||||
@update:model-value="setSeasonSelected(item.season_number || 0, $event)"
|
||||
/>
|
||||
<VBtnToggle
|
||||
v-if="isSeasonSelected(item.season_number || 0)"
|
||||
:model-value="seasonModes[item.season_number || 0] || 'normal'"
|
||||
density="compact"
|
||||
divided
|
||||
mandatory
|
||||
variant="outlined"
|
||||
class="subscribe-season-mode-toggle"
|
||||
@click.stop
|
||||
@update:model-value="updateSeasonMode(item.season_number || 0, $event)"
|
||||
>
|
||||
<VBtn
|
||||
v-for="mode in subscribeModeOptions"
|
||||
:key="mode.value"
|
||||
:value="mode.value"
|
||||
:color="getSubscribeModeColor(mode.value)"
|
||||
size="small"
|
||||
class="subscribe-season-mode-button"
|
||||
>
|
||||
<VIcon :icon="mode.icon" size="small" />
|
||||
<span>{{ mode.title }}</span>
|
||||
</VBtn>
|
||||
</VBtnToggle>
|
||||
</VListItemAction>
|
||||
</template>
|
||||
</VListItem>
|
||||
@@ -269,15 +516,182 @@ onMounted(async () => {
|
||||
</div>
|
||||
<NoDataFound v-else errorTitle="出错啦!" :errorDescription="`${props.media?.title} 未查询到季集信息`" />
|
||||
</VCardText>
|
||||
<div class="my-2 text-center">
|
||||
<VBtn :disabled="seasonsSelected.length === 0" width="30%" @click="subscribeSeasons">
|
||||
{{
|
||||
seasonsSelected.length === 0
|
||||
? t('dialog.subscribeSeason.selectSeasons')
|
||||
: t('dialog.subscribeSeason.submit')
|
||||
}}
|
||||
<VCardActions class="justify-center py-3">
|
||||
<VBtn
|
||||
:disabled="!hasSelectionChanges"
|
||||
width="30%"
|
||||
min-width="8rem"
|
||||
color="primary"
|
||||
variant="elevated"
|
||||
class="subscribe-season-submit"
|
||||
@click="subscribeSeasons"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-check" />
|
||||
</template>
|
||||
{{ submitButtonText }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VBottomSheet>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* stylelint-disable selector-pseudo-class-no-unknown */
|
||||
|
||||
.subscribe-season-actions {
|
||||
display: grid;
|
||||
align-content: space-between;
|
||||
align-self: stretch;
|
||||
block-size: 100%;
|
||||
gap: 0.5rem;
|
||||
justify-items: end;
|
||||
min-inline-size: 18rem;
|
||||
}
|
||||
|
||||
.subscribe-season-group-selector {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
margin-block-end: 1rem;
|
||||
}
|
||||
|
||||
.subscribe-season-group-label {
|
||||
color: rgba(var(--v-theme-on-surface), 0.72);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
}
|
||||
|
||||
.subscribe-season-group-options {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
overflow-x: auto;
|
||||
padding-block: 0.125rem 0.375rem;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.subscribe-season-group-option {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-radius: 8px;
|
||||
background: rgba(var(--v-theme-surface), 0.72);
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
gap: 0.5rem;
|
||||
max-inline-size: 16rem;
|
||||
min-inline-size: 11rem;
|
||||
padding-block: 0.5rem;
|
||||
padding-inline: 0.75rem;
|
||||
text-align: start;
|
||||
transition:
|
||||
border-color 0.16s ease,
|
||||
background-color 0.16s ease,
|
||||
color 0.16s ease;
|
||||
}
|
||||
|
||||
.subscribe-season-group-option:hover {
|
||||
border-color: rgba(var(--v-theme-primary), 0.45);
|
||||
background: rgba(var(--v-theme-primary), 0.08);
|
||||
}
|
||||
|
||||
.subscribe-season-group-option--active {
|
||||
border-color: rgb(var(--v-theme-primary));
|
||||
background: rgba(var(--v-theme-primary), 0.14);
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
.subscribe-season-group-text {
|
||||
display: grid;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.subscribe-season-group-title,
|
||||
.subscribe-season-group-subtitle {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subscribe-season-group-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.125rem;
|
||||
}
|
||||
|
||||
.subscribe-season-group-subtitle {
|
||||
color: rgba(var(--v-theme-on-surface), 0.62);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
}
|
||||
|
||||
.subscribe-season-group-option--active .subscribe-season-group-subtitle {
|
||||
color: rgba(var(--v-theme-primary), 0.82);
|
||||
}
|
||||
|
||||
.subscribe-season-list {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.subscribe-season-list-item {
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
}
|
||||
|
||||
.subscribe-season-list-item :deep(.v-list-item__append) {
|
||||
align-items: stretch;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.subscribe-season-mode-toggle {
|
||||
block-size: 2rem;
|
||||
max-inline-size: 18rem;
|
||||
}
|
||||
|
||||
.subscribe-season-mode-button {
|
||||
min-inline-size: 0;
|
||||
padding-inline: 0.5rem;
|
||||
}
|
||||
|
||||
.subscribe-season-mode-button span {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
margin-inline-start: 0.25rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (width <= 960px) {
|
||||
.subscribe-season-actions {
|
||||
gap: 0.375rem;
|
||||
min-inline-size: 6.75rem;
|
||||
}
|
||||
|
||||
.subscribe-season-mode-toggle {
|
||||
inline-size: 6.75rem;
|
||||
max-inline-size: 6.75rem;
|
||||
}
|
||||
|
||||
.subscribe-season-mode-button {
|
||||
flex: 1 1 0;
|
||||
padding-inline: 0.25rem;
|
||||
}
|
||||
|
||||
.subscribe-season-mode-button span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 640px) {
|
||||
.subscribe-season-submit {
|
||||
inline-size: 100% !important;
|
||||
}
|
||||
|
||||
.subscribe-season-list-item :deep(.v-list-item__content) {
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.subscribe-season-group-option {
|
||||
max-inline-size: 12rem;
|
||||
min-inline-size: 9.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,8 +6,8 @@ import useDragAndDrop from '@core/utils/workflow'
|
||||
import { Workflow } from '@/api/types'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import api from '@/api'
|
||||
import WorkflowSidebar from '@/layouts/components/WorkflowSidebar.vue'
|
||||
import DropzoneBackground from '@/layouts/components/DropzoneBackground.vue'
|
||||
import WorkflowSidebar from '@/components/workflow/WorkflowSidebar.vue'
|
||||
import DropzoneBackground from '@/components/workflow/DropzoneBackground.vue'
|
||||
import ImportCodeDialog from '@/components/dialog/ImportCodeDialog.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import FileList from './filebrowser/FileList.vue'
|
||||
import FileToolbar from './filebrowser/FileToolbar.vue'
|
||||
import FileNavigator from './filebrowser/FileNavigator.vue'
|
||||
import FileList from './FileList.vue'
|
||||
import FileToolbar from './FileToolbar.vue'
|
||||
import FileNavigator from './FileNavigator.vue'
|
||||
import type { EndPoints, FileItem, StorageConf } from '@/api/types'
|
||||
import { storageIconDict } from '@/api/constants'
|
||||
import type { AxiosInstance } from 'axios'
|
||||
@@ -0,0 +1,41 @@
|
||||
import { TransitionPresets, usePreferredReducedMotion, useTransition, type UseTransitionOptions } from '@vueuse/core'
|
||||
import { computed, type MaybeRefOrGetter } from 'vue'
|
||||
|
||||
const fileSizeUnits = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'] as const
|
||||
|
||||
export function useDashboardMotionDisabled() {
|
||||
const preferredMotion = usePreferredReducedMotion()
|
||||
|
||||
return computed(() => preferredMotion.value === 'reduce')
|
||||
}
|
||||
|
||||
export function useAnimatedDashboardNumber(source: MaybeRefOrGetter<number>, options: UseTransitionOptions = {}) {
|
||||
const disabled = useDashboardMotionDisabled()
|
||||
|
||||
return useTransition(source, {
|
||||
duration: 420,
|
||||
transition: TransitionPresets.easeOutQuart,
|
||||
disabled,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export function formatDashboardCount(value: number) {
|
||||
return Math.round(Math.max(Number(value) || 0, 0)).toLocaleString()
|
||||
}
|
||||
|
||||
export function formatDashboardFileSize(bytes: number, decimals = 2, targetBytes = bytes) {
|
||||
let size = Math.abs(Number(targetBytes) || Number(bytes) || 0)
|
||||
let unitIndex = 0
|
||||
|
||||
while (size >= 1024 && unitIndex < fileSizeUnits.length - 1) {
|
||||
size /= 1024
|
||||
unitIndex++
|
||||
}
|
||||
|
||||
const divisor = 1024 ** unitIndex
|
||||
const value = (Math.abs(Number(bytes) || 0) / divisor).toFixed(decimals)
|
||||
const prefix = bytes < 0 ? '-' : ''
|
||||
|
||||
return `${prefix}${value} ${fileSizeUnits[unitIndex]}`
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
import { defineAsyncComponent, ref, type Ref } from 'vue'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import api from '@/api'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import { formatSeason } from '@/@core/utils/formatters'
|
||||
import type { MediaInfo, MediaSeason, Subscribe } from '@/api/types'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { setCachedMediaSubscribeStatus } from '@/utils/mediaStatusCache'
|
||||
|
||||
export type SubscribeMode = 'normal' | 'best_version' | 'best_version_full'
|
||||
|
||||
interface SubscribePayload {
|
||||
best_version?: number
|
||||
best_version_full?: number
|
||||
}
|
||||
|
||||
interface SubscribeConfig {
|
||||
show_edit_dialog?: boolean
|
||||
best_version?: unknown
|
||||
best_version_full?: unknown
|
||||
}
|
||||
|
||||
interface AddSubscribeOptions {
|
||||
openEditDialog?: boolean
|
||||
}
|
||||
|
||||
interface RemoveSubscribeOptions {
|
||||
confirm?: boolean
|
||||
}
|
||||
|
||||
interface UseMediaSubscribeOptions {
|
||||
media: () => MediaInfo | undefined
|
||||
canSubscribe: () => boolean
|
||||
isSubscribed?: Ref<boolean>
|
||||
isExists?: () => boolean
|
||||
seasonsSubscribed?: Ref<{ [key: number]: boolean }>
|
||||
subscribedSeasons?: Ref<number[]>
|
||||
subscribedSeasonModes?: Ref<SeasonSubscribeModes>
|
||||
primarySeason?: () => number | null
|
||||
getSubscribeStatusKey?: (season: number | null) => string
|
||||
onEditRemove?: () => void
|
||||
}
|
||||
|
||||
const SubscribeEditDialog = defineAsyncComponent(() => import('@/components/dialog/SubscribeEditDialog.vue'))
|
||||
const SubscribeModeDialog = defineAsyncComponent(() => import('@/components/dialog/SubscribeModeDialog.vue'))
|
||||
const SubscribeSeasonDialog = defineAsyncComponent(() => import('@/components/dialog/SubscribeSeasonDialog.vue'))
|
||||
|
||||
export type SeasonSubscribeModes = Record<number, SubscribeMode>
|
||||
|
||||
export function getMediaSubscribeId(media?: MediaInfo) {
|
||||
if (media?.tmdb_id) return `tmdb:${media.tmdb_id}`
|
||||
if (media?.douban_id) return `douban:${media.douban_id}`
|
||||
if (media?.bangumi_id) return `bangumi:${media.bangumi_id}`
|
||||
return `${media?.mediaid_prefix}:${media?.media_id}`
|
||||
}
|
||||
|
||||
function getSubscribePayload(mode: SubscribeMode): SubscribePayload {
|
||||
return {
|
||||
best_version: mode === 'normal' ? 0 : 1,
|
||||
best_version_full: mode === 'best_version_full' ? 1 : 0,
|
||||
}
|
||||
}
|
||||
|
||||
function isEnabledFlag(value: unknown) {
|
||||
return value === true || value === 1 || value === '1'
|
||||
}
|
||||
|
||||
export function getSubscribeMode(subscribe: { best_version?: unknown; best_version_full?: unknown }): SubscribeMode {
|
||||
if (!isEnabledFlag(subscribe.best_version)) return 'normal'
|
||||
|
||||
return isEnabledFlag(subscribe.best_version_full) ? 'best_version_full' : 'best_version'
|
||||
}
|
||||
|
||||
function getSubscribeConfigMode(config?: SubscribeConfig): SubscribeMode {
|
||||
return getSubscribeMode({
|
||||
best_version: config?.best_version,
|
||||
best_version_full: config?.best_version_full,
|
||||
})
|
||||
}
|
||||
|
||||
function getModeName(t: ReturnType<typeof useI18n>['t'], mode: SubscribeMode) {
|
||||
if (mode === 'normal') return t('dialog.subscribeMode.normal')
|
||||
if (mode === 'best_version') return t('dialog.subscribeMode.bestVersionEpisode')
|
||||
return t('dialog.subscribeMode.bestVersionFull')
|
||||
}
|
||||
|
||||
export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
|
||||
const { t } = useI18n()
|
||||
const $toast = useToast()
|
||||
const createConfirm = useConfirm()
|
||||
const episodeGroup = ref('')
|
||||
|
||||
function currentMedia() {
|
||||
return options.media()
|
||||
}
|
||||
|
||||
function getMediaId() {
|
||||
return getMediaSubscribeId(currentMedia())
|
||||
}
|
||||
|
||||
function getPrimarySeason() {
|
||||
return options.primarySeason?.() ?? currentMedia()?.season ?? null
|
||||
}
|
||||
|
||||
function updateSubscribeStatus(season: number | null, subscribed: boolean, mode: SubscribeMode = 'normal') {
|
||||
const media = currentMedia()
|
||||
|
||||
if (media?.type === '电影' || season === null) {
|
||||
if (options.isSubscribed) options.isSubscribed.value = subscribed
|
||||
} else {
|
||||
if (options.seasonsSubscribed) options.seasonsSubscribed.value[season] = subscribed
|
||||
else if (options.subscribedSeasons) {
|
||||
const nextSeasons = new Set(options.subscribedSeasons.value)
|
||||
if (subscribed) nextSeasons.add(season)
|
||||
else nextSeasons.delete(season)
|
||||
options.subscribedSeasons.value = [...nextSeasons].sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
if (options.isSubscribed) {
|
||||
if (subscribed) {
|
||||
options.isSubscribed.value = true
|
||||
} else if (options.seasonsSubscribed) {
|
||||
options.isSubscribed.value = Object.values(options.seasonsSubscribed.value).some(Boolean)
|
||||
} else if (options.subscribedSeasons) {
|
||||
options.isSubscribed.value = options.subscribedSeasons.value.length > 0
|
||||
} else {
|
||||
options.isSubscribed.value = false
|
||||
}
|
||||
}
|
||||
|
||||
if (options.subscribedSeasonModes) {
|
||||
const nextModes = { ...options.subscribedSeasonModes.value }
|
||||
if (subscribed) nextModes[season] = mode
|
||||
else delete nextModes[season]
|
||||
options.subscribedSeasonModes.value = nextModes
|
||||
}
|
||||
}
|
||||
|
||||
if (options.getSubscribeStatusKey) {
|
||||
setCachedMediaSubscribeStatus(options.getSubscribeStatusKey(season), subscribed)
|
||||
}
|
||||
}
|
||||
|
||||
function openSubscribeEditDialog(subid: number) {
|
||||
openSharedDialog(
|
||||
SubscribeEditDialog,
|
||||
{ subid },
|
||||
{
|
||||
remove: () => {
|
||||
if (options.onEditRemove) {
|
||||
options.onEditRemove()
|
||||
} else if (options.isSubscribed) {
|
||||
options.isSubscribed.value = false
|
||||
}
|
||||
},
|
||||
},
|
||||
{ closeOn: ['close', 'save', 'remove'] },
|
||||
)
|
||||
}
|
||||
|
||||
function openSubscribeModeDialog(
|
||||
modes: SubscribeMode[],
|
||||
choose: (payload: SubscribePayload, mode: SubscribeMode) => void,
|
||||
) {
|
||||
openSharedDialog(
|
||||
SubscribeModeDialog,
|
||||
{ modes, type: currentMedia()?.type },
|
||||
{
|
||||
choose: (mode: SubscribeMode) => choose(getSubscribePayload(mode), mode),
|
||||
},
|
||||
{ closeOn: ['close', 'choose'] },
|
||||
)
|
||||
}
|
||||
|
||||
async function openSubscribeSeasonDialog(selectedSeason?: number | null) {
|
||||
const media = currentMedia()
|
||||
if (!media) return
|
||||
const defaultSubscribeConfig = await queryDefaultSubscribeConfig()
|
||||
|
||||
openSharedDialog(
|
||||
SubscribeSeasonDialog,
|
||||
{
|
||||
media,
|
||||
selectedSeason,
|
||||
subscribedSeasons: options.subscribedSeasons?.value ?? [],
|
||||
subscribedSeasonModes: options.subscribedSeasonModes?.value ?? {},
|
||||
defaultSubscribeMode: getSubscribeConfigMode(defaultSubscribeConfig),
|
||||
},
|
||||
{
|
||||
subscribe: subscribeSeasons,
|
||||
},
|
||||
{ closeOn: ['close', 'subscribe'] },
|
||||
)
|
||||
}
|
||||
|
||||
async function queryDefaultSubscribeConfig(): Promise<SubscribeConfig | undefined> {
|
||||
if (!options.canSubscribe()) return undefined
|
||||
|
||||
try {
|
||||
const media = currentMedia()
|
||||
const subscribeConfigUrl =
|
||||
media?.type === '电影'
|
||||
? 'system/setting/public/DefaultMovieSubscribeConfig'
|
||||
: 'system/setting/public/DefaultTvSubscribeConfig'
|
||||
const result: { [key: string]: any } = await api.get(subscribeConfigUrl)
|
||||
|
||||
return result.data?.value
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function showSubscribeAddToast(
|
||||
result: boolean,
|
||||
title: string,
|
||||
season: number | null,
|
||||
message: string,
|
||||
bestVersion: number,
|
||||
) {
|
||||
if (season !== null) title = `${title} ${formatSeason(season.toString())}`
|
||||
|
||||
const subname = bestVersion > 0 ? t('subscribe.versionSub') : t('subscribe.normalSub')
|
||||
|
||||
if (result) $toast.success(`${title} ${t('subscribe.addSuccess', { name: subname })}`)
|
||||
else $toast.error(`${title} ${t('subscribe.addFailed', { name: subname, message })}`)
|
||||
}
|
||||
|
||||
async function addSubscribe(
|
||||
season: number | null = null,
|
||||
payload: SubscribePayload = {},
|
||||
addOptions: AddSubscribeOptions = {},
|
||||
) {
|
||||
const media = currentMedia()
|
||||
if (!media) return
|
||||
|
||||
startNProgress()
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post('subscribe/', {
|
||||
name: media.title,
|
||||
type: media.type,
|
||||
year: media.year,
|
||||
tmdbid: media.tmdb_id,
|
||||
doubanid: media.douban_id,
|
||||
bangumiid: media.bangumi_id,
|
||||
mediaid: media.media_id ? `${media.mediaid_prefix}:${media.media_id}` : '',
|
||||
season: media.type === '电影' ? null : season,
|
||||
...payload,
|
||||
episode_group: episodeGroup.value,
|
||||
})
|
||||
|
||||
if (result.success) updateSubscribeStatus(media.type === '电影' ? null : season, true, getSubscribeMode(payload))
|
||||
|
||||
showSubscribeAddToast(result.success, media.title ?? '', season, result.message, payload.best_version ?? 0)
|
||||
|
||||
if (result.success && (addOptions.openEditDialog ?? true)) {
|
||||
const subscribeConfig = await queryDefaultSubscribeConfig()
|
||||
if (subscribeConfig?.show_edit_dialog) openSubscribeEditDialog(result.data.id)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
doneNProgress()
|
||||
}
|
||||
}
|
||||
|
||||
async function removeSubscribe(season: number | null = null, removeOptions: RemoveSubscribeOptions = {}) {
|
||||
if (removeOptions.confirm ?? true) {
|
||||
const confirmed = await createConfirm({
|
||||
title: t('common.confirm'),
|
||||
content: t('dialog.subscribeEdit.cancelSubscribeConfirm'),
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
|
||||
const media = currentMedia()
|
||||
if (!media) return
|
||||
|
||||
startNProgress()
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.delete(`subscribe/media/${getMediaId()}`, {
|
||||
params: {
|
||||
season: media.type === '电影' ? null : season,
|
||||
},
|
||||
})
|
||||
let title = media.title ?? ''
|
||||
if (media.type !== '电影' && season !== null) title = `${title} ${formatSeason(season.toString())}`
|
||||
|
||||
if (result.success) {
|
||||
updateSubscribeStatus(media.type === '电影' ? null : season, false)
|
||||
$toast.success(`${title} ${t('subscribe.cancelSuccess')}`)
|
||||
} else {
|
||||
$toast.error(`${title} ${t('subscribe.cancelFailed', { message: result.message })}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
doneNProgress()
|
||||
}
|
||||
}
|
||||
|
||||
async function checkSubscribe(season: number | null = null) {
|
||||
try {
|
||||
const result: Subscribe = await api.get(`subscribe/media/${getMediaId()}`, {
|
||||
params: {
|
||||
season,
|
||||
title: currentMedia()?.title,
|
||||
},
|
||||
})
|
||||
|
||||
return Boolean(result.id)
|
||||
} catch (error: any) {
|
||||
if (error?.response?.status === 404) return false
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function querySubscribe(season: number | null = null) {
|
||||
try {
|
||||
const result: Subscribe = await api.get(`subscribe/media/${getMediaId()}`, {
|
||||
params: {
|
||||
season,
|
||||
title: currentMedia()?.title,
|
||||
},
|
||||
})
|
||||
|
||||
return result.id ? result : null
|
||||
} catch (error: any) {
|
||||
if (error?.response?.status === 404) return null
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function updateSubscribeMode(season: number, mode: SubscribeMode) {
|
||||
const media = currentMedia()
|
||||
if (!media) return
|
||||
|
||||
startNProgress()
|
||||
try {
|
||||
const subscribe = await querySubscribe(season)
|
||||
if (!subscribe?.id) {
|
||||
$toast.error(`${media.title ?? ''} ${formatSeason(season.toString())} ${t('subscribe.notFound')}`)
|
||||
return
|
||||
}
|
||||
|
||||
const payload = getSubscribePayload(mode)
|
||||
const result: { [key: string]: any } = await api.put('subscribe/', {
|
||||
...subscribe,
|
||||
...payload,
|
||||
})
|
||||
const title = `${media.title ?? ''} ${formatSeason(season.toString())}`
|
||||
|
||||
if (result.success) {
|
||||
updateSubscribeStatus(season, true, mode)
|
||||
$toast.success(`${title} ${t('subscribe.modeUpdateSuccess', { mode: getModeName(t, mode) })}`)
|
||||
} else {
|
||||
$toast.error(`${title} ${t('subscribe.addFailed', { name: getModeName(t, mode), message: result.message })}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
doneNProgress()
|
||||
}
|
||||
}
|
||||
|
||||
function handleSeasonSubscribe(season: number) {
|
||||
if (options.seasonsSubscribed?.value[season]) {
|
||||
removeSubscribe(season)
|
||||
return
|
||||
}
|
||||
|
||||
openSubscribeSeasonDialog(season)
|
||||
}
|
||||
|
||||
function handlePrimarySubscribe() {
|
||||
const media = currentMedia()
|
||||
if (!media) return
|
||||
|
||||
const season = media.type === '电影' ? null : getPrimarySeason()
|
||||
|
||||
if (media.type === '电视剧') {
|
||||
openSubscribeSeasonDialog()
|
||||
return
|
||||
}
|
||||
|
||||
if (options.isSubscribed?.value) {
|
||||
removeSubscribe(season)
|
||||
return
|
||||
}
|
||||
|
||||
if (options.isExists?.()) {
|
||||
openSubscribeModeDialog(['normal', 'best_version'], payload => addSubscribe(null, payload))
|
||||
return
|
||||
}
|
||||
|
||||
addSubscribe(null)
|
||||
}
|
||||
|
||||
function handleSubscribe(season?: number | null) {
|
||||
if (season !== undefined && season !== null) {
|
||||
handleSeasonSubscribe(season)
|
||||
return
|
||||
}
|
||||
|
||||
handlePrimarySubscribe()
|
||||
}
|
||||
|
||||
function subscribeSeasons(
|
||||
seasons: MediaSeason[] = [],
|
||||
seasonExistsStates: { [key: number]: number } = {},
|
||||
groupId = '',
|
||||
seasonModes: SubscribeMode | SeasonSubscribeModes = 'normal',
|
||||
visibleSeasonNumbers: number[] = [],
|
||||
) {
|
||||
episodeGroup.value = groupId
|
||||
const subscribedSeasonSet = new Set(options.subscribedSeasons?.value ?? [])
|
||||
const selectedSeasonSet = new Set(
|
||||
seasons.map(season => season.season_number).filter((season): season is number => season !== null && season !== undefined),
|
||||
)
|
||||
const visibleSeasonSet = new Set(visibleSeasonNumbers)
|
||||
const seasonsToSubscribe = seasons.filter(season => {
|
||||
const seasonNumber = season.season_number ?? null
|
||||
return seasonNumber !== null && !subscribedSeasonSet.has(seasonNumber)
|
||||
})
|
||||
const seasonsToUnsubscribe = [...subscribedSeasonSet].filter(
|
||||
season => visibleSeasonSet.has(season) && !selectedSeasonSet.has(season),
|
||||
)
|
||||
const seasonsToUpdateMode = seasons.filter(season => {
|
||||
const seasonNumber = season.season_number ?? null
|
||||
if (seasonNumber === null || !subscribedSeasonSet.has(seasonNumber)) return false
|
||||
|
||||
const nextMode = typeof seasonModes === 'string' ? seasonModes : seasonModes[seasonNumber] ?? 'normal'
|
||||
return (options.subscribedSeasonModes?.value[seasonNumber] ?? 'normal') !== nextMode
|
||||
})
|
||||
|
||||
seasonsToUnsubscribe.forEach(season => {
|
||||
removeSubscribe(season, { confirm: false })
|
||||
})
|
||||
|
||||
seasonsToUpdateMode.forEach(season => {
|
||||
const seasonNumber = season.season_number ?? null
|
||||
if (seasonNumber === null) return
|
||||
|
||||
const mode = typeof seasonModes === 'string' ? seasonModes : seasonModes[seasonNumber] ?? 'normal'
|
||||
updateSubscribeMode(seasonNumber, mode)
|
||||
})
|
||||
|
||||
seasonsToSubscribe.forEach(season => {
|
||||
const seasonNumber = season.season_number ?? null
|
||||
if (seasonNumber === null) return
|
||||
|
||||
const mode = typeof seasonModes === 'string' ? seasonModes : seasonModes[seasonNumber] ?? 'normal'
|
||||
const payload = getSubscribePayload(mode)
|
||||
addSubscribe(
|
||||
seasonNumber,
|
||||
payload,
|
||||
{
|
||||
openEditDialog: seasonsToSubscribe.length === 1 && seasonsToUnsubscribe.length === 0,
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
addSubscribe,
|
||||
checkSubscribe,
|
||||
handleSubscribe,
|
||||
openSubscribeSeasonDialog,
|
||||
removeSubscribe,
|
||||
subscribeSeasons,
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import DefaultLayout from './components/DefaultLayout.vue'
|
||||
import DefaultLayout from './default/components/DefaultLayout.vue'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
|
||||
+19
-10
@@ -2,15 +2,15 @@
|
||||
import VerticalNavSectionTitle from '@/@layouts/components/VerticalNavSectionTitle.vue'
|
||||
import VerticalNavLayout from '@layouts/components/VerticalNavLayout.vue'
|
||||
import VerticalNavLink from '@layouts/components/VerticalNavLink.vue'
|
||||
import Footer from '@/layouts/components/Footer.vue'
|
||||
import UserNofification from '@/layouts/components/UserNotification.vue'
|
||||
import SearchBar from '@/layouts/components/SearchBar.vue'
|
||||
import ShortcutBar from '@/layouts/components/ShortcutBar.vue'
|
||||
import UserProfile from '@/layouts/components/UserProfile.vue'
|
||||
import QuickAccess from '@/layouts/components/QuickAccess.vue'
|
||||
import HeaderTab from '@/layouts/components/HeaderTab.vue'
|
||||
import AgentAssistantWidget from '@/components/AgentAssistantWidget.vue'
|
||||
import ThemeCustomizer from '@/components/ThemeCustomizer.vue'
|
||||
import Footer from './Footer.vue'
|
||||
import UserNofification from './UserNotification.vue'
|
||||
import SearchBar from './SearchBar.vue'
|
||||
import ShortcutBar from './ShortcutBar.vue'
|
||||
import UserProfile from './UserProfile.vue'
|
||||
import QuickAccess from './QuickAccess.vue'
|
||||
import HeaderTab from './HeaderTab.vue'
|
||||
import AgentAssistantWidget from '@/components/agent/AgentAssistantWidget.vue'
|
||||
import ThemeCustomizer from '@/components/theme/ThemeCustomizer.vue'
|
||||
import { useGlobalSettingsStore, usePluginSidebarNavStore, useUserStore } from '@/stores'
|
||||
import { getNavMenus } from '@/router/i18n-menu'
|
||||
import { filterPluginSidebarNavEntries } from '@/utils/pluginSidebarNav'
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
} from '@/utils/permission'
|
||||
import { usePullDownGesture } from '@/composables/usePullDownGesture'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import OfflinePage from '@/layouts/components/OfflinePage.vue'
|
||||
import OfflinePage from './OfflinePage.vue'
|
||||
import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus'
|
||||
import {
|
||||
readThemeCustomizerSettings,
|
||||
@@ -264,6 +264,15 @@ const {
|
||||
config: PULL_CONFIG,
|
||||
} = usePullDownGesture({
|
||||
enabled: true,
|
||||
config: {
|
||||
START_THRESHOLD: 28,
|
||||
SHOW_INDICATOR: 80,
|
||||
TRIGGER_THRESHOLD: 140,
|
||||
MAX_PULL_DISTANCE: 220,
|
||||
PULL_RESISTANCE: 0.7,
|
||||
CONTENT_FOLLOW_RATIO: 0.35,
|
||||
TOLERANCE: 96,
|
||||
},
|
||||
canUsePullGesture,
|
||||
onTrigger: () => {
|
||||
showPluginQuickAccess.value = true
|
||||
+6
-4
@@ -51,10 +51,11 @@ const pluginBackgroundColors = ref<Record<string, string>>({})
|
||||
|
||||
// 上滑关闭配置常量
|
||||
const SWIPE_CONFIG = {
|
||||
START_THRESHOLD: 10, // 开始检测上滑的最小距离
|
||||
CLOSE_THRESHOLD: 100, // 触发关闭的距离
|
||||
START_THRESHOLD: 16, // 开始检测上滑的最小距离
|
||||
CLOSE_THRESHOLD: 144, // 触发关闭的距离
|
||||
QUICK_CLOSE_MIN_DISTANCE: 56, // 快速关闭所需的最小位移
|
||||
MAX_DRAG_DISTANCE: 1000, // 最大拖拽距离
|
||||
VELOCITY_THRESHOLD: 0.8, // 快速滑动速度阈值 (px/ms)
|
||||
VELOCITY_THRESHOLD: 1.15, // 快速滑动速度阈值 (px/ms)
|
||||
}
|
||||
|
||||
// 上滑关闭相关状态
|
||||
@@ -367,7 +368,8 @@ function handleTouchEnd() {
|
||||
if (isDraggingToClose.value) {
|
||||
// 判断是否应该关闭:距离超过阈值或者快速上滑
|
||||
const shouldClose =
|
||||
dragOffset.value >= SWIPE_CONFIG.CLOSE_THRESHOLD || velocity.value >= SWIPE_CONFIG.VELOCITY_THRESHOLD
|
||||
dragOffset.value >= SWIPE_CONFIG.CLOSE_THRESHOLD ||
|
||||
(dragOffset.value >= SWIPE_CONFIG.QUICK_CLOSE_MIN_DISTANCE && velocity.value >= SWIPE_CONFIG.VELOCITY_THRESHOLD)
|
||||
|
||||
if (shouldClose) {
|
||||
emit('close')
|
||||
+206
-87
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import type { SystemNotification } from '@/api/types'
|
||||
import api from '@/api'
|
||||
import { clearUnreadMessages } from '@/utils/badge'
|
||||
import { appUnreadMessageCount, clearUnreadMessages } from '@/utils/badge'
|
||||
import { emitAgentAssistantNotificationBubble } from '@/utils/agentAssistantBubble'
|
||||
import { formatDateDifference } from '@core/utils/formatters'
|
||||
import { useBackground } from '@/composables/useBackground'
|
||||
import { useToast } from 'vue-toastification'
|
||||
@@ -11,6 +12,7 @@ import { useConfirm } from '@/composables/useConfirm'
|
||||
type NotificationDisplayItem =
|
||||
| { kind: 'section'; key: string; title: string; count: number }
|
||||
| { kind: 'notification'; key: string; notification: SystemNotification }
|
||||
type NotificationClearScope = 'all' | 'system' | 'media'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { useDelayedSSE } = useBackground()
|
||||
@@ -20,8 +22,6 @@ const createConfirm = useConfirm()
|
||||
const PAGE_SIZE = 20
|
||||
// 虚拟滚动的默认通知项高度,展开后的实际高度由 VVirtualScroll 的 itemRef 动态测量。
|
||||
const NOTIFICATION_ITEM_HEIGHT = 136
|
||||
const CLEAR_NOTIFICATION_ENDPOINTS = ['message/notification', 'message/notification/clear']
|
||||
const NOTIFICATION_CLEAR_BEFORE_STORAGE_KEY = 'moviepilot-notification-clear-before'
|
||||
|
||||
const appsMenu = ref(false)
|
||||
const hasNewMessage = ref(false)
|
||||
@@ -31,26 +31,37 @@ const loading = ref(false)
|
||||
const clearing = ref(false)
|
||||
const hasMore = ref(true)
|
||||
const notificationKeys = new Set<string>()
|
||||
const notificationClearBefore = ref(readNotificationClearBefore())
|
||||
const expandedNotificationKeys = ref(new Set<string>())
|
||||
|
||||
const hasUnreadNotifications = computed(() => notificationList.value.some(item => item.read === false))
|
||||
const hasBadgeUnreadMessages = computed(() => appUnreadMessageCount.value > 0)
|
||||
const canMarkAllAsRead = computed(() => hasUnreadNotifications.value || hasBadgeUnreadMessages.value)
|
||||
const hasUnreadMessageIndicator = computed(() => hasNewMessage.value || canMarkAllAsRead.value)
|
||||
const notificationDisplayList = computed(() => buildNotificationDisplayList(notificationList.value))
|
||||
|
||||
/** 从本地存储读取通知清理时间戳,用于过滤已清理的历史通知。 */
|
||||
function readNotificationClearBefore() {
|
||||
if (typeof localStorage === 'undefined') return 0
|
||||
|
||||
return Number(localStorage.getItem(NOTIFICATION_CLEAR_BEFORE_STORAGE_KEY) || 0)
|
||||
}
|
||||
|
||||
/** 写入通知清理时间戳,使清理结果在刷新后仍然生效。 */
|
||||
function writeNotificationClearBefore(value: number) {
|
||||
notificationClearBefore.value = value
|
||||
if (typeof localStorage === 'undefined') return
|
||||
|
||||
localStorage.setItem(NOTIFICATION_CLEAR_BEFORE_STORAGE_KEY, String(value))
|
||||
}
|
||||
const notificationClearCounts = computed(() => getNotificationClearCounts())
|
||||
const notificationClearOptions = computed(() => [
|
||||
{
|
||||
scope: 'system' as const,
|
||||
title: t('notification.clearSystemMessages'),
|
||||
icon: 'mdi-alert-circle-outline',
|
||||
color: 'error',
|
||||
count: notificationClearCounts.value.system,
|
||||
},
|
||||
{
|
||||
scope: 'media' as const,
|
||||
title: t('notification.clearMediaMessages'),
|
||||
icon: 'mdi-image-outline',
|
||||
color: 'primary',
|
||||
count: notificationClearCounts.value.media,
|
||||
},
|
||||
{
|
||||
scope: 'all' as const,
|
||||
title: t('notification.clearAllMessages'),
|
||||
icon: 'mdi-trash-can-outline',
|
||||
color: 'secondary',
|
||||
count: notificationClearCounts.value.all,
|
||||
},
|
||||
])
|
||||
|
||||
/** 将通知备注统一转换成稳定字符串,用于生成去重 key。 */
|
||||
function normalizeNote(note: SystemNotification['note']) {
|
||||
@@ -118,15 +129,6 @@ function parseNotificationTime(value: string) {
|
||||
return new Date(value.includes('T') ? value : value.replaceAll(/-/g, '/')).getTime() || 0
|
||||
}
|
||||
|
||||
/** 判断历史通知是否早于本地清理时间,需要从列表中过滤。 */
|
||||
function isClearedHistoryNotification(item: SystemNotification) {
|
||||
const clearBefore = notificationClearBefore.value
|
||||
if (!clearBefore) return false
|
||||
|
||||
const notificationTime = parseNotificationTime(getNotificationTime(item))
|
||||
return notificationTime > 0 && notificationTime <= clearBefore
|
||||
}
|
||||
|
||||
/** 按通知时间倒序重排当前列表。 */
|
||||
function sortNotifications() {
|
||||
notificationList.value = [...notificationList.value].sort(
|
||||
@@ -198,59 +200,107 @@ function resetNotifications() {
|
||||
hasNewMessage.value = false
|
||||
}
|
||||
|
||||
/** 通过后端接口清理通知历史,兼容新旧后端可能暴露的清理路径。 */
|
||||
async function deleteNotificationHistory() {
|
||||
let lastError: unknown = null
|
||||
|
||||
for (const endpoint of CLEAR_NOTIFICATION_ENDPOINTS) {
|
||||
try {
|
||||
return await api.delete(endpoint)
|
||||
} catch (error: any) {
|
||||
lastError = error
|
||||
if (error?.response?.status !== 404 && error?.response?.status !== 405) break
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError
|
||||
/** 重新根据当前列表生成去重 key,避免分类清理后遗留已移除消息的去重状态。 */
|
||||
function rebuildNotificationKeys() {
|
||||
notificationKeys.clear()
|
||||
notificationList.value.forEach(item => {
|
||||
getNotificationKeys(item).forEach(key => notificationKeys.add(key))
|
||||
})
|
||||
}
|
||||
|
||||
/** 尝试调用后端清理接口,不支持时回退为本地清理。 */
|
||||
async function tryDeleteNotificationHistory() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await deleteNotificationHistory()
|
||||
return result?.success !== false
|
||||
} catch (error: any) {
|
||||
if (error?.response?.status === 404 || error?.response?.status === 405) return true
|
||||
throw error
|
||||
/** 清理已移除通知的展开状态,避免虚拟列表复用时保留无效 key。 */
|
||||
function rebuildExpandedNotificationKeys() {
|
||||
const currentKeys = new Set(notificationList.value.map(getNotificationExpansionKey))
|
||||
expandedNotificationKeys.value = new Set(
|
||||
[...expandedNotificationKeys.value].filter(key => currentKeys.has(key)),
|
||||
)
|
||||
}
|
||||
|
||||
/** 列表内容变化后同步未读红点和应用角标状态。 */
|
||||
function syncUnreadStateAfterListChange() {
|
||||
// 只用当前列表更新通知中心红点,应用 badge 数量由 badge 工具维护。
|
||||
hasNewMessage.value = canMarkAllAsRead.value
|
||||
}
|
||||
|
||||
/** 统计当前已加载通知中各清理范围的数量,用于菜单展示和禁用空操作。 */
|
||||
function getNotificationClearCounts() {
|
||||
const counts: Record<NotificationClearScope, number> = {
|
||||
all: notificationList.value.length,
|
||||
system: 0,
|
||||
media: 0,
|
||||
}
|
||||
|
||||
notificationList.value.forEach(item => {
|
||||
counts[getNotificationClearScope(item)] += 1
|
||||
})
|
||||
|
||||
return counts
|
||||
}
|
||||
|
||||
/** 移除指定范围的通知,并让分页从第一页重新校验,方便继续加载剩余分类历史。 */
|
||||
function removeNotificationsByScope(scope: NotificationClearScope) {
|
||||
if (scope === 'all') {
|
||||
resetNotifications()
|
||||
hasMore.value = false
|
||||
return
|
||||
}
|
||||
|
||||
notificationList.value = notificationList.value.filter(item => getNotificationClearScope(item) !== scope)
|
||||
page.value = 1
|
||||
hasMore.value = true
|
||||
rebuildNotificationKeys()
|
||||
rebuildExpandedNotificationKeys()
|
||||
syncUnreadStateAfterListChange()
|
||||
}
|
||||
|
||||
/** 获取不同清理范围的确认文案。 */
|
||||
function getClearConfirmText(scope: NotificationClearScope) {
|
||||
if (scope === 'system') return t('notification.clearSystemConfirm')
|
||||
if (scope === 'media') return t('notification.clearMediaConfirm')
|
||||
return t('notification.clearAllConfirm')
|
||||
}
|
||||
|
||||
/** 获取不同清理范围的成功文案。 */
|
||||
function getClearSuccessText(scope: NotificationClearScope) {
|
||||
if (scope === 'system') return t('notification.clearSystemSuccess')
|
||||
if (scope === 'media') return t('notification.clearMediaSuccess')
|
||||
return t('notification.clearAllSuccess')
|
||||
}
|
||||
|
||||
/** 调用后端记录清理范围,后续分页查询会直接返回过滤后的通知。 */
|
||||
async function tryDeleteNotificationHistory(scope: NotificationClearScope) {
|
||||
const result: { [key: string]: any } = await api.delete('message/notification', {
|
||||
params: { scope },
|
||||
})
|
||||
return result?.success !== false
|
||||
}
|
||||
|
||||
/** 确认并清空通知中心历史,同时同步清理未读角标。 */
|
||||
async function clearNotifications() {
|
||||
if (clearing.value || notificationList.value.length === 0) return
|
||||
async function clearNotifications(scope: NotificationClearScope) {
|
||||
if (clearing.value || notificationClearCounts.value[scope] === 0) return
|
||||
|
||||
const confirmed = await createConfirm({
|
||||
type: 'warn',
|
||||
title: t('notification.clear'),
|
||||
content: t('notification.clearConfirm'),
|
||||
content: getClearConfirmText(scope),
|
||||
confirmText: t('notification.clear'),
|
||||
})
|
||||
if (!confirmed) return
|
||||
|
||||
clearing.value = true
|
||||
try {
|
||||
const cleared = await tryDeleteNotificationHistory()
|
||||
const cleared = await tryDeleteNotificationHistory(scope)
|
||||
if (!cleared) {
|
||||
$toast.error(t('notification.clearFailed'))
|
||||
return
|
||||
}
|
||||
|
||||
writeNotificationClearBefore(Date.now())
|
||||
resetNotifications()
|
||||
await clearUnreadMessages()
|
||||
appsMenu.value = false
|
||||
hasMore.value = false
|
||||
$toast.success(t('notification.clearSuccess'))
|
||||
removeNotificationsByScope(scope)
|
||||
if (scope === 'all') {
|
||||
await clearUnreadMessages()
|
||||
appsMenu.value = false
|
||||
}
|
||||
$toast.success(getClearSuccessText(scope))
|
||||
} catch (error: any) {
|
||||
$toast.error(error?.response?.data?.message || error?.message || t('notification.clearFailed'))
|
||||
} finally {
|
||||
@@ -258,6 +308,46 @@ async function clearNotifications() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 按页请求历史通知,并合并到当前虚拟列表。 */
|
||||
async function fetchNotificationPage() {
|
||||
const items = (await api.get('message/notification', {
|
||||
params: {
|
||||
page: page.value,
|
||||
count: PAGE_SIZE,
|
||||
},
|
||||
})) as SystemNotification[]
|
||||
|
||||
if (items.length === 0) {
|
||||
hasMore.value = false
|
||||
return items
|
||||
}
|
||||
|
||||
mergeNotifications(items, { read: true })
|
||||
page.value += 1
|
||||
hasMore.value = items.length >= PAGE_SIZE
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
/** 刷新通知中心首屏数据,确保点开红点时能立即看到后端已有的新消息。 */
|
||||
async function refreshNotificationsOnOpen() {
|
||||
if (loading.value) return
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
page.value = 1
|
||||
hasMore.value = true
|
||||
notificationKeys.clear()
|
||||
notificationList.value = compactNotifications(notificationList.value)
|
||||
rebuildNotificationKeys()
|
||||
await fetchNotificationPage()
|
||||
} catch (error) {
|
||||
console.error('刷新通知失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 按页加载历史通知,并合并到当前虚拟列表。 */
|
||||
async function loadNotifications({ done }: { done: (status: 'ok' | 'empty' | 'error') => void }) {
|
||||
if (loading.value) {
|
||||
@@ -272,23 +362,13 @@ async function loadNotifications({ done }: { done: (status: 'ok' | 'empty' | 'er
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
const items = (await api.get('message/notification', {
|
||||
params: {
|
||||
page: page.value,
|
||||
count: PAGE_SIZE,
|
||||
},
|
||||
})) as SystemNotification[]
|
||||
|
||||
const items = await fetchNotificationPage()
|
||||
if (items.length === 0) {
|
||||
hasMore.value = false
|
||||
done('empty')
|
||||
return
|
||||
}
|
||||
|
||||
const visibleItems = items.filter(item => !isClearedHistoryNotification(item))
|
||||
mergeNotifications(visibleItems, { read: true })
|
||||
page.value += 1
|
||||
hasMore.value = visibleItems.length === items.length && items.length >= PAGE_SIZE
|
||||
done(hasMore.value ? 'ok' : 'empty')
|
||||
} catch (error) {
|
||||
console.error('加载通知失败:', error)
|
||||
@@ -304,8 +384,10 @@ function handleMessage(event: MessageEvent) {
|
||||
|
||||
try {
|
||||
const notification = JSON.parse(event.data) as SystemNotification
|
||||
|
||||
if (mergeNotifications([notification], { prepend: true, read: false })) {
|
||||
hasNewMessage.value = true
|
||||
emitAgentAssistantNotificationBubble(notification)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('解析通知失败:', error)
|
||||
@@ -314,6 +396,8 @@ function handleMessage(event: MessageEvent) {
|
||||
|
||||
/** 将通知列表标记为已读,并同步清理应用角标、未读红点和通知弹窗。 */
|
||||
function markAllAsRead() {
|
||||
if (!canMarkAllAsRead.value) return
|
||||
|
||||
hasNewMessage.value = false
|
||||
notificationList.value.forEach(item => {
|
||||
item.read = true
|
||||
@@ -347,6 +431,11 @@ function isMediaNotification(item: SystemNotification) {
|
||||
return Boolean(item.image)
|
||||
}
|
||||
|
||||
/** 获取通知清理范围,目前通知中心展示上以是否包含图片区分媒体和系统消息。 */
|
||||
function getNotificationClearScope(item: SystemNotification): Exclude<NotificationClearScope, 'all'> {
|
||||
return isMediaNotification(item) ? 'media' : 'system'
|
||||
}
|
||||
|
||||
/** 按系统类消息和媒体消息生成带分组标题的虚拟列表数据。 */
|
||||
function buildNotificationDisplayList(items: SystemNotification[]) {
|
||||
const systemItems = items.filter(item => !isMediaNotification(item))
|
||||
@@ -383,11 +472,10 @@ function isNotificationExpanded(item: SystemNotification) {
|
||||
return expandedNotificationKeys.value.has(getNotificationExpansionKey(item))
|
||||
}
|
||||
|
||||
/** 标记单条通知为已读,并在全部已读时同步清理未读角标。 */
|
||||
/** 标记单条通知为已读,仅同步当前通知中心列表的未读状态。 */
|
||||
function markNotificationAsRead(item: SystemNotification) {
|
||||
item.read = true
|
||||
hasNewMessage.value = hasUnreadNotifications.value
|
||||
if (!hasUnreadNotifications.value) void clearUnreadMessages()
|
||||
}
|
||||
|
||||
/** 切换通知正文展开状态。 */
|
||||
@@ -413,6 +501,13 @@ useDelayedSSE(
|
||||
maxReconnectAttempts: 3,
|
||||
},
|
||||
)
|
||||
|
||||
/** 监听通知中心展开状态,展开时主动刷新首屏通知。 */
|
||||
function handleNotificationMenuVisibleChange(open: boolean) {
|
||||
if (open) void refreshNotificationsOnOpen()
|
||||
}
|
||||
|
||||
watch(appsMenu, handleNotificationMenuVisibleChange)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -426,7 +521,7 @@ useDelayedSSE(
|
||||
scrim
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<VBadge v-if="hasNewMessage" dot color="error" :offset-x="5" :offset-y="5" v-bind="props">
|
||||
<VBadge v-if="hasUnreadMessageIndicator" dot color="error" :offset-x="5" :offset-y="5" v-bind="props">
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-bell-outline" size="22" />
|
||||
</IconBtn>
|
||||
@@ -443,19 +538,39 @@ useDelayedSSE(
|
||||
<div class="notification-actions">
|
||||
<VTooltip :text="t('notification.clear')">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn
|
||||
v-bind="props"
|
||||
:disabled="notificationList.length === 0 || clearing"
|
||||
@click.stop="clearNotifications"
|
||||
>
|
||||
<VProgressCircular v-if="clearing" indeterminate size="18" width="2" />
|
||||
<VIcon v-else icon="mdi-trash-can-outline" size="20" />
|
||||
</IconBtn>
|
||||
<VMenu location="bottom end" :close-on-content-click="true">
|
||||
<template #activator="{ props: menuProps }">
|
||||
<IconBtn
|
||||
v-bind="{ ...props, ...menuProps }"
|
||||
:disabled="notificationList.length === 0 || clearing"
|
||||
@click.stop
|
||||
>
|
||||
<VProgressCircular v-if="clearing" indeterminate size="18" width="2" />
|
||||
<VIcon v-else icon="mdi-trash-can-outline" size="20" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
<VList density="compact" min-width="180">
|
||||
<VListItem
|
||||
v-for="option in notificationClearOptions"
|
||||
:key="option.scope"
|
||||
:disabled="option.count === 0 || clearing"
|
||||
@click="clearNotifications(option.scope)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="option.icon" :color="option.color" size="20" />
|
||||
</template>
|
||||
<VListItemTitle>{{ option.title }}</VListItemTitle>
|
||||
<template #append>
|
||||
<span class="notification-clear-count">{{ option.count }}</span>
|
||||
</template>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VTooltip :text="t('notification.markRead')">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn v-bind="props" :disabled="!hasUnreadNotifications" @click.stop="markAllAsRead">
|
||||
<IconBtn v-bind="props" :disabled="!canMarkAllAsRead" @click.stop="markAllAsRead">
|
||||
<VIcon icon="mdi-email-check-outline" size="20" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
@@ -479,10 +594,7 @@ useDelayedSSE(
|
||||
</div>
|
||||
</template>
|
||||
<template #empty>
|
||||
<div v-if="notificationList.length > 0" class="py-3 text-center text-caption text-medium-emphasis">
|
||||
{{ t('message.noMoreData') }}
|
||||
</div>
|
||||
<div v-else class="notification-empty">
|
||||
<div v-if="notificationList.length === 0" class="notification-empty">
|
||||
<div class="notification-empty__icon">
|
||||
<VIcon icon="mdi-bell-sleep-outline" size="22" />
|
||||
</div>
|
||||
@@ -575,6 +687,13 @@ useDelayedSSE(
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.notification-clear-count {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-disabled-opacity));
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
margin-inline-start: 16px;
|
||||
}
|
||||
|
||||
.notification-list-container {
|
||||
overflow: hidden;
|
||||
max-block-size: min(560px, 62vh);
|
||||
+26
-1
@@ -279,6 +279,7 @@ export default {
|
||||
login: {
|
||||
wallpapers: 'Wallpapers',
|
||||
tagline: 'Your smart media library',
|
||||
welcomeBack: 'Welcome Back',
|
||||
copyright: '© {year} MoviePilot',
|
||||
username: 'Username',
|
||||
password: 'Password',
|
||||
@@ -461,7 +462,16 @@ export default {
|
||||
markRead: 'Mark as Read',
|
||||
clear: 'Clear Notifications',
|
||||
clearConfirm: 'Clear all notification history from Notification Center?',
|
||||
clearAllMessages: 'Clear All Messages',
|
||||
clearSystemMessages: 'Clear System Messages',
|
||||
clearMediaMessages: 'Clear Media Messages',
|
||||
clearAllConfirm: 'Clear all notification history from Notification Center?',
|
||||
clearSystemConfirm: 'Clear system message history from Notification Center?',
|
||||
clearMediaConfirm: 'Clear media message history from Notification Center?',
|
||||
clearSuccess: 'Notifications cleared',
|
||||
clearAllSuccess: 'All notifications cleared',
|
||||
clearSystemSuccess: 'System messages cleared',
|
||||
clearMediaSuccess: 'Media messages cleared',
|
||||
clearFailed: 'Failed to clear notifications',
|
||||
empty: 'No Notifications',
|
||||
systemMessages: 'System Messages',
|
||||
@@ -712,7 +722,10 @@ export default {
|
||||
untitledSession: 'Untitled chat',
|
||||
emptyTitle: 'What should we handle today?',
|
||||
emptySubtitle: 'Ask about sites, subscriptions, downloads, or organization tasks.',
|
||||
placeholder: 'Ask MoviePilot...',
|
||||
placeholder: 'Ask MoviePilot, Type / for commands',
|
||||
processingPlaceholder: 'MoviePilot is working, please wait...',
|
||||
commandLoading: 'Loading commands...',
|
||||
commandLoadFailed: 'Failed to load commands',
|
||||
stop: 'Stop generating',
|
||||
download: 'Download',
|
||||
attachFile: 'Choose image or file',
|
||||
@@ -980,6 +993,8 @@ export default {
|
||||
missing: 'Missing',
|
||||
partiallyMissing: 'Partially Missing',
|
||||
subscribed: 'Subscribed',
|
||||
seasonsSubscribed: '{count} Seasons Subscribed',
|
||||
allSeasonsSubscribed: 'All Seasons Subscribed',
|
||||
},
|
||||
minutes: 'minutes',
|
||||
overview: 'Overview',
|
||||
@@ -1039,8 +1054,10 @@ export default {
|
||||
versionSub: 'Version Upgrade Subscribe',
|
||||
addSuccess: 'Added {name} successfully!',
|
||||
addFailed: 'Failed to add {name}: {message}!',
|
||||
modeUpdateSuccess: 'Updated to {mode}!',
|
||||
cancelSuccess: 'Subscription cancelled!',
|
||||
cancelFailed: 'Failed to cancel subscription: {message}!',
|
||||
notFound: 'Subscription not found!',
|
||||
filterSubscriptions: 'Filter Subscriptions',
|
||||
name: 'Name',
|
||||
searchShares: 'Search Subscription Shares',
|
||||
@@ -2280,6 +2297,7 @@ export default {
|
||||
},
|
||||
subscribeSeason: {
|
||||
title: 'Subscribe - {title}',
|
||||
selectMode: 'Subscription Type',
|
||||
selectGroup: 'Select Episode Group',
|
||||
defaultGroup: 'Default',
|
||||
seasonCount: '{count} Seasons',
|
||||
@@ -2804,6 +2822,13 @@ export default {
|
||||
processing: 'Processing ...',
|
||||
successMessage: 'File {name} has been added to the organization queue!',
|
||||
},
|
||||
subscribeMode: {
|
||||
title: 'Choose Subscription Type',
|
||||
normal: 'Normal',
|
||||
bestVersion: 'Version Upgrade',
|
||||
bestVersionEpisode: 'Episode',
|
||||
bestVersionFull: 'Full',
|
||||
},
|
||||
subscribeEdit: {
|
||||
titleDefault: 'Default Subscription Rules',
|
||||
titleEdit: 'Edit Subscription',
|
||||
|
||||
+26
-1
@@ -278,6 +278,7 @@ export default {
|
||||
login: {
|
||||
wallpapers: '壁纸',
|
||||
tagline: '你的智能影视媒体库',
|
||||
welcomeBack: '欢迎回来',
|
||||
copyright: '© {year} MoviePilot',
|
||||
username: '用户名',
|
||||
password: '密码',
|
||||
@@ -459,7 +460,16 @@ export default {
|
||||
markRead: '设为已读',
|
||||
clear: '清理通知',
|
||||
clearConfirm: '是否确认清理通知中心内的全部历史消息?',
|
||||
clearAllMessages: '清理全部消息',
|
||||
clearSystemMessages: '清理系统消息',
|
||||
clearMediaMessages: '清理媒体消息',
|
||||
clearAllConfirm: '是否确认清理通知中心内的全部历史消息?',
|
||||
clearSystemConfirm: '是否确认清理通知中心内的系统类历史消息?',
|
||||
clearMediaConfirm: '是否确认清理通知中心内的媒体历史消息?',
|
||||
clearSuccess: '通知已清理',
|
||||
clearAllSuccess: '全部通知已清理',
|
||||
clearSystemSuccess: '系统消息已清理',
|
||||
clearMediaSuccess: '媒体消息已清理',
|
||||
clearFailed: '通知清理失败',
|
||||
empty: '暂无通知',
|
||||
systemMessages: '系统类消息',
|
||||
@@ -708,7 +718,10 @@ export default {
|
||||
untitledSession: '未命名会话',
|
||||
emptyTitle: '今天想处理什么?',
|
||||
emptySubtitle: '站点、订阅、下载、整理任务,都可以直接问我。',
|
||||
placeholder: '询问 MoviePilot...',
|
||||
placeholder: '询问 MoviePilot,输入 / 使用命令',
|
||||
processingPlaceholder: '智能体正在处理,请稍候...',
|
||||
commandLoading: '正在加载命令...',
|
||||
commandLoadFailed: '命令列表加载失败',
|
||||
stop: '停止生成',
|
||||
download: '下载',
|
||||
attachFile: '选择图片或文件',
|
||||
@@ -976,6 +989,8 @@ export default {
|
||||
missing: '缺失',
|
||||
partiallyMissing: '部分缺失',
|
||||
subscribed: '已订阅',
|
||||
seasonsSubscribed: '已订阅 {count} 季',
|
||||
allSeasonsSubscribed: '已全部订阅',
|
||||
},
|
||||
minutes: '分钟',
|
||||
overview: '简介',
|
||||
@@ -1035,8 +1050,10 @@ export default {
|
||||
versionSub: '洗版订阅',
|
||||
addSuccess: '添加{name}成功!',
|
||||
addFailed: '添加{name}失败:{message}!',
|
||||
modeUpdateSuccess: '已更新为{mode}!',
|
||||
cancelSuccess: '已取消订阅!',
|
||||
cancelFailed: '取消订阅失败:{message}!',
|
||||
notFound: '订阅不存在!',
|
||||
filterSubscriptions: '筛选订阅',
|
||||
name: '名称',
|
||||
searchShares: '搜索订阅分享',
|
||||
@@ -2235,6 +2252,7 @@ export default {
|
||||
},
|
||||
subscribeSeason: {
|
||||
title: '订阅 - {title}',
|
||||
selectMode: '选择订阅方式',
|
||||
selectGroup: '选择剧集组',
|
||||
defaultGroup: '默认',
|
||||
seasonCount: '{count} 季',
|
||||
@@ -2753,6 +2771,13 @@ export default {
|
||||
processing: '正在处理 ...',
|
||||
successMessage: '文件 {name} 已加入整理队列!',
|
||||
},
|
||||
subscribeMode: {
|
||||
title: '选择订阅方式',
|
||||
normal: '普通订阅',
|
||||
bestVersion: '洗版',
|
||||
bestVersionEpisode: '分集洗版',
|
||||
bestVersionFull: '全集洗版',
|
||||
},
|
||||
subscribeEdit: {
|
||||
titleDefault: '默认订阅规则',
|
||||
titleEdit: '编辑订阅',
|
||||
|
||||
+26
-1
@@ -278,6 +278,7 @@ export default {
|
||||
login: {
|
||||
wallpapers: '壁紙',
|
||||
tagline: '你的智能影視媒體庫',
|
||||
welcomeBack: '歡迎回來',
|
||||
copyright: '© {year} MoviePilot',
|
||||
username: '用戶名',
|
||||
password: '密碼',
|
||||
@@ -459,7 +460,16 @@ export default {
|
||||
markRead: '設為已讀',
|
||||
clear: '清理通知',
|
||||
clearConfirm: '是否確認清理通知中心內的全部歷史消息?',
|
||||
clearAllMessages: '清理全部消息',
|
||||
clearSystemMessages: '清理系統消息',
|
||||
clearMediaMessages: '清理媒體消息',
|
||||
clearAllConfirm: '是否確認清理通知中心內的全部歷史消息?',
|
||||
clearSystemConfirm: '是否確認清理通知中心內的系統類歷史消息?',
|
||||
clearMediaConfirm: '是否確認清理通知中心內的媒體歷史消息?',
|
||||
clearSuccess: '通知已清理',
|
||||
clearAllSuccess: '全部通知已清理',
|
||||
clearSystemSuccess: '系統消息已清理',
|
||||
clearMediaSuccess: '媒體消息已清理',
|
||||
clearFailed: '通知清理失敗',
|
||||
empty: '暫無通知',
|
||||
systemMessages: '系統類消息',
|
||||
@@ -708,7 +718,10 @@ export default {
|
||||
untitledSession: '未命名會話',
|
||||
emptyTitle: '今天想處理什麼?',
|
||||
emptySubtitle: '站點、訂閱、下載、整理任務,都可以直接問我。',
|
||||
placeholder: '詢問 MoviePilot...',
|
||||
placeholder: '詢問 MoviePilot,輸入 / 使用命令',
|
||||
processingPlaceholder: '智能體正在處理,請稍候...',
|
||||
commandLoading: '正在載入命令...',
|
||||
commandLoadFailed: '命令列表載入失敗',
|
||||
stop: '停止生成',
|
||||
download: '下載',
|
||||
attachFile: '選擇圖片或文件',
|
||||
@@ -976,6 +989,8 @@ export default {
|
||||
missing: '缺失',
|
||||
partiallyMissing: '部分缺失',
|
||||
subscribed: '已訂閱',
|
||||
seasonsSubscribed: '已訂閱 {count} 季',
|
||||
allSeasonsSubscribed: '已全部訂閱',
|
||||
},
|
||||
minutes: '分鐘',
|
||||
overview: '簡介',
|
||||
@@ -1035,8 +1050,10 @@ export default {
|
||||
versionSub: '洗版訂閱',
|
||||
addSuccess: '添加{name}成功!',
|
||||
addFailed: '添加{name}失敗:{message}!',
|
||||
modeUpdateSuccess: '已更新為{mode}!',
|
||||
cancelSuccess: '已取消訂閱!',
|
||||
cancelFailed: '取消訂閱失敗:{message}!',
|
||||
notFound: '訂閱不存在!',
|
||||
filterSubscriptions: '篩選訂閱',
|
||||
name: '名稱',
|
||||
searchShares: '搜索訂閱分享',
|
||||
@@ -2236,6 +2253,7 @@ export default {
|
||||
},
|
||||
subscribeSeason: {
|
||||
title: '訂閱 - {title}',
|
||||
selectMode: '選擇訂閱方式',
|
||||
selectGroup: '選擇劇集組',
|
||||
defaultGroup: '默認',
|
||||
seasonCount: '{count} 季',
|
||||
@@ -2754,6 +2772,13 @@ export default {
|
||||
processing: '正在處理 ...',
|
||||
successMessage: '文件 {name} 已加入整理隊列!',
|
||||
},
|
||||
subscribeMode: {
|
||||
title: '選擇訂閱方式',
|
||||
normal: '普通訂閱',
|
||||
bestVersion: '洗版',
|
||||
bestVersionEpisode: '分集洗版',
|
||||
bestVersionFull: '全集洗版',
|
||||
},
|
||||
subscribeEdit: {
|
||||
titleDefault: '默認訂閱規則',
|
||||
titleEdit: '編輯訂閱',
|
||||
|
||||
+64
-1
@@ -14,9 +14,14 @@ import App from '@/App.vue'
|
||||
import { PerfectScrollbarPlugin } from 'vue3-perfect-scrollbar'
|
||||
|
||||
// 4. 其他插件和功能模块
|
||||
import Toast from 'vue-toastification'
|
||||
import Toast, { TYPE, type PluginOptions } from 'vue-toastification'
|
||||
import ConfirmDialog from '@/composables/useConfirm'
|
||||
import { configureApexChartsTheme } from '@/utils/apexCharts'
|
||||
import {
|
||||
canUseAgentAssistantBubble,
|
||||
emitAgentAssistantToastBubble,
|
||||
type AgentAssistantBubbleVariant,
|
||||
} from '@/utils/agentAssistantBubble'
|
||||
|
||||
// 5. 注册自定义组件
|
||||
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
|
||||
@@ -29,6 +34,8 @@ import '@/styles/main.scss'
|
||||
// 7. 状态恢复插件
|
||||
import stateRestorePlugin from '@/plugins/stateRestore'
|
||||
|
||||
type ToastFilterPayload = Parameters<NonNullable<PluginOptions['filterBeforeCreate']>>[0]
|
||||
|
||||
function runWhenBrowserIdle(callback: () => void, timeout = 1500) {
|
||||
const requestIdle = globalThis.requestIdleCallback
|
||||
if (requestIdle) {
|
||||
@@ -53,6 +60,61 @@ function loadRemoteComponentsAfterLogin() {
|
||||
})
|
||||
}
|
||||
|
||||
function shouldUseAgentAssistantToastBubble() {
|
||||
const settings = pinia.state.value.globalSettings
|
||||
if (!settings?.initialized) return false
|
||||
|
||||
return (
|
||||
settings.data?.AI_AGENT_ENABLE === true &&
|
||||
settings.data?.AI_AGENT_HIDE_ENTRY !== true &&
|
||||
canUseAgentAssistantBubble()
|
||||
)
|
||||
}
|
||||
|
||||
function getAgentAssistantToastVariant(type?: ToastFilterPayload['type']): AgentAssistantBubbleVariant {
|
||||
const variants: Record<string, AgentAssistantBubbleVariant> = {
|
||||
[TYPE.DEFAULT]: 'default',
|
||||
[TYPE.ERROR]: 'error',
|
||||
[TYPE.INFO]: 'info',
|
||||
[TYPE.SUCCESS]: 'success',
|
||||
[TYPE.WARNING]: 'warning',
|
||||
}
|
||||
|
||||
return variants[type || TYPE.DEFAULT] || 'default'
|
||||
}
|
||||
|
||||
function getToastBubbleDuration(type?: ToastFilterPayload['type'], timeout?: ToastFilterPayload['timeout']) {
|
||||
if (typeof timeout === 'number') return timeout
|
||||
if (timeout === false) return undefined
|
||||
|
||||
return type === TYPE.ERROR || type === TYPE.WARNING ? 7000 : 4500
|
||||
}
|
||||
|
||||
function getToastTextContent(content: ToastFilterPayload['content']) {
|
||||
if (typeof content === 'string') return content
|
||||
|
||||
// 组件型 toast 可能包含操作按钮或复杂布局,无法可靠转成气泡文本时继续使用原生 toast。
|
||||
return ''
|
||||
}
|
||||
|
||||
function routeToastToAgentAssistantBubble(toast: ToastFilterPayload) {
|
||||
const text = getToastTextContent(toast.content)
|
||||
if (!text || !shouldUseAgentAssistantToastBubble()) return toast
|
||||
|
||||
const variant = getAgentAssistantToastVariant(toast.type)
|
||||
|
||||
emitAgentAssistantToastBubble({
|
||||
id: `toast-${String(toast.id)}`,
|
||||
kind: 'toast',
|
||||
variant,
|
||||
text,
|
||||
duration: getToastBubbleDuration(toast.type, toast.timeout),
|
||||
keepOpen: toast.timeout === false,
|
||||
})
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
let remoteComponentsInitialized = false
|
||||
|
||||
const AsyncAceEditor = defineAsyncComponent(async () => {
|
||||
@@ -111,6 +173,7 @@ app
|
||||
.use(Toast, {
|
||||
position: 'bottom-right',
|
||||
hideProgressBar: true,
|
||||
filterBeforeCreate: routeToastToAgentAssistantBubble,
|
||||
})
|
||||
.use(ConfirmDialog)
|
||||
.use(i18n)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import NoDataFound from '@/components/states/NoDataFound.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// 国际化
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import api from '@/api'
|
||||
import { DownloaderConf } from '@/api/types'
|
||||
import DownloadingListView from '@/views/reorganize/DownloadingListView.vue'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import NoDataFound from '@/components/states/NoDataFound.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
|
||||
import { useKeepAliveRefresh } from '@/composables/useKeepAliveRefresh'
|
||||
|
||||
+371
-54
@@ -161,12 +161,13 @@ function clearLoginAutofillSyncTimers() {
|
||||
autofillSyncTimerIds.length = 0
|
||||
}
|
||||
|
||||
// 延迟多次同步自动填充值,兼容只对聚焦字段派发事件的密码管理器。
|
||||
// 延迟多次同步自动填充值,兼容浏览器或密码管理器不派发事件的自动填充。
|
||||
function scheduleLoginAutofillSync() {
|
||||
clearLoginAutofillSyncTimers()
|
||||
syncLoginCredentialValues()
|
||||
autofillSyncTimerIds.push(window.setTimeout(syncLoginCredentialValues, 50))
|
||||
autofillSyncTimerIds.push(window.setTimeout(syncLoginCredentialValues, 250))
|
||||
autofillSyncTimerIds.push(window.setTimeout(syncLoginCredentialValues, 1000))
|
||||
}
|
||||
|
||||
// 生成 MFA 共享弹窗使用的最新 props。
|
||||
@@ -568,6 +569,7 @@ async function handleLoginSuccess(response: any) {
|
||||
// 登录获取token事件
|
||||
async function login() {
|
||||
errorMessage.value = ''
|
||||
syncLoginCredentialValues()
|
||||
|
||||
// 进行表单校验
|
||||
if (!form.value.username || !form.value.password) {
|
||||
@@ -669,6 +671,10 @@ onMounted(async () => {
|
||||
// 加载系统和插件声明的未登录认证入口
|
||||
await loadAuthProviders()
|
||||
|
||||
// 捕获页面加载后由浏览器或密码管理器写入的静默自动填充值。
|
||||
await nextTick()
|
||||
scheduleLoginAutofillSync()
|
||||
|
||||
// 初始化 Conditional UI 的 PassKey 自动填充
|
||||
await initConditionalPasskey()
|
||||
})
|
||||
@@ -719,6 +725,13 @@ onUnmounted(() => {
|
||||
<template>
|
||||
<!-- 登录页面容器 -->
|
||||
<div class="login-root">
|
||||
<!-- 装饰性背景光晕 -->
|
||||
<div class="login-bg-decor" aria-hidden="true">
|
||||
<div class="login-orb login-orb--1" />
|
||||
<div class="login-orb login-orb--2" />
|
||||
<div class="login-orb login-orb--3" />
|
||||
</div>
|
||||
|
||||
<!-- 顶部漂浮语言切换 -->
|
||||
<VMenu v-model="langMenu" :close-on-content-click="false">
|
||||
<template #activator="{ props }">
|
||||
@@ -728,7 +741,7 @@ onUnmounted(() => {
|
||||
<span class="ms-1">{{ SUPPORTED_LOCALES[currentLocale].title }}</span>
|
||||
</VBtn>
|
||||
</template>
|
||||
<VCard min-width="180">
|
||||
<VCard min-width="180" class="lang-menu-card">
|
||||
<VList>
|
||||
<VListItem
|
||||
v-for="locale in locales"
|
||||
@@ -749,15 +762,18 @@ onUnmounted(() => {
|
||||
<!-- 登录表单 -->
|
||||
<div v-if="!mfaDialog" class="auth-wrapper d-flex align-center justify-center">
|
||||
<VCard
|
||||
class="auth-card login-card pa-7 pa-sm-8 w-full h-full login-card--enter"
|
||||
class="auth-card login-card pa-7 pa-sm-9 w-full h-full login-card--enter"
|
||||
:class="{ 'glass-effect': !isTransparentTheme }"
|
||||
max-width="23rem"
|
||||
max-width="24rem"
|
||||
flat
|
||||
>
|
||||
<!-- 卡片头部:Logo + 标题 + 标语 -->
|
||||
<!-- 卡片头部:Logo + 标题 + 欢迎语 -->
|
||||
<div class="login-head">
|
||||
<VImg :src="logo" width="68" height="68" class="login-logo" />
|
||||
<div class="login-logo-wrapper">
|
||||
<VImg :src="logo" width="72" height="72" class="login-logo" />
|
||||
</div>
|
||||
<h1 class="login-title">MoviePilot</h1>
|
||||
<p class="login-subtitle">{{ t('login.welcomeBack') || 'Welcome Back' }}</p>
|
||||
</div>
|
||||
|
||||
<VCardText class="login-body">
|
||||
@@ -777,6 +793,8 @@ onUnmounted(() => {
|
||||
hide-details
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
prepend-inner-icon="mdi-account-outline"
|
||||
class="login-input"
|
||||
@input="scheduleLoginAutofillSync"
|
||||
@change="scheduleLoginAutofillSync"
|
||||
/>
|
||||
@@ -790,11 +808,13 @@ onUnmounted(() => {
|
||||
name="password"
|
||||
id="password"
|
||||
autocomplete="current-password"
|
||||
prepend-inner-icon="mdi-lock-outline"
|
||||
:append-inner-icon="isPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
|
||||
:rules="[requiredValidator]"
|
||||
hide-details
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
class="login-input"
|
||||
@click:append-inner="isPasswordVisible = !isPasswordVisible"
|
||||
@input="scheduleLoginAutofillSync"
|
||||
@change="scheduleLoginAutofillSync"
|
||||
@@ -809,6 +829,7 @@ onUnmounted(() => {
|
||||
required
|
||||
hide-details
|
||||
density="compact"
|
||||
class="login-checkbox"
|
||||
/>
|
||||
</div>
|
||||
</VCol>
|
||||
@@ -842,14 +863,15 @@ onUnmounted(() => {
|
||||
block
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
class="mt-3"
|
||||
class="mt-3 plugin-auth-btn"
|
||||
:prepend-icon="provider.icon || 'mdi-login-variant'"
|
||||
:loading="pluginAuthLoading && selectedAuthProvider?.id === provider.id"
|
||||
rounded="lg"
|
||||
@click="openPluginAuth(provider)"
|
||||
>
|
||||
{{ provider.name }}
|
||||
</VBtn>
|
||||
<VAlert v-if="errorMessage" type="error" variant="tonal" class="mt-3">
|
||||
<VAlert v-if="errorMessage" type="error" variant="tonal" class="mt-4 login-alert">
|
||||
{{ errorMessage }}
|
||||
</VAlert>
|
||||
</VCol>
|
||||
@@ -865,7 +887,7 @@ onUnmounted(() => {
|
||||
</VCard>
|
||||
</div>
|
||||
<VDialog v-model="pluginAuthDialog" max-width="520" persistent>
|
||||
<VCard>
|
||||
<VCard class="plugin-auth-card">
|
||||
<VCardItem>
|
||||
<VCardTitle>{{ selectedAuthProvider?.name }}</VCardTitle>
|
||||
<template #append>
|
||||
@@ -894,12 +916,15 @@ onUnmounted(() => {
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* stylelint-disable selector-pseudo-class-no-unknown */
|
||||
|
||||
@use '@core/scss/pages/page-auth';
|
||||
|
||||
/* ===================== 布局根容器 ===================== */
|
||||
.login-root {
|
||||
position: relative;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -908,16 +933,99 @@ onUnmounted(() => {
|
||||
min-block-size: 100dvh;
|
||||
}
|
||||
|
||||
/* ===================== 装饰性背景光晕 ===================== */
|
||||
.login-bg-decor {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.login-orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.login-orb--1 {
|
||||
animation: orb-float-1 12s ease-in-out infinite alternate;
|
||||
background: rgba(var(--v-theme-primary), 0.35);
|
||||
block-size: 360px;
|
||||
filter: blur(60px);
|
||||
inline-size: 360px;
|
||||
inset-block-start: -15%;
|
||||
inset-inline-end: -12%;
|
||||
}
|
||||
|
||||
.login-orb--2 {
|
||||
animation: orb-float-2 15s ease-in-out infinite alternate;
|
||||
background: rgba(var(--v-theme-primary), 0.25);
|
||||
block-size: 300px;
|
||||
filter: blur(55px);
|
||||
inline-size: 300px;
|
||||
inset-block-end: -10%;
|
||||
inset-inline-start: -15%;
|
||||
}
|
||||
|
||||
.login-orb--3 {
|
||||
animation: orb-float-3 10s ease-in-out infinite alternate;
|
||||
background: rgba(var(--v-theme-primary), 0.15);
|
||||
block-size: 220px;
|
||||
filter: blur(50px);
|
||||
inline-size: 220px;
|
||||
inset-block-start: 50%;
|
||||
inset-inline-end: 15%;
|
||||
}
|
||||
|
||||
@keyframes orb-float-1 {
|
||||
0% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(-30px, 40px) scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes orb-float-2 {
|
||||
0% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(25px, -30px) scale(1.08);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes orb-float-3 {
|
||||
0% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(-20px, 20px) scale(0.92);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===================== 浮动语言切换 ===================== */
|
||||
.lang-switch-btn {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
border: 1px solid rgba(var(--v-border-color), calc(var(--v-border-opacity) * 0.6));
|
||||
border-radius: 999px;
|
||||
backdrop-filter: blur(10px);
|
||||
background: rgba(var(--v-theme-surface), 0.55);
|
||||
backdrop-filter: blur(12px) saturate(140%);
|
||||
background: rgba(var(--v-theme-surface), 0.6);
|
||||
inset-block-start: calc(env(safe-area-inset-top, 0px) + 16px);
|
||||
inset-inline-end: calc(env(safe-area-inset-right, 0px) + 16px);
|
||||
transition:
|
||||
background 200ms ease,
|
||||
border-color 200ms ease;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(var(--v-theme-primary), 0.3);
|
||||
background: rgba(var(--v-theme-surface), 0.85);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===================== 表单容器 ===================== */
|
||||
@@ -935,17 +1043,19 @@ onUnmounted(() => {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
border: none !important;
|
||||
border-radius: var(--app-surface-radius, 16px) !important;
|
||||
box-shadow: var(
|
||||
--app-overlay-shadow,
|
||||
0 18px 42px rgba(var(--app-shadow-rgb, 0, 0, 0), 0.14),
|
||||
0 6px 18px rgba(var(--app-shadow-rgb, 0, 0, 0), 0.08)
|
||||
) !important;
|
||||
border-radius: var(--app-surface-radius, 20px) !important;
|
||||
box-shadow:
|
||||
0 20px 50px rgba(var(--app-shadow-rgb, 0, 0, 0), 0.12),
|
||||
0 8px 20px rgba(var(--app-shadow-rgb, 0, 0, 0), 0.06),
|
||||
0 0 0 1px rgba(var(--v-theme-primary), 0.04) !important;
|
||||
transition: box-shadow 300ms ease;
|
||||
|
||||
/* 顶部高光线,营造立体感 */
|
||||
&::before {
|
||||
position: absolute;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 40%), transparent);
|
||||
z-index: 1;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, transparent 10%, rgba(255, 255, 255, 35%) 50%, transparent 90%);
|
||||
block-size: 1px;
|
||||
content: '';
|
||||
inset-block-start: 0;
|
||||
@@ -956,8 +1066,8 @@ onUnmounted(() => {
|
||||
|
||||
/* 非透明主题:磨砂玻璃卡片 */
|
||||
.glass-effect {
|
||||
backdrop-filter: blur(24px) saturate(160%) !important;
|
||||
background: rgba(var(--v-theme-surface), 0.72) !important;
|
||||
backdrop-filter: blur(28px) saturate(170%) !important;
|
||||
background: rgba(var(--v-theme-surface), 0.75) !important;
|
||||
}
|
||||
|
||||
/* 深色主题上叠一条更亮的描边,区分背景 */
|
||||
@@ -976,34 +1086,80 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-block-end: 6px;
|
||||
gap: 4px;
|
||||
margin-block-end: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-logo-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-block-end: 8px;
|
||||
|
||||
/* Logo 背后的柔光环 */
|
||||
&::before {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
border-radius: 50%;
|
||||
animation: logo-pulse 4s ease-in-out infinite;
|
||||
background: radial-gradient(circle, rgba(var(--v-theme-primary), 0.2) 0%, transparent 70%);
|
||||
block-size: 120px;
|
||||
content: '';
|
||||
inline-size: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
filter: drop-shadow(0 6px 16px rgba(var(--v-theme-primary), 0.35));
|
||||
margin-block-end: 4px;
|
||||
animation: logo-float 6s ease-in-out infinite;
|
||||
filter: drop-shadow(0 8px 20px rgba(var(--v-theme-primary), 0.3));
|
||||
}
|
||||
|
||||
@keyframes logo-float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes logo-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.6;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
.login-title {
|
||||
margin: 0;
|
||||
background: linear-gradient(120deg, rgb(var(--v-theme-on-surface)), rgba(var(--v-theme-primary), 1));
|
||||
background: linear-gradient(135deg, rgb(var(--v-theme-on-surface)) 30%, rgba(var(--v-theme-primary), 1) 100%);
|
||||
background-clip: text;
|
||||
font-size: 1.8rem;
|
||||
font-size: 1.85rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.025em;
|
||||
letter-spacing: 0.03em;
|
||||
line-height: 1.2;
|
||||
-webkit-text-fill-color: transparent;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.login-tagline {
|
||||
margin: 0;
|
||||
.login-subtitle {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.01em;
|
||||
margin-block: 4px 0;
|
||||
margin-inline: 0;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* ===================== 卡片主体 ===================== */
|
||||
@@ -1011,29 +1167,110 @@ onUnmounted(() => {
|
||||
padding-block: 8px !important;
|
||||
}
|
||||
|
||||
/* 输入框聚焦时增加主色光晕 */
|
||||
:deep(.login-body .v-field.v-field--focused) {
|
||||
box-shadow: 0 0 0 2px rgba(var(--v-theme-primary), 0.18);
|
||||
/* 输入框增强样式 */
|
||||
:deep(.login-input .v-field) {
|
||||
border-radius: 12px;
|
||||
transition:
|
||||
box-shadow 200ms ease,
|
||||
border-color 200ms ease;
|
||||
}
|
||||
|
||||
/* 登录按钮:主色 + 悬浮抬升 */
|
||||
.login-submit {
|
||||
box-shadow: 0 8px 20px rgba(var(--v-theme-primary), 0.35);
|
||||
letter-spacing: 0.02em;
|
||||
transition:
|
||||
transform var(--mp-motion-duration-overlay, 160ms) var(--mp-motion-ease-standard, ease),
|
||||
box-shadow var(--mp-motion-duration-overlay, 160ms) var(--mp-motion-ease-standard, ease);
|
||||
/* 输入框聚焦时增加主色光晕 */
|
||||
:deep(.login-body .v-field.v-field--focused) {
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(var(--v-theme-primary), 0.12),
|
||||
0 4px 12px rgba(var(--v-theme-primary), 0.08);
|
||||
}
|
||||
|
||||
/* 输入框悬停 */
|
||||
:deep(.login-input .v-field:hover:not(.v-field--focused)) {
|
||||
border-color: rgba(var(--v-theme-primary), 0.4);
|
||||
}
|
||||
|
||||
/* Remember me 复选框样式优化 */
|
||||
.login-checkbox {
|
||||
opacity: 0.85;
|
||||
transition: opacity 150ms ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 12px 26px rgba(var(--v-theme-primary), 0.42);
|
||||
transform: translateY(-1px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* 登录按钮:渐变 + 悬浮抬升 + 光泽 */
|
||||
.login-submit {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8px 24px rgba(var(--v-theme-primary), 0.35);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
transition:
|
||||
transform 200ms cubic-bezier(0.2, 0.8, 0.2, 1),
|
||||
box-shadow 200ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 12px 32px rgba(var(--v-theme-primary), 0.45);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
box-shadow: 0 4px 12px rgba(var(--v-theme-primary), 0.3);
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 登录按钮内部光泽扫描层 */
|
||||
.login-submit :deep(.v-btn__content)::after {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
background: linear-gradient(
|
||||
105deg,
|
||||
transparent 35%,
|
||||
rgba(255, 255, 255, 30%) 43%,
|
||||
rgba(255, 255, 255, 40%) 50%,
|
||||
rgba(255, 255, 255, 30%) 57%,
|
||||
transparent 65%
|
||||
);
|
||||
content: '';
|
||||
inset-block: -50%;
|
||||
inset-inline: -50%;
|
||||
pointer-events: none;
|
||||
transform: translateX(-120%);
|
||||
transition: transform 700ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.login-submit:hover :deep(.v-btn__content)::after {
|
||||
transform: translateX(120%);
|
||||
}
|
||||
|
||||
/* Passkey 按钮 */
|
||||
.passkey-btn {
|
||||
border-radius: 12px;
|
||||
font-weight: 500;
|
||||
transition:
|
||||
background 200ms ease,
|
||||
border-color 200ms ease,
|
||||
transform 150ms ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
/* 插件认证按钮 */
|
||||
.plugin-auth-btn {
|
||||
border-radius: 12px;
|
||||
font-weight: 500;
|
||||
transition:
|
||||
background 200ms ease,
|
||||
border-color 200ms ease,
|
||||
transform 150ms ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
/* or 分隔线 */
|
||||
.or-divider {
|
||||
position: relative;
|
||||
@@ -1044,21 +1281,26 @@ onUnmounted(() => {
|
||||
&::before,
|
||||
&::after {
|
||||
flex: 1;
|
||||
border-block-end: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-block-end: 1px solid rgba(var(--v-border-color), calc(var(--v-border-opacity) * 0.7));
|
||||
content: '';
|
||||
}
|
||||
|
||||
.or-divider-text {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
padding-inline: 14px;
|
||||
letter-spacing: 0.1em;
|
||||
padding-inline: 16px;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
/* 错误提示 */
|
||||
.login-alert {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* 浅色主题下 passkey 按钮保持绿色辨识度 */
|
||||
:deep(.v-theme--light) .passkey-btn.v-btn--variant-outlined {
|
||||
color: rgb(86, 170, 0) !important;
|
||||
@@ -1070,43 +1312,103 @@ onUnmounted(() => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(var(--v-theme-on-surface), calc(var(--v-disabled-opacity) * 1.4));
|
||||
font-size: 0.72rem;
|
||||
font-size: 0.7rem;
|
||||
gap: 8px;
|
||||
letter-spacing: 0.02em;
|
||||
margin-block-start: 8px;
|
||||
letter-spacing: 0.03em;
|
||||
margin-block-start: 14px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.login-version {
|
||||
border-inline-start: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-inline-start: 1px solid rgba(var(--v-border-color), calc(var(--v-border-opacity) * 0.6));
|
||||
padding-inline: 6px;
|
||||
}
|
||||
|
||||
/* ===================== 入场动画 ===================== */
|
||||
.login-card--enter {
|
||||
animation: login-enter 520ms var(--mp-motion-ease-standard, cubic-bezier(0.2, 0.8, 0.2, 1)) both;
|
||||
animation: login-enter 600ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
@keyframes login-enter {
|
||||
0% {
|
||||
filter: blur(4px);
|
||||
opacity: 0;
|
||||
transform: translateY(14px) scale(0.985);
|
||||
transform: translateY(20px) scale(0.97);
|
||||
}
|
||||
|
||||
100% {
|
||||
filter: blur(0);
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Logo 入场 */
|
||||
.login-logo-wrapper {
|
||||
animation: logo-enter 700ms cubic-bezier(0.16, 1, 0.3, 1) 100ms both;
|
||||
}
|
||||
|
||||
@keyframes logo-enter {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.8) translateY(10px);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 标题入场 */
|
||||
.login-title {
|
||||
animation: text-enter 600ms cubic-bezier(0.16, 1, 0.3, 1) 200ms both;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
animation: text-enter 600ms cubic-bezier(0.16, 1, 0.3, 1) 300ms both;
|
||||
}
|
||||
|
||||
@keyframes text-enter {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===================== 无障碍:尊重减少动态偏好 ===================== */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.login-card--enter {
|
||||
.login-card--enter,
|
||||
.login-logo-wrapper,
|
||||
.login-title,
|
||||
.login-subtitle {
|
||||
animation-duration: 1ms !important;
|
||||
}
|
||||
|
||||
.login-submit {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.login-submit :deep(.v-btn__content)::after {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
.login-orb {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
.login-logo-wrapper::before {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===================== 小屏适配 ===================== */
|
||||
@@ -1121,6 +1423,21 @@ onUnmounted(() => {
|
||||
|
||||
.login-card {
|
||||
padding: 1.5rem !important;
|
||||
border-radius: 16px !important;
|
||||
}
|
||||
|
||||
.login-orb--1 {
|
||||
block-size: 220px;
|
||||
inline-size: 220px;
|
||||
}
|
||||
|
||||
.login-orb--2 {
|
||||
block-size: 180px;
|
||||
inline-size: 180px;
|
||||
}
|
||||
|
||||
.login-orb--3 {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { debounce } from 'lodash-es'
|
||||
import type { LocationQuery } from 'vue-router'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import NoDataFound from '@/components/states/NoDataFound.vue'
|
||||
import api from '@/api'
|
||||
import type { Context, SubtitleInfo } from '@/api/types'
|
||||
import TorrentCard from '@/components/cards/TorrentCard.vue'
|
||||
|
||||
+18
-1
@@ -283,6 +283,17 @@ async function setStoredUnreadCount(count: number): Promise<void> {
|
||||
await set(UNREAD_COUNT_KEY, count)
|
||||
}
|
||||
|
||||
// 通知已打开的页面同步未读计数,保证前台通知中心能感知 PWA badge 的变化。
|
||||
async function broadcastUnreadCount(count: number) {
|
||||
const clients = await self.clients.matchAll({ includeUncontrolled: true, type: 'window' })
|
||||
clients.forEach(client => {
|
||||
client.postMessage({
|
||||
type: 'UNREAD_COUNT_UPDATE',
|
||||
count,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function updateBadge(count: number) {
|
||||
if ('setAppBadge' in self.navigator) {
|
||||
try {
|
||||
@@ -309,6 +320,7 @@ async function clearBadge() {
|
||||
|
||||
try {
|
||||
await setStoredUnreadCount(0)
|
||||
await broadcastUnreadCount(0)
|
||||
} catch (error) {
|
||||
console.error('Failed to clear unread count:', error)
|
||||
}
|
||||
@@ -422,7 +434,11 @@ self.addEventListener('push', function (event) {
|
||||
const currentCount = await getStoredUnreadCount()
|
||||
const newCount = currentCount + 1
|
||||
await setStoredUnreadCount(newCount)
|
||||
await Promise.all([self.registration.showNotification(payload.title, content), updateBadge(newCount)])
|
||||
await Promise.all([
|
||||
self.registration.showNotification(payload.title, content),
|
||||
updateBadge(newCount),
|
||||
broadcastUnreadCount(newCount),
|
||||
])
|
||||
})(),
|
||||
)
|
||||
} catch (e) {
|
||||
@@ -454,6 +470,7 @@ self.addEventListener('message', function (event) {
|
||||
const count = event.data.count || 0
|
||||
setStoredUnreadCount(count)
|
||||
.then(() => updateBadge(count))
|
||||
.then(() => broadcastUnreadCount(count))
|
||||
.then(() => {
|
||||
event.ports[0]?.postMessage({ success: true })
|
||||
})
|
||||
|
||||
@@ -184,6 +184,16 @@ html[data-theme-radius='extra'] {
|
||||
box-shadow: var(--app-surface-shadow) !important;
|
||||
}
|
||||
|
||||
// 统一卡片上浮反馈;hover 命中区域应放在静止外层,避免上浮后底边反复触发 mouseleave。
|
||||
.app-hover-lift-card {
|
||||
transition: transform 0.3s ease, box-shadow 0.2s ease;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.app-hover-lift-card--hovering {
|
||||
transform: translate3d(0, -0.25rem, 0);
|
||||
}
|
||||
|
||||
// 全局页面与 overlay 动效:短距离、轻缩放,保持快速但不生硬。
|
||||
.mp-page-route {
|
||||
inline-size: 100%;
|
||||
|
||||
@@ -157,6 +157,10 @@ html[data-theme="transparent"] {
|
||||
}
|
||||
|
||||
.agent-assistant-fab {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__bubble {
|
||||
backdrop-filter: blur(var(--transparent-blur));
|
||||
background-color: rgba(var(--v-theme-surface), var(--transparent-opacity-heavy));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { SystemNotification } from '@/api/types'
|
||||
|
||||
const AGENT_ASSISTANT_BUBBLE_EVENT = 'agentAssistantBubble'
|
||||
let agentAssistantBubbleListenerCount = 0
|
||||
let agentAssistantBubbleEntryActive = false
|
||||
|
||||
export type AgentAssistantBubbleKind = 'assistant' | 'custom' | 'notification' | 'toast'
|
||||
export type AgentAssistantBubbleVariant = 'default' | 'info' | 'success' | 'warning' | 'error'
|
||||
|
||||
export interface AgentAssistantBubblePayload {
|
||||
id: string
|
||||
kind?: AgentAssistantBubbleKind
|
||||
variant?: AgentAssistantBubbleVariant
|
||||
title?: string
|
||||
text?: string
|
||||
duration?: number
|
||||
keepOpen?: boolean
|
||||
type?: string
|
||||
mtype?: string
|
||||
source?: string
|
||||
date?: string
|
||||
reg_time?: string
|
||||
}
|
||||
|
||||
export interface AgentAssistantNotificationBubblePayload extends AgentAssistantBubblePayload {
|
||||
kind?: 'notification'
|
||||
}
|
||||
|
||||
export interface AgentAssistantToastBubblePayload extends AgentAssistantBubblePayload {
|
||||
kind: 'toast'
|
||||
variant: AgentAssistantBubbleVariant
|
||||
}
|
||||
|
||||
interface AgentAssistantBubbleEvent extends CustomEvent<AgentAssistantBubblePayload> {}
|
||||
|
||||
function createNotificationBubbleId(notification: SystemNotification) {
|
||||
if (notification.id) return `notification-${notification.id}`
|
||||
|
||||
return `notification-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
}
|
||||
|
||||
function emitAgentAssistantBubble(payload: AgentAssistantBubblePayload) {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<AgentAssistantBubblePayload>(AGENT_ASSISTANT_BUBBLE_EVENT, {
|
||||
detail: payload,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// 通知中心、toast 和智能助手入口没有父子关系,通过全局事件传递实时气泡数据。
|
||||
export function emitAgentAssistantNotificationBubble(notification: SystemNotification) {
|
||||
emitAgentAssistantBubble({
|
||||
id: createNotificationBubbleId(notification),
|
||||
kind: 'notification',
|
||||
title: notification.title,
|
||||
text: notification.text,
|
||||
type: notification.type,
|
||||
mtype: notification.mtype,
|
||||
source: notification.source,
|
||||
date: notification.date,
|
||||
reg_time: notification.reg_time,
|
||||
})
|
||||
}
|
||||
|
||||
export function emitAgentAssistantToastBubble(payload: AgentAssistantToastBubblePayload) {
|
||||
emitAgentAssistantBubble(payload)
|
||||
}
|
||||
|
||||
export function setAgentAssistantBubbleEntryActive(active: boolean) {
|
||||
agentAssistantBubbleEntryActive = active
|
||||
}
|
||||
|
||||
export function canUseAgentAssistantBubble() {
|
||||
return agentAssistantBubbleEntryActive && agentAssistantBubbleListenerCount > 0
|
||||
}
|
||||
|
||||
export function onAgentAssistantBubble(callback: (payload: AgentAssistantBubblePayload) => void) {
|
||||
if (typeof window === 'undefined') return () => {}
|
||||
|
||||
const handler = (event: Event) => {
|
||||
callback((event as AgentAssistantBubbleEvent).detail)
|
||||
}
|
||||
|
||||
window.addEventListener(AGENT_ASSISTANT_BUBBLE_EVENT, handler)
|
||||
agentAssistantBubbleListenerCount += 1
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(AGENT_ASSISTANT_BUBBLE_EVENT, handler)
|
||||
agentAssistantBubbleListenerCount = Math.max(0, agentAssistantBubbleListenerCount - 1)
|
||||
}
|
||||
}
|
||||
|
||||
export function onAgentAssistantNotificationBubble(
|
||||
callback: (payload: AgentAssistantNotificationBubblePayload) => void,
|
||||
) {
|
||||
return onAgentAssistantBubble(payload => {
|
||||
if ((payload.kind || 'notification') === 'notification') callback(payload as AgentAssistantNotificationBubblePayload)
|
||||
})
|
||||
}
|
||||
+42
-8
@@ -1,3 +1,5 @@
|
||||
import { readonly, ref } from 'vue'
|
||||
|
||||
/**
|
||||
* PWA 徽章管理工具
|
||||
*/
|
||||
@@ -7,9 +9,27 @@ interface UnreadMessageEvent extends CustomEvent {
|
||||
detail: { count: number }
|
||||
}
|
||||
|
||||
const unreadMessageCount = ref(0)
|
||||
|
||||
// 暴露只读未读计数,供通知中心等组件直接判断应用角标状态。
|
||||
export const appUnreadMessageCount = readonly(unreadMessageCount)
|
||||
|
||||
function normalizeUnreadMessageCount(count: unknown) {
|
||||
const normalizedCount = Number(count)
|
||||
if (!Number.isFinite(normalizedCount) || normalizedCount <= 0) return 0
|
||||
|
||||
return Math.floor(normalizedCount)
|
||||
}
|
||||
|
||||
function setUnreadMessageCount(count: unknown) {
|
||||
unreadMessageCount.value = normalizeUnreadMessageCount(count)
|
||||
return unreadMessageCount.value
|
||||
}
|
||||
|
||||
// 发送全局未读消息事件
|
||||
export function emitUnreadMessageEvent(count: number) {
|
||||
const event = new CustomEvent('unreadMessage', { detail: { count } }) as UnreadMessageEvent
|
||||
const normalizedCount = setUnreadMessageCount(count)
|
||||
const event = new CustomEvent('unreadMessage', { detail: { count: normalizedCount } }) as UnreadMessageEvent
|
||||
window.dispatchEvent(event)
|
||||
}
|
||||
|
||||
@@ -88,9 +108,8 @@ export async function checkUnreadOnStartup(): Promise<number> {
|
||||
export async function checkAndEmitUnreadMessages() {
|
||||
try {
|
||||
const count = await checkUnreadOnStartup()
|
||||
if (count > 0) {
|
||||
emitUnreadMessageEvent(count)
|
||||
}
|
||||
// 启动时同步 0 值,避免组件复用上一轮角标状态。
|
||||
emitUnreadMessageEvent(count)
|
||||
} catch (error) {
|
||||
// 静默处理错误
|
||||
}
|
||||
@@ -139,11 +158,13 @@ export async function clearAppBadge(): Promise<boolean> {
|
||||
|
||||
// 更新桌面图标徽章数量
|
||||
export async function updateAppBadge(count: number): Promise<boolean> {
|
||||
const normalizedCount = normalizeUnreadMessageCount(count)
|
||||
|
||||
try {
|
||||
// 如果浏览器支持原生Badge API,直接调用
|
||||
if ('setAppBadge' in navigator) {
|
||||
if (count > 0) {
|
||||
await navigator.setAppBadge(count)
|
||||
if (normalizedCount > 0) {
|
||||
await navigator.setAppBadge(normalizedCount)
|
||||
} else {
|
||||
await navigator.clearAppBadge()
|
||||
}
|
||||
@@ -155,13 +176,18 @@ export async function updateAppBadge(count: number): Promise<boolean> {
|
||||
|
||||
return new Promise(resolve => {
|
||||
messageChannel.port1.onmessage = event => {
|
||||
resolve(event.data.success)
|
||||
const success = Boolean(event.data.success)
|
||||
if (success) emitUnreadMessageEvent(normalizedCount)
|
||||
resolve(success)
|
||||
}
|
||||
|
||||
navigator.serviceWorker.controller?.postMessage({ type: 'UPDATE_BADGE', count }, [messageChannel.port2])
|
||||
navigator.serviceWorker.controller?.postMessage({ type: 'UPDATE_BADGE', count: normalizedCount }, [
|
||||
messageChannel.port2,
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
emitUnreadMessageEvent(normalizedCount)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to update app badge:', error)
|
||||
@@ -195,3 +221,11 @@ export async function getUnreadCount(): Promise<number> {
|
||||
export function supportsBadgeAPI(): boolean {
|
||||
return 'setAppBadge' in navigator && 'clearAppBadge' in navigator
|
||||
}
|
||||
|
||||
if (typeof navigator !== 'undefined' && 'serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.addEventListener('message', event => {
|
||||
if (event.data?.type === 'UNREAD_COUNT_UPDATE') {
|
||||
emitUnreadMessageEvent(event.data.count || 0)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { useTheme } from 'vuetify'
|
||||
import { hexToRgb } from '@layouts/utils'
|
||||
import api from '@/api'
|
||||
import { useAnimatedDashboardNumber } from '@/composables/useDashboardMotion'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useBackground } from '@/composables/useBackground'
|
||||
import { useKeepAliveRefresh } from '@/composables/useKeepAliveRefresh'
|
||||
@@ -39,6 +40,10 @@ const series = ref([
|
||||
|
||||
// 当前值
|
||||
const current = ref(0)
|
||||
const animatedCurrent = useAnimatedDashboardNumber(current, {
|
||||
duration: 520,
|
||||
})
|
||||
const animatedCurrentText = computed(() => Math.round(animatedCurrent.value).toLocaleString())
|
||||
|
||||
const chartOptions = controlledComputed(
|
||||
() => vuetifyTheme.name.value,
|
||||
@@ -109,7 +114,7 @@ async function loadCpuData() {
|
||||
if (!props.allowRefresh) return
|
||||
try {
|
||||
// 请求数据
|
||||
current.value = (await api.get('dashboard/cpu')) ?? 0
|
||||
current.value = Number(await api.get('dashboard/cpu')) || 0
|
||||
// 使用nextTick确保DOM更新完成后再更新图表数据
|
||||
await nextTick()
|
||||
// 添加到序列
|
||||
@@ -141,7 +146,9 @@ useKeepAliveRefresh(refresh)
|
||||
<div class="dashboard-chart-plot">
|
||||
<VApexChart type="line" :options="chartOptions" :series="series" height="100%" />
|
||||
</div>
|
||||
<p class="text-center font-weight-medium mb-0">{{ t('dashboard.current') }}:{{ current }}%</p>
|
||||
<p class="dashboard-chart-value text-center font-weight-medium mb-0">
|
||||
{{ t('dashboard.current') }}:{{ animatedCurrentText }}%
|
||||
</p>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</template>
|
||||
@@ -159,4 +166,8 @@ useKeepAliveRefresh(refresh)
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.dashboard-chart-value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,44 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import api from '@/api'
|
||||
import type { MediaStatistic } from '@/api/types'
|
||||
import { formatDashboardCount, useAnimatedDashboardNumber } from '@/composables/useDashboardMotion'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
const statistics = ref<{ [key: string]: string }[]>([])
|
||||
const movieCount = ref(0)
|
||||
const tvCount = ref(0)
|
||||
const episodeCount = ref<number | null>(null)
|
||||
const userCount = ref(0)
|
||||
|
||||
const animatedMovieCount = useAnimatedDashboardNumber(movieCount, {
|
||||
duration: 720,
|
||||
})
|
||||
|
||||
const animatedTvCount = useAnimatedDashboardNumber(tvCount, {
|
||||
delay: 60,
|
||||
duration: 720,
|
||||
})
|
||||
|
||||
const animatedEpisodeCount = useAnimatedDashboardNumber(computed(() => episodeCount.value ?? 0), {
|
||||
delay: 120,
|
||||
duration: 720,
|
||||
})
|
||||
|
||||
const animatedUserCount = useAnimatedDashboardNumber(userCount, {
|
||||
delay: 180,
|
||||
duration: 720,
|
||||
})
|
||||
|
||||
const statistics = computed(() => [
|
||||
{
|
||||
title: t('mediaType.movie'),
|
||||
stats: formatDashboardCount(animatedMovieCount.value),
|
||||
icon: 'mdi-movie-roll',
|
||||
color: 'primary',
|
||||
},
|
||||
{
|
||||
title: t('mediaType.tv'),
|
||||
stats: formatDashboardCount(animatedTvCount.value),
|
||||
icon: 'mdi-television-box',
|
||||
color: 'success',
|
||||
},
|
||||
{
|
||||
title: t('dashboard.episodes'),
|
||||
stats: episodeCount.value == null ? t('common.notFetched') : formatDashboardCount(animatedEpisodeCount.value),
|
||||
icon: 'mdi-television-classic',
|
||||
color: 'warning',
|
||||
},
|
||||
{
|
||||
title: t('dashboard.users'),
|
||||
stats: formatDashboardCount(animatedUserCount.value),
|
||||
icon: 'mdi-account',
|
||||
color: 'info',
|
||||
},
|
||||
])
|
||||
|
||||
// 调用API加载媒体统计数据
|
||||
async function loadMediaStatistic() {
|
||||
try {
|
||||
const res: MediaStatistic = await api.get('dashboard/statistic')
|
||||
|
||||
statistics.value = [
|
||||
{
|
||||
title: t('mediaType.movie'),
|
||||
stats: res.movie_count.toLocaleString(),
|
||||
icon: 'mdi-movie-roll',
|
||||
color: 'primary',
|
||||
},
|
||||
{
|
||||
title: t('mediaType.tv'),
|
||||
stats: res.tv_count.toLocaleString(),
|
||||
icon: 'mdi-television-box',
|
||||
color: 'success',
|
||||
},
|
||||
{
|
||||
title: t('dashboard.episodes'),
|
||||
stats: res.episode_count == null ? t('common.notFetched') : res.episode_count.toLocaleString(),
|
||||
icon: 'mdi-television-classic',
|
||||
color: 'warning',
|
||||
},
|
||||
{
|
||||
title: t('dashboard.users'),
|
||||
stats: res.user_count.toLocaleString(),
|
||||
icon: 'mdi-account',
|
||||
color: 'info',
|
||||
},
|
||||
]
|
||||
movieCount.value = Number(res.movie_count) || 0
|
||||
tvCount.value = Number(res.tv_count) || 0
|
||||
episodeCount.value = res.episode_count == null ? null : Number(res.episode_count) || 0
|
||||
userCount.value = Number(res.user_count) || 0
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
@@ -73,7 +101,7 @@ onActivated(() => {
|
||||
<span class="text-caption">
|
||||
{{ item.title }}
|
||||
</span>
|
||||
<span class="text-h6">{{ item.stats }}</span>
|
||||
<span class="dashboard-number text-h6">{{ item.stats }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</VCol>
|
||||
@@ -81,3 +109,9 @@ onActivated(() => {
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.dashboard-number {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { useTheme } from 'vuetify'
|
||||
import { hexToRgb } from '@layouts/utils'
|
||||
import api from '@/api'
|
||||
import { formatBytes } from '@/@core/utils/formatters'
|
||||
import { formatDashboardFileSize, useAnimatedDashboardNumber } from '@/composables/useDashboardMotion'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useBackground } from '@/composables/useBackground'
|
||||
import { useKeepAliveRefresh } from '@/composables/useKeepAliveRefresh'
|
||||
@@ -42,6 +42,10 @@ const series = ref([
|
||||
const usedMemory = ref(0)
|
||||
// 内存使用百分比
|
||||
const memoryUsage = ref(0)
|
||||
const animatedUsedMemory = useAnimatedDashboardNumber(usedMemory, {
|
||||
duration: 650,
|
||||
})
|
||||
const animatedUsedMemoryText = computed(() => formatDashboardFileSize(animatedUsedMemory.value, 2, usedMemory.value))
|
||||
|
||||
const chartOptions = controlledComputed(
|
||||
() => vuetifyTheme.name.value,
|
||||
@@ -115,7 +119,9 @@ async function loadMemoryData() {
|
||||
if (!props.allowRefresh) return
|
||||
try {
|
||||
// 请求数据
|
||||
;[usedMemory.value, memoryUsage.value] = await api.get('dashboard/memory')
|
||||
const [memory, usage]: [number, number] = await api.get('dashboard/memory')
|
||||
usedMemory.value = Number(memory) || 0
|
||||
memoryUsage.value = Number(usage) || 0
|
||||
// 使用nextTick确保DOM更新完成后再更新图表数据
|
||||
await nextTick()
|
||||
series.value[0].data.push(memoryUsage.value)
|
||||
@@ -146,7 +152,9 @@ useKeepAliveRefresh(refresh)
|
||||
<div class="dashboard-chart-plot">
|
||||
<VApexChart type="area" :options="chartOptions" :series="series" height="100%" />
|
||||
</div>
|
||||
<p class="text-center font-weight-medium mb-0">{{ t('dashboard.current') }}:{{ formatBytes(usedMemory) }}</p>
|
||||
<p class="dashboard-chart-value text-center font-weight-medium mb-0">
|
||||
{{ t('dashboard.current') }}:{{ animatedUsedMemoryText }}
|
||||
</p>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</template>
|
||||
@@ -163,4 +171,8 @@ useKeepAliveRefresh(refresh)
|
||||
flex: 1 1 auto;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.dashboard-chart-value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { useTheme } from 'vuetify'
|
||||
import { hexToRgb } from '@layouts/utils'
|
||||
import api from '@/api'
|
||||
import { formatDashboardFileSize, useAnimatedDashboardNumber } from '@/composables/useDashboardMotion'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useBackground } from '@/composables/useBackground'
|
||||
import { useKeepAliveRefresh } from '@/composables/useKeepAliveRefresh'
|
||||
@@ -45,15 +46,16 @@ const series = ref([
|
||||
// 当前值
|
||||
const currentUpload = ref(0)
|
||||
const currentDownload = ref(0)
|
||||
|
||||
// 格式化流量显示
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B/s'
|
||||
const k = 1024
|
||||
const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
const animatedCurrentUpload = useAnimatedDashboardNumber(currentUpload, {
|
||||
duration: 520,
|
||||
})
|
||||
const animatedCurrentDownload = useAnimatedDashboardNumber(currentDownload, {
|
||||
duration: 520,
|
||||
})
|
||||
const animatedCurrentUploadText = computed(() => `${formatDashboardFileSize(animatedCurrentUpload.value, 2, currentUpload.value)}/s`)
|
||||
const animatedCurrentDownloadText = computed(
|
||||
() => `${formatDashboardFileSize(animatedCurrentDownload.value, 2, currentDownload.value)}/s`,
|
||||
)
|
||||
|
||||
const chartOptions = controlledComputed(
|
||||
() => vuetifyTheme.name.value,
|
||||
@@ -139,8 +141,8 @@ async function getNetworkUsage() {
|
||||
try {
|
||||
// 请求数据 - 接口返回 [上行流量, 下行流量]
|
||||
const data: [number, number] = (await api.get('dashboard/network')) ?? [0, 0]
|
||||
currentUpload.value = data[0] || 0
|
||||
currentDownload.value = data[1] || 0
|
||||
currentUpload.value = Number(data[0]) || 0
|
||||
currentDownload.value = Number(data[1]) || 0
|
||||
|
||||
// 使用nextTick确保DOM更新完成后再更新图表数据
|
||||
await nextTick()
|
||||
@@ -180,13 +182,13 @@ useKeepAliveRefresh(refresh)
|
||||
<VApexChart type="line" :options="chartOptions" :series="series" height="100%" />
|
||||
</div>
|
||||
<div class="d-flex justify-space-between">
|
||||
<p class="text-center font-weight-medium mb-0">
|
||||
<p class="dashboard-chart-value text-center font-weight-medium mb-0">
|
||||
<span class="text-warning">{{ t('dashboard.upload') }}</span
|
||||
>:{{ formatBytes(currentUpload) }}
|
||||
>:{{ animatedCurrentUploadText }}
|
||||
</p>
|
||||
<p class="text-center font-weight-medium mb-0">
|
||||
<p class="dashboard-chart-value text-center font-weight-medium mb-0">
|
||||
<span class="text-info">{{ t('dashboard.download') }}</span
|
||||
>:{{ formatBytes(currentDownload) }}
|
||||
>:{{ animatedCurrentDownloadText }}
|
||||
</p>
|
||||
</div>
|
||||
</VCardText>
|
||||
@@ -206,4 +208,8 @@ useKeepAliveRefresh(refresh)
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.dashboard-chart-value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -101,4 +101,5 @@ useDataRefresh(
|
||||
.card-list::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
import type { DownloaderInfo } from '@/api/types'
|
||||
import { formatDashboardFileSize, useAnimatedDashboardNumber } from '@/composables/useDashboardMotion'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useBackground } from '@/composables/useBackground'
|
||||
|
||||
@@ -36,12 +36,48 @@ const downloadInfo = ref<DownloaderInfo>({
|
||||
free_space: 0,
|
||||
})
|
||||
|
||||
const animatedUploadSpeed = useAnimatedDashboardNumber(computed(() => downloadInfo.value.upload_speed), {
|
||||
duration: 520,
|
||||
})
|
||||
|
||||
const animatedDownloadSpeed = useAnimatedDashboardNumber(computed(() => downloadInfo.value.download_speed), {
|
||||
duration: 520,
|
||||
})
|
||||
|
||||
const animatedUploadSize = useAnimatedDashboardNumber(computed(() => downloadInfo.value.upload_size), {
|
||||
delay: 80,
|
||||
duration: 760,
|
||||
})
|
||||
|
||||
const animatedDownloadSize = useAnimatedDashboardNumber(computed(() => downloadInfo.value.download_size), {
|
||||
delay: 130,
|
||||
duration: 760,
|
||||
})
|
||||
|
||||
const animatedFreeSpace = useAnimatedDashboardNumber(computed(() => downloadInfo.value.free_space), {
|
||||
delay: 180,
|
||||
duration: 760,
|
||||
})
|
||||
|
||||
const uploadSpeedText = computed(() => `${formatDashboardFileSize(animatedUploadSpeed.value, 2, downloadInfo.value.upload_speed)}/s`)
|
||||
const downloadSpeedText = computed(() => `${formatDashboardFileSize(animatedDownloadSpeed.value, 2, downloadInfo.value.download_speed)}/s`)
|
||||
|
||||
// 显示项
|
||||
const infoItems = ref([
|
||||
const infoItems = computed(() => [
|
||||
{
|
||||
avatar: '',
|
||||
title: '',
|
||||
amount: '',
|
||||
avatar: 'mdi-cloud-upload',
|
||||
title: t('dashboard.speed.totalUpload'),
|
||||
amount: formatDashboardFileSize(animatedUploadSize.value, 2, downloadInfo.value.upload_size),
|
||||
},
|
||||
{
|
||||
avatar: 'mdi-download-box',
|
||||
title: t('dashboard.speed.totalDownload'),
|
||||
amount: formatDashboardFileSize(animatedDownloadSize.value, 2, downloadInfo.value.download_size),
|
||||
},
|
||||
{
|
||||
avatar: 'mdi-content-save',
|
||||
title: t('dashboard.speed.freeSpace'),
|
||||
amount: formatDashboardFileSize(animatedFreeSpace.value, 2, downloadInfo.value.free_space),
|
||||
},
|
||||
])
|
||||
|
||||
@@ -54,24 +90,13 @@ async function loadDownloaderInfo() {
|
||||
try {
|
||||
const res: DownloaderInfo = await api.get('dashboard/downloader')
|
||||
|
||||
downloadInfo.value = res
|
||||
infoItems.value = [
|
||||
{
|
||||
avatar: 'mdi-cloud-upload',
|
||||
title: t('dashboard.speed.totalUpload'),
|
||||
amount: formatFileSize(res.upload_size),
|
||||
},
|
||||
{
|
||||
avatar: 'mdi-download-box',
|
||||
title: t('dashboard.speed.totalDownload'),
|
||||
amount: formatFileSize(res.download_size),
|
||||
},
|
||||
{
|
||||
avatar: 'mdi-content-save',
|
||||
title: t('dashboard.speed.freeSpace'),
|
||||
amount: formatFileSize(res.free_space),
|
||||
},
|
||||
]
|
||||
downloadInfo.value = {
|
||||
download_speed: Number(res.download_speed) || 0,
|
||||
upload_speed: Number(res.upload_speed) || 0,
|
||||
download_size: Number(res.download_size) || 0,
|
||||
upload_size: Number(res.upload_size) || 0,
|
||||
free_space: Number(res.free_space) || 0,
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
@@ -94,8 +119,8 @@ const { loading } = useDataRefresh(
|
||||
|
||||
<VCardText class="dashboard-work-content pt-4">
|
||||
<div>
|
||||
<p class="text-h5 me-2">↑{{ formatFileSize(downloadInfo.upload_speed) }}/s</p>
|
||||
<p class="text-h4 me-2">↓{{ formatFileSize(downloadInfo.download_speed) }}/s</p>
|
||||
<p class="dashboard-speed-number text-h5 me-2">↑{{ uploadSpeedText }}</p>
|
||||
<p class="dashboard-speed-number text-h4 me-2">↓{{ downloadSpeedText }}</p>
|
||||
</div>
|
||||
<VList class="card-list mt-9">
|
||||
<VListItem v-for="item in infoItems" :key="item.title">
|
||||
@@ -109,7 +134,7 @@ const { loading } = useDataRefresh(
|
||||
|
||||
<template #append>
|
||||
<div>
|
||||
<h6 class="text-sm font-weight-medium mb-2">
|
||||
<h6 class="dashboard-speed-number text-sm font-weight-medium mb-2">
|
||||
{{ item.amount }}
|
||||
</h6>
|
||||
</div>
|
||||
@@ -135,4 +160,9 @@ const { loading } = useDataRefresh(
|
||||
flex-direction: column;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.dashboard-speed-number {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { useTheme } from 'vuetify'
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
import type { Storage } from '@/api/types'
|
||||
import trophy from '@images/misc/storage.png'
|
||||
import triangleDark from '@images/misc/triangle-dark.png'
|
||||
import triangleLight from '@images/misc/triangle-light.png'
|
||||
import { formatDashboardFileSize, useAnimatedDashboardNumber } from '@/composables/useDashboardMotion'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// 国际化
|
||||
@@ -22,16 +23,31 @@ const used = ref(0)
|
||||
|
||||
// 计算已使用存储空间百分比,精确到小数点后1位
|
||||
const usedPercent = computed(() => {
|
||||
return Math.round((used.value / (storage.value || 1)) * 1000) / 10
|
||||
const percent = Math.round((used.value / (storage.value || 1)) * 1000) / 10
|
||||
|
||||
return Math.min(Math.max(percent, 0), 100)
|
||||
})
|
||||
|
||||
const animatedStorage = useAnimatedDashboardNumber(storage, {
|
||||
duration: 900,
|
||||
})
|
||||
|
||||
const animatedUsedPercent = useAnimatedDashboardNumber(usedPercent, {
|
||||
delay: 80,
|
||||
duration: 780,
|
||||
})
|
||||
|
||||
const animatedStorageText = computed(() => formatDashboardFileSize(animatedStorage.value, 2, storage.value))
|
||||
const animatedUsedPercentValue = computed(() => Math.round(animatedUsedPercent.value * 10) / 10)
|
||||
const animatedUsedPercentText = computed(() => animatedUsedPercentValue.value.toFixed(1))
|
||||
|
||||
// 调用API,查询存储空间
|
||||
async function getStorage() {
|
||||
try {
|
||||
const res: Storage = await api.get('dashboard/storage')
|
||||
|
||||
storage.value = res.total_storage
|
||||
used.value = res.used_storage
|
||||
storage.value = Number(res.total_storage) || 0
|
||||
used.value = Number(res.used_storage) || 0
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
@@ -54,12 +70,18 @@ onActivated(() => {
|
||||
<VCardTitle>{{ t('dashboard.storage') }}</VCardTitle>
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<h5 class="text-2xl font-weight-medium text-primary">
|
||||
{{ formatFileSize(storage) }}
|
||||
<h5 class="animated-storage-value text-2xl font-weight-medium text-primary">
|
||||
{{ animatedStorageText }}
|
||||
</h5>
|
||||
<p class="mt-2">{{ t('storage.usedPercent', { percent: usedPercent }) }} 🚀</p>
|
||||
<p class="mt-2">{{ t('storage.usedPercent', { percent: animatedUsedPercentText }) }} 🚀</p>
|
||||
<p class="mt-1">
|
||||
<VProgressLinear :model-value="usedPercent" color="primary" />
|
||||
<VProgressLinear
|
||||
:model-value="animatedUsedPercentValue"
|
||||
class="animated-storage-progress"
|
||||
color="primary"
|
||||
height="6"
|
||||
rounded
|
||||
/>
|
||||
</p>
|
||||
</VCardText>
|
||||
<!-- Trophy -->
|
||||
@@ -84,4 +106,12 @@ onActivated(() => {
|
||||
inset-inline-end: 2rem;
|
||||
}
|
||||
|
||||
.animated-storage-value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.animated-storage-progress {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { useTheme } from 'vuetify'
|
||||
import api from '@/api'
|
||||
import { hexToRgb } from '@layouts/utils'
|
||||
import { formatDashboardCount, useAnimatedDashboardNumber } from '@/composables/useDashboardMotion'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// 国际化
|
||||
@@ -27,6 +28,7 @@ const options = controlledComputed(
|
||||
chart: {
|
||||
parentHeightOffset: 0,
|
||||
toolbar: { show: false },
|
||||
animations: { enabled: false },
|
||||
},
|
||||
plotOptions: {
|
||||
bar: {
|
||||
@@ -97,6 +99,11 @@ const series = ref([{ data: [0, 0, 0, 0, 0, 0, 0] }])
|
||||
|
||||
// 总数
|
||||
const totalCount = computed(() => series.value[0].data.reduce((a, b) => a + b, 0))
|
||||
const animatedTotalCount = useAnimatedDashboardNumber(totalCount, {
|
||||
delay: 100,
|
||||
duration: 850,
|
||||
})
|
||||
const animatedTotalCountText = computed(() => formatDashboardCount(animatedTotalCount.value))
|
||||
|
||||
// 调用API接口获取数据近7天数据
|
||||
async function getWeeklyData() {
|
||||
@@ -136,10 +143,10 @@ onActivated(() => {
|
||||
<VApexChart type="bar" :options="options" :series="series" height="100%" />
|
||||
</div>
|
||||
<div class="d-flex align-center mb-3">
|
||||
<h5 class="text-h5 me-4">
|
||||
{{ totalCount }}
|
||||
<h5 class="dashboard-weekly-count text-h5 me-4">
|
||||
{{ animatedTotalCountText }}
|
||||
</h5>
|
||||
<p>{{ t('dashboard.weeklyOverviewDescription', { count: totalCount }) }} 😎</p>
|
||||
<p>{{ t('dashboard.weeklyOverviewDescription', { count: animatedTotalCountText }) }} 😎</p>
|
||||
</div>
|
||||
<div>
|
||||
<VBtn block to="/history"> {{ t('common.viewDetails') }} </VBtn>
|
||||
@@ -160,4 +167,8 @@ onActivated(() => {
|
||||
flex: 1 1 auto;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.dashboard-weekly-count {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import api from '@/api'
|
||||
import type { MediaServerConf, MediaServerPlayItem } from '@/api/types'
|
||||
import PosterCard from '@/components/cards/PosterCard.vue'
|
||||
|
||||
@@ -3,7 +3,7 @@ import api from '@/api'
|
||||
import type { MediaInfo } from '@/api/types'
|
||||
import MediaCard from '@/components/cards/MediaCard.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import NoDataFound from '@/components/states/NoDataFound.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -4,9 +4,7 @@ import PersonCardSlideView from './PersonCardSlideView.vue'
|
||||
import MediaCardSlideView from './MediaCardSlideView.vue'
|
||||
import api from '@/api'
|
||||
import type { MediaInfo, NotExistMediaInfo, Site, Subscribe, TmdbEpisode } from '@/api/types'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import { formatSeason } from '@/@core/utils/formatters'
|
||||
import NoDataFound from '@/components/states/NoDataFound.vue'
|
||||
import { formatSeasonLabel } from '@/@core/utils/season'
|
||||
import router from '@/router'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
@@ -18,13 +16,20 @@ import { useGlobalSettingsStore } from '@/stores'
|
||||
import { openMediaServerItem, openDoubanApp } from '@/utils/appDeepLink'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||
import {
|
||||
getMediaSubscribeId,
|
||||
getSubscribeMode,
|
||||
useMediaSubscribe,
|
||||
type SeasonSubscribeModes,
|
||||
} from '@/composables/useMediaSubscribe'
|
||||
|
||||
const SearchSiteDialog = defineAsyncComponent(() => import('@/components/dialog/SearchSiteDialog.vue'))
|
||||
const SubscribeEditDialog = defineAsyncComponent(() => import('@/components/dialog/SubscribeEditDialog.vue'))
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
const $toast = useToast()
|
||||
|
||||
// 输入参数
|
||||
const mediaProps = defineProps({
|
||||
mediaid: String,
|
||||
@@ -44,9 +49,6 @@ const userPermissions = computed(() => buildUserPermissionContext(userStore.supe
|
||||
const canSearch = computed(() => hasPermission(userPermissions.value, 'search'))
|
||||
const canSubscribe = computed(() => hasPermission(userPermissions.value, 'subscribe'))
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 获取主题信息
|
||||
const theme = useTheme()
|
||||
|
||||
@@ -74,6 +76,9 @@ const seasonsNotExisted = ref<{ [key: number]: number }>({})
|
||||
// 各季的订阅状态
|
||||
const seasonsSubscribed = ref<{ [key: number]: boolean }>({})
|
||||
|
||||
// 各季的订阅模式
|
||||
const subscribedSeasonModes = ref<SeasonSubscribeModes>({})
|
||||
|
||||
// 所有站点
|
||||
const allSites = ref<Site[]>([])
|
||||
|
||||
@@ -99,18 +104,6 @@ const isTransparentTheme = computed(() => {
|
||||
return theme.name.value === 'transparent'
|
||||
})
|
||||
|
||||
// 打开订阅编辑弹窗,关闭和保存时由共享 Host 自动释放实例。
|
||||
function openSubscribeEditDialog(subid: number) {
|
||||
openSharedDialog(
|
||||
SubscribeEditDialog,
|
||||
{ subid },
|
||||
{
|
||||
remove: onSubscribeEditRemove,
|
||||
},
|
||||
{ closeOn: ['close', 'save', 'remove'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 打开站点选择弹窗,并把站点选择结果交回详情页执行搜索。
|
||||
function openSearchSiteDialog() {
|
||||
openSharedDialog(
|
||||
@@ -151,10 +144,11 @@ async function querySelectedSites() {
|
||||
|
||||
// 获得mediaid
|
||||
function getMediaId() {
|
||||
if (mediaDetail.value?.tmdb_id) return `tmdb:${mediaDetail.value?.tmdb_id}`
|
||||
else if (mediaDetail.value?.douban_id) return `douban:${mediaDetail.value?.douban_id}`
|
||||
else if (mediaDetail.value?.bangumi_id) return `bangumi:${mediaDetail.value?.bangumi_id}`
|
||||
else return `${mediaDetail.value?.mediaid_prefix}:${mediaDetail.value?.media_id}`
|
||||
return getMediaSubscribeId(mediaDetail.value)
|
||||
}
|
||||
|
||||
function getSubscribeStatusKey(season: number | null = mediaDetail.value?.season ?? null) {
|
||||
return `${getMediaId()}::${season ?? 'all'}`
|
||||
}
|
||||
|
||||
// 调用API查询详情
|
||||
@@ -228,16 +222,7 @@ async function checkExists() {
|
||||
// 查询当前媒体是否已订阅
|
||||
async function checkSubscribe(season: number | null = null) {
|
||||
try {
|
||||
const mediaid = getMediaId()
|
||||
|
||||
const result: Subscribe = await api.get(`subscribe/media/${mediaid}`, {
|
||||
params: {
|
||||
season,
|
||||
title: mediaDetail.value.title,
|
||||
},
|
||||
})
|
||||
|
||||
if (result.id) return true
|
||||
return await subscribeActions.checkSubscribe(season)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
@@ -245,6 +230,17 @@ async function checkSubscribe(season: number | null = null) {
|
||||
return false
|
||||
}
|
||||
|
||||
function isSameSubscribeMedia(subscribe: Subscribe) {
|
||||
if (mediaDetail.value?.tmdb_id && subscribe.tmdbid) return mediaDetail.value.tmdb_id === subscribe.tmdbid
|
||||
if (mediaDetail.value?.douban_id && subscribe.doubanid) return mediaDetail.value.douban_id === subscribe.doubanid
|
||||
if (mediaDetail.value?.bangumi_id && subscribe.bangumiid) return mediaDetail.value.bangumi_id === subscribe.bangumiid
|
||||
|
||||
const mediaId = mediaDetail.value?.media_id
|
||||
? `${mediaDetail.value.mediaid_prefix}:${mediaDetail.value.media_id}`
|
||||
: ''
|
||||
return Boolean(mediaId && subscribe.mediaid === mediaId)
|
||||
}
|
||||
|
||||
// 检查所有季的缺失状态
|
||||
async function checkSeasonsNotExists() {
|
||||
if (mediaDetail.value.type !== '电视剧') return
|
||||
@@ -284,100 +280,50 @@ const getMediaSeasons = computed(() => {
|
||||
async function checkSeasonsSubscribed() {
|
||||
if (mediaDetail.value.type !== '电视剧') return
|
||||
try {
|
||||
mediaDetail.value?.season_info?.forEach(async item => {
|
||||
seasonsSubscribed.value[item.season_number ?? 0] = await checkSubscribe(item.season_number ?? null)
|
||||
const subscribes: Subscribe[] = await api.get('subscribe/')
|
||||
const mediaSubscribes = subscribes.filter(
|
||||
item => item.type === '电视剧' && item.season !== undefined && isSameSubscribeMedia(item),
|
||||
)
|
||||
const nextSubscribed: { [key: number]: boolean } = {}
|
||||
const nextModes: SeasonSubscribeModes = {}
|
||||
|
||||
mediaDetail.value?.season_info?.forEach(item => {
|
||||
const season = item.season_number ?? 0
|
||||
nextSubscribed[season] = false
|
||||
})
|
||||
|
||||
mediaSubscribes.forEach(item => {
|
||||
const season = item.season as number
|
||||
nextSubscribed[season] = true
|
||||
nextModes[season] = getSubscribeMode(item)
|
||||
})
|
||||
|
||||
seasonsSubscribed.value = nextSubscribed
|
||||
subscribedSeasonModes.value = nextModes
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 调用API添加订阅,电视剧的话需要指定季
|
||||
async function addSubscribe(season: number | null) {
|
||||
// 开始处理
|
||||
startNProgress()
|
||||
try {
|
||||
// 是否洗版
|
||||
let best_version = existsItemId.value ? 1 : 0
|
||||
if (season !== null)
|
||||
// 全部存在时洗版
|
||||
best_version = !seasonsNotExisted.value[season] ? 1 : 0
|
||||
// 请求API
|
||||
const result: { [key: string]: any } = await api.post('subscribe/', {
|
||||
name: mediaDetail.value?.title,
|
||||
type: mediaDetail.value?.type,
|
||||
year: mediaDetail.value?.year,
|
||||
tmdbid: mediaDetail.value?.tmdb_id,
|
||||
doubanid: mediaDetail.value?.douban_id,
|
||||
bangumiid: mediaDetail.value?.bangumi_id,
|
||||
season: mediaDetail.value?.type === '电影' ? null : season,
|
||||
best_version,
|
||||
})
|
||||
const subscribedSeasonNumbers = computed(() =>
|
||||
Object.entries(seasonsSubscribed.value)
|
||||
.filter(([, subscribed]) => subscribed)
|
||||
.map(([season]) => Number(season))
|
||||
.sort((a, b) => a - b),
|
||||
)
|
||||
|
||||
// 订阅状态
|
||||
if (result.success) {
|
||||
// 订阅成功
|
||||
isSubscribed.value = true
|
||||
if (season !== null) seasonsSubscribed.value[season] = true
|
||||
}
|
||||
const subscribeSeasonTotal = computed(() => getMediaSeasons.value.length)
|
||||
|
||||
// 提示
|
||||
showSubscribeAddToast(result.success, mediaDetail.value?.title ?? '', season, result.message, best_version)
|
||||
|
||||
// 显示编辑弹窗
|
||||
if (result.success) {
|
||||
const show_edit_dialog = await queryDefaultSubscribeConfig()
|
||||
if (show_edit_dialog) {
|
||||
openSubscribeEditDialog(result.data.id)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
doneNProgress()
|
||||
}
|
||||
|
||||
// 弹出添加订阅提示
|
||||
function showSubscribeAddToast(result: boolean, title: string, season: number | null, message: string, best_version: number) {
|
||||
if (season !== null) title = `${title} ${formatSeason(season.toString())}`
|
||||
|
||||
let subname = t('subscribe.normalSub')
|
||||
if (best_version > 0) subname = t('subscribe.versionSub')
|
||||
|
||||
if (result) $toast.success(`${title} ${t('subscribe.addSuccess', { name: subname })}`)
|
||||
else $toast.error(`${title} ${t('media.subscribe.addFailed', { reason: message })}`)
|
||||
}
|
||||
|
||||
// 调用API取消订阅
|
||||
async function removeSubscribe(season: number | null) {
|
||||
// 开始处理
|
||||
startNProgress()
|
||||
try {
|
||||
const mediaid = getMediaId()
|
||||
|
||||
const result: { [key: string]: any } = await api.delete(`subscribe/media/${mediaid}`, {
|
||||
params: {
|
||||
season,
|
||||
},
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
isSubscribed.value = false
|
||||
if (season !== null) seasonsSubscribed.value[season] = false
|
||||
$toast.success(`${mediaDetail.value?.title} ${t('media.subscribe.canceled')}`)
|
||||
} else {
|
||||
$toast.error(`${mediaDetail.value?.title} ${t('media.subscribe.cancelFailed', { reason: result.message })}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
doneNProgress()
|
||||
}
|
||||
const isAllSeasonsSubscribed = computed(
|
||||
() =>
|
||||
mediaDetail.value.type === '电视剧' &&
|
||||
subscribeSeasonTotal.value > 0 &&
|
||||
subscribedSeasonNumbers.value.length >= subscribeSeasonTotal.value,
|
||||
)
|
||||
|
||||
// 订阅按钮响应
|
||||
function handleSubscribe(season: number | null = null) {
|
||||
if (isSubscribed.value) removeSubscribe(season)
|
||||
else addSubscribe(season)
|
||||
subscribeActions.handleSubscribe(season)
|
||||
}
|
||||
|
||||
// 从genres中获取name,使用、分隔
|
||||
@@ -491,16 +437,33 @@ function getExistText(season: number) {
|
||||
|
||||
// 计算订阅图标
|
||||
const getSubscribeIcon = computed(() => {
|
||||
if (mediaDetail.value.type === '电视剧') return subscribedSeasonNumbers.value.length > 0 ? 'mdi-heart' : 'mdi-heart-outline'
|
||||
if (isSubscribed.value) return 'mdi-heart'
|
||||
else return 'mdi-heart-outline'
|
||||
})
|
||||
|
||||
// 计算订阅按钮颜色
|
||||
const getSubscribeColor = computed(() => {
|
||||
if (mediaDetail.value.type === '电视剧') {
|
||||
if (isAllSeasonsSubscribed.value) return 'error'
|
||||
if (subscribedSeasonNumbers.value.length > 0) return 'warning'
|
||||
return 'warning'
|
||||
}
|
||||
if (isSubscribed.value) return 'error'
|
||||
else return 'warning'
|
||||
})
|
||||
|
||||
const getSubscribeText = computed(() => {
|
||||
if (mediaDetail.value.type === '电视剧') {
|
||||
if (isAllSeasonsSubscribed.value) return t('media.status.allSeasonsSubscribed')
|
||||
if (subscribedSeasonNumbers.value.length > 0) {
|
||||
return t('media.status.seasonsSubscribed', { count: subscribedSeasonNumbers.value.length })
|
||||
}
|
||||
return t('media.actions.subscribe')
|
||||
}
|
||||
return isSubscribed.value ? t('media.status.subscribed') : t('media.actions.subscribe')
|
||||
})
|
||||
|
||||
// 使用、拼装数组为字符串
|
||||
function joinArray(arr: string[]) {
|
||||
return arr.join('、')
|
||||
@@ -548,28 +511,25 @@ async function handlePlay() {
|
||||
}
|
||||
}
|
||||
|
||||
async function queryDefaultSubscribeConfig() {
|
||||
if (!canSubscribe.value) return false
|
||||
try {
|
||||
let subscribe_config_url = ''
|
||||
if (mediaProps.type === '电影') subscribe_config_url = 'system/setting/public/DefaultMovieSubscribeConfig'
|
||||
else subscribe_config_url = 'system/setting/public/DefaultTvSubscribeConfig'
|
||||
|
||||
const result: { [key: string]: any } = await api.get(subscribe_config_url)
|
||||
|
||||
if (result.data?.value) return result.data.value.show_edit_dialog
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 删除订阅处理
|
||||
function onSubscribeEditRemove() {
|
||||
if (mediaDetail.value.type === '电影') checkMovieSubscribed()
|
||||
else checkSeasonsSubscribed()
|
||||
}
|
||||
|
||||
const subscribeActions = useMediaSubscribe({
|
||||
media: () => mediaDetail.value,
|
||||
canSubscribe: () => canSubscribe.value,
|
||||
isSubscribed,
|
||||
isExists: () => Boolean(existsItemId.value),
|
||||
seasonsSubscribed,
|
||||
subscribedSeasons: subscribedSeasonNumbers,
|
||||
subscribedSeasonModes,
|
||||
primarySeason: () => mediaDetail.value?.season ?? null,
|
||||
getSubscribeStatusKey,
|
||||
onEditRemove: onSubscribeEditRemove,
|
||||
})
|
||||
|
||||
// 搜索前弹出站点选择框,确认后执行资源或字幕搜索。
|
||||
async function clickSearch(type: string, resultType: 'torrent' | 'subtitle' = 'torrent', options: MediaSearchOptions = {}) {
|
||||
searchType.value = type
|
||||
@@ -670,7 +630,7 @@ onBeforeMount(() => {
|
||||
canSearch
|
||||
"
|
||||
variant="tonal"
|
||||
color="info"
|
||||
color="primary"
|
||||
class="mb-2"
|
||||
>
|
||||
<template #prepend>
|
||||
@@ -704,7 +664,7 @@ onBeforeMount(() => {
|
||||
{{ t('media.actions.searchSubtitle') }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
v-if="canSubscribe && (mediaDetail.type === '电影' || mediaDetail.douban_id || mediaDetail.bangumi_id)"
|
||||
v-if="canSubscribe && (mediaDetail.type === '电影' || mediaDetail.tmdb_id || mediaDetail.douban_id || mediaDetail.bangumi_id)"
|
||||
class="ms-2 mb-2"
|
||||
:color="getSubscribeColor"
|
||||
variant="tonal"
|
||||
@@ -713,7 +673,7 @@ onBeforeMount(() => {
|
||||
<template #prepend>
|
||||
<VIcon :icon="getSubscribeIcon" />
|
||||
</template>
|
||||
{{ isSubscribed ? t('media.status.subscribed') : t('media.actions.subscribe') }}
|
||||
{{ getSubscribeText }}
|
||||
</VBtn>
|
||||
<VBtn v-if="existsItemId" class="ms-2 mb-2" variant="tonal" @click="handlePlay()">
|
||||
<template #prepend>
|
||||
|
||||
@@ -3,7 +3,7 @@ import api from '@/api'
|
||||
import type { Person } from '@/api/types'
|
||||
import PersonCard from '@/components/cards/PersonCard.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import NoDataFound from '@/components/states/NoDataFound.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -3,7 +3,7 @@ import MediaCardListView from './MediaCardListView.vue'
|
||||
import api from '@/api'
|
||||
import personIcon from '@images/misc/person.png'
|
||||
import type { Person } from '@/api/types'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import NoDataFound from '@/components/states/NoDataFound.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { useToast } from 'vue-toastification'
|
||||
import api from '@/api'
|
||||
import type { Plugin } from '@/api/types'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import NoDataFound from '@/components/states/NoDataFound.vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import { getPluginTabs } from '@/router/i18n-menu'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import api from '@/api'
|
||||
import type { DownloadingInfo } from '@/api/types'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import NoDataFound from '@/components/states/NoDataFound.vue'
|
||||
import DownloadingCard from '@/components/cards/DownloadingCard.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import { useUserStore } from '@/stores'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import api from '@/api'
|
||||
import { FileItem, StorageConf, TransferDirectoryConf } from '@/api/types'
|
||||
import FileBrowser from '@/components/FileBrowser.vue'
|
||||
import FileBrowser from '@/components/filebrowser/FileBrowser.vue'
|
||||
|
||||
const endpoints = {
|
||||
list: {
|
||||
|
||||
@@ -157,11 +157,12 @@ onMounted(() => {
|
||||
<VCardText>
|
||||
<p class="text-body-2 text-medium-emphasis mb-4">{{ t('setupWizard.preferences.quickPresetsDesc') }}</p>
|
||||
<VRow>
|
||||
<VCol v-for="(preset, key) in presetConfigs" :key="key" cols="12" sm="6" md="3">
|
||||
<!-- Hover 命中区域保持静止,避免预设卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<VCol v-for="(preset, key) in presetConfigs" :key="key" class="preset-card-hover-area" cols="12" sm="6" md="3">
|
||||
<VCard
|
||||
:color="selectedPreset === key ? preset.color : 'default'"
|
||||
:variant="selectedPreset === key ? 'tonal' : 'outlined'"
|
||||
class="cursor-pointer preset-card"
|
||||
class="app-hover-lift-card cursor-pointer preset-card"
|
||||
@click="selectPreset(key)"
|
||||
>
|
||||
<VCardText class="text-center pa-4">
|
||||
@@ -218,11 +219,10 @@ onMounted(() => {
|
||||
<style scoped>
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.preset-card:hover {
|
||||
transform: translateY(-4px);
|
||||
.preset-card-hover-area:hover .preset-card {
|
||||
transform: translate3d(0, -0.25rem, 0);
|
||||
}
|
||||
|
||||
.preset-card:active {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import api from '@/api'
|
||||
import type { Site, SiteUserData } from '@/api/types'
|
||||
import SiteCard from '@/components/cards/SiteCard.vue'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import NoDataFound from '@/components/states/NoDataFound.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import { useDynamicButton, type DynamicButtonMenuItem } from '@/composables/useDynamicButton'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import draggable from 'vuedraggable'
|
||||
import api from '@/api'
|
||||
import type { Subscribe } from '@/api/types'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import NoDataFound from '@/components/states/NoDataFound.vue'
|
||||
import SubscribeCard from '@/components/cards/SubscribeCard.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import { useUserStore } from '@/stores'
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import api from '@/api'
|
||||
import type { MediaInfo } from '@/api/types'
|
||||
import MediaCard from '@/components/cards/MediaCard.vue'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import NoDataFound from '@/components/states/NoDataFound.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import api from '@/api'
|
||||
import type { SubscribeShare } from '@/api/types'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import NoDataFound from '@/components/states/NoDataFound.vue'
|
||||
import SubscribeShareCard from '@/components/cards/SubscribeShareCard.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import api from '@/api'
|
||||
import type { User } from '@/api/types'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import NoDataFound from '@/components/states/NoDataFound.vue'
|
||||
import UserCard from '@/components/cards/UserCard.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import { useDynamicButton } from '@/composables/useDynamicButton'
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import api from '@/api'
|
||||
import { Workflow } from '@/api/types'
|
||||
import WorkflowTaskCard from '@/components/cards/WorkflowTaskCard.vue'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import NoDataFound from '@/components/states/NoDataFound.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useKeepAliveRefresh } from '@/composables/useKeepAliveRefresh'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import api from '@/api'
|
||||
import type { WorkflowShare } from '@/api/types'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import NoDataFound from '@/components/states/NoDataFound.vue'
|
||||
import WorkflowShareCard from '@/components/cards/WorkflowShareCard.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
Reference in New Issue
Block a user