Compare commits

...
48 Commits
Author SHA1 Message Date
jxxghp afb617724c fix: recover web agent stream after background 2026-06-27 15:09:41 +08:00
InfinityPacer 05bbecba53 fix(subscribe): refine season mode defaults (#503) 2026-06-27 06:55:45 +08:00
jxxghp deb9b103da Improve assistant input and choice handling 2026-06-26 13:43:47 +08:00
jxxghp 2d92315e17 Enhance command prompts in localization files
- Updated placeholder text in English, Simplified Chinese, and Traditional Chinese to guide users on using commands.
- Added loading and load failed messages for commands in all three languages.
2026-06-26 12:42:49 +08:00
jxxghp 077f0c1366 fix: improve login autofill compatibility 2026-06-25 23:26:57 +08:00
jxxghp eab4caa279 fix: disable MediaCard ripple feedback 2026-06-25 23:15:40 +08:00
jxxghp 3bba73abf1 fix: remove unused formatSeason function and subscribedSeasonText for cleaner code 2026-06-25 22:59:29 +08:00
jxxghp 8edf1f686a fix: refactor media card interaction for improved touch handling and state management 2026-06-25 22:45:12 +08:00
jxxghp 3440b377ee fix: improve mobile interaction handling for media cards 2026-06-25 21:33:37 +08:00
jxxghp 9161e5e300 fix: replace VBottomSheet with VDialog for improved layout and accessibility 2026-06-25 20:11:52 +08:00
jxxghp 25fdb09c61 fix: correct ios safe area in subscribe sheet 2026-06-25 17:39:35 +08:00
jxxghp 25119b224d fix: refactor SubscribeSeasonDialog for improved readability and style consistency 2026-06-25 17:22:29 +08:00
jxxghp 76f79e1656 fix: align bubbles after assistant undocks 2026-06-25 16:11:39 +08:00
jxxghp b5230cef79 fix: match agent bubble arrow style 2026-06-25 16:02:25 +08:00
jxxghp f871746efb fix: reposition agent assistant bubbles 2026-06-25 15:53:52 +08:00
jxxghp d17ca69a28 fix: expand subscribe season sheet on mobile 2026-06-25 15:12:50 +08:00
jxxghp e445437129 fix: hide agent assistant message scrollbar 2026-06-25 13:56:46 +08:00
jxxghp 090c5476c6 feat: unify media subscription flow 2026-06-25 13:52:13 +08:00
jxxghp 646c7b33dd Tune pull and swipe gesture thresholds 2026-06-25 12:33:49 +08:00
jxxghp 0902f16f84 fix ui 2026-06-25 11:39:52 +08:00
InfinityPacer ec4eaed1df fix: refine subscribe mode selection (#501)
* fix(subscribe): refine subscribe mode selection

* fix(subscribe): prompt mode for single existing season
2026-06-25 11:30:24 +08:00
jxxghp 175f610524 Animate dashboard metrics and standardize card sizing 2026-06-25 11:28:36 +08:00
jxxghp 530fe9d35b fix: 修复卡片 hover 上浮抖动 2026-06-24 21:34:38 +08:00
jxxghp 48ed396a19 Refresh notifications when opening the menu 2026-06-24 20:58:46 +08:00
jxxghp b0356c217d feat: add welcome back message to login page and enhance styles 2026-06-24 17:38:03 +08:00
jxxghp 55eed1ecb5 Fix notification read and clear state 2026-06-24 16:58:08 +08:00
jxxghp 50ae739a4d fix: resolve stylelint warnings and enhance bubble layout for agent assistant 2026-06-24 14:33:55 +08:00
jxxghp d9cbcc2991 Increase agent assistant bubble text size 2026-06-24 13:44:09 +08:00
jxxghp ad12701fe2 chore: update version to 2.13.15 2026-06-24 12:55:32 +08:00
jxxghp 2b426a47c6 Route toast notifications to agent assistant bubbles 2026-06-24 12:55:01 +08:00
jxxghp 7f0f12ac41 fix: adjust wave action duration and refine animation keyframes 2026-06-24 12:01:24 +08:00
jxxghp 6789d63ca1 Add happy-jump animation to agent assistant FAB 2026-06-24 08:45:44 +08:00
jxxghp 3202251f55 Add disassemble animation to assistant FAB 2026-06-24 08:33:59 +08:00
jxxghp 8e99ad9cf9 Fix agent assistant arm animation 2026-06-24 07:39:29 +08:00
jxxghp 83dde400e7 fix: refine agent wave animation 2026-06-24 07:28:06 +08:00
jxxghp 1b57f925ee Add random FAB actions and global pointer tracking 2026-06-24 07:14:52 +08:00
InfinityPacer 16428066b9 ci: review PRs with PR-Agent (#500) 2026-06-24 06:10:12 +08:00
InfinityPacer e211a80cf4 ci: add PR-Agent review workflow (#499) 2026-06-23 23:21:55 +08:00
jxxghp ea0b5b62d9 Remove obsolete frontend code 2026-06-23 20:16:39 +08:00
jxxghp 62dc2c4f66 feat(agent-assistant): enhance FAB interactive bounds calculation for improved drag functionality 2026-06-23 19:58:13 +08:00
jxxghp b2a2c7080e feat(agent-assistant): add active prop to AgentAssistantEntry for visibility control 2026-06-23 17:56:53 +08:00
jxxghp 05c2e7855a feat: add agent assistant notification bubble functionality
- Introduced a new utility for managing agent assistant notifications.
- Created functions to emit and listen for notification bubbles using custom events.
- Defined types for notification payloads to ensure type safety.
2026-06-23 17:37:48 +08:00
jxxghp 8d9c622dc5 Improve agent assistant message scrolling 2026-06-23 16:24:23 +08:00
jxxghp bf0b17c314 style(agent-assistant): update FAB styles for transparency and layout adjustments 2026-06-23 16:13:00 +08:00
jxxghp 37f31f6554 style(agent-assistant): update FAB and mini-bot styles with new color variables 2026-06-23 15:57:11 +08:00
jxxghp 3de409fb07 style(agent-assistant): update FAB styles for transparency and bubble background 2026-06-23 15:52:07 +08:00
jxxghp 7e9c0fd206 feat(notification): support scoped message clearing 2026-06-23 13:43:33 +08:00
jxxghp fb4f5658a8 更新 package.json 2026-06-23 11:46:32 +08:00
85 changed files with 7204 additions and 1159 deletions
+123
View File
@@ -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 }}
# 仓库设置中添加的 SecretSettings -> 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"
+1
View File
@@ -37,3 +37,4 @@ src/@iconify/*.js
public/plugin_icon/**
docs-lock/
.trae/
output/
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "moviepilot",
"version": "2.13.13",
"version": "2.13.15",
"private": true,
"type": "module",
"bin": "dist/service.js",
+1 -1
View File
@@ -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
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>
+65 -15
View File
@@ -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>
+16 -11
View File
@@ -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%;
}
+64 -13
View File
@@ -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
View File
@@ -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>
+17 -10
View File
@@ -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,
+17 -11
View File
@@ -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;
+19 -14
View File
@@ -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 */
+22 -17
View File
@@ -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 {
+54 -13
View File
@@ -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>
+33 -22
View File
@@ -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;
+24 -18
View File
@@ -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%);
}
+15 -9
View File
@@ -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%);
}
+12 -3
View File
@@ -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>
+12 -3
View File
@@ -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>
+12 -3
View File
@@ -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);
}
+12 -3
View File
@@ -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);
}
+20 -9
View File
@@ -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%;
}
+16 -19
View File
@@ -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>
+17 -9
View File
@@ -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>
+12 -7
View File
@@ -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>
+466 -52
View File
@@ -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'
+41
View File
@@ -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]}`
}
+477
View File
@@ -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 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts" setup>
import DefaultLayout from './components/DefaultLayout.vue'
import DefaultLayout from './default/components/DefaultLayout.vue'
const route = useRoute()
@@ -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
@@ -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')
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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'
//
+1 -1
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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 })
})
+10
View File
@@ -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%;
+4
View File
@@ -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));
}
+101
View File
@@ -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
View File
@@ -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)
}
})
}
+13 -2
View File
@@ -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
// 使nextTickDOM
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>
+62 -28
View File
@@ -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>
+15 -3
View File
@@ -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
// 使nextTickDOM
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>
+21 -15
View File
@@ -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
// 使nextTickDOM
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>
+56 -26
View File
@@ -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>
+38 -8
View File
@@ -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))
// API7
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'
+1 -1
View File
@@ -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()
+95 -135
View File
@@ -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)
}
// genresname使
@@ -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>
+1 -1
View File
@@ -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()
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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: {
+5 -5
View File
@@ -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 {
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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'
+1 -1
View File
@@ -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 -1
View File
@@ -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'