Compare commits

..

61 Commits

Author SHA1 Message Date
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
jxxghp
a9f4ec963b fix(plugin): align market detail actions on small screens 2026-06-22 13:25:23 +08:00
jxxghp
542e33d7b4 fix(discover): stabilize media card hover 2026-06-22 12:51:48 +08:00
jxxghp
39c250ba09 更新 package.json 2026-06-21 16:17:11 +08:00
jxxghp
924fcef403 fix(setting): 将LLM最大上下文令牌数从64增加到128 2026-06-19 19:41:10 +08:00
InfinityPacer
e586342b19 fix(pwa): prevent stale service worker caching (#498) 2026-06-19 18:52:23 +08:00
Album
c795de9b2d feat(reorganize): 优化手动整理预览的分页、排序与样式显示 (#497) 2026-06-19 16:05:39 +08:00
jxxghp
6fa1cf28f4 fix(calendar): 优化日历组件中的代码格式和样式 2026-06-19 14:32:33 +08:00
jxxghp
3f70aafdad feat(theme): 添加阴影级别支持并优化主题定制器的阴影设置 2026-06-19 14:12:58 +08:00
jxxghp
f8ceee39b3 fix(plugin): 优化插件市场详情对话框的按钮布局和样式 2026-06-19 11:31:46 +08:00
jxxghp
0a22f33e34 fix(package): 更新版本号至2.13.12 2026-06-19 10:22:01 +08:00
jxxghp
cf88ed9a58 fix(notification): 调整通知标题和计数样式以改善可读性 2026-06-19 10:17:10 +08:00
jxxghp
49dfd794c1 feat(notification): add support for grouped notification display and localization 2026-06-19 10:08:03 +08:00
jxxghp
68f2f010d1 fix(notification): improve notification expansion handling and styling 2026-06-19 10:02:16 +08:00
jxxghp
9eed2fea87 feat: optimize notification center controls 2026-06-19 09:49:22 +08:00
jxxghp
1f170030ee fix: harden plugin market list rendering 2026-06-18 19:45:21 +08:00
jxxghp
e78ed20936 fix: 更新 viewport meta 标签,移除不必要的 interactive-widget 属性 2026-06-18 19:30:52 +08:00
InfinityPacer
b1787b207d fix(plugin): sanitize version history markdown (#496) 2026-06-18 19:30:24 +08:00
InfinityPacer
fdb34732cc fix(plugin): refine release version history UI (#495) 2026-06-18 18:52:54 +08:00
jxxghp
fc1f163a94 fix: 更新 SiteResourceDialog 组件样式,优化移动端显示和用户交互体验 2026-06-18 17:54:25 +08:00
InfinityPacer
a771dc5354 feat(plugin): add release version install actions (#494) 2026-06-18 15:48:09 +08:00
jxxghp
d28360a161 feat: load agent assistant history 2026-06-18 11:45:50 +08:00
jxxghp
a730abc437 refactor: 优化通知组件的样式和逻辑,移除不必要的状态,改善用户体验 2026-06-17 22:23:04 +08:00
jxxghp
5b72eda4fc fix: keep notification read action available 2026-06-17 18:17:38 +08:00
jxxghp
6c49d7a59e feat: 添加日历事件展开功能,优化事件显示和用户交互体验 2026-06-17 18:05:40 +08:00
jxxghp
8900366faf fix: 修复订阅卡片的待定状态样式,确保正确应用 pending tint 2026-06-17 17:21:56 +08:00
jxxghp
e8e0ac9084 refactor: remove scoped global css escapes 2026-06-17 17:19:09 +08:00
jxxghp
c66ee881b1 refactor: remove ShortcutMessageDialog and MessageView components; update ShortcutBar and UserNotification for improved notification handling 2026-06-17 16:32:00 +08:00
jxxghp
c055740926 feat: 优化错误处理逻辑,避免重复提示并改善用户体验 2026-06-17 12:36:34 +08:00
PKC278
a5bc4e6baf fix: restore search filter chip colors (#493) 2026-06-17 12:03:20 +08:00
jxxghp
15b4ee5893 feat: 隐藏主题定制器的滚动条,改善用户界面体验 2026-06-17 12:00:11 +08:00
jxxghp
8868403ff3 feat: 在组件挂载时同步主题定制器状态,确保全局 FAB 预留右侧空间 2026-06-17 11:29:26 +08:00
jxxghp
3abff72e25 feat: 添加语音录制功能,支持录音和相关提示信息 2026-06-17 11:11:55 +08:00
jxxghp
0c56cf0be7 feat: 优化消息列表样式,增加内容存在时的底部填充,改善滚动体验 2026-06-17 08:31:23 +08:00
jxxghp
ce12d04648 feat: 添加新会话时自动滚动到顶部,优化空态展示 2026-06-17 08:15:30 +08:00
jxxghp
efc0ae4df6 fix: keep agent composer floating without early scroll 2026-06-17 07:15:36 +08:00
jxxghp
2530c3bcd9 fix: stabilize mobile panel height 2026-06-17 07:01:33 +08:00
jxxghp
60e2402aff feat: 优化 AgentAssistantWidget 组件的样式和结构,增强可读性和用户体验 2026-06-16 23:29:58 +08:00
jxxghp
1a478f97fb feat: 添加历史会话功能,支持会话恢复和删除,更新多语言文本 2026-06-16 23:18:14 +08:00
jxxghp
33666703af feat: 添加选择功能和附件上传支持,更新多语言文本 2026-06-16 22:55:26 +08:00
jxxghp
cd69172a99 feat: 更新 AgentAssistantWidget 组件的空状态样式和文本内容 2026-06-16 22:23:37 +08:00
jxxghp
61749e3595 feat: Web Agent 透明主题磨砂效果 (#492) 2026-06-16 21:52:45 +08:00
jxxghp
b658533262 更新 package.json 2026-06-16 21:20:58 +08:00
jxxghp
d8015b7def feat: add Agent Assistant component and integrate with theme customizer
- Implemented Agent Assistant widget with chat functionality, including message handling and streaming responses.
- Added new localization strings for Agent Assistant in English, Simplified Chinese, and Traditional Chinese.
- Updated DefaultLayout to include the Agent Assistant and Theme Customizer components.
- Enhanced UserProfile to manage the opening of the Theme Customizer through global events.
- Adjusted CSS styles for the Agent Assistant and its interactions with other components.
- Introduced new events for opening the Theme Customizer and managing its state.
2026-06-16 20:44:00 +08:00
75 changed files with 8768 additions and 2535 deletions

123
.github/workflows/pr-agent.yml vendored Normal file
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
.gitignore vendored
View File

@@ -37,3 +37,4 @@ src/@iconify/*.js
public/plugin_icon/**
docs-lock/
.trae/
output/

View File

@@ -18,7 +18,7 @@
<meta charset="UTF-8" />
<!-- 核心viewport设置 - 针对PWA优化 -->
<meta name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover, shrink-to-fit=no, interactive-widget=resizes-content" />
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover, shrink-to-fit=no" />
<!-- 防止缩放和选择,提供原生应用体验 -->
<meta name="format-detection" content="telephone=no, date=no, email=no, address=no" />

View File

@@ -1,6 +1,6 @@
{
"name": "moviepilot",
"version": "2.13.10",
"version": "2.13.14",
"private": true,
"type": "module",
"bin": "dist/service.js",

View File

@@ -35,6 +35,23 @@ http {
try_files $uri $uri/ /index.html;
}
location = /service-worker.js {
# Service Worker 必须保持稳定 URL 并每次重新验证,避免前端更新后继续注册旧版本。
expires off;
add_header Cache-Control "no-cache, must-revalidate";
root html;
try_files $uri =404;
}
location = /manifest.webmanifest {
# Web App Manifest 参与 PWA 安装与资源发现,不能跟普通静态资源一起长缓存。
expires off;
default_type application/manifest+json;
add_header Cache-Control "no-cache, must-revalidate";
root html;
try_files $uri =404;
}
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
# 静态资源
expires 1y;
@@ -44,8 +61,7 @@ http {
location /assets {
# 静态资源
expires 1y;
add_header Cache-Control "public";
add_header Cache-Control "public, max-age=31536000, immutable";
root html;
}

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'
@@ -59,8 +59,9 @@ const loginStateKey = computed(() => (isLogin.value ? 'logged-in' : 'logged-out'
const backgroundImages = ref<string[]>([])
const activeImageIndex = ref(0)
const isTransparentTheme = computed(() => globalTheme.name.value === 'transparent')
const isLoginWallpaperRoute = computed(() => !isLogin.value && route.path === LOGIN_WALLPAPER_ROUTE)
const shouldLoadBackgroundImages = computed(
() => (!isLogin.value && route.path === LOGIN_WALLPAPER_ROUTE) || (Boolean(isLogin.value) && isTransparentTheme.value),
() => isLoginWallpaperRoute.value || (Boolean(isLogin.value) && isTransparentTheme.value),
)
let backgroundRetryTimer: number | null = null
let backgroundRequestController: AbortController | null = null
@@ -434,7 +435,7 @@ onUnmounted(() => {
<div v-if="isLogin && isTransparentTheme" class="global-blur-layer"></div>
</div>
<!-- 页面内容 -->
<VApp>
<VApp :class="{ 'app-shell--login-wallpaper': isLoginWallpaperRoute }">
<RouterView />
<!-- 全局共享弹窗入口列表与卡片按需在这里挂载业务弹窗 -->
<SharedDialogHost />
@@ -504,4 +505,9 @@ onUnmounted(() => {
inset-block-start: 0;
inset-inline-start: 0;
}
/* 登录页壁纸在 VApp 外层渲染,登录页 VApp 需要透明才能露出壁纸。 */
.app-shell--login-wallpaper.v-application {
background: transparent !important;
}
</style>

View File

@@ -656,6 +656,8 @@ export interface Plugin {
system_version_message?: string
// 主系统版本限定范围
system_version?: string
// 是否声明支持通过 GitHub Release 资产安装
release?: boolean
// 是否本地插件
is_local?: boolean
// 插件仓库地址
@@ -668,6 +670,38 @@ export interface Plugin {
page_open?: boolean
}
// 插件 Release 可安装版本
export interface PluginReleaseVersion {
// 插件版本
version: string
// GitHub Release tag
tag_name: string
// Release 标题
name?: string
// 发布时间
published_at?: string
// Release 说明
body?: string
// 匹配到的资产文件名
asset_name?: string
// 是否为当前市场最新版本
is_latest?: boolean
// 是否为本地已安装版本
is_current?: boolean
}
// 插件 Release 可安装版本响应
export interface PluginReleaseVersionsResponse {
// 当前插件是否存在可直接安装的 Release 资产
release_supported: boolean
// 当前市场 package 声明的最新版本
latest_version?: string | null
// 本地已安装版本
current_version?: string | null
// 可安装版本列表
items: PluginReleaseVersion[]
}
// 插件侧栏全页导航项(与后端 PluginSidebarNavItem 对齐)
export interface PluginSidebarNavItem {
plugin_id: string
@@ -1131,6 +1165,12 @@ export interface MediaServerLibrary {
// 消息通知
export interface Message {
// 消息ID
id?: number
// 消息渠道
channel?: string
// 消息来源
source?: string
// 消息类型
mtype?: string
// 消息标题
@@ -1150,19 +1190,15 @@ export interface Message {
// 消息方向0-接收1-发送
action?: number
// JSON
note?: string
note?: string | any[] | Record<string, any>
}
// 系统通知
export interface SystemNotification {
// 通知类型 user/system/plugin
type: string
// 通知标题
title: string
// 通知内容
text: string
export interface SystemNotification extends Message {
// 通知类型 user/system/plugin/notification
type?: string
// 通知时间
date: string
date?: string
// 是否已读
read?: boolean
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -493,14 +493,14 @@ onBeforeUnmount(() => {
<template>
<VHover>
<template #default="hover">
<div ref="mediaCardRef">
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div ref="mediaCardRef" v-bind="hover.props" class="media-card-hover-area">
<VCard
v-bind="hover.props"
:height="props.height"
:width="props.width"
class="outline-none ring-gray-500 media-card"
:class="{
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
'ring-1': isImageLoaded,
}"
@click.stop="goMediaDetail(hover.isHovering ?? false)"
@@ -591,6 +591,10 @@ onBeforeUnmount(() => {
</VHover>
</template>
<style scoped>
.media-card-hover-area {
width: 100%;
}
.media-card-title {
font-size: 1.125rem;
line-height: 1.25rem;

View File

@@ -1,226 +0,0 @@
<script lang="ts" setup>
import MarkdownIt from 'markdown-it'
import mdLinkAttributes from 'markdown-it-link-attributes'
import { isNullOrEmptyObject } from '@/@core/utils'
import type { Message } from '@/api/types'
import { formatDateDifference } from '@core/utils/formatters'
// 输入参数
const props = defineProps({
message: Object as PropType<Message>,
width: String,
height: String,
})
// 定义事件
const emit = defineEmits(['imageload'])
// 图片是否加载完成
const isImageLoaded = ref(false)
// 图片是否加载失败
const imageLoadError = ref(false)
// 初始化 markdown-it
const md = new MarkdownIt({
html: true,
breaks: true,
linkify: true,
typographer: true,
})
// 插件:链接在新窗口打开
md.use(mdLinkAttributes, {
attrs: {
target: '_blank',
rel: 'noopener noreferrer',
},
})
// 图片加载完成
async function imageLoaded() {
isImageLoaded.value = true
emit('imageload')
}
// 链接打开新窗口
function openLink() {
if (props.message?.link) window.open(props.message.link, '_blank')
}
// 将note转换为json
function noteToJson() {
if (props.message?.note) {
try {
return JSON.parse(props.message.note)
} catch (error) {
return props.message.note
}
}
return {}
}
// 渲染 Markdown
function renderMarkdown(value: string) {
if (!value) return ''
return md.render(value)
}
</script>
<template>
<VCard variant="tonal" :width="props.width" :height="props.height" @click="openLink" max-width="23rem">
<div v-if="props.message?.image" class="relative text-center card-cover-blurred">
<VImg
:src="props.message?.image"
aspect-ratio="3/2"
cover
position="top"
@load="imageLoaded"
@error="imageLoadError = true"
min-height="10rem"
>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
</VImg>
</div>
<div
v-if="
props.message?.title &&
!props.message?.text &&
!props.message?.image &&
isNullOrEmptyObject(props.message?.note) &&
props.message?.action === 0
"
class="rounded-md text-body-1 py-2 px-4 elevation-2 bg-primary text-white chat-right mb-1"
>
<p class="mb-0">{{ props.message?.title }}</p>
</div>
<VCardTitle v-else-if="props.message?.title" class="break-words whitespace-break-spaces">
{{ props.message?.title }}
</VCardTitle>
<div
v-if="props.message?.text && props.message?.action === 0"
class="rounded-md text-body-1 py-1 px-4 elevation-2 bg-primary text-white chat-right"
>
<div class="markdown-body" v-html="renderMarkdown(props.message?.text)" />
</div>
<VCardText
v-if="props.message?.text && props.message?.action === 1"
class="markdown-body"
v-html="renderMarkdown(props.message?.text)"
/>
<VCardText v-if="!isNullOrEmptyObject(props.message?.note)">
<VList>
<VListItem v-for="(value, key) in noteToJson()" :key="key" two-line>
<VListItemTitle v-if="value.title_year" class="font-bold break-words whitespace-break-spaces">
{{ Number(key) + 1 }}. {{ value.title_year }}
</VListItemTitle>
<VListItemTitle v-if="value.enclosure" class="font-bold break-words whitespace-break-spaces">
{{ Number(key) + 1 }}. {{ value.title }} {{ value.volume_factor }} ↑{{ value.seeders }}
</VListItemTitle>
<VListItemSubtitle v-if="value.type">
类型:{{ value.type }} 评分:{{ value.vote_average }}
</VListItemSubtitle>
<VListItemSubtitle v-if="value.enclosure" class="whitespace-break-spaces">
{{ value.description }}
</VListItemSubtitle>
</VListItem>
</VList>
</VCardText>
</VCard>
<div class="text-end">
<span v-if="props.message?.action === 0" class="text-sm italic me-2">{{ props.message?.userid }}</span>
<span class="text-sm italic me-2">{{
formatDateDifference(props.message?.reg_time || props.message?.date || '')
}}</span>
</div>
</template>
<style lang="scss">
.markdown-body {
word-break: break-all;
p {
margin-block-end: 0.5rem;
}
p:last-child {
margin-block-end: 0;
}
a {
color: inherit;
text-decoration: underline;
}
ul {
list-style-type: disc;
margin-block-end: 0.5rem;
padding-inline-start: 1.5rem;
}
ol {
list-style-type: decimal;
margin-block-end: 0.5rem;
padding-inline-start: 1.5rem;
}
li {
display: list-item;
margin-block-end: 0.25rem;
}
code {
border-radius: 4px;
background-color: rgba(var(--v-border-color), 0.1);
font-family: monospace;
padding-block: 0.2rem;
padding-inline: 0.4rem;
}
pre {
overflow: auto;
padding: 1rem;
border-radius: 8px;
background-color: rgba(var(--v-border-color), 0.1);
margin-block-end: 0.5rem;
code {
padding: 0;
background-color: transparent;
}
}
blockquote {
border-inline-start: 4px solid rgba(var(--v-border-color), 0.2);
font-style: italic;
margin-block-end: 0.5rem;
padding-inline-start: 1rem;
}
table {
border-collapse: collapse;
inline-size: 100%;
margin-block-end: 1rem;
th,
td {
padding: 0.5rem;
border: 1px solid rgba(var(--v-border-color), 0.1);
text-align: start;
}
th {
background-color: rgba(var(--v-border-color), 0.05);
}
}
img {
block-size: auto;
max-inline-size: 100%;
}
}
</style>

View File

@@ -1,16 +1,20 @@
<script lang="ts" setup>
import api from '@/api'
import type { Plugin } from '@/api/types'
import { getLogoUrl } from '@/utils/imageUtils'
import { getDominantColor } from '@/@core/utils/image'
import { isNullOrEmptyObject } from '@/@core/utils'
import { formatDownloadCount } from '@/@core/utils/formatters'
import { useToast } from 'vue-toastification'
import { useI18n } from 'vue-i18n'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { useConfirm } from '@/composables/useConfirm'
const PluginMarketDetailDialog = defineAsyncComponent(() => import('@/components/dialog/PluginMarketDetailDialog.vue'))
const PluginVersionHistoryDialog = defineAsyncComponent(
() => import('@/components/dialog/PluginVersionHistoryDialog.vue'),
)
const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue'))
// 输入参数
const props = defineProps({
@@ -26,6 +30,11 @@ const emit = defineEmits(['install'])
// 多语言
const { t } = useI18n()
// 提示框
const $toast = useToast()
const createConfirm = useConfirm()
// 背景颜色
const backgroundColor = ref('#28A9E1')
@@ -48,6 +57,21 @@ const isImageLoaded = ref(false)
// 图片是否加载失败
const imageLoadError = ref(false)
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
let versionHistoryDialogController: ReturnType<typeof openSharedDialog> | null = null
/** 打开插件安装进度弹窗。 */
function showInstallProgress(text: string) {
progressDialogController?.close()
progressDialogController = openSharedDialog(ProgressDialog, { text }, {}, { closeOn: false })
}
/** 关闭插件安装进度弹窗。 */
function closeInstallProgress() {
progressDialogController?.close()
progressDialogController = null
}
// 图片加载完成
async function imageLoaded() {
isImageLoaded.value = true
@@ -96,14 +120,69 @@ function visitPluginPage() {
// 显示更新日志
function showUpdateHistory() {
openSharedDialog(
versionHistoryDialogController?.close()
versionHistoryDialogController = openSharedDialog(
PluginVersionHistoryDialog,
{ plugin: props.plugin },
{},
{ plugin: props.plugin, actionMode: 'install' },
{
update: installPlugin,
},
{ closeOn: ['close', 'update:modelValue'] },
)
}
/** 从插件市场版本历史安装指定 Release最新版本走普通安装路径以保留主程序兼容校验。 */
async function installPlugin(releaseVersion?: string, repoUrl?: string) {
if (!releaseVersion && props.plugin?.system_version_compatible === false) {
$toast.error(props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion'))
return
}
if (releaseVersion) {
const isConfirmed = await createConfirm({
title: t('common.confirm'),
content: t('plugin.confirmInstallOldRelease', {
name: props.plugin?.plugin_name,
version: releaseVersion,
}),
confirmText: t('common.confirm'),
})
if (!isConfirmed) return
}
try {
showInstallProgress(
t('plugin.installing', {
name: props.plugin?.plugin_name,
version: releaseVersion || props.plugin?.plugin_version,
}),
)
const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
params: {
repo_url: repoUrl || props.plugin?.repo_url,
release_version: releaseVersion,
force: props.plugin?.has_update || Boolean(releaseVersion),
},
})
closeInstallProgress()
if (result.success) {
$toast.success(t('plugin.installSuccess', { name: props.plugin?.plugin_name }))
versionHistoryDialogController?.close()
versionHistoryDialogController = null
emit('install')
} else {
$toast.error(t('plugin.installFailed', { name: props.plugin?.plugin_name, message: result.message }))
}
} catch (error) {
closeInstallProgress()
console.error(error)
}
}
/** 打开共享插件市场详情弹窗。 */
function showPluginDetail() {
openSharedDialog(
@@ -140,6 +219,11 @@ const dropdownItems = ref([
},
},
])
onUnmounted(() => {
closeInstallProgress()
versionHistoryDialogController?.close()
})
</script>
<template>

View File

@@ -69,6 +69,7 @@ const imageLoadError = ref(false)
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
let cloneDialogController: ReturnType<typeof openSharedDialog> | null = null
let versionHistoryDialogController: ReturnType<typeof openSharedDialog> | null = null
/** 打开插件操作进度弹窗,插件卡片自身不再持有进度弹窗实例。 */
function showPluginProgress(text: string) {
@@ -103,11 +104,12 @@ async function imageLoaded() {
// 显示更新日志
function showUpdateHistory(showUpdateAction: boolean = false) {
openSharedDialog(
versionHistoryDialogController?.close()
versionHistoryDialogController = openSharedDialog(
PluginVersionHistoryDialog,
{ plugin: props.plugin, showUpdateAction },
{ update: updatePlugin },
{ closeOn: ['close', 'update', 'update:modelValue'] },
{ closeOn: ['close', 'update:modelValue'] },
)
}
@@ -219,19 +221,37 @@ async function resetPlugin() {
}
// 更新插件
async function updatePlugin() {
if (props.plugin?.system_version_compatible === false) {
async function updatePlugin(releaseVersion?: string, repoUrl?: string) {
if (!releaseVersion && props.plugin?.system_version_compatible === false) {
$toast.error(props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion'))
return
}
if (releaseVersion) {
const isConfirmed = await createConfirm({
title: t('common.confirm'),
content: t('plugin.confirmInstallOldRelease', {
name: props.plugin?.plugin_name,
version: releaseVersion,
}),
confirmText: t('common.confirm'),
})
if (!isConfirmed) return
}
try {
// 显示等待提示框
showPluginProgress(t('plugin.updating', { name: props.plugin?.plugin_name }))
showPluginProgress(
releaseVersion
? t('plugin.installing', { name: props.plugin?.plugin_name, version: releaseVersion })
: t('plugin.updating', { name: props.plugin?.plugin_name }),
)
const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
params: {
repo_url: props.plugin?.repo_url,
repo_url: repoUrl || props.plugin?.repo_url,
release_version: releaseVersion,
force: true,
},
})
@@ -241,6 +261,8 @@ async function updatePlugin() {
if (result.success) {
$toast.success(t('plugin.updateSuccess', { name: props.plugin?.plugin_name }))
versionHistoryDialogController?.close()
versionHistoryDialogController = null
// 通知父组件刷新
emit('save')

View File

@@ -409,7 +409,6 @@ function handleCardClick() {
:class="{
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering && !props.sortable,
'outline-dotted outline-pink-500 outline-2': props.batchMode && props.selected,
'subscribe-card-pending-tint': subscribeState === 'P',
}"
>
<VCard
@@ -418,6 +417,7 @@ function handleCardClick() {
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"
@@ -588,7 +588,7 @@ function handleCardClick() {
}
/**
* 待定:用 ::after 浮层在 VCard 之上渲染 sky 漫反射式内发光
* 待定:内发光挂在实际 VCard 上,跟随卡片圆角并被 overflow-hidden 裁剪。
*/
.subscribe-card-pending-tint {
position: relative;

View File

@@ -99,9 +99,9 @@ function submitCustomCSS() {
<style scoped>
.custom-css-dialog {
display: flex;
overflow: hidden;
flex-direction: column;
max-block-size: calc(100dvh - 2rem);
overflow: hidden;
}
.custom-css-header {
@@ -111,7 +111,7 @@ function submitCustomCSS() {
.custom-css-editor-body {
flex: 1 1 auto;
min-block-size: 0;
min-block-size: 240px;
}
.custom-css-editor {
@@ -141,8 +141,8 @@ function submitCustomCSS() {
.custom-css-editor {
flex: 1 1 auto;
min-block-size: 0;
block-size: auto;
min-block-size: 0;
}
.custom-css-actions {

View File

@@ -6,8 +6,12 @@ import { getLogoUrl } from '@/utils/imageUtils'
import { useToast } from 'vue-toastification'
import { useI18n } from 'vue-i18n'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { useConfirm } from '@/composables/useConfirm'
const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue'))
const PluginVersionHistoryDialog = defineAsyncComponent(
() => import('@/components/dialog/PluginVersionHistoryDialog.vue'),
)
// 多语言
const { t } = useI18n()
@@ -15,6 +19,8 @@ const { t } = useI18n()
// 提示框
const $toast = useToast()
const createConfirm = useConfirm()
// 输入参数
const props = defineProps({
modelValue: {
@@ -47,6 +53,7 @@ const imageRef = ref<any>()
const imageLoadError = ref(false)
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
let versionHistoryDialogController: ReturnType<typeof openSharedDialog> | null = null
/** 打开插件安装进度弹窗。 */
function showInstallProgress(text: string) {
@@ -97,24 +104,38 @@ function visitPluginPage() {
}
/** 安装插件并通知父级刷新市场列表。 */
async function installPlugin() {
if (props.plugin?.system_version_compatible === false) {
async function installPlugin(releaseVersion?: string, repoUrl?: string) {
if (!releaseVersion && props.plugin?.system_version_compatible === false) {
$toast.error(props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion'))
return
}
if (releaseVersion) {
const isConfirmed = await createConfirm({
title: t('common.confirm'),
content: t('plugin.confirmInstallOldRelease', {
name: props.plugin?.plugin_name,
version: releaseVersion,
}),
confirmText: t('common.confirm'),
})
if (!isConfirmed) return
}
try {
showInstallProgress(
t('plugin.installing', {
name: props.plugin?.plugin_name,
version: props?.plugin?.plugin_version,
version: releaseVersion || props?.plugin?.plugin_version,
}),
)
const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
params: {
repo_url: props.plugin?.repo_url,
force: props.plugin?.has_update,
repo_url: repoUrl || props.plugin?.repo_url,
release_version: releaseVersion,
force: props.plugin?.has_update || Boolean(releaseVersion),
},
})
@@ -122,6 +143,8 @@ async function installPlugin() {
if (result.success) {
$toast.success(t('plugin.installSuccess', { name: props.plugin?.plugin_name }))
versionHistoryDialogController?.close()
versionHistoryDialogController = null
visible.value = false
emit('install')
} else {
@@ -133,8 +156,22 @@ async function installPlugin() {
}
}
/** 打开版本历史并支持从 Release 资产安装指定版本。 */
function showUpdateHistory() {
versionHistoryDialogController?.close()
versionHistoryDialogController = openSharedDialog(
PluginVersionHistoryDialog,
{ plugin: props.plugin, actionMode: 'install' },
{
update: installPlugin,
},
{ closeOn: ['close', 'update:modelValue'] },
)
}
onUnmounted(() => {
closeInstallProgress()
versionHistoryDialogController?.close()
})
</script>
@@ -190,16 +227,21 @@ onUnmounted(() => {
class="mb-3"
:text="props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion')"
/>
<div class="text-center text-md-left">
<VBtn
color="primary"
@click="installPlugin"
prepend-icon="mdi-download"
:disabled="props.plugin?.system_version_compatible === false"
>
{{ t('plugin.installToLocal') }}
</VBtn>
<div class="text-xs mt-2" v-if="props.count">
<div class="plugin-market-detail-actions">
<div class="plugin-market-detail-actions__buttons">
<VBtn
color="primary"
@click="installPlugin()"
prepend-icon="mdi-download"
:disabled="props.plugin?.system_version_compatible === false"
>
{{ t('plugin.installToLocal') }}
</VBtn>
<VBtn variant="tonal" @click="showUpdateHistory" prepend-icon="mdi-update">
{{ t('plugin.versionHistory') }}
</VBtn>
</div>
<div class="plugin-market-detail-actions__downloads" v-if="props.count">
<VIcon icon="mdi-fire" />
{{ t('plugin.totalDownloads', { count: formatDownloadCount(props.count) }) }}
</div>
@@ -212,3 +254,42 @@ onUnmounted(() => {
</VCard>
</VDialog>
</template>
<style scoped>
.plugin-market-detail-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 0.75rem;
}
.plugin-market-detail-actions__buttons {
/* 窄屏换行时用统一 gap 控制按钮间距,避免第二个按钮带左边距导致视觉偏移。 */
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.5rem;
}
.plugin-market-detail-actions__downloads {
flex-basis: 100%;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.75rem;
text-align: center;
}
@media (width >= 960px) {
.plugin-market-detail-actions {
justify-content: flex-start;
}
.plugin-market-detail-actions__buttons {
justify-content: flex-start;
}
.plugin-market-detail-actions__downloads {
text-align: start;
}
}
</style>

View File

@@ -1,11 +1,11 @@
<script setup lang="ts">
import api from '@/api'
import type { Plugin } from '@/api/types'
import type { Plugin, PluginReleaseVersion, PluginReleaseVersionsResponse } from '@/api/types'
import VersionHistory from '@/components/misc/VersionHistory.vue'
import { useI18n } from 'vue-i18n'
// 多语言
const { t } = useI18n()
const { t, locale } = useI18n()
// 输入参数
const props = defineProps({
@@ -21,14 +21,25 @@ const props = defineProps({
type: Boolean,
default: false,
},
actionMode: {
type: String as PropType<'install' | 'update'>,
default: 'update',
},
})
// 定义触发的自定义事件
const emit = defineEmits(['update:modelValue', 'close', 'update'])
const emit = defineEmits<{
(event: 'update:modelValue', value: boolean): void
(event: 'close'): void
(event: 'update', releaseVersion?: string, repoUrl?: string): void
}>()
const loading = ref(false)
const loadError = ref('')
const pluginDetail = ref<Plugin | null>(null)
const releaseLoading = ref(false)
const releaseError = ref('')
const releaseDetail = ref<PluginReleaseVersionsResponse | null>(null)
// 弹窗显示状态
const visible = computed({
@@ -41,19 +52,73 @@ const visible = computed({
const resolvedPlugin = computed(() => pluginDetail.value ?? props.plugin)
const resolvedHistory = computed(() => resolvedPlugin.value?.history || {})
const resolvedHistory = computed(() => {
const history = { ...(resolvedPlugin.value?.history || {}) }
releaseItems.value.forEach(item => {
const key = normalizeHistoryVersion(item.version)
if (!(key in history)) history[key] = item.body || ''
})
return history
})
const hasHistory = computed(() => Object.keys(resolvedHistory.value).length > 0)
const latestActionText = computed(() => props.actionMode === 'install' ? t('plugin.installReleaseVersion') : t('plugin.updateToLatest'))
const releaseItems = computed(() => releaseDetail.value?.items || [])
const shouldShowUpdatePanel = computed(() => props.showUpdateAction)
const releaseByHistoryVersion = computed(() => {
const releaseMap = new Map<string, PluginReleaseVersion>()
releaseItems.value.forEach(item => {
releaseMap.set(normalizeHistoryVersion(item.version), item)
})
return releaseMap
})
function normalizeHistoryVersion(version: string) {
return version.startsWith('v') ? version : `v${version}`
}
function formatReleaseDate(value?: string) {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleDateString(locale.value)
}
function releaseItemByHistoryVersion(version: string) {
return releaseByHistoryVersion.value.get(version)
}
function shouldShowReleaseButton(item?: PluginReleaseVersion) {
if (!item || item.is_current) return false
return !(item.is_latest && shouldShowUpdatePanel.value && props.actionMode === 'update')
}
async function loadPluginHistory() {
if (!props.plugin?.id) {
pluginDetail.value = null
loadError.value = ''
releaseDetail.value = null
releaseError.value = ''
return
}
loading.value = true
loadError.value = ''
releaseDetail.value = null
releaseError.value = ''
// 插件市场条目已经携带远端信息history 接口只查询已安装插件,
// 未安装插件打开版本历史时只能基于传入的市场数据和 Release 列表展示。
if (props.actionMode === 'install' && props.plugin?.repo_url) {
pluginDetail.value = null
loading.value = false
loadPluginReleases(props.plugin, false)
return
}
try {
pluginDetail.value = await api.get(`plugin/history/${props.plugin.id}`, {
@@ -61,6 +126,7 @@ async function loadPluginHistory() {
force: true,
},
})
loadPluginReleases(pluginDetail.value ?? props.plugin, true)
} catch (error) {
pluginDetail.value = null
loadError.value = t('plugin.updateHistoryLoadFailed')
@@ -70,36 +136,108 @@ async function loadPluginHistory() {
}
}
async function loadPluginReleases(plugin: Plugin | null | undefined = resolvedPlugin.value, force = false) {
if (!plugin?.id || !plugin?.repo_url || !plugin?.release) {
releaseDetail.value = null
releaseError.value = ''
return
}
releaseLoading.value = true
releaseError.value = ''
try {
releaseDetail.value = await api.get(`plugin/releases/${plugin.id}`, {
params: {
repo_url: plugin.repo_url,
force,
},
})
} catch (error) {
releaseDetail.value = null
releaseError.value = t('plugin.releaseVersionsLoadFailed')
console.error(error)
} finally {
releaseLoading.value = false
}
}
/** 触发插件更新操作。 */
function handleUpdate() {
emit('update')
function handleUpdate(releaseItem?: PluginReleaseVersion) {
emit('update', releaseItem?.is_latest ? undefined : releaseItem?.version, resolvedPlugin.value?.repo_url)
}
watch(
() => [visible.value, props.plugin?.id],
([isVisible]) => {
if (isVisible) loadPluginHistory()
if (isVisible) {
loadPluginHistory()
}
},
{ immediate: true },
)
</script>
<template>
<VDialog v-if="visible" v-model="visible" width="600" max-height="85vh" scrollable>
<VDialog v-if="visible" v-model="visible" width="680" max-height="85vh" scrollable>
<VCard :title="t('plugin.updateHistoryTitle', { name: resolvedPlugin?.plugin_name })">
<VDialogCloseBtn v-model="visible" />
<VDivider />
<VProgressLinear v-if="releaseLoading && !loading" indeterminate color="primary" height="2" />
<div v-if="loading" class="plugin-version-history-dialog__loading">
<VProgressCircular indeterminate color="primary" />
</div>
<VCardText v-else-if="loadError && !hasHistory">
<VAlert type="warning" variant="tonal" density="compact" :text="loadError" />
</VCardText>
<VCardText v-else-if="!hasHistory">
<VCardText v-else-if="!hasHistory && !releaseLoading">
<VAlert type="info" variant="tonal" density="compact" :text="t('plugin.updateHistoryEmpty')" />
</VCardText>
<VersionHistory v-else :history="resolvedHistory" />
<template v-if="props.showUpdateAction">
<template v-else>
<VCardText v-if="releaseError" class="pb-0">
<VAlert type="warning" variant="tonal" density="compact" :text="releaseError" />
</VCardText>
<VersionHistory
:history="resolvedHistory"
:has-action="version => shouldShowReleaseButton(releaseItemByHistoryVersion(version))"
>
<template #meta="{ version }">
<div v-if="releaseItemByHistoryVersion(version)" class="plugin-release-meta">
<span v-if="formatReleaseDate(releaseItemByHistoryVersion(version)?.published_at)" class="plugin-release-meta__date">
{{ formatReleaseDate(releaseItemByHistoryVersion(version)?.published_at) }}
</span>
<VChip v-if="releaseItemByHistoryVersion(version)?.is_latest" size="x-small" color="primary" variant="tonal">
{{ t('plugin.latestVersion') }}
</VChip>
<VChip v-if="releaseItemByHistoryVersion(version)?.is_current" size="x-small" color="success" variant="tonal">
{{ t('plugin.currentVersion') }}
</VChip>
</div>
</template>
<template #action="{ version }">
<VBtn
v-if="shouldShowReleaseButton(releaseItemByHistoryVersion(version))"
class="plugin-release-button"
size="small"
min-width="5rem"
:color="releaseItemByHistoryVersion(version)?.is_latest ? 'primary' : undefined"
:variant="releaseItemByHistoryVersion(version)?.is_latest ? 'flat' : 'tonal'"
:disabled="
releaseItemByHistoryVersion(version)?.is_current ||
(releaseItemByHistoryVersion(version)?.is_latest && resolvedPlugin?.system_version_compatible === false)
"
@click.stop="handleUpdate(releaseItemByHistoryVersion(version))"
>
{{
releaseItemByHistoryVersion(version)?.is_latest
? latestActionText
: t('plugin.installReleaseVersion')
}}
</VBtn>
</template>
</VersionHistory>
</template>
<template v-if="shouldShowUpdatePanel">
<VDivider />
<VCardItem>
<VAlert
@@ -110,7 +248,11 @@ watch(
class="mb-3"
:text="resolvedPlugin?.system_version_message || t('plugin.incompatibleSystemVersion')"
/>
<VBtn @click="handleUpdate" block :disabled="resolvedPlugin?.system_version_compatible === false">
<VBtn
@click="handleUpdate()"
block
:disabled="resolvedPlugin?.system_version_compatible === false"
>
<template #prepend>
<VIcon icon="mdi-arrow-up-circle-outline" />
</template>
@@ -129,4 +271,23 @@ watch(
align-items: center;
justify-content: center;
}
.plugin-release-button {
white-space: nowrap;
}
.plugin-release-meta {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
min-width: 0;
}
.plugin-release-meta__date {
color: rgba(var(--v-theme-on-surface), var(--v-disabled-opacity));
font-size: 0.875rem;
white-space: nowrap;
}
</style>

View File

@@ -18,7 +18,6 @@ import { useBackground } from '@/composables/useBackground'
import MediaIdSelector from '../misc/MediaIdSelector.vue'
import ProgressDialog from './ProgressDialog.vue'
import { useI18n } from 'vue-i18n'
import { nextTick } from 'vue'
import { useDisplay } from 'vuetify'
import { useGlobalSettingsStore } from '@/stores'
@@ -150,13 +149,7 @@ const normalizedItems = computed(() => dedupeFileItems(props.items))
// 分页
const previewPage = ref(1)
const previewPageSize = ref(10)
// 预览列表主体元素
const previewFileBodyRef = ref<HTMLElement>()
// 预览列表尺寸观察器
let previewFileBodyResizeObserver: ResizeObserver | undefined
const previewPageSize = ref(20)
// 所有存储
const storages = ref<StorageConf[]>([])
@@ -419,9 +412,39 @@ watch(
},
)
// 过滤后的预览数据
// 过滤并排序后的预览数据
const filteredPreviewItems = computed(() => {
return previewData.value?.items ?? []
const items = [...(previewData.value?.items ?? [])]
return items.sort((a, b) => {
// 1. 获取季号(如果有的话优先按季号排)
const seasonA = getPreviewSeasonNumber(a)
const seasonB = getPreviewSeasonNumber(b)
if (seasonA !== seasonB) {
if (seasonA === undefined) return 1
if (seasonB === undefined) return -1
return seasonA - seasonB
}
// 2. 获取集数
const epA = toPreviewNumber(a.episode)
const epB = toPreviewNumber(b.episode)
// 如果都有集数,按集数排序
if (epA !== undefined && epB !== undefined) {
if (epA !== epB) return epA - epB
// 集数相同(可能是同集的视频、字幕等),退化到按文件名排序,保证相关文件挨在一起
}
// 3. 有集数的排前面,没集数的(通常是其他文件)排后面
if (epA !== undefined && epB === undefined) return -1
if (epA === undefined && epB !== undefined) return 1
// 4. 如果都没集数,或者集数完全相同,则按照目标路径(或源路径)的字母顺序排
const nameA = a.target || a.source || ''
const nameB = b.target || b.source || ''
return nameA.localeCompare(nameB, undefined, { numeric: true })
})
})
// 分页后的预览数据(含文件名解析)
@@ -1110,7 +1133,6 @@ async function previewTransfer() {
previewData.value = mergedPreviewData
previewLoaded.value = true
nextTick(() => updatePreviewPageSize())
if (previewHasFailures(mergedPreviewData)) {
$toast.warning(getPreviewResultSummaryMessage(mergedPreviewData))
@@ -1137,45 +1159,6 @@ async function togglePreview() {
await previewTransfer()
}
// 根据可用高度自动计算每页条数,保持统一行高
function updatePreviewPageSize() {
const bodyHeight = previewFileBodyRef.value?.clientHeight ?? 0
if (bodyHeight <= 0) return
const firstRow = previewFileBodyRef.value?.querySelector('.preview-file-row')
const rowHeight = firstRow?.getBoundingClientRect().height ?? 46
const pageSize = Math.max(1, Math.floor(bodyHeight / rowHeight))
previewPageSize.value = pageSize
const totalPages = Math.max(1, Math.ceil(filteredPreviewItems.value.length / pageSize))
if (previewPage.value > totalPages) {
previewPage.value = totalPages
}
}
// 启动预览列表高度监听
function setupPreviewFileBodyObserver() {
previewFileBodyResizeObserver?.disconnect()
if (!previewFileBodyRef.value || typeof ResizeObserver === 'undefined') return
previewFileBodyResizeObserver = new ResizeObserver(() => {
updatePreviewPageSize()
})
previewFileBodyResizeObserver.observe(previewFileBodyRef.value)
}
watch([() => previewLoaded.value, () => previewVisible.value], ([loaded, visible]) => {
if (loaded && visible) {
nextTick(() => {
setupPreviewFileBodyObserver()
updatePreviewPageSize()
})
} else {
previewFileBodyResizeObserver?.disconnect()
}
})
// 整理文件
async function handleTransfer(item: FileItem, background: boolean = false) {
try {
@@ -1303,7 +1286,6 @@ onMounted(async () => {
onUnmounted(() => {
stopLoadingProgress()
if (episodeGroupQueryTimer) clearTimeout(episodeGroupQueryTimer)
previewFileBodyResizeObserver?.disconnect()
})
</script>
@@ -1671,7 +1653,7 @@ onUnmounted(() => {
</div>
</div>
<div class="reorganize-preview-list">
<div v-if="pagedPreviewRows.length" ref="previewFileBodyRef" class="preview-file-body">
<div v-if="pagedPreviewRows.length" class="preview-file-body">
<div
v-for="(item, index) in pagedPreviewRows"
:key="`${item.source}-${item.target}-${index}`"
@@ -1894,6 +1876,8 @@ onUnmounted(() => {
.preview-overview-card {
display: flex;
flex-direction: column;
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 0.5rem;
gap: 0.375rem;
min-inline-size: 0;
padding-block: 0.875rem;
@@ -1919,6 +1903,8 @@ onUnmounted(() => {
.preview-custom-words {
display: flex;
flex-direction: column;
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 0.5rem;
gap: 0.75rem;
padding-block: 0.875rem;
padding-inline: 1rem;
@@ -1970,8 +1956,12 @@ onUnmounted(() => {
}
.preview-custom-words__chip {
block-size: auto !important;
max-inline-size: 100%;
min-block-size: 1.5rem;
padding-block: 0.25rem;
white-space: normal;
word-break: break-all;
}
.reorganize-preview-pane__scroll {
@@ -2011,9 +2001,9 @@ onUnmounted(() => {
flex: 0 0 auto;
flex-direction: column;
margin-block-end: 1.5rem;
margin-inline: 1.5rem;
min-block-size: 0;
min-inline-size: 0;
padding-inline: 1.5rem;
}
.preview-file-body {
@@ -2024,13 +2014,13 @@ onUnmounted(() => {
gap: 0.75rem;
min-block-size: 0;
min-inline-size: 0;
padding-block: 1rem;
padding-inline: 1rem;
}
.preview-file-row {
display: grid;
align-items: center;
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 0.5rem;
gap: 0.875rem;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
min-block-size: 5.25rem;
@@ -2039,10 +2029,6 @@ onUnmounted(() => {
padding-inline: 1rem;
}
.preview-file-row + .preview-file-row {
border-block-start: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
.preview-file-row--failed {
background: rgba(var(--v-theme-error), 0.04);
}
@@ -2168,7 +2154,7 @@ onUnmounted(() => {
.reorganize-preview-list {
margin-block-end: 1rem;
margin-inline: 1rem;
padding-inline: 1rem;
}
}

View File

@@ -1,139 +0,0 @@
<script setup lang="ts">
import api from '@/api'
import { clearUnreadMessages } from '@/utils/badge'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
const MessageView = defineAsyncComponent(() => import('@/views/system/MessageView.vue'))
type MessageViewExpose = {
pauseSSE?: () => void
resumeSSE?: () => void
refreshLatestMessages?: () => Promise<void> | void
forceScrollToEnd?: () => void
}
// 国际化
const { t } = useI18n()
// 显示器宽度
const display = useDisplay()
// 输入参数
const props = defineProps({
modelValue: {
type: Boolean,
default: true,
},
})
// 定义触发的自定义事件
const emit = defineEmits(['update:modelValue', 'close'])
// 弹窗显示状态
const visible = computed({
get: () => props.modelValue,
set: value => {
emit('update:modelValue', value)
if (!value) emit('close')
},
})
// 输入消息
const user_message = ref('')
// 发送按钮是否可用
const sendButtonDisabled = ref(false)
// 消息视图引用
const messageViewRef = ref<MessageViewExpose | null>(null)
/** 发送 Web 消息。 */
async function sendMessage() {
const messageText = user_message.value.trim()
if (!messageText) {
return
}
try {
sendButtonDisabled.value = true
await api.post(`message/web?text=${encodeURIComponent(messageText)}`)
user_message.value = ''
messageViewRef.value?.forceScrollToEnd?.()
} catch (error) {
console.error(error)
} finally {
sendButtonDisabled.value = false
}
}
/** 清除未读消息计数和桌面角标。 */
function clearUnreadMessageState() {
window.setTimeout(() => {
void clearUnreadMessages()
}, 500)
}
watch(visible, async newValue => {
if (newValue) {
await nextTick()
messageViewRef.value?.resumeSSE?.()
clearUnreadMessageState()
return
}
messageViewRef.value?.pauseSSE?.()
})
onMounted(async () => {
await nextTick()
clearUnreadMessageState()
})
onUnmounted(() => {
messageViewRef.value?.pauseSSE?.()
})
</script>
<template>
<VDialog v-if="visible" v-model="visible" max-width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-message" class="me-2" />
{{ t('shortcut.message.subtitle') }}
</VCardTitle>
<VDialogCloseBtn v-model="visible" />
</VCardItem>
<VDivider />
<VCardText>
<MessageView ref="messageViewRef" />
</VCardText>
<VDivider />
<VCardActions class="pa-4">
<div class="d-flex w-100 gap-2">
<VTextField
v-model="user_message"
variant="outlined"
hide-details
density="compact"
:placeholder="t('common.inputMessage')"
@keyup.enter="sendMessage"
/>
<VBtn
variant="elevated"
:disabled="sendButtonDisabled"
@click="sendMessage"
:loading="sendButtonDisabled"
color="primary"
prepend-icon="mdi-send"
>{{ t('common.send') }}
</VBtn>
</div>
</VCardActions>
</VCard>
</VDialog>
</template>

View File

@@ -39,10 +39,21 @@ const visible = computed({
if (!value) emit('close')
},
})
const isFullscreen = computed(() => !display.mdAndUp.value)
// 仅系统健康检查弹窗需要在全屏时取消固定高度,避免其它快捷弹窗被误伤。
const bodyClasses = computed(() => [
props.bodyClass,
{
'system-health-dialog-body--fullscreen':
isFullscreen.value && props.bodyClass.split(/\s+/).includes('system-health-dialog-body'),
},
])
</script>
<template>
<VDialog v-if="visible" v-model="visible" :max-width="props.maxWidth" scrollable :fullscreen="!display.mdAndUp.value">
<VDialog v-if="visible" v-model="visible" :max-width="props.maxWidth" scrollable :fullscreen="isFullscreen">
<VCard :class="props.cardClass">
<VCardItem>
<VCardTitle>
@@ -53,7 +64,7 @@ const visible = computed({
<VDialogCloseBtn v-model="visible" />
</VCardItem>
<VDivider />
<VCardText :class="props.bodyClass">
<VCardText :class="bodyClasses">
<Component :is="props.view" v-bind="props.viewProps" />
</VCardText>
</VCard>
@@ -61,8 +72,6 @@ const visible = computed({
</template>
<style scoped>
/* stylelint-disable selector-pseudo-class-no-unknown */
.system-health-dialog-card {
display: flex;
overflow: hidden;
@@ -78,7 +87,7 @@ const visible = computed({
min-block-size: 0;
}
:global(.v-dialog--fullscreen) .system-health-dialog-body {
.system-health-dialog-body--fullscreen {
block-size: auto;
}
</style>

View File

@@ -475,26 +475,26 @@ onMounted(() => {
:items="mobileResourceList"
:columns="1"
:gap="12"
:estimated-item-height="320"
:estimated-item-height="220"
:overscan-rows="5"
:get-item-key="getResourceItemKey"
>
<template #default="{ item }">
<VCard>
<VCardText class="pa-4">
<VCard class="site-resource-card" variant="flat">
<VCardText class="pa-3">
<button type="button" class="site-resource-title-btn text-start" @click="addDownload(item)">
<div class="text-body-1 font-weight-medium text-high-emphasis">
<div class="site-resource-card__title text-body-1 font-weight-medium text-high-emphasis">
{{ item.title }}
</div>
<div
v-if="item.description"
class="site-resource-card__description mt-2 text-body-2 text-medium-emphasis"
class="site-resource-card__description mt-1 text-body-2 text-medium-emphasis"
>
{{ item.description }}
</div>
</button>
<div class="mt-3">
<div class="site-resource-card__chips mt-2">
<VChip
v-if="item.hit_and_run"
variant="elevated"
@@ -533,47 +533,82 @@ onMounted(() => {
</VChip>
</div>
<div class="site-resource-card__meta mt-4">
<div class="site-resource-card__meta-item">
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.timeColumn') }}</div>
<div class="text-body-2 font-weight-medium">{{ item.date_elapsed || item.pubdate || '-' }}</div>
<div v-if="item.pubdate" class="text-caption text-medium-emphasis mt-1">{{ item.pubdate }}</div>
<!-- 移动端在操作区前展示关键资源指标方便点击前快速判断 -->
<div class="site-resource-card__summary mt-3">
<div class="site-resource-card__stat">
<VIcon icon="mdi-clock-outline" size="15" />
<span>{{ item.date_elapsed || item.pubdate || '-' }}</span>
</div>
<div class="site-resource-card__meta-item">
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.sizeColumn') }}</div>
<div class="text-body-2 font-weight-medium">{{ formatFileSize(item.size) }}</div>
<div class="site-resource-card__stat">
<VIcon icon="mdi-harddisk" size="15" />
<span>{{ formatFileSize(item.size) }}</span>
</div>
<div class="site-resource-card__meta-item">
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.seedersColumn') }}</div>
<div class="text-body-2 font-weight-medium">{{ item.seeders }}</div>
<div class="site-resource-card__stat site-resource-card__stat--success">
<VIcon icon="mdi-arrow-up" size="15" />
<span>{{ item.seeders ?? '-' }}</span>
</div>
<div class="site-resource-card__meta-item">
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.peersColumn') }}</div>
<div class="text-body-2 font-weight-medium">{{ item.peers }}</div>
<div class="site-resource-card__stat site-resource-card__stat--warning">
<VIcon icon="mdi-arrow-down" size="15" />
<span>{{ item.peers ?? '-' }}</span>
</div>
</div>
<div class="site-resource-card__actions mt-4">
<VBtn color="primary" variant="flat" block prepend-icon="mdi-download" @click="addDownload(item)">
<!-- 下载保留文本其它低频操作改为图标按钮并保持同一行 -->
<div class="site-resource-card__actions mt-2">
<VBtn
color="primary"
variant="flat"
class="site-resource-card__download-btn"
prepend-icon="mdi-download"
@click="addDownload(item)"
>
{{ t('actionStep.addDownload') }}
</VBtn>
<div class="site-resource-card__secondary-actions mt-2">
<VBtn
variant="tonal"
prepend-icon="mdi-open-in-new"
@click="openTorrentDetail(item.page_url || '')"
>
{{ t('common.viewDetails') }}
</VBtn>
<VBtn
v-if="item.enclosure?.startsWith('http')"
variant="tonal"
prepend-icon="mdi-tray-arrow-down"
@click="downloadTorrentFile(item.enclosure)"
>
{{ t('dialog.siteResource.downloadTorrent') }}
</VBtn>
</div>
<VTooltip :text="t('common.viewDetails')" location="top">
<template #activator="{ props: tooltipProps }">
<VBtn
v-bind="tooltipProps"
icon
variant="tonal"
color="primary"
class="site-resource-card__icon-btn"
:aria-label="t('common.viewDetails')"
@click="openTorrentDetail(item.page_url || '')"
>
<VIcon icon="mdi-open-in-new" />
</VBtn>
</template>
</VTooltip>
<VTooltip
v-if="item.enclosure?.startsWith('http')"
:text="t('dialog.siteResource.downloadTorrent')"
location="top"
>
<template #activator="{ props: tooltipProps }">
<VBtn
v-bind="tooltipProps"
icon
variant="tonal"
color="primary"
class="site-resource-card__icon-btn"
:aria-label="t('dialog.siteResource.downloadTorrent')"
@click="downloadTorrentFile(item.enclosure)"
>
<VIcon icon="mdi-file-download-outline" />
</VBtn>
</template>
</VTooltip>
<VBtn
v-else
icon
variant="tonal"
color="primary"
disabled
class="site-resource-card__icon-btn"
:aria-label="t('dialog.siteResource.downloadTorrent')"
>
<VIcon icon="mdi-file-download-outline" />
</VBtn>
</div>
</VCardText>
</VCard>
@@ -702,44 +737,107 @@ onMounted(() => {
white-space: nowrap;
}
.site-resource-card {
--site-resource-card-bg:
linear-gradient(180deg, rgba(var(--v-theme-surface), 0.98), rgba(var(--v-theme-surface), 0.94)),
radial-gradient(circle at top right, rgba(var(--v-theme-primary), 0.08), transparent 34%);
border: 1px solid rgba(var(--v-border-color), calc(var(--v-border-opacity) * 0.9));
background: var(--site-resource-card-bg);
}
:global(html[data-theme="transparent"]) .site-resource-card {
--site-resource-card-bg: rgba(var(--v-theme-surface), var(--transparent-opacity));
backdrop-filter: blur(var(--transparent-blur));
}
.site-resource-card__summary {
display: grid;
gap: 0.35rem;
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) minmax(2.5rem, 0.62fr) minmax(2.5rem, 0.62fr);
align-items: center;
}
.site-resource-card__stat {
display: inline-flex;
overflow: hidden;
align-items: center;
justify-content: center;
gap: 0.22rem;
border-radius: 6px;
background: rgba(var(--v-theme-on-surface), 0.05);
color: rgba(var(--v-theme-on-surface), 0.72);
font-size: 0.74rem;
font-weight: 600;
line-height: 1;
min-block-size: 1.65rem;
min-inline-size: 0;
padding-inline: 0.4rem;
}
.site-resource-card__stat span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.site-resource-card__stat--success {
color: rgb(var(--v-theme-success));
}
.site-resource-card__stat--warning {
color: rgb(var(--v-theme-warning));
}
.site-resource-card__title {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-height: 1.38;
}
.site-resource-card__description {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
-webkit-line-clamp: 2;
line-height: 1.35;
}
.site-resource-card__meta {
.site-resource-card__chips {
max-block-size: 4.75rem;
overflow: hidden;
}
.site-resource-card__actions {
display: grid;
gap: 0.55rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.45rem;
grid-template-columns: minmax(0, 1fr) 2.5rem 2.5rem;
align-items: center;
}
.site-resource-card__meta-item {
background: rgba(var(--v-theme-surface), 0.78);
min-block-size: 0;
padding-block: 0.55rem;
padding-inline: 0.65rem;
.site-resource-card__download-btn {
min-block-size: 2.5rem;
min-inline-size: 0;
box-shadow: 0 6px 16px rgba(var(--v-theme-primary), 0.17);
}
.site-resource-card__meta-item :deep(.text-caption) {
font-size: 0.72rem !important;
line-height: 1.2;
.site-resource-card__download-btn :deep(.v-btn__content) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.site-resource-card__meta-item :deep(.text-body-2) {
font-size: 0.82rem !important;
line-height: 1.25;
.site-resource-card__icon-btn {
block-size: 2.5rem;
inline-size: 2.5rem;
min-inline-size: 2.5rem;
}
.site-resource-card__secondary-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.site-resource-card__secondary-actions :deep(.v-btn) {
flex: 1 1 12rem;
.site-resource-card__icon-btn :deep(.v-btn__content) {
font-size: 1.05rem;
}
@media (width >= 960px) {
@@ -761,4 +859,14 @@ onMounted(() => {
min-block-size: 2.5rem;
}
}
@media (width <= 420px) {
.site-resource-card__summary {
grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.95fr) minmax(2.3rem, 0.55fr) minmax(2.3rem, 0.55fr);
}
.site-resource-card__stat {
padding-inline: 0.3rem;
}
}
</style>

View File

@@ -2,7 +2,7 @@
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'

View File

@@ -141,4 +141,29 @@ function updateFilter(key: string, values: string[]) {
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
}
.filter-options {
display: flex;
flex-wrap: wrap;
}
.filter-chip {
border: 1px solid rgba(var(--v-theme-primary), 0.2);
margin: 4px;
background-color: rgba(var(--v-theme-primary), 0.1) !important;
color: rgba(var(--v-theme-on-surface), 0.9) !important;
font-weight: 500;
transition: all 0.2s ease;
}
.filter-chip:hover {
background-color: rgba(var(--v-theme-primary), 0.15) !important;
}
.filter-chip.v-chip--selected {
background-color: rgba(var(--v-theme-primary), 0.85) !important;
box-shadow: 0 2px 4px rgba(var(--v-theme-primary), 0.3);
color: rgb(var(--v-theme-on-primary)) !important;
font-weight: 600;
}
</style>

View File

@@ -142,4 +142,24 @@ function handleDetail(item: Context) {
max-block-size: 60vh;
overflow-y: auto;
}
.chip-season {
background-color: #3f51b5;
color: white;
}
.chip-free {
background-color: #4caf50;
color: white;
}
.chip-discount {
background-color: #ff5722;
color: white;
}
.chip-bonus {
background-color: #9c27b0;
color: white;
}
</style>

View File

@@ -85,7 +85,7 @@ function updateFilter(values: string[]) {
@update:model-value="updateFilter"
>
<VChip
v-for="option in options"
v-for="option in options"
:key="option"
:value="option"
filter
@@ -106,3 +106,30 @@ function updateFilter(values: string[]) {
</VCard>
</VDialog>
</template>
<style scoped>
.filter-options {
display: flex;
flex-wrap: wrap;
}
.filter-chip {
border: 1px solid rgba(var(--v-theme-primary), 0.2);
margin: 4px;
background-color: rgba(var(--v-theme-primary), 0.1) !important;
color: rgba(var(--v-theme-on-surface), 0.9) !important;
font-weight: 500;
transition: all 0.2s ease;
}
.filter-chip:hover {
background-color: rgba(var(--v-theme-primary), 0.15) !important;
}
.filter-chip.v-chip--selected {
background-color: rgba(var(--v-theme-primary), 0.85) !important;
box-shadow: 0 2px 4px rgba(var(--v-theme-primary), 0.3);
color: rgb(var(--v-theme-on-primary)) !important;
font-weight: 600;
}
</style>

View File

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

View File

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

View File

@@ -372,7 +372,7 @@ onMounted(() => {
:key="key"
variant="tonal"
size="small"
:color="filterForm[key].length > 0 ? 'primary' : undefined"
color="primary"
:prepend-icon="getFilterIcon(key)"
class="filter-btn"
rounded="pill"
@@ -555,7 +555,7 @@ onMounted(() => {
v-for="(title, key) in filterTitles"
v-show="filterOptions[key].length > 0"
:key="key"
variant="text"
variant="tonal"
color="primary"
class="filter-btn-mobile"
@click="toggleFilterMenu(key)"
@@ -575,7 +575,7 @@ onMounted(() => {
</VBtn>
<!-- 全部筛选按钮 -->
<VBtn variant="text" color="primary" class="filter-btn-mobile" @click="toggleAllFilterMenu">
<VBtn variant="tonal" color="primary" class="filter-btn-mobile" @click="toggleAllFilterMenu">
<VIcon icon="mdi-filter-variant" class="filter-icon me-1"></VIcon>
<span class="filter-label">
{{ t('torrent.allFilters') }}
@@ -665,7 +665,6 @@ onMounted(() => {
.filter-btn {
min-inline-size: 0;
background: rgba(var(--v-theme-surface-variant), 0.1);
transition: opacity 0.2s;
}
@@ -733,7 +732,6 @@ onMounted(() => {
justify-content: center;
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
border-radius: 8px;
background-color: rgba(var(--v-theme-surface-variant), 0.08);
block-size: auto;
min-block-size: 48px;
padding-block: 4px;

View File

@@ -3,9 +3,9 @@ import type { PropType } from 'vue'
import MarkdownIt from 'markdown-it'
import mdLinkAttributes from 'markdown-it-link-attributes'
// 初始化 markdown-it
// 版本历史可能来自插件市场或 Release 内容,禁止透传原始 HTML避免外部内容注入脚本或事件属性。
const md = new MarkdownIt({
html: true,
html: false,
linkify: true,
typographer: true,
})
@@ -27,23 +27,100 @@ function renderMarkdown(value: string) {
// 输入参数
const props = defineProps({
history: Object as PropType<{ [key: string]: string }>,
hasAction: Function as PropType<(version: string) => boolean>,
})
function shouldRenderAction(version: string) {
return props.hasAction?.(version) ?? true
}
</script>
<template>
<VCardText>
<VList>
<VListItem v-for="(value, key) in props.history" :key="key">
<VListItemTitle class="font-bold text-lg">
{{ key }}
</VListItemTitle>
<div class="markdown-body text-gray-500" v-html="renderMarkdown(value)" />
</VListItem>
</VList>
<VCardText class="version-history">
<div class="version-history__list">
<section v-for="(value, key) in props.history" :key="key" class="version-history__item">
<div
class="version-history__top"
:class="{ 'version-history__top--with-action': $slots.action && shouldRenderAction(String(key)) }"
>
<div class="version-history__header">
<div class="version-history__version">
{{ key }}
</div>
<div v-if="$slots.meta" class="version-history__meta">
<slot name="meta" :version="String(key)" />
</div>
</div>
<div v-if="$slots.action && shouldRenderAction(String(key))" class="version-history__action">
<slot name="action" :version="String(key)" />
</div>
</div>
<div class="markdown-body text-medium-emphasis" v-html="renderMarkdown(value)" />
</section>
</div>
</VCardText>
</template>
<style scoped>
.version-history {
padding: 0;
}
.version-history__list {
display: flex;
flex-direction: column;
}
.version-history__item {
padding: 1.25rem 2rem;
}
.version-history__item + .version-history__item {
border-block-start: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
.version-history__top {
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-areas: "main";
gap: 0;
align-items: center;
margin-block-end: 0.5rem;
}
.version-history__top--with-action {
grid-template-columns: minmax(0, 1fr) max-content;
grid-template-areas: "main action";
gap: 1rem;
}
.version-history__header {
grid-area: main;
display: flex;
gap: 0.75rem;
align-items: center;
justify-content: flex-start;
min-width: 0;
}
.version-history__version {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
font-size: 1.25rem;
font-weight: 700;
line-height: 1.25;
}
.version-history__meta {
display: flex;
min-width: 0;
}
.version-history__action {
grid-area: action;
align-self: center;
justify-self: end;
}
.markdown-body :deep(h1),
.markdown-body :deep(h2),
.markdown-body :deep(h3) {
@@ -112,4 +189,28 @@ const props = defineProps({
border-inline-start: 3px solid rgba(127, 127, 127, 0.4);
color: rgba(127, 127, 127, 0.8);
}
@media (max-width: 600px) {
.version-history {
padding: 0;
}
.version-history__item {
padding: 1rem;
}
.version-history__top--with-action {
gap: 0.75rem;
}
.version-history__header {
flex-wrap: wrap;
justify-content: flex-start;
}
.version-history__version {
font-size: 1.125rem;
}
}
</style>

View File

@@ -0,0 +1,894 @@
<script setup lang="ts">
import {
themeCustomizerPrimaryColors,
themeCustomizerShadowLevels,
useThemeCustomizer,
type ThemeCustomizerLayout,
type ThemeCustomizerRadius,
type ThemeCustomizerShadow,
type ThemeCustomizerSkin,
type ThemeCustomizerTheme,
} from '@/composables/useThemeCustomizer'
import { usePWA } from '@/composables/usePWA'
import { useI18n } from 'vue-i18n'
import { useTheme } from 'vuetify'
const emit = defineEmits<{
'close': []
}>()
const customColorInput = ref<HTMLInputElement | null>(null)
const {
isCustomized,
resetSettings,
setLayout,
setPrimaryColor,
setRadius,
setSemiDarkMenu,
setShadow,
setSkin,
setTheme,
settings,
} = useThemeCustomizer()
const { appMode } = usePWA()
const { t } = useI18n()
const { global: globalTheme } = useTheme()
const defaultPrimaryColor = themeCustomizerPrimaryColors[0].value
// 将主题定制器打开状态同步到根节点,供全局悬浮按钮避让右侧面板。
function syncThemeCustomizerOpenState(isOpen: boolean) {
if (typeof document === 'undefined') return
if (isOpen) {
document.documentElement.setAttribute('data-theme-customizer-open', 'true')
document.body.setAttribute('data-theme-customizer-open', 'true')
return
}
document.documentElement.removeAttribute('data-theme-customizer-open')
document.body.removeAttribute('data-theme-customizer-open')
}
// 组件卸载时清理根节点状态,避免路由切换后悬浮按钮继续保持让位。
function clearThemeCustomizerOpenState() {
syncThemeCustomizerOpenState(false)
}
function handleGlobalKeydown(event: KeyboardEvent) {
// 固定侧栏不再依赖 Vuetify overlay手动补上常见的 Esc 关闭行为。
if (event.key === 'Escape') emit('close')
}
onMounted(() => {
// 面板一挂载就代表已打开,及时同步根节点状态让全局 FAB 预留右侧空间。
syncThemeCustomizerOpenState(true)
window.addEventListener('keydown', handleGlobalKeydown)
})
onScopeDispose(clearThemeCustomizerOpenState)
onScopeDispose(() => {
if (typeof window === 'undefined') return
window.removeEventListener('keydown', handleGlobalKeydown)
})
const themeOptions = computed<Array<{ icon: string; title: string; value: ThemeCustomizerTheme }>>(() => [
{ title: t('theme.light'), value: 'light', icon: 'mdi-white-balance-sunny' },
{ title: t('theme.dark'), value: 'dark', icon: 'mdi-weather-night' },
{ title: t('theme.auto'), value: 'auto', icon: 'mdi-monitor' },
{ title: t('theme.purple'), value: 'purple', icon: 'mdi-theme-light-dark' },
{ title: t('theme.transparent'), value: 'transparent', icon: 'mdi-blur' },
])
const skinOptions = computed<Array<{ title: string; value: ThemeCustomizerSkin }>>(() => [
{ title: t('theme.customizer.skinDefault'), value: 'default' },
{ title: t('theme.customizer.skinBordered'), value: 'bordered' },
])
// 当前阴影滑杆数值,界面使用 number主题设置继续存储 Vuetify elevation 字符串档位。
const shadowSliderValue = computed(() => Number(settings.value.shadow))
const radiusOptions = computed<
Array<{
title: string
value: ThemeCustomizerRadius
}>
>(() => [
{
title: t('theme.customizer.radiusNone'),
value: 'none',
},
{
title: t('theme.customizer.radiusSmall'),
value: 'small',
},
{
title: t('theme.customizer.radiusDefault'),
value: 'default',
},
{
title: t('theme.customizer.radiusLarge'),
value: 'large',
},
{
title: t('theme.customizer.radiusExtra'),
value: 'extra',
},
])
const layoutOptions = computed<Array<{ icon: string; title: string; value: ThemeCustomizerLayout }>>(() => [
{ title: t('theme.customizer.layoutVertical'), value: 'vertical', icon: 'mdi-dock-left' },
{ title: t('theme.customizer.layoutCollapsed'), value: 'collapsed', icon: 'mdi-dock-window' },
{ title: t('theme.customizer.layoutHorizontal'), value: 'horizontal', icon: 'mdi-dock-top' },
])
const showLayoutSection = computed(() => !appMode.value)
const hasAppModeCustomization = computed(() => {
return (
settings.value.primaryColor !== defaultPrimaryColor ||
settings.value.radius !== 'default' ||
settings.value.shadow !== '0' ||
settings.value.skin !== 'default' ||
settings.value.theme !== 'auto'
)
})
const showResetBadge = computed(() => (appMode.value ? hasAppModeCustomization.value : isCustomized.value))
const showSemiDarkMenuOption = computed(() => {
return (
!appMode.value &&
!globalTheme.current.value.dark &&
(settings.value.layout === 'vertical' || settings.value.layout === 'collapsed')
)
})
function openColorPicker() {
customColorInput.value?.click()
}
function handleCustomColorInput(event: Event) {
const color = (event.target as HTMLInputElement).value
setPrimaryColor(color)
}
function handleLayoutChange(layout: ThemeCustomizerLayout) {
// App 模式固定使用移动端导航,避免切换桌面布局后破坏底部导航体验。
if (appMode.value) return
setLayout(layout)
}
// 将 Vuetify 滑杆的数字步进写回字符串型 elevation 档位。
function handleShadowSliderChange(value: unknown) {
const rawValue = Array.isArray(value) ? value[0] : value
const numericValue = Number(rawValue)
if (!Number.isFinite(numericValue)) return
const clampedValue = Math.min(24, Math.max(0, Math.round(numericValue)))
const shadow = String(clampedValue) as ThemeCustomizerShadow
if (themeCustomizerShadowLevels.includes(shadow)) setShadow(shadow)
}
async function handleResetSettings() {
if (!appMode.value) {
await resetSettings()
return
}
// App 模式共享定制器,但保留桌面导航相关偏好,只重置 App 侧可调整的外观设置。
await setPrimaryColor(defaultPrimaryColor)
await setRadius('default')
await setShadow('0')
await setSkin('default')
await setTheme('auto')
}
</script>
<template>
<aside
class="theme-customizer-panel-host"
role="dialog"
:aria-label="t('theme.customizer.title')"
>
<div class="theme-customizer-panel" :class="{ 'theme-customizer-panel--dialog': appMode, 'app-surface': appMode }">
<div class="theme-customizer-header py-5 px-4">
<div>
<h2 class="theme-customizer-title">{{ t('theme.customizer.title') }}</h2>
</div>
<div class="theme-customizer-header-actions">
<VBadge color="error" dot :model-value="showResetBadge" location="top end" offset-x="2" offset-y="2">
<IconBtn :aria-label="t('theme.customizer.reset')" @click="handleResetSettings">
<VIcon class="text-high-emphasis" icon="mdi-refresh" />
</IconBtn>
</VBadge>
<IconBtn :aria-label="t('common.close')" @click="emit('close')">
<VIcon class="text-high-emphasis" icon="mdi-close" />
</IconBtn>
</div>
</div>
<VDivider />
<PerfectScrollbar class="theme-customizer-body" :options="{ wheelPropagation: false }">
<section class="theme-customizer-section">
<h3 class="theme-customizer-section-title">{{ t('theme.customizer.primaryColor') }}</h3>
<div class="theme-customizer-color-grid">
<div
v-for="color in themeCustomizerPrimaryColors"
:key="color.value"
class="theme-customizer-color-option"
:class="{ 'is-active': settings.primaryColor === color.value }"
:aria-label="t('theme.customizer.usePrimaryColor', { color: color.name })"
@click="setPrimaryColor(color.value)"
>
<span class="theme-customizer-color-swatch" :style="{ backgroundColor: color.value }" />
</div>
<div
v-if="!appMode"
class="theme-customizer-color-option theme-customizer-color-option--picker"
:class="{
'is-active': !themeCustomizerPrimaryColors.some(color => color.value === settings.primaryColor),
}"
:aria-label="t('theme.customizer.chooseCustomColor')"
@click="openColorPicker"
>
<VIcon class="theme-customizer-native-icon" icon="mdi-palette-outline" size="30" />
<input
ref="customColorInput"
class="theme-customizer-native-color"
type="color"
:value="settings.primaryColor"
@input="handleCustomColorInput"
/>
</div>
</div>
<h3 class="theme-customizer-section-title">{{ t('common.theme') }}</h3>
<div class="theme-customizer-option-grid theme-customizer-option-grid--theme">
<div
v-for="theme in themeOptions"
:key="theme.value"
class="theme-customizer-card-option"
:class="{ 'is-active': settings.theme === theme.value }"
@click="setTheme(theme.value)"
>
<VIcon class="theme-customizer-theme-icon" :icon="theme.icon" size="36" />
<span>{{ theme.title }}</span>
</div>
</div>
<VDivider class="mt-7" />
<h3 class="theme-customizer-section-title">{{ t('theme.customizer.skins') }}</h3>
<div class="theme-customizer-preview-grid theme-customizer-preview-grid--skins">
<div
v-for="skin in skinOptions"
:key="skin.value"
class="theme-customizer-preview-option"
:class="{ 'is-active': settings.skin === skin.value }"
@click="setSkin(skin.value)"
>
<span class="theme-customizer-mini-layout" :class="`theme-customizer-mini-layout--${skin.value}`">
<span class="mini-sidebar">
<i />
<i />
<i />
<i />
</span>
<span class="mini-content">
<i />
<i />
<i />
</span>
</span>
<span>{{ skin.title }}</span>
</div>
</div>
<VDivider class="mt-7" />
<h3 class="theme-customizer-section-title">{{ t('theme.customizer.radius') }}</h3>
<div class="theme-customizer-preview-grid theme-customizer-preview-grid--radius">
<div
v-for="radius in radiusOptions"
:key="radius.value"
class="theme-customizer-preview-option"
:class="{ 'is-active': settings.radius === radius.value }"
@click="setRadius(radius.value)"
>
<span
class="theme-customizer-radius-scene"
:class="`theme-customizer-radius-scene--${radius.value}`"
>
<span class="theme-customizer-radius-scene__card">
<span class="theme-customizer-radius-scene__badge" />
<span class="theme-customizer-radius-scene__line" />
<span class="theme-customizer-radius-scene__line theme-customizer-radius-scene__line--short" />
</span>
</span>
<span>{{ radius.title }}</span>
</div>
</div>
<VDivider class="mt-7" />
<h3 class="theme-customizer-section-title">{{ t('theme.customizer.shadow') }}</h3>
<div class="theme-customizer-shadow-slider">
<div class="theme-customizer-shadow-slider__header">
<span>{{ t('theme.customizer.shadowLevel', { level: settings.shadow }) }}</span>
<span>0 - 24</span>
</div>
<div class="theme-customizer-shadow-slider__control">
<span
class="theme-customizer-shadow-slider__sample"
:style="{ boxShadow: `var(--app-elevation-${settings.shadow})` }"
>
<span class="theme-customizer-shadow-slider__sample-accent" />
<span class="theme-customizer-shadow-slider__sample-line" />
<span class="theme-customizer-shadow-slider__sample-line theme-customizer-shadow-slider__sample-line--short" />
</span>
<VSlider
:model-value="shadowSliderValue"
:aria-label="t('theme.customizer.shadow')"
:max="24"
:min="0"
:step="1"
color="primary"
density="comfortable"
hide-details
show-ticks="always"
thumb-label
tick-size="2"
@update:model-value="handleShadowSliderChange"
/>
</div>
<div
class="theme-customizer-shadow-slider__scale"
aria-hidden="true"
>
<span>0</span>
<span>24</span>
</div>
</div>
<div v-if="showSemiDarkMenuOption" class="theme-customizer-semi-dark">
<span>{{ t('theme.customizer.semiDarkMenu') }}</span>
<VSwitch
:model-value="settings.semiDarkMenu"
color="primary"
inset
hide-details
@update:model-value="setSemiDarkMenu(Boolean($event))"
/>
</div>
</section>
<VDivider v-if="showLayoutSection" />
<section v-if="showLayoutSection" class="theme-customizer-section">
<h3 class="theme-customizer-section-title">{{ t('theme.customizer.layout') }}</h3>
<div class="theme-customizer-preview-grid">
<div
v-for="layout in layoutOptions"
:key="layout.value"
class="theme-customizer-preview-option"
:class="{ 'is-active': settings.layout === layout.value, 'is-disabled': appMode }"
@click="handleLayoutChange(layout.value)"
>
<span class="theme-customizer-mini-layout" :class="`theme-customizer-mini-layout--${layout.value}`">
<span class="mini-sidebar">
<i />
<i />
<i />
</span>
<span class="mini-content">
<i />
<i />
<i />
</span>
</span>
<span>{{ layout.title }}</span>
</div>
</div>
</section>
</PerfectScrollbar>
</div>
</aside>
</template>
<style lang="scss" scoped>
/* stylelint-disable no-descending-specificity */
.theme-customizer-panel-host {
position: fixed !important;
z-index: 2102 !important;
display: flex;
overflow: hidden;
border-radius: 0;
background: rgb(var(--v-theme-surface));
/* 背景层保持完整视口高度,避免 iOS 键盘触发 visual viewport resize 后露出底层页面。 */
block-size: 100vh !important;
border-inline-start: 1px solid rgba(var(--v-theme-on-surface), 0.08) !important;
box-shadow: var(--app-surface-shadow) !important;
inline-size: 420px !important;
inset-block-start: 0 !important;
inset-inline-end: 0 !important;
max-block-size: none !important;
min-block-size: 100vh !important;
}
@supports (block-size: 100lvh) {
.theme-customizer-panel-host {
block-size: 100lvh !important;
min-block-size: 100lvh !important;
}
}
.theme-customizer-panel {
position: relative;
display: flex;
flex-direction: column;
block-size: 100%;
inline-size: 100%;
min-block-size: 0;
}
.theme-customizer-panel--dialog {
overflow: hidden;
block-size: 100%;
max-block-size: 100%;
/* 独立 App 模式会贴近 viewport-fit=cover 顶部,面板内部需要避开 iOS 状态栏。 */
padding-block-start: env(safe-area-inset-top, 0px);
}
.theme-customizer-panel--dialog .theme-customizer-body {
block-size: auto;
padding-block-end: env(safe-area-inset-bottom, 0px);
}
@media (width <= 600px) {
.theme-customizer-panel-host {
inline-size: 100vw !important;
}
}
.theme-customizer-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.theme-customizer-title {
margin: 0;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
font-size: 1.45rem;
font-weight: 600;
line-height: 1.2;
}
.theme-customizer-header-actions {
display: flex;
align-items: center;
gap: 4px;
}
.theme-customizer-body {
flex: 1 1 auto;
min-block-size: 0;
-ms-overflow-style: none;
overscroll-behavior: contain;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
:deep(.ps__rail-x),
:deep(.ps__rail-y) {
display: none !important;
}
}
.theme-customizer-section {
padding-block-end: 28px;
padding-inline: 32px;
}
.theme-customizer-section-title {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
font-size: 1.1rem;
font-weight: 600;
line-height: 1.25;
margin-block: 28px 16px;
}
.theme-customizer-section-note {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.875rem;
line-height: 1.45;
margin-block: -6px 16px;
}
.theme-customizer-color-grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fill, 48px);
}
.theme-customizer-color-option {
position: relative;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
border-radius: 10px;
appearance: none;
block-size: 48px;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
cursor: pointer;
inline-size: 48px;
transition:
border-color 0.18s ease,
background-color 0.18s ease,
box-shadow 0.18s ease;
&.is-active {
border-width: 2px;
border-color: rgb(var(--v-theme-primary));
box-shadow: 0 0 0 3px rgba(var(--v-theme-primary), 0.12);
}
}
.theme-customizer-color-swatch {
display: block;
border-radius: 8px;
block-size: 30px;
inline-size: 30px;
}
.theme-customizer-color-option--picker {
background: rgba(var(--v-theme-on-surface), 0.04);
}
.theme-customizer-native-color {
position: absolute;
block-size: 1px;
inline-size: 1px;
inset-block: 50% auto;
inset-inline: 50% auto;
opacity: 0;
pointer-events: none;
}
.theme-customizer-option-grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.theme-customizer-option-grid--theme {
grid-template-columns: repeat(auto-fit, minmax(96px, 1fr));
}
.theme-customizer-card-option,
.theme-customizer-preview-option {
display: flex;
flex-direction: column;
align-items: center;
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
border-radius: 10px;
appearance: none;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
cursor: pointer;
font-size: 1rem;
gap: 10px;
transition:
border-color 0.18s ease,
background-color 0.18s ease,
color 0.18s ease,
box-shadow 0.18s ease;
&.is-active {
border-width: 2px;
border-color: rgb(var(--v-theme-primary));
background: rgba(var(--v-theme-primary), 0.08);
box-shadow: 0 0 0 3px rgba(var(--v-theme-primary), 0.12);
color: rgb(var(--v-theme-primary));
}
}
.theme-customizer-card-option {
justify-content: center;
padding: 16px;
min-block-size: 112px;
}
.theme-customizer-preview-grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.theme-customizer-preview-grid--skins {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.theme-customizer-preview-grid--radius {
grid-template-columns: repeat(auto-fit, minmax(92px, 1fr));
}
.theme-customizer-preview-option {
align-items: flex-start;
padding: 0;
border: 0;
background: transparent;
box-shadow: none !important;
&.is-active {
background: transparent;
box-shadow: none !important;
.theme-customizer-mini-layout,
.theme-customizer-radius-scene {
border-width: 2px;
border-color: rgb(var(--v-theme-primary));
background: rgba(var(--v-theme-primary), 0.04);
}
}
> span:last-child {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
padding-inline-start: 2px;
}
&.is-disabled {
cursor: not-allowed;
opacity: 0.52;
}
}
.theme-customizer-semi-dark {
display: flex;
align-items: center;
justify-content: space-between;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
font-size: 1.1rem;
font-weight: 600;
margin-block-start: 28px;
margin-inline: -32px;
padding-inline: 32px;
}
.theme-customizer-mini-layout {
display: grid;
overflow: hidden;
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
border-radius: 10px;
block-size: 74px;
grid-template-columns: 34% 1fr;
inline-size: 100%;
min-inline-size: 92px;
}
.theme-customizer-mini-layout--collapsed {
grid-template-columns: 18% 1fr;
}
.theme-customizer-mini-layout--horizontal {
grid-template-columns: 1fr;
grid-template-rows: 24% 1fr;
.mini-sidebar {
flex-direction: row;
align-items: center;
}
}
.mini-sidebar,
.mini-content {
display: flex;
flex-direction: column;
padding: 10px;
gap: 8px;
}
.mini-sidebar {
background: rgba(var(--v-theme-on-surface), 0.04);
}
.mini-sidebar i,
.mini-content i {
display: block;
border-radius: 4px;
background: rgba(var(--v-theme-on-surface), 0.12);
block-size: 6px;
}
.mini-content i {
background: rgba(var(--v-theme-on-surface), 0.06);
block-size: 18px;
}
.theme-customizer-mini-layout--bordered {
.mini-content i,
.mini-sidebar i {
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
background: transparent;
}
}
.theme-customizer-radius-scene {
position: relative;
display: block;
overflow: hidden;
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
border-radius: 10px;
background:
linear-gradient(180deg, rgba(var(--v-theme-on-surface), 0.02), rgba(var(--v-theme-on-surface), 0.05)),
rgb(var(--v-theme-surface));
block-size: 90px;
inline-size: 100%;
min-inline-size: 0;
--theme-customizer-preview-radius: var(--app-vuetify-rounded);
}
.theme-customizer-radius-scene--none {
--theme-customizer-preview-radius: var(--app-vuetify-rounded-0);
}
.theme-customizer-radius-scene--small {
--theme-customizer-preview-radius: var(--app-vuetify-rounded-sm);
}
.theme-customizer-radius-scene--large {
--theme-customizer-preview-radius: var(--app-vuetify-rounded-lg);
}
.theme-customizer-radius-scene--extra {
--theme-customizer-preview-radius: var(--app-vuetify-rounded-xl);
}
.theme-customizer-radius-scene__card {
position: absolute;
display: flex;
flex-direction: column;
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
border-radius: var(--theme-customizer-preview-radius);
background: rgb(var(--v-theme-surface));
gap: 8px;
inset: 16px;
padding-block: 12px;
padding-inline: 14px;
}
.theme-customizer-radius-scene__badge,
.theme-customizer-radius-scene__line {
display: block;
background: rgba(var(--v-theme-on-surface), 0.1);
}
.theme-customizer-radius-scene__badge {
border-radius: var(--theme-customizer-preview-radius);
block-size: 8px;
inline-size: 42%;
min-inline-size: 28px;
}
.theme-customizer-radius-scene__line {
border-radius: var(--theme-customizer-preview-radius);
block-size: 7px;
}
.theme-customizer-radius-scene__line--short {
inline-size: 66%;
}
.theme-customizer-shadow-slider {
padding: 16px 18px 12px;
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
border-radius: var(--app-vuetify-rounded-lg);
background: rgba(var(--v-theme-surface), 0.72);
}
.theme-customizer-shadow-slider__header,
.theme-customizer-shadow-slider__scale {
display: flex;
align-items: center;
justify-content: space-between;
}
.theme-customizer-shadow-slider__header {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.875rem;
line-height: 1.3;
margin-block-end: 14px;
> span:first-child {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
font-weight: 600;
}
}
.theme-customizer-shadow-slider__control {
display: grid;
align-items: center;
gap: 16px;
grid-template-columns: 56px minmax(0, 1fr);
}
.theme-customizer-shadow-slider__sample {
display: flex;
flex-direction: column;
justify-content: center;
border-radius: var(--app-vuetify-rounded);
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
background: rgb(var(--v-theme-surface));
block-size: 42px;
gap: 5px;
inline-size: 42px;
padding-block: 8px;
padding-inline: 9px;
}
.theme-customizer-shadow-slider__sample-accent,
.theme-customizer-shadow-slider__sample-line {
display: block;
border-radius: 999px;
}
.theme-customizer-shadow-slider__sample-accent {
background: rgba(var(--v-theme-primary), 0.48);
block-size: 5px;
inline-size: 44%;
}
.theme-customizer-shadow-slider__sample-line {
background: rgba(var(--v-theme-on-surface), 0.12);
block-size: 4px;
inline-size: 100%;
}
.theme-customizer-shadow-slider__sample-line--short {
inline-size: 68%;
}
.theme-customizer-shadow-slider__scale {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.75rem;
line-height: 1;
margin-block-start: 2px;
margin-inline-start: 72px;
}
.theme-customizer-shadow-slider :deep(.v-slider.v-input) {
margin: 0;
}
.theme-customizer-shadow-slider :deep(.v-slider-track__tick) {
opacity: 0.5;
}
@media (width <= 600px) {
.theme-customizer-header,
.theme-customizer-section {
padding-inline: 22px;
}
.theme-customizer-preview-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>

View File

@@ -7,6 +7,7 @@ import { themeManager } from '@/utils/themeManager'
export const THEME_CUSTOMIZER_STORAGE_KEY = 'moviepilot-theme-customizer'
export const THEME_CUSTOMIZER_CHANGE_EVENT = 'moviepilot-theme-customizer-change'
export const THEME_CUSTOMIZER_OPEN_EVENT = 'moviepilot-theme-customizer-open'
export const themeCustomizerPrimaryColors = [
{ name: 'Purple', value: '#9155FD' },
@@ -23,9 +24,37 @@ export const themeCustomizerPrimaryColors = [
{ name: 'Slate', value: '#607D8B' },
] as const
export const themeCustomizerShadowLevels = [
'0',
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
'10',
'11',
'12',
'13',
'14',
'15',
'16',
'17',
'18',
'19',
'20',
'21',
'22',
'23',
'24',
] as const
export type ThemeCustomizerLayout = 'collapsed' | 'horizontal' | 'vertical'
export type ThemeCustomizerRadius = 'default' | 'extra' | 'huge' | 'large' | 'small'
export type ThemeCustomizerShadow = 'none' | 'low' | 'medium' | 'high'
export type ThemeCustomizerRadius = 'default' | 'extra' | 'large' | 'none' | 'small'
export type ThemeCustomizerShadow = (typeof themeCustomizerShadowLevels)[number]
export type ThemeCustomizerSkin = 'bordered' | 'default'
export type ThemeCustomizerTheme = 'auto' | 'dark' | 'light' | 'purple' | 'transparent'
@@ -43,10 +72,16 @@ type VuetifyThemeApi = ReturnType<typeof useTheme>
const defaultPrimaryColor = themeCustomizerPrimaryColors[0].value
const validLayouts: ThemeCustomizerLayout[] = ['vertical', 'collapsed', 'horizontal']
const validRadii: ThemeCustomizerRadius[] = ['small', 'default', 'large', 'extra', 'huge']
const validShadows: ThemeCustomizerShadow[] = ['none', 'low', 'medium', 'high']
const validRadii: ThemeCustomizerRadius[] = ['none', 'small', 'default', 'large', 'extra']
const validShadows: readonly ThemeCustomizerShadow[] = themeCustomizerShadowLevels
const validSkins: ThemeCustomizerSkin[] = ['default', 'bordered']
const validThemes: ThemeCustomizerTheme[] = ['auto', 'light', 'dark', 'purple', 'transparent']
const legacyShadowMap: Record<string, ThemeCustomizerShadow> = {
high: '24',
low: '6',
medium: '12',
none: '0',
}
let themeApplyVersion = 0
@@ -72,27 +107,35 @@ function getDefaultThemeCustomizerSettings(): ThemeCustomizerSettings {
primaryColor: defaultPrimaryColor,
radius: 'default',
semiDarkMenu: false,
shadow: 'none',
shadow: '0',
skin: 'default',
theme: readStoredThemePreference(),
}
}
/** 将旧版语义阴影档位迁移到 Vuetify elevation 数值档位。 */
function normalizeThemeCustomizerShadow(shadow: unknown): ThemeCustomizerShadow {
if (validShadows.includes(shadow as ThemeCustomizerShadow)) return shadow as ThemeCustomizerShadow
if (typeof shadow === 'string' && legacyShadowMap[shadow]) return legacyShadowMap[shadow]
return getDefaultThemeCustomizerSettings().shadow
}
function normalizeThemeCustomizerSettings(settings: Partial<ThemeCustomizerSettings>): ThemeCustomizerSettings {
const fallback = getDefaultThemeCustomizerSettings()
const storedRadius = settings.radius as string | undefined
const radius = storedRadius === 'huge' ? 'extra' : storedRadius
return {
layout: validLayouts.includes(settings.layout as ThemeCustomizerLayout)
? (settings.layout as ThemeCustomizerLayout)
: fallback.layout,
primaryColor: isHexColor(settings.primaryColor) ? settings.primaryColor.toUpperCase() : fallback.primaryColor,
radius: validRadii.includes(settings.radius as ThemeCustomizerRadius)
? (settings.radius as ThemeCustomizerRadius)
radius: validRadii.includes(radius as ThemeCustomizerRadius)
? (radius as ThemeCustomizerRadius)
: fallback.radius,
semiDarkMenu: typeof settings.semiDarkMenu === 'boolean' ? settings.semiDarkMenu : fallback.semiDarkMenu,
shadow: validShadows.includes(settings.shadow as ThemeCustomizerShadow)
? (settings.shadow as ThemeCustomizerShadow)
: fallback.shadow,
shadow: normalizeThemeCustomizerShadow(settings.shadow),
skin: validSkins.includes(settings.skin as ThemeCustomizerSkin)
? (settings.skin as ThemeCustomizerSkin)
: fallback.skin,
@@ -246,7 +289,7 @@ export function isDefaultThemeCustomizerSettings(settings: ThemeCustomizerSettin
primaryColor: defaultPrimaryColor,
radius: 'default',
semiDarkMenu: false,
shadow: 'none',
shadow: '0',
skin: 'default',
theme: 'auto',
})
@@ -323,7 +366,7 @@ export function useThemeCustomizer() {
primaryColor: defaultPrimaryColor,
radius: 'default',
semiDarkMenu: false,
shadow: 'none',
shadow: '0',
skin: 'default',
theme: 'auto',
})

View File

@@ -1,129 +0,0 @@
<script setup lang="ts">
import { formatDateDifference } from '@core/utils/formatters'
import { SystemNotification } from '@/api/types'
import { useI18n } from 'vue-i18n'
import { useBackground } from '@/composables/useBackground'
const { t } = useI18n()
const { useDelayedSSE } = useBackground()
// 是否有新消息
const hasNewMessage = ref(false)
// 通知列表
const notificationList = ref<SystemNotification[]>([])
const MAX_NOTIFICATIONS = 100
// 弹窗
const appsMenu = ref(false)
// 标记所有消息为已读
function markAllAsRead() {
hasNewMessage.value = false
// 标记所有消息为已读
notificationList.value.forEach(item => {
item.read = true
})
appsMenu.value = false
}
// 消息处理函数
function handleMessage(event: MessageEvent) {
if (event.data) {
const noti: SystemNotification = JSON.parse(event.data)
notificationList.value.unshift(noti)
if (notificationList.value.length > MAX_NOTIFICATIONS) {
notificationList.value.length = MAX_NOTIFICATIONS
}
hasNewMessage.value = true
}
}
// 延迟3秒启动SSE连接避免认证信息尚未准备好。
useDelayedSSE(
`${import.meta.env.VITE_API_BASE_URL}system/message`,
handleMessage,
'user-notification',
3000,
{
backgroundCloseDelay: 5000,
reconnectDelay: 3000,
maxReconnectAttempts: 3
}
)
</script>
<template>
<VMenu
v-model="appsMenu"
width="400"
transition="scale-transition"
close-on-content-click
class="notification-menu"
scrim
>
<!-- Menu Activator -->
<template #activator="{ props }">
<VBadge v-if="hasNewMessage" dot color="error" :offset-x="5" :offset-y="5" v-bind="props">
<IconBtn>
<VIcon icon="mdi-bell-outline" />
</IconBtn>
</VBadge>
<IconBtn v-else v-bind="props">
<VIcon icon="mdi-bell-outline" />
</IconBtn>
</template>
<!-- Menu Content -->
<VCard>
<VCardItem class="py-3">
<VCardTitle>{{ t('notification.center') }}</VCardTitle>
<template #append>
<VTooltip :text="t('notification.markRead')">
<template #activator="{ props }">
<IconBtn v-bind="props" @click="markAllAsRead">
<VIcon icon="mdi-email-check-outline" size="20" />
</IconBtn>
</template>
</VTooltip>
</template>
</VCardItem>
<VDivider />
<div class="notification-list-container">
<div v-if="notificationList.length > 0">
<VListItem v-for="(item, i) in notificationList" :key="i" lines="two" class="mb-1">
<template #prepend>
<VAvatar rounded>
<VIcon v-if="item.type === 'user'" icon="mdi-account-alert" size="large"></VIcon>
<VIcon v-else-if="item.type === 'plugin'" icon="mdi-robot" size="large"></VIcon>
<VIcon v-else icon="mdi-laptop" size="large"></VIcon>
</VAvatar>
</template>
<div>
<div class="text-body-1 text-high-emphasis break-words whitespace-break-spaces">
{{ item.title }}
</div>
<div class="text-caption mt-1.5">
{{ item.text }}
</div>
<div class="text-sm text-primary mt-1.5">
{{ formatDateDifference(item.date) }}
</div>
</div>
</VListItem>
</div>
<div v-else class="py-8 text-center">
<VIcon icon="mdi-bell-sleep-outline" size="40" class="mb-3" />
<div>{{ t('notification.empty') }}</div>
</div>
</div>
</VCard>
</VMenu>
</template>
<style scoped>
.notification-list-container {
max-block-size: 50vh;
overflow-y: auto;
scrollbar-width: thin;
}
</style>

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

View File

@@ -2,14 +2,16 @@
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 { usePluginSidebarNavStore, useUserStore } from '@/stores'
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'
import { NavMenu } from '@/@layouts/types'
@@ -26,11 +28,12 @@ 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,
THEME_CUSTOMIZER_CHANGE_EVENT,
THEME_CUSTOMIZER_OPEN_EVENT,
type ThemeCustomizerSettings,
} from '@/composables/useThemeCustomizer'
import logo from '@images/logo.svg?raw'
@@ -42,14 +45,19 @@ const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const themeLayout = ref(readThemeCustomizerSettings().layout)
const showThemeCustomizer = ref(false)
// Store
const userStore = useUserStore()
const pluginSidebarNavStore = usePluginSidebarNavStore()
const globalSettingsStore = useGlobalSettingsStore()
//
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
const canAdmin = computed(() => hasPermission(userPermissions.value, 'admin'))
const showAgentAssistant = computed(
() => globalSettingsStore.get('AI_AGENT_ENABLE') === true && globalSettingsStore.get('AI_AGENT_HIDE_ENTRY') !== true,
)
//
const startMenus = ref<NavMenu[]>([])
@@ -279,6 +287,10 @@ function handleThemeCustomizerChange(event: Event) {
themeLayout.value = (event as CustomEvent<ThemeCustomizerSettings>).detail.layout
}
function handleThemeCustomizerOpen() {
showThemeCustomizer.value = true
}
function isHorizontalNavActive(item: NavMenu) {
const targetPath = normalizeMenuPath(item.to)
if (!targetPath) return false
@@ -416,6 +428,10 @@ function appendPluginSidebarMenus() {
}
onMounted(async () => {
//
window.addEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange)
window.addEventListener(THEME_CUSTOMIZER_OPEN_EVENT, handleThemeCustomizerOpen)
//
startMenus.value = getMenuList(t('menu.start'))
discoveryMenus.value = getMenuList(t('menu.discovery'))
@@ -431,11 +447,10 @@ onMounted(async () => {
navigator.serviceWorker.addEventListener('message', handleServiceWorkerMessage)
}
window.addEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange)
//
onBeforeUnmount(() => {
window.removeEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange)
window.removeEventListener(THEME_CUSTOMIZER_OPEN_EVENT, handleThemeCustomizerOpen)
if ('serviceWorker' in navigator) {
navigator.serviceWorker.removeEventListener('message', handleServiceWorkerMessage)
}
@@ -692,6 +707,12 @@ onMounted(async () => {
@close="handleClosePluginQuickAccess"
@plugin-click="handlePluginClick"
/>
<!-- 👉 Theme Customizer -->
<ThemeCustomizer v-if="showThemeCustomizer" @close="showThemeCustomizer = false" />
<!-- 👉 Agent Assistant -->
<AgentAssistantWidget v-if="showAgentAssistant" />
</template>
<style lang="scss" scoped>

View File

@@ -223,7 +223,7 @@ function handleDynamicMenuItemClick(item: DynamicButtonMenuItem) {
<VCardText class="footer-card-content">
<!-- 添加指示器 -->
<div ref="indicator" class="nav-indicator"></div>
<VBtnToggle class="footer-btn-group" :mandatory="true" v-model="currentMenu">
<VBtnToggle class="footer-btn-group" :mandatory="true" variant="plain" v-model="currentMenu">
<!-- 遍历底部菜单项 -->
<VBtn
v-for="menu in footerMenus"
@@ -343,6 +343,9 @@ function handleDynamicMenuItemClick(item: DynamicButtonMenuItem) {
transition: all 0.5s cubic-bezier(0.25, 1, 0.5, 1);
will-change: transform, max-inline-size, opacity;
--app-control-radius: var(--app-vuetify-rounded-pill);
--app-surface-radius: var(--app-vuetify-rounded-pill);
//
.v-theme--transparent & {
backdrop-filter: blur(var(--transparent-blur-heavy, 16px));
@@ -361,13 +364,19 @@ function handleDynamicMenuItemClick(item: DynamicButtonMenuItem) {
padding-inline: 6px;
}
.footer-btn-group {
.footer-nav-card .footer-btn-group.v-btn-group {
position: relative;
display: flex;
justify-content: space-around;
border: none;
border-radius: 9999px !important;
background-color: transparent;
box-shadow: none !important;
inline-size: 100%;
&:hover {
box-shadow: none !important;
}
}
.footer-nav-btn {
@@ -377,12 +386,15 @@ function handleDynamicMenuItemClick(item: DynamicButtonMenuItem) {
flex-grow: 1;
align-items: center;
justify-content: center;
border-radius: 9999px !important;
background-color: transparent;
block-size: 48px;
box-shadow: none !important;
&:hover,
&.v-btn--active {
background-color: transparent;
box-shadow: none;
box-shadow: none !important;
}
.btn-content {

View File

@@ -810,12 +810,6 @@ function handleBackdropClick(event: MouseEvent) {
-webkit-user-select: none;
}
:global(html.quick-access-scroll-locked),
:global(html.quick-access-scroll-locked body) {
overflow: hidden !important;
overscroll-behavior: none;
}
@media (hover: none) and (pointer: coarse) {
.plugin-item:hover {
background: transparent;

View File

@@ -5,7 +5,6 @@ import { openSharedDialog } from '@/composables/useSharedDialog'
import { useI18n } from 'vue-i18n'
import { useUserStore } from '@/stores'
import { buildUserPermissionContext, filterItemsByPermission, hasItemPermission, type PermissionProtectedItem } from '@/utils/permission'
import { clearUnreadMessages, getUnreadCount, onUnreadMessage } from '@/utils/badge'
//
const { t } = useI18n()
@@ -21,7 +20,6 @@ const WordsView = defineAsyncComponent(() => import('@/views/system/WordsView.vu
const CacheView = defineAsyncComponent(() => import('@/views/system/CacheView.vue'))
const AccountSettingService = defineAsyncComponent(() => import('@/views/system/ServiceView.vue'))
const ShortcutLogDialog = defineAsyncComponent(() => import('@/components/dialog/ShortcutLogDialog.vue'))
const ShortcutMessageDialog = defineAsyncComponent(() => import('@/components/dialog/ShortcutMessageDialog.vue'))
const ShortcutToolDialog = defineAsyncComponent(() => import('@/components/dialog/ShortcutToolDialog.vue'))
type ShortcutItem = PermissionProtectedItem & {
@@ -44,12 +42,6 @@ const appsMenu = ref(false)
//
const menuMaxWidth = ref(420)
//
const unreadMessageCount = ref(0)
const hasUnreadMessages = computed(() => unreadMessageCount.value > 0)
let unreadStateRevision = 0
let stopUnreadMessageListener: (() => void) | null = null
//
const shortcuts: ShortcutItem[] = [
{
@@ -123,55 +115,16 @@ const shortcuts: ShortcutItem[] = [
component: ModuleTestView,
titleText: t('shortcut.system.subtitle'),
},
{
title: t('shortcut.message.title'),
subtitle: t('shortcut.message.subtitle'),
icon: 'mdi-message',
dialog: 'message',
customDialog: ShortcutMessageDialog,
},
].map(item => ({ ...item, permission: 'admin' }))
const visibleShortcuts = computed(() => filterItemsByPermission(shortcuts, userPermissions.value))
/** 设置消息捷径卡片的未读数量。 */
function setUnreadMessageCount(count: number) {
unreadMessageCount.value = Math.max(0, count)
}
/** 同步全局未读消息数量到消息捷径卡片。 */
function handleUnreadMessage(count: number) {
unreadStateRevision += 1
setUnreadMessageCount(count)
}
/** 从 Service Worker 读取当前未读数量,避免错过启动早期事件。 */
async function syncUnreadMessageStateFromBadge() {
const revision = unreadStateRevision
const count = await getUnreadCount()
if (revision === unreadStateRevision) {
setUnreadMessageCount(count)
}
}
/** 清空未读消息数量和 PWA 桌面角标。 */
function clearUnreadMessageState() {
unreadStateRevision += 1
setUnreadMessageCount(0)
void clearUnreadMessages()
}
/** 打开快捷工具对应的共享弹窗。 */
function openShortcutDialog(item: (typeof shortcuts)[number]) {
if (!hasItemPermission(item, userPermissions.value)) return
appsMenu.value = false
if (item.dialog === 'message') {
clearUnreadMessageState()
}
if (item.customDialog) {
openSharedDialog(item.customDialog, {}, {}, { closeOn: ['close', 'update:modelValue'] })
return
@@ -195,21 +148,7 @@ function openShortcutDialog(item: (typeof shortcuts)[number]) {
)
}
/** 供外部调用的打开消息弹窗方法。 */
function openMessageDialogFromExternal() {
const messageShortcut = visibleShortcuts.value.find(item => item.dialog === 'message')
if (messageShortcut) openShortcutDialog(messageShortcut)
}
//
defineExpose({
openMessageDialog: openMessageDialogFromExternal,
})
onMounted(() => {
stopUnreadMessageListener = onUnreadMessage(handleUnreadMessage)
void syncUnreadMessageStateFromBadge()
const shortcut = getQueryValue('shortcut')
if (shortcut) {
const found = visibleShortcuts.value.find(item => item.dialog === shortcut)
@@ -218,10 +157,6 @@ onMounted(() => {
}
}
})
onBeforeUnmount(() => {
stopUnreadMessageListener?.()
})
</script>
<template>
@@ -257,30 +192,20 @@ onBeforeUnmount(() => {
<div class="grid grid-cols-2 gap-3">
<!-- 循环渲染快捷方式 -->
<div v-for="(item, index) in visibleShortcuts" :key="index">
<VBadge
:model-value="item.dialog === 'message' && hasUnreadMessages"
dot
color="error"
location="top end"
offset-x="8"
offset-y="8"
class="d-block h-full w-100"
<VCard
flat
class="pa-2 d-flex align-center cursor-pointer transition-transform duration-300 hover:-translate-y-1 border h-full w-100"
hover
@click="openShortcutDialog(item)"
>
<VCard
flat
class="pa-2 d-flex align-center cursor-pointer transition-transform duration-300 hover:-translate-y-1 border h-full w-100"
hover
@click="openShortcutDialog(item)"
>
<VAvatar variant="text" size="48" rounded="lg">
<VIcon color="primary" :icon="item.icon" size="24" />
</VAvatar>
<div>
<div class="text-body-1 text-high-emphasis font-weight-medium">{{ item.title }}</div>
<div class="text-caption text-medium-emphasis">{{ item.subtitle }}</div>
</div>
</VCard>
</VBadge>
<VAvatar variant="text" size="48" rounded="lg">
<VIcon color="primary" :icon="item.icon" size="24" />
</VAvatar>
<div>
<div class="text-body-1 text-high-emphasis font-weight-medium">{{ item.title }}</div>
<div class="text-caption text-medium-emphasis">{{ item.subtitle }}</div>
</div>
</VCard>
</div>
</div>
</div>

View File

@@ -0,0 +1,945 @@
<script setup lang="ts">
import type { SystemNotification } from '@/api/types'
import api from '@/api'
import { 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'
import { useI18n } from 'vue-i18n'
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'
type NotificationClearBefore = Record<NotificationClearScope, number>
const { t } = useI18n()
const { useDelayedSSE } = useBackground()
const $toast = useToast()
const createConfirm = useConfirm()
const PAGE_SIZE = 20
// 虚拟滚动的默认通知项高度,展开后的实际高度由 VVirtualScroll 的 itemRef 动态测量。
const NOTIFICATION_ITEM_HEIGHT = 136
const MAX_FILTERED_PAGES_PER_LOAD = 5
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)
const notificationList = ref<SystemNotification[]>([])
const page = ref(1)
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 notificationDisplayList = computed(() => buildNotificationDisplayList(notificationList.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,
},
])
/** 生成默认清理时间,分别记录全部、系统消息和媒体消息的清理范围。 */
function createDefaultNotificationClearBefore(): NotificationClearBefore {
return {
all: 0,
system: 0,
media: 0,
}
}
/** 规范化清理时间,兼容旧版本只存一个数字的本地存储格式。 */
function normalizeNotificationClearBefore(value: unknown): NotificationClearBefore {
const clearBefore = createDefaultNotificationClearBefore()
if (typeof value === 'number' && Number.isFinite(value)) {
clearBefore.all = value
return clearBefore
}
if (!value || typeof value !== 'object') return clearBefore
const scopes: NotificationClearScope[] = ['all', 'system', 'media']
scopes.forEach(scope => {
const scopeValue = Number((value as Partial<NotificationClearBefore>)[scope] || 0)
clearBefore[scope] = Number.isFinite(scopeValue) ? scopeValue : 0
})
return clearBefore
}
/** 从本地存储读取通知清理时间戳,用于过滤已清理的历史通知。 */
function readNotificationClearBefore() {
if (typeof localStorage === 'undefined') return createDefaultNotificationClearBefore()
const storedValue = localStorage.getItem(NOTIFICATION_CLEAR_BEFORE_STORAGE_KEY)
if (!storedValue) return createDefaultNotificationClearBefore()
try {
return normalizeNotificationClearBefore(JSON.parse(storedValue))
} catch {
return normalizeNotificationClearBefore(Number(storedValue))
}
}
/** 写入指定范围的通知清理时间戳,使清理结果在刷新后仍然生效。 */
function writeNotificationClearBefore(scope: NotificationClearScope, value: number) {
notificationClearBefore.value = {
...notificationClearBefore.value,
[scope]: value,
}
if (typeof localStorage === 'undefined') return
localStorage.setItem(NOTIFICATION_CLEAR_BEFORE_STORAGE_KEY, JSON.stringify(notificationClearBefore.value))
}
/** 将通知备注统一转换成稳定字符串,用于生成去重 key。 */
function normalizeNote(note: SystemNotification['note']) {
if (note == null) return ''
if (typeof note === 'string') return note
if (typeof note === 'object' && !Array.isArray(note) && Object.keys(note).length === 0) return ''
return JSON.stringify(note)
}
/** 获取通知时间字段,兼容历史数据中的不同命名。 */
function getNotificationTime(item: SystemNotification) {
return item.reg_time || item.date || ''
}
/** 归一化文本内容,避免空白差异影响通知去重。 */
function normalizeText(value: unknown) {
return String(value ?? '')
.replace(/\s+/g, ' ')
.trim()
}
/** 获取通知分类,统一插件、系统等历史字段差异。 */
function getNotificationKind(item: SystemNotification) {
if (item.type === 'plugin' || item.mtype === '插件') return 'plugin'
if (item.type === 'system' || item.mtype === '其它') return 'system'
return item.mtype || item.type || ''
}
/** 按分钟生成时间桶,降低同一通知秒级差异导致的重复展示。 */
function getNotificationTimeBucket(item: SystemNotification) {
return getNotificationTime(item).slice(0, 16)
}
/** 基于主要展示字段生成内容去重 key。 */
function getNotificationContentKey(item: SystemNotification) {
return [
getNotificationKind(item),
getNotificationTimeBucket(item),
normalizeText(item.title),
normalizeText(item.text),
item.image ?? '',
item.link ?? '',
normalizeNote(item.note),
].join('::')
}
/** 生成通知可用于去重的全部 key。 */
function getNotificationKeys(item: SystemNotification) {
return [item.id ? `id:${item.id}` : '', `content:${getNotificationContentKey(item)}`].filter(Boolean)
}
/** 获取用于虚拟列表渲染的稳定 key。 */
function getNotificationKey(item: SystemNotification) {
return item.id ? `id:${item.id}` : `content:${getNotificationContentKey(item)}`
}
/** 获取通知正文展开状态使用的稳定 key。 */
function getNotificationExpansionKey(item: SystemNotification) {
return getNotificationKey(item)
}
/** 将通知时间解析成时间戳,用于列表降序排序。 */
function parseNotificationTime(value: string) {
if (!value) return 0
return new Date(value.includes('T') ? value : value.replaceAll(/-/g, '/')).getTime() || 0
}
/** 判断历史通知是否早于本地清理时间,需要从列表中过滤。 */
function isClearedHistoryNotification(item: SystemNotification) {
const scope = getNotificationClearScope(item)
const clearBefore = Math.max(notificationClearBefore.value.all, notificationClearBefore.value[scope])
if (!clearBefore) return false
const notificationTime = parseNotificationTime(getNotificationTime(item))
return notificationTime > 0 && notificationTime <= clearBefore
}
/** 按通知时间倒序重排当前列表。 */
function sortNotifications() {
notificationList.value = [...notificationList.value].sort(
(a, b) => parseNotificationTime(getNotificationTime(b)) - parseNotificationTime(getNotificationTime(a)),
)
}
/** 压缩当前通知列表,移除同一内容或同一 ID 的重复项。 */
function compactNotifications(items: SystemNotification[]) {
const contentKeys = new Set<string>()
const idKeys = new Set<string>()
const compactedItems: SystemNotification[] = []
items.forEach(item => {
const contentKey = getNotificationContentKey(item)
const idKey = item.id ? `id:${item.id}` : ''
if (contentKeys.has(contentKey) || (idKey && idKeys.has(idKey))) return
contentKeys.add(contentKey)
if (idKey) idKeys.add(idKey)
compactedItems.push(item)
})
return compactedItems
}
/** 规范化通知展示字段,并补齐默认标题、类型和已读状态。 */
function normalizeNotification(item: SystemNotification, read = true): SystemNotification {
return {
...item,
read,
title: item.title || item.source || item.mtype || t('notification.center'),
type: item.type || (item.action === 1 ? 'notification' : item.type),
}
}
/** 合并新通知到当前列表,并维护去重集合、排序和已读状态。 */
function mergeNotifications(items: SystemNotification[], options: { prepend?: boolean; read?: boolean } = {}) {
const normalizedItems = items.map(item => normalizeNotification(item, options.read ?? true))
const acceptedItems: SystemNotification[] = []
normalizedItems.forEach(item => {
const keys = getNotificationKeys(item)
if (keys.some(key => notificationKeys.has(key))) return
keys.forEach(key => notificationKeys.add(key))
acceptedItems.push(item)
})
if (acceptedItems.length === 0) return false
notificationList.value = options.prepend
? [...acceptedItems, ...notificationList.value]
: [...notificationList.value, ...acceptedItems]
notificationList.value = compactNotifications(notificationList.value)
sortNotifications()
return true
}
/** 重置通知分页状态,用于清理后重新进入空列表状态。 */
function resetNotifications() {
notificationList.value = []
notificationKeys.clear()
expandedNotificationKeys.value = new Set()
page.value = 1
hasMore.value = true
hasNewMessage.value = false
}
/** 重新根据当前列表生成去重 key避免分类清理后遗留已移除消息的去重状态。 */
function rebuildNotificationKeys() {
notificationKeys.clear()
notificationList.value.forEach(item => {
getNotificationKeys(item).forEach(key => notificationKeys.add(key))
})
}
/** 清理已移除通知的展开状态,避免虚拟列表复用时保留无效 key。 */
function rebuildExpandedNotificationKeys() {
const currentKeys = new Set(notificationList.value.map(getNotificationExpansionKey))
expandedNotificationKeys.value = new Set(
[...expandedNotificationKeys.value].filter(key => currentKeys.has(key)),
)
}
/** 列表内容变化后同步未读红点和应用角标状态。 */
function syncUnreadStateAfterListChange() {
hasNewMessage.value = hasUnreadNotifications.value
if (!hasUnreadNotifications.value) void clearUnreadMessages()
}
/** 统计当前已加载通知中各清理范围的数量,用于菜单展示和禁用空操作。 */
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 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
}
/** 尝试调用后端清理接口;分类清理使用本地时间戳过滤以兼容当前全量接口语义。 */
async function tryDeleteNotificationHistory(scope: NotificationClearScope) {
if (scope !== 'all') return true
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
}
}
/** 确认并清空通知中心历史,同时同步清理未读角标。 */
async function clearNotifications(scope: NotificationClearScope) {
if (clearing.value || notificationClearCounts.value[scope] === 0) return
const confirmed = await createConfirm({
type: 'warn',
title: t('notification.clear'),
content: getClearConfirmText(scope),
confirmText: t('notification.clear'),
})
if (!confirmed) return
clearing.value = true
try {
const cleared = await tryDeleteNotificationHistory(scope)
if (!cleared) {
$toast.error(t('notification.clearFailed'))
return
}
writeNotificationClearBefore(scope, Date.now())
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 {
clearing.value = false
}
}
/** 按页加载历史通知,并合并到当前虚拟列表。 */
async function loadNotifications({ done }: { done: (status: 'ok' | 'empty' | 'error') => void }) {
if (loading.value) {
done('ok')
return
}
if (!hasMore.value) {
done('empty')
return
}
try {
loading.value = true
let accepted = false
let loadedPages = 0
// 清理后的分页里可能连续出现已被本地过滤的历史消息,循环跳过这些空页。
while (hasMore.value && !accepted && loadedPages < MAX_FILTERED_PAGES_PER_LOAD) {
const items = (await api.get('message/notification', {
params: {
page: page.value,
count: PAGE_SIZE,
},
})) as SystemNotification[]
if (items.length === 0) {
hasMore.value = false
break
}
const visibleItems = items.filter(item => !isClearedHistoryNotification(item))
accepted = mergeNotifications(visibleItems, { read: true })
page.value += 1
loadedPages += 1
hasMore.value = items.length >= PAGE_SIZE
}
done(hasMore.value ? 'ok' : 'empty')
} catch (error) {
console.error('加载通知失败:', error)
done('error')
} finally {
loading.value = false
}
}
/** 处理 SSE 推送的新通知,并置为未读状态展示红点。 */
function handleMessage(event: MessageEvent) {
if (!event.data) return
try {
const notification = JSON.parse(event.data) as SystemNotification
if (isClearedHistoryNotification(notification)) return
if (mergeNotifications([notification], { prepend: true, read: false })) {
hasNewMessage.value = true
emitAgentAssistantNotificationBubble(notification)
}
} catch (error) {
console.error('解析通知失败:', error)
}
}
/** 将通知列表标记为已读,并同步清理应用角标、未读红点和通知弹窗。 */
function markAllAsRead() {
hasNewMessage.value = false
notificationList.value.forEach(item => {
item.read = true
})
appsMenu.value = false
void clearUnreadMessages()
}
/** 根据通知分类和业务类型选择列表图标。 */
function getNotificationIcon(item: SystemNotification) {
if (getNotificationKind(item) === 'plugin') return 'mdi-puzzle-outline'
if (item.mtype === '资源下载') return 'mdi-download'
if (item.mtype === '整理入库') return 'mdi-folder-check-outline'
if (item.mtype === '订阅') return 'mdi-rss'
if (item.mtype === '智能体') return 'lucide:bot'
return getNotificationKind(item) === 'system' ? 'mdi-alert-circle-outline' : 'mdi-bell-outline'
}
/** 根据通知分类和业务类型选择图标颜色。 */
function getNotificationColor(item: SystemNotification) {
if (getNotificationKind(item) === 'system') return 'error'
if (getNotificationKind(item) === 'plugin') return 'warning'
if (item.mtype === '资源下载') return 'info'
if (item.mtype === '整理入库') return 'success'
if (item.mtype === '订阅') return 'primary'
return 'secondary'
}
/** 判断通知是否有真实媒体图,决定是否使用媒体缩略图样式。 */
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))
const mediaItems = items.filter(isMediaNotification)
const sections = [
{ key: 'system', title: t('notification.systemMessages'), items: systemItems },
{ key: 'media', title: t('notification.mediaMessages'), items: mediaItems },
]
const displayItems: NotificationDisplayItem[] = []
sections.forEach(section => {
if (section.items.length === 0) return
displayItems.push({
kind: 'section',
key: `section:${section.key}`,
title: section.title,
count: section.items.length,
})
section.items.forEach(item => {
displayItems.push({
kind: 'notification',
key: `notification:${getNotificationKey(item)}`,
notification: item,
})
})
})
return displayItems
}
/** 判断通知正文是否已经展开。 */
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()
}
/** 切换通知正文展开状态。 */
function toggleNotificationExpanded(item: SystemNotification) {
markNotificationAsRead(item)
if (!item.text) return
const key = getNotificationExpansionKey(item)
const expandedKeys = new Set(expandedNotificationKeys.value)
if (expandedKeys.has(key)) expandedKeys.delete(key)
else expandedKeys.add(key)
expandedNotificationKeys.value = expandedKeys
}
useDelayedSSE(
`${import.meta.env.VITE_API_BASE_URL}system/message?role=notification`,
handleMessage,
'user-notification',
3000,
{
backgroundCloseDelay: 5000,
reconnectDelay: 3000,
maxReconnectAttempts: 3,
},
)
</script>
<template>
<VMenu
v-model="appsMenu"
width="420"
max-width="calc(100vw - 24px)"
transition="scale-transition"
:close-on-content-click="false"
class="notification-menu"
scrim
>
<template #activator="{ props }">
<VBadge v-if="hasNewMessage" dot color="error" :offset-x="5" :offset-y="5" v-bind="props">
<IconBtn>
<VIcon icon="mdi-bell-outline" size="22" />
</IconBtn>
</VBadge>
<IconBtn v-else v-bind="props">
<VIcon icon="mdi-bell-outline" size="22" />
</IconBtn>
</template>
<VCard class="notification-panel">
<VCardItem class="py-3">
<VCardTitle>{{ t('notification.center') }}</VCardTitle>
<template #append>
<div class="notification-actions">
<VTooltip :text="t('notification.clear')">
<template #activator="{ props }">
<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">
<VIcon icon="mdi-email-check-outline" size="20" />
</IconBtn>
</template>
</VTooltip>
</div>
</template>
</VCardItem>
<VDivider />
<div class="notification-list-container">
<VInfiniteScroll
mode="intersect"
side="end"
:items="notificationList"
class="notification-list-scroll"
@load="loadNotifications"
>
<template #loading>
<div class="py-3 text-center text-caption text-medium-emphasis">
{{ t('message.loadMore') }}
</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 class="notification-empty__icon">
<VIcon icon="mdi-bell-sleep-outline" size="22" />
</div>
<div>{{ t('notification.empty') }}</div>
</div>
</template>
<VVirtualScroll
v-if="notificationList.length > 0"
renderless
:items="notificationDisplayList"
:item-height="NOTIFICATION_ITEM_HEIGHT"
>
<template #default="{ item, itemRef }">
<div
:ref="itemRef"
:key="item.key"
class="notification-virtual-item"
:class="{ 'notification-virtual-item--section': item.kind === 'section' }"
>
<div v-if="item.kind === 'section'" class="notification-section-heading">
<span class="notification-section-heading__title">{{ item.title }}</span>
<span class="notification-section-heading__count">{{ item.count }}</span>
</div>
<div
v-else
class="notification-row"
:class="{
'notification-row--unread': item.notification.read === false,
'notification-row--media': isMediaNotification(item.notification),
}"
role="button"
tabindex="0"
:aria-expanded="item.notification.text ? isNotificationExpanded(item.notification) : undefined"
@click="toggleNotificationExpanded(item.notification)"
@keydown.enter.prevent="toggleNotificationExpanded(item.notification)"
@keydown.space.prevent="toggleNotificationExpanded(item.notification)"
>
<div v-if="item.notification.image" class="notification-media">
<VImg
v-if="item.notification.image"
:src="item.notification.image"
cover
class="notification-media__image"
>
<template #placeholder>
<VSkeletonLoader class="h-100 w-100" />
</template>
</VImg>
</div>
<div v-else class="notification-icon" :class="`text-${getNotificationColor(item.notification)}`">
<VIcon :icon="getNotificationIcon(item.notification)" size="22" />
</div>
<div class="notification-content">
<div class="notification-title-row">
<span class="notification-title">{{ item.notification.title }}</span>
<span v-if="item.notification.read === false" class="notification-unread-dot" />
</div>
<div
v-if="item.notification.text"
class="notification-text"
:class="{ 'notification-text--expanded': isNotificationExpanded(item.notification) }"
>
{{ item.notification.text }}
</div>
<div class="notification-meta">
<span v-if="item.notification.mtype" class="notification-type">{{ item.notification.mtype }}</span>
<span>{{ formatDateDifference(getNotificationTime(item.notification)) }}</span>
</div>
</div>
</div>
</div>
</template>
</VVirtualScroll>
</VInfiniteScroll>
</div>
</VCard>
</VMenu>
</template>
<style scoped>
.notification-panel {
overflow: hidden;
}
.notification-actions {
display: inline-flex;
align-items: center;
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);
scrollbar-width: thin;
}
.notification-list-scroll {
max-block-size: min(560px, 62vh);
min-block-size: 160px;
}
.notification-virtual-item {
padding-block: 4px;
padding-inline: 8px;
}
.notification-virtual-item--section {
padding-block: 10px 2px;
}
.notification-section-heading {
display: flex;
align-items: center;
color: rgba(var(--v-theme-on-surface), 0.42);
gap: 8px;
letter-spacing: 0;
padding-inline: 10px;
}
.notification-section-heading__title {
font-size: 0.6875rem;
font-weight: 500;
line-height: 1.2;
}
.notification-section-heading__count {
color: rgba(var(--v-theme-on-surface), 0.34);
font-size: 0.625rem;
line-height: 1;
}
.notification-row {
position: relative;
display: flex;
align-items: flex-start;
padding: 10px;
border: 0;
border-radius: 8px;
background: transparent;
color: inherit;
cursor: pointer;
gap: 12px;
inline-size: 100%;
text-align: start;
transition:
background-color 0.2s ease,
transform 0.2s ease;
}
.notification-row:hover {
background: rgba(var(--v-theme-primary), 0.08);
}
.notification-row--unread {
background: rgba(var(--v-theme-error), 0.07);
}
.notification-row--media {
min-block-size: 0;
}
.notification-media {
overflow: hidden;
flex: 0 0 56px;
border-radius: 6px;
background: rgba(var(--v-theme-on-surface), 0.06);
block-size: 84px;
}
.notification-media__image {
block-size: 100%;
inline-size: 100%;
}
.notification-icon {
display: grid;
place-items: center;
}
.notification-icon {
flex: 0 0 40px;
border-radius: 8px;
background: rgba(var(--v-theme-on-surface), 0.06);
block-size: 40px;
}
.notification-content {
flex: 1;
min-inline-size: 0;
}
.notification-title-row {
display: flex;
align-items: flex-start;
gap: 8px;
min-block-size: 24px;
}
.notification-title {
display: -webkit-box;
overflow: hidden;
flex: 1 1 auto;
-webkit-box-orient: vertical;
font-size: 0.925rem;
font-weight: 600;
line-height: 1.35;
-webkit-line-clamp: 2;
text-overflow: ellipsis;
white-space: normal;
}
.notification-unread-dot {
flex: 0 0 7px;
border-radius: 999px;
background: rgb(var(--v-theme-error));
block-size: 7px;
inline-size: 7px;
margin-block-start: 0.45rem;
}
.notification-text {
display: block;
overflow: hidden;
padding: 0;
border: 0;
background: transparent;
block-size: auto;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
cursor: pointer;
font-size: 0.8125rem;
line-height: 1.45;
margin-block-start: 4px;
max-block-size: calc(0.8125rem * 1.45 * 3);
text-align: start;
white-space: pre-wrap;
}
.notification-text--expanded {
max-block-size: none;
overflow: visible;
}
.notification-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
color: rgba(var(--v-theme-on-surface), var(--v-disabled-opacity));
font-size: 0.75rem;
gap: 6px;
line-height: 1.2;
margin-block-start: 6px;
}
.notification-type {
border-radius: 999px;
background: rgba(var(--v-theme-primary), 0.1);
color: rgb(var(--v-theme-primary));
padding-block: 2px;
padding-inline: 6px;
}
.notification-empty {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
padding-block: 32px;
padding-inline: 16px;
text-align: center;
}
.notification-empty__icon {
display: inline-grid;
place-items: center;
border-radius: 8px;
background: rgba(var(--v-theme-on-surface), 0.06);
block-size: 40px;
inline-size: 40px;
margin-block-end: 12px;
}
</style>

View File

@@ -20,6 +20,7 @@ import {
persistPartialThemeCustomizerSettings,
readThemeCustomizerSettings,
THEME_CUSTOMIZER_CHANGE_EVENT,
THEME_CUSTOMIZER_OPEN_EVENT,
type ThemeCustomizerSettings,
} from '@/composables/useThemeCustomizer'
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
@@ -30,7 +31,6 @@ const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/Pr
const TransparencySettingsDialog = defineAsyncComponent(
() => import('@/components/dialog/TransparencySettingsDialog.vue'),
)
const ThemeCustomizer = defineAsyncComponent(() => import('@/components/ThemeCustomizer.vue'))
const UserAuthDialog = defineAsyncComponent(() => import('@/components/dialog/UserAuthDialog.vue'))
// Store
@@ -50,12 +50,12 @@ const $toast = useToast()
// UI
const showUIModeMenu = ref(false)
// overlay
const showUserMenu = ref(false)
//
const showThemeMenu = ref(false)
//
const showThemeCustomizer = ref(false)
//
const showLanguageMenu = ref(false)
@@ -442,8 +442,11 @@ function showTransparencySettingsDialog() {
/** 从用户菜单打开主题定制器App 模式会在面板内部隐藏布局设置。 */
function showThemeCustomizerDrawer() {
showUserMenu.value = false
showThemeMenu.value = false
showThemeCustomizer.value = true
// DefaultLayout
window.dispatchEvent(new CustomEvent(THEME_CUSTOMIZER_OPEN_EVENT))
}
/** 保存自定义 CSS。 */
@@ -558,6 +561,7 @@ onUnmounted(() => {
<VImg :src="avatar" />
<VMenu
v-model="showUserMenu"
activator="parent"
width="15rem"
location="bottom end"
@@ -777,7 +781,6 @@ onUnmounted(() => {
</VMenu>
<!-- !SECTION -->
</VAvatar>
<ThemeCustomizer v-model="showThemeCustomizer" />
</template>
<style lang="scss" scoped>

View File

@@ -170,16 +170,13 @@ export default {
skinDefault: 'Default',
skinBordered: 'Bordered',
radius: 'Corners',
radiusNone: 'Square',
radiusSmall: 'Small',
radiusDefault: 'Default',
radiusLarge: 'Large',
radiusExtra: 'Larger',
radiusHuge: 'Extra Large',
shadow: 'Shadows',
shadowNone: 'Flat',
shadowLow: 'Soft',
shadowMedium: 'Balanced',
shadowHigh: 'Bold',
shadowLevel: 'Level {level}',
semiDarkMenu: 'Semi Dark Menu',
layout: 'Layout',
layoutVertical: 'Vertical',
@@ -462,7 +459,22 @@ export default {
notification: {
center: 'Notification Center',
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',
mediaMessages: 'Media Messages',
channel: 'Notification Channel',
name: 'Name',
nameHint: 'Name of notification channel',
@@ -680,10 +692,6 @@ export default {
title: 'System',
subtitle: 'Health Check',
},
message: {
title: 'Messages',
subtitle: 'Message Center',
},
words: {
title: 'Words',
subtitle: 'Word Settings',
@@ -697,6 +705,39 @@ export default {
subtitle: 'Scheduled Services',
},
},
agentAssistant: {
title: 'AI Assistant',
assistant: 'Assistant',
ready: 'Ready',
thinking: 'Thinking',
newChat: 'New Chat',
history: 'Chat History',
historyLoading: 'Loading chat history...',
historyLoadFailed: 'Failed to load chat history',
noHistory: 'No chat history yet',
deleteHistory: 'Delete chat history',
unknownChannel: 'Unknown channel',
webAgentChannel: 'Web Assistant',
untitledSession: 'Untitled chat',
emptyTitle: 'What should we handle today?',
emptySubtitle: 'Ask about sites, subscriptions, downloads, or organization tasks.',
placeholder: 'Ask MoviePilot...',
stop: 'Stop generating',
download: 'Download',
attachFile: 'Choose image or file',
recordVoice: 'Record voice',
stopRecording: 'Stop recording ({time})',
attachmentMessage: 'Attachment message',
removeAttachment: 'Remove attachment',
uploadFailed: 'Attachment upload failed',
recordUnsupported: 'Voice recording is not supported by this browser',
recordPermissionDenied: 'Cannot access the microphone. Please check browser permissions.',
recordFailed: 'Voice recording failed. Please try again.',
choiceSelected: 'Selected: {option}',
choiceExpired: 'This choice expired. Please ask again.',
error: 'Assistant response failed',
noStream: 'This browser cannot read streaming responses',
},
workflow: {
components: 'Action Components',
clickToAdd: 'Click to Add',
@@ -1169,6 +1210,7 @@ export default {
currentEpisodeNotInLibrary: 'Current not in library',
libraryUpdatedAt: 'Updated {time}',
libraryUpdatedAtShort: '{time}',
expandDayEvents: 'Show {count} more items for this day',
},
storage: {
name: 'Name',
@@ -1586,6 +1628,9 @@ export default {
'Set the check interval for scheduled wake. Select "Disabled" to disable scheduled tasks.',
aiAgentVerbose: 'Verbose Mode',
aiAgentVerboseHint: 'When enabled, tool call process will be displayed in AI agent responses',
aiAgentHideEntry: 'Hide Global Entry',
aiAgentHideEntryHint:
'Only hide the floating AI assistant entry in the bottom-right corner. Message channels and background assistant features are not affected.',
aiAgentJobIntervalDisabled: 'Disabled',
aiAgentJobInterval1h: '1 Hour',
aiAgentJobInterval3h: '3 Hours',
@@ -3006,6 +3051,12 @@ export default {
projectHome: 'Project Home',
updateHistory: 'Update History',
versionHistory: 'Version History',
releaseVersionsLoadFailed: 'Failed to load Release versions',
latestVersion: 'Latest',
currentVersion: 'Current',
installReleaseVersion: 'Install',
confirmInstallOldRelease:
'Install {name} v{version}? This version has no MoviePilot compatibility metadata and may fail to load or run.',
local: 'Local',
systemVersion: 'System Version',
incompatibleSystemVersion: 'The current MoviePilot version does not meet this plugin requirement.',

View File

@@ -170,16 +170,13 @@ export default {
skinDefault: '默认',
skinBordered: '边框',
radius: '圆角',
radiusNone: '无圆角',
radiusSmall: '小圆角',
radiusDefault: '默认',
radiusLarge: '大圆角',
radiusExtra: '更大圆角',
radiusHuge: '超大圆角',
shadow: '阴影',
shadowNone: '无阴影',
shadowLow: '柔和',
shadowMedium: '标准',
shadowHigh: '强烈',
shadowLevel: '层级 {level}',
semiDarkMenu: '半暗菜单',
layout: '布局',
layoutVertical: '垂直',
@@ -460,7 +457,22 @@ export default {
notification: {
center: '通知中心',
markRead: '设为已读',
clear: '清理通知',
clearConfirm: '是否确认清理通知中心内的全部历史消息?',
clearAllMessages: '清理全部消息',
clearSystemMessages: '清理系统消息',
clearMediaMessages: '清理媒体消息',
clearAllConfirm: '是否确认清理通知中心内的全部历史消息?',
clearSystemConfirm: '是否确认清理通知中心内的系统类历史消息?',
clearMediaConfirm: '是否确认清理通知中心内的媒体历史消息?',
clearSuccess: '通知已清理',
clearAllSuccess: '全部通知已清理',
clearSystemSuccess: '系统消息已清理',
clearMediaSuccess: '媒体消息已清理',
clearFailed: '通知清理失败',
empty: '暂无通知',
systemMessages: '系统类消息',
mediaMessages: '媒体消息',
channel: '通知渠道',
name: '名称',
nameHint: '通知渠道名称',
@@ -676,10 +688,6 @@ export default {
title: '系统',
subtitle: '健康检查',
},
message: {
title: '消息',
subtitle: '消息中心',
},
words: {
title: '词表',
subtitle: '词表设置',
@@ -693,6 +701,39 @@ export default {
subtitle: '定时服务',
},
},
agentAssistant: {
title: '智能助手',
assistant: '助手',
ready: '随时待命',
thinking: '思考中',
newChat: '新会话',
history: '历史会话',
historyLoading: '正在加载历史会话...',
historyLoadFailed: '历史会话加载失败',
noHistory: '暂无历史会话',
deleteHistory: '删除历史会话',
unknownChannel: '未知渠道',
webAgentChannel: '网页助手',
untitledSession: '未命名会话',
emptyTitle: '今天想处理什么?',
emptySubtitle: '站点、订阅、下载、整理任务,都可以直接问我。',
placeholder: '询问 MoviePilot...',
stop: '停止生成',
download: '下载',
attachFile: '选择图片或文件',
recordVoice: '录制语音',
stopRecording: '停止录音({time}',
attachmentMessage: '附件消息',
removeAttachment: '移除附件',
uploadFailed: '附件上传失败',
recordUnsupported: '当前浏览器不支持录音',
recordPermissionDenied: '无法访问麦克风,请检查浏览器权限',
recordFailed: '录音失败,请重试',
choiceSelected: '已选择:{option}',
choiceExpired: '该选择已失效,请重新发起选择',
error: '智能助手响应失败',
noStream: '当前浏览器无法读取流式响应',
},
workflow: {
components: '动作组件',
clickToAdd: '点击添加',
@@ -1164,6 +1205,7 @@ export default {
currentEpisodeNotInLibrary: '本集未入库',
libraryUpdatedAt: '最近更新 {time}',
libraryUpdatedAtShort: '{time}',
expandDayEvents: '展开当天剩余 {count} 个条目',
},
storage: {
name: '名称',
@@ -1572,6 +1614,8 @@ export default {
aiAgentJobIntervalHint: '设置定时唤醒的检查间隔,选择"不启用"则不执行定时任务',
aiAgentVerbose: '啰嗦模式',
aiAgentVerboseHint: '开启后会在智能体回复时显示工具调用过程',
aiAgentHideEntry: '隐藏全局入口',
aiAgentHideEntryHint: '仅隐藏页面右下角的智能助手浮动入口,不影响消息渠道和后台智能助手功能',
aiAgentJobIntervalDisabled: '不启用',
aiAgentJobInterval1h: '1小时',
aiAgentJobInterval3h: '3小时',
@@ -2956,6 +3000,12 @@ export default {
projectHome: '项目主页',
updateHistory: '更新说明',
versionHistory: '版本历史',
releaseVersionsLoadFailed: 'Release 版本加载失败',
latestVersion: '最新',
currentVersion: '当前',
installReleaseVersion: '安装',
confirmInstallOldRelease:
'是否确认安装 {name} v{version}?该版本缺少主程序兼容元数据,安装后可能无法加载或运行异常。',
local: '本地',
systemVersion: '系统版本',
incompatibleSystemVersion: '当前 MoviePilot 版本不满足插件要求,无法安装',

View File

@@ -170,16 +170,13 @@ export default {
skinDefault: '默認',
skinBordered: '邊框',
radius: '圓角',
radiusNone: '無圓角',
radiusSmall: '小圓角',
radiusDefault: '默認',
radiusLarge: '大圓角',
radiusExtra: '更大圓角',
radiusHuge: '超大圓角',
shadow: '陰影',
shadowNone: '無陰影',
shadowLow: '柔和',
shadowMedium: '標準',
shadowHigh: '強烈',
shadowLevel: '層級 {level}',
semiDarkMenu: '半暗菜單',
layout: '佈局',
layoutVertical: '垂直',
@@ -460,7 +457,22 @@ export default {
notification: {
center: '通知中心',
markRead: '設為已讀',
clear: '清理通知',
clearConfirm: '是否確認清理通知中心內的全部歷史消息?',
clearAllMessages: '清理全部消息',
clearSystemMessages: '清理系統消息',
clearMediaMessages: '清理媒體消息',
clearAllConfirm: '是否確認清理通知中心內的全部歷史消息?',
clearSystemConfirm: '是否確認清理通知中心內的系統類歷史消息?',
clearMediaConfirm: '是否確認清理通知中心內的媒體歷史消息?',
clearSuccess: '通知已清理',
clearAllSuccess: '全部通知已清理',
clearSystemSuccess: '系統消息已清理',
clearMediaSuccess: '媒體消息已清理',
clearFailed: '通知清理失敗',
empty: '暫無通知',
systemMessages: '系統類消息',
mediaMessages: '媒體消息',
channel: '通知渠道',
name: '名稱',
nameHint: '通知渠道名稱',
@@ -676,10 +688,6 @@ export default {
title: '系統',
subtitle: '健康檢查',
},
message: {
title: '消息',
subtitle: '消息中心',
},
words: {
title: '詞表',
subtitle: '詞表設置',
@@ -693,6 +701,39 @@ export default {
subtitle: '定時服務',
},
},
agentAssistant: {
title: '智能助手',
assistant: '助手',
ready: '隨時待命',
thinking: '思考中',
newChat: '新會話',
history: '歷史會話',
historyLoading: '正在載入歷史會話...',
historyLoadFailed: '歷史會話載入失敗',
noHistory: '暫無歷史會話',
deleteHistory: '刪除歷史會話',
unknownChannel: '未知渠道',
webAgentChannel: '網頁助手',
untitledSession: '未命名會話',
emptyTitle: '今天想處理什麼?',
emptySubtitle: '站點、訂閱、下載、整理任務,都可以直接問我。',
placeholder: '詢問 MoviePilot...',
stop: '停止生成',
download: '下載',
attachFile: '選擇圖片或文件',
recordVoice: '錄製語音',
stopRecording: '停止錄音({time}',
attachmentMessage: '附件消息',
removeAttachment: '移除附件',
uploadFailed: '附件上傳失敗',
recordUnsupported: '目前瀏覽器不支援錄音',
recordPermissionDenied: '無法存取麥克風,請檢查瀏覽器權限',
recordFailed: '錄音失敗,請重試',
choiceSelected: '已選擇:{option}',
choiceExpired: '該選擇已失效,請重新發起選擇',
error: '智能助手響應失敗',
noStream: '目前瀏覽器無法讀取串流響應',
},
workflow: {
components: '動作組件',
clickToAdd: '點擊添加',
@@ -1164,6 +1205,7 @@ export default {
currentEpisodeNotInLibrary: '本集未入庫',
libraryUpdatedAt: '最近更新 {time}',
libraryUpdatedAtShort: '{time}',
expandDayEvents: '展開當天剩餘 {count} 個條目',
},
storage: {
name: '名稱',
@@ -1573,6 +1615,8 @@ export default {
aiAgentJobIntervalHint: '設置定時喚醒的檢查間隔,選擇「不啟用」則不執行定時任務',
aiAgentVerbose: '囉嗦模式',
aiAgentVerboseHint: '開啟後會在智能體回覆時顯示工具調用過程',
aiAgentHideEntry: '隱藏全域入口',
aiAgentHideEntryHint: '僅隱藏頁面右下角的智能助手浮動入口,不影響消息渠道和後台智能助手功能',
aiAgentJobIntervalDisabled: '不啟用',
aiAgentJobInterval1h: '1小時',
aiAgentJobInterval3h: '3小時',
@@ -2957,6 +3001,12 @@ export default {
projectHome: '項目主頁',
updateHistory: '更新說明',
versionHistory: '版本歷史',
releaseVersionsLoadFailed: 'Release 版本載入失敗',
latestVersion: '最新',
currentVersion: '當前',
installReleaseVersion: '安裝',
confirmInstallOldRelease:
'是否確認安裝 {name} v{version}?該版本缺少主程序兼容元數據,安裝後可能無法載入或運行異常。',
local: '本地',
installToLocal: '安裝到本地',
totalDownloads: '共 {count} 次下載',

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'
// 国际化

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'

View File

@@ -894,8 +894,6 @@ onUnmounted(() => {
</template>
<style lang="scss" scoped>
/* stylelint-disable selector-pseudo-class-no-unknown */
@use '@core/scss/pages/page-auth';
/* ===================== 布局根容器 ===================== */
@@ -910,11 +908,6 @@ onUnmounted(() => {
min-block-size: 100dvh;
}
/* 登录页需要透出 App.vue 注入的壁纸层。 */
:global(.v-application:has(.login-root)) {
background: transparent !important;
}
/* ===================== 浮动语言切换 ===================== */
.lang-switch-btn {
position: absolute;

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'

View File

@@ -1,6 +1,30 @@
/* stylelint-disable custom-property-pattern */
/* stylelint-disable no-duplicate-selectors */
/* stylelint-disable scss/at-rule-no-unknown */
/* stylelint-disable no-descending-specificity */
@use 'sass:map';
@use 'vuetify/settings' as vuetify-settings;
// 返回 Vuetify 指定 elevation 的完整三层 box-shadow供主题定制器映射全局阴影档位。
@function app-vuetify-elevation($level) {
@return map.get(vuetify-settings.$shadow-key-umbra, $level),
map.get(vuetify-settings.$shadow-key-penumbra, $level),
map.get(vuetify-settings.$shadow-key-ambient, $level);
}
// 将相对阴影层级限制在 Vuetify elevation 的 0 到 24 档范围内。
@function app-clamp-elevation($level) {
@if $level < 0 {
@return 0;
}
@if $level > 24 {
@return 24;
}
@return $level;
}
// 公共样式 - 所有主题都需要
@tailwind base;
@tailwind components;
@@ -17,6 +41,12 @@ html.v-overlay-scroll-blocked body {
inset-block-start: var(--v-body-scroll-y);
}
html.quick-access-scroll-locked,
html.quick-access-scroll-locked body {
overflow: hidden !important;
overscroll-behavior: none;
}
@mixin hide-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
@@ -32,24 +62,36 @@ html.v-overlay-scroll-blocked body {
}
}
// 全局卡片外观 token圆角和阴影在主题定制器中即时切换
// 全局外观 token圆角和阴影复用 Vuetify 的 rounded / elevation 分级
html {
--app-theme-surface-radius: 8px;
--app-vuetify-rounded-0: #{map.get(vuetify-settings.$rounded, 0)};
--app-vuetify-rounded-sm: #{map.get(vuetify-settings.$rounded, 'sm')};
--app-vuetify-rounded: #{map.get(vuetify-settings.$rounded, null)};
--app-vuetify-rounded-lg: #{map.get(vuetify-settings.$rounded, 'lg')};
--app-vuetify-rounded-xl: #{map.get(vuetify-settings.$rounded, 'xl')};
--app-vuetify-rounded-pill: #{map.get(vuetify-settings.$rounded, 'pill')};
@for $level from 0 through 24 {
--app-elevation-#{$level}: #{app-vuetify-elevation($level)};
}
--app-theme-surface-radius: var(--app-vuetify-rounded);
--app-surface-radius: var(--app-theme-surface-radius);
--app-field-radius: var(--app-theme-surface-radius);
--app-field-radius: var(--app-vuetify-rounded);
--app-control-radius: var(--app-vuetify-rounded);
--app-overlay-radius: var(--app-vuetify-rounded);
--app-surface-border-opacity: 0.06;
--app-surface-border: 1px solid rgba(var(--v-theme-on-surface), var(--app-surface-border-opacity));
--app-shadow-rgb: 15, 23, 42;
--app-card-rest-shadow: none;
--app-card-hover-shadow: none;
--app-fab-shadow: none;
--app-fab-shadow-strong: none;
--app-fab-shadow-hover: none;
--app-fab-shadow-strong-hover: none;
--app-fab-shadow-active: none;
--app-overlay-shadow: none;
--app-surface-shadow: none;
--app-surface-hover-shadow: none;
--app-card-rest-shadow: var(--app-elevation-0);
--app-card-hover-shadow: var(--app-elevation-0);
--app-fab-shadow: var(--app-elevation-0);
--app-fab-shadow-strong: var(--app-elevation-0);
--app-fab-shadow-hover: var(--app-elevation-0);
--app-fab-shadow-strong-hover: var(--app-elevation-0);
--app-fab-shadow-active: var(--app-elevation-0);
--app-overlay-shadow: var(--app-elevation-0);
--app-surface-shadow: var(--app-elevation-0);
--app-surface-hover-shadow: var(--app-elevation-0);
--mp-motion-duration-page: 180ms;
--mp-motion-duration-overlay: 160ms;
--mp-motion-ease-standard: cubic-bezier(0.2, 0.8, 0.2, 1);
@@ -60,65 +102,47 @@ html[data-theme-skin='bordered'] {
--app-surface-border-opacity: 0.1;
}
html[data-theme-radius='none'] {
--app-theme-surface-radius: var(--app-vuetify-rounded-0);
--app-field-radius: var(--app-vuetify-rounded-0);
--app-control-radius: var(--app-vuetify-rounded-0);
--app-overlay-radius: var(--app-vuetify-rounded-0);
}
html[data-theme-radius='small'] {
--app-theme-surface-radius: 4px;
--app-theme-surface-radius: var(--app-vuetify-rounded-sm);
--app-field-radius: var(--app-vuetify-rounded-sm);
--app-control-radius: var(--app-vuetify-rounded-sm);
--app-overlay-radius: var(--app-vuetify-rounded-sm);
}
html[data-theme-radius='large'] {
--app-theme-surface-radius: 12px;
--app-theme-surface-radius: var(--app-vuetify-rounded-lg);
--app-field-radius: var(--app-vuetify-rounded-lg);
--app-control-radius: var(--app-vuetify-rounded-lg);
--app-overlay-radius: var(--app-vuetify-rounded-lg);
}
html[data-theme-radius='extra'] {
--app-theme-surface-radius: 16px;
--app-theme-surface-radius: var(--app-vuetify-rounded-xl);
--app-field-radius: var(--app-vuetify-rounded-xl);
--app-control-radius: var(--app-vuetify-rounded-xl);
--app-overlay-radius: var(--app-vuetify-rounded-xl);
}
html[data-theme-radius='huge'] {
--app-theme-surface-radius: 24px;
}
html[data-theme='dark'],
html[data-theme='purple'],
html[data-theme='transparent'] {
--app-shadow-rgb: 0, 0, 0;
}
html[data-theme-shadow='low'] {
--app-card-rest-shadow: 0 10px 24px rgba(var(--app-shadow-rgb), 0.06), 0 2px 8px rgba(var(--app-shadow-rgb), 0.04);
--app-card-hover-shadow: 0 14px 30px rgba(var(--app-shadow-rgb), 0.08), 0 4px 12px rgba(var(--app-shadow-rgb), 0.05);
--app-fab-shadow: 0 16px 34px rgba(var(--app-shadow-rgb), 0.16), 0 6px 16px rgba(var(--app-shadow-rgb), 0.1);
--app-fab-shadow-strong: 0 20px 40px rgba(var(--app-shadow-rgb), 0.2), 0 8px 18px rgba(var(--app-shadow-rgb), 0.12);
--app-fab-shadow-hover: 0 22px 42px rgba(var(--app-shadow-rgb), 0.22), 0 8px 18px rgba(var(--app-shadow-rgb), 0.12);
--app-fab-shadow-strong-hover: 0 26px 46px rgba(var(--app-shadow-rgb), 0.24), 0 10px 22px rgba(var(--app-shadow-rgb), 0.14);
--app-fab-shadow-active: 0 10px 22px rgba(var(--app-shadow-rgb), 0.16), 0 3px 8px rgba(var(--app-shadow-rgb), 0.1);
--app-overlay-shadow: 0 18px 42px rgba(var(--app-shadow-rgb), 0.14), 0 6px 18px rgba(var(--app-shadow-rgb), 0.08);
--app-surface-shadow: 0 10px 24px rgba(var(--app-shadow-rgb), 0.07), 0 2px 8px rgba(var(--app-shadow-rgb), 0.05);
--app-surface-hover-shadow: 0 14px 30px rgba(var(--app-shadow-rgb), 0.09), 0 4px 12px rgba(var(--app-shadow-rgb), 0.06);
}
html[data-theme-shadow='medium'] {
--app-card-rest-shadow: 0 14px 32px rgba(var(--app-shadow-rgb), 0.09), 0 4px 12px rgba(var(--app-shadow-rgb), 0.06);
--app-card-hover-shadow: 0 18px 40px rgba(var(--app-shadow-rgb), 0.11), 0 6px 16px rgba(var(--app-shadow-rgb), 0.07);
--app-fab-shadow: 0 18px 40px rgba(var(--app-shadow-rgb), 0.2), 0 7px 18px rgba(var(--app-shadow-rgb), 0.12);
--app-fab-shadow-strong: 0 24px 48px rgba(var(--app-shadow-rgb), 0.24), 0 10px 24px rgba(var(--app-shadow-rgb), 0.14);
--app-fab-shadow-hover: 0 24px 46px rgba(var(--app-shadow-rgb), 0.24), 0 10px 22px rgba(var(--app-shadow-rgb), 0.14);
--app-fab-shadow-strong-hover: 0 30px 54px rgba(var(--app-shadow-rgb), 0.28), 0 12px 28px rgba(var(--app-shadow-rgb), 0.16);
--app-fab-shadow-active: 0 12px 26px rgba(var(--app-shadow-rgb), 0.18), 0 4px 10px rgba(var(--app-shadow-rgb), 0.12);
--app-overlay-shadow: 0 24px 56px rgba(var(--app-shadow-rgb), 0.18), 0 10px 24px rgba(var(--app-shadow-rgb), 0.1);
--app-surface-shadow: 0 14px 32px rgba(var(--app-shadow-rgb), 0.1), 0 4px 12px rgba(var(--app-shadow-rgb), 0.07);
--app-surface-hover-shadow: 0 18px 40px rgba(var(--app-shadow-rgb), 0.12), 0 6px 16px rgba(var(--app-shadow-rgb), 0.08);
}
html[data-theme-shadow='high'] {
--app-card-rest-shadow: 0 18px 40px rgba(var(--app-shadow-rgb), 0.12), 0 6px 18px rgba(var(--app-shadow-rgb), 0.08);
--app-card-hover-shadow: 0 22px 50px rgba(var(--app-shadow-rgb), 0.15), 0 8px 22px rgba(var(--app-shadow-rgb), 0.1);
--app-fab-shadow: 0 22px 48px rgba(var(--app-shadow-rgb), 0.24), 0 10px 24px rgba(var(--app-shadow-rgb), 0.14);
--app-fab-shadow-strong: 0 28px 58px rgba(var(--app-shadow-rgb), 0.3), 0 12px 30px rgba(var(--app-shadow-rgb), 0.18);
--app-fab-shadow-hover: 0 28px 56px rgba(var(--app-shadow-rgb), 0.28), 0 12px 28px rgba(var(--app-shadow-rgb), 0.17);
--app-fab-shadow-strong-hover: 0 34px 64px rgba(var(--app-shadow-rgb), 0.34), 0 14px 32px rgba(var(--app-shadow-rgb), 0.2);
--app-fab-shadow-active: 0 14px 30px rgba(var(--app-shadow-rgb), 0.22), 0 5px 12px rgba(var(--app-shadow-rgb), 0.14);
--app-overlay-shadow: 0 30px 70px rgba(var(--app-shadow-rgb), 0.22), 0 14px 30px rgba(var(--app-shadow-rgb), 0.12);
--app-surface-shadow: 0 18px 40px rgba(var(--app-shadow-rgb), 0.13), 0 6px 18px rgba(var(--app-shadow-rgb), 0.09);
--app-surface-hover-shadow: 0 22px 50px rgba(var(--app-shadow-rgb), 0.16), 0 8px 22px rgba(var(--app-shadow-rgb), 0.11);
@for $level from 1 through 24 {
html[data-theme-shadow='#{$level}'] {
--app-card-rest-shadow: var(--app-elevation-#{app-clamp-elevation($level - 1)});
--app-card-hover-shadow: var(--app-elevation-#{app-clamp-elevation($level + 1)});
--app-fab-shadow: var(--app-elevation-#{$level});
--app-fab-shadow-active: var(--app-elevation-#{app-clamp-elevation($level - 2)});
--app-fab-shadow-hover: var(--app-elevation-#{app-clamp-elevation($level + 3)});
--app-fab-shadow-strong: var(--app-elevation-#{app-clamp-elevation($level + 2)});
--app-fab-shadow-strong-hover: var(--app-elevation-#{app-clamp-elevation($level + 4)});
--app-overlay-shadow: var(--app-elevation-#{app-clamp-elevation($level + 6)});
--app-surface-shadow: var(--app-elevation-#{$level});
--app-surface-hover-shadow: var(--app-elevation-#{app-clamp-elevation($level + 2)});
}
}
// 进度条样式
@@ -415,12 +439,12 @@ html[data-theme-shadow='high'] {
}
.v-btn:not(.v-btn--rounded, .v-btn--flat, .v-btn--icon, [class^='rounded-'], [class*=' rounded-']) {
border-radius: var(--app-surface-radius);
border-radius: var(--app-control-radius);
transition: border-radius 0.2s ease;
}
.v-btn-group:not(.v-btn-group--variant-plain, .v-btn-group--variant-text) {
border-radius: var(--app-surface-radius);
border-radius: var(--app-control-radius);
box-shadow: var(--app-surface-shadow) !important;
transition: box-shadow 0.2s ease, border-radius 0.2s ease;
}
@@ -697,7 +721,7 @@ html[data-theme="transparent"] .app-card-colorful,
}
.Vue-Toastification__toast {
border-radius: var(--app-surface-radius);
border-radius: var(--app-overlay-radius);
box-shadow: var(--app-overlay-shadow);
}
@@ -922,6 +946,7 @@ html[data-theme="transparent"] .app-card-colorful,
}
:root {
--agent-assistant-fab-offset: 30rem;
--theme-customizer-fab-offset: 420px;
}
@@ -1040,6 +1065,14 @@ html[data-theme="transparent"] .app-card-colorful,
html[data-theme-customizer-open='true'] .global-action-buttons {
inset-inline-end: calc(var(--theme-customizer-fab-offset) + 2rem);
}
html[data-agent-assistant-open='true'] .compact-fab-stack {
inset-inline-end: calc(var(--agent-assistant-fab-offset) + max(1rem, calc(env(safe-area-inset-right) + 1rem)));
}
html[data-agent-assistant-open='true'] .global-action-buttons {
inset-inline-end: calc(var(--agent-assistant-fab-offset) + 2rem);
}
}
@media (width >= 601px) and (width <= 768px) {
@@ -1048,6 +1081,12 @@ html[data-theme="transparent"] .app-card-colorful,
var(--theme-customizer-fab-offset) + max(0.875rem, calc(env(safe-area-inset-right) + 0.875rem))
);
}
html[data-agent-assistant-open='true'] .compact-fab-stack {
inset-inline-end: calc(
var(--agent-assistant-fab-offset) + max(0.875rem, calc(env(safe-area-inset-right) + 0.875rem))
);
}
}
.apexcharts-title-text {
@@ -1154,6 +1193,7 @@ html[data-theme="transparent"] .app-card-colorful,
.v-bottom-sheet > .v-bottom-sheet__content.v-overlay__content > .v-card,
.v-menu > .v-overlay__content > .v-card,
.v-menu > .v-overlay__content > .v-list {
border-radius: var(--app-overlay-radius) !important;
box-shadow: var(--app-overlay-shadow) !important;
}
@@ -1170,8 +1210,8 @@ html[data-theme="transparent"] .app-card-colorful,
overflow: hidden;
border-end-end-radius: 0 !important;
border-end-start-radius: 0 !important;
border-start-end-radius: var(--app-theme-surface-radius) !important;
border-start-start-radius: var(--app-theme-surface-radius) !important;
border-start-end-radius: var(--app-overlay-radius) !important;
border-start-start-radius: var(--app-overlay-radius) !important;
}
.v-dialog--fullscreen > .v-overlay__content > .v-card,
@@ -1180,8 +1220,8 @@ html[data-theme="transparent"] .app-card-colorful,
.v-dialog--fullscreen > .v-overlay__content > form > .v-sheet {
border-end-end-radius: 0 !important;
border-end-start-radius: 0 !important;
border-start-end-radius: var(--app-theme-surface-radius) !important;
border-start-start-radius: var(--app-theme-surface-radius) !important;
border-start-end-radius: var(--app-overlay-radius) !important;
border-start-start-radius: var(--app-overlay-radius) !important;
}
}

View File

@@ -130,4 +130,38 @@ html[data-theme="transparent"] {
background-color: rgba(var(--v-theme-surface), var(--transparent-opacity));
}
}
// 主题定制器面板
.theme-customizer-panel-host {
backdrop-filter: blur(var(--transparent-blur-heavy));
background-color: rgba(var(--v-theme-surface), var(--transparent-opacity-heavy)) !important;
border-inline-start: 1px solid rgba(var(--v-theme-on-surface), 0.08) !important;
}
.theme-customizer-panel {
backdrop-filter: blur(var(--transparent-blur));
background-color: transparent;
}
// 智能助手面板
.agent-assistant-panel {
backdrop-filter: blur(var(--transparent-blur-heavy));
background-color: rgba(var(--v-theme-surface), var(--transparent-opacity-heavy)) !important;
border-inline-start: 1px solid rgba(var(--v-theme-on-surface), 0.08);
}
.agent-assistant-shell {
--agent-assistant-panel-bg: rgba(var(--v-theme-surface), var(--transparent-opacity-heavy));
--agent-assistant-panel-blur: var(--transparent-blur);
--agent-assistant-assistant-bg: rgba(var(--v-theme-surface), var(--transparent-opacity-heavy));
}
.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));
}
}

View File

@@ -0,0 +1,56 @@
import type { SystemNotification } from '@/api/types'
const AGENT_ASSISTANT_BUBBLE_EVENT = 'agentAssistantBubble'
export interface AgentAssistantNotificationBubblePayload {
id: string
title?: string
text?: string
type?: string
mtype?: string
source?: string
date?: string
reg_time?: string
}
interface AgentAssistantBubbleEvent extends CustomEvent<AgentAssistantNotificationBubblePayload> {}
function createNotificationBubbleId(notification: SystemNotification) {
if (notification.id) return `notification-${notification.id}`
return `notification-${Date.now()}-${Math.random().toString(16).slice(2)}`
}
// 通知中心和智能助手入口没有父子关系,通过全局事件传递实时通知气泡数据。
export function emitAgentAssistantNotificationBubble(notification: SystemNotification) {
if (typeof window === 'undefined') return
window.dispatchEvent(
new CustomEvent<AgentAssistantNotificationBubblePayload>(AGENT_ASSISTANT_BUBBLE_EVENT, {
detail: {
id: createNotificationBubbleId(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 onAgentAssistantNotificationBubble(
callback: (payload: AgentAssistantNotificationBubblePayload) => void,
) {
if (typeof window === 'undefined') return () => {}
const handler = (event: Event) => {
callback((event as AgentAssistantBubbleEvent).detail)
}
window.addEventListener(AGENT_ASSISTANT_BUBBLE_EVENT, handler)
return () => window.removeEventListener(AGENT_ASSISTANT_BUBBLE_EVENT, handler)
}

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

View File

@@ -4,7 +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 NoDataFound from '@/components/states/NoDataFound.vue'
import { doneNProgress, startNProgress } from '@/api/nprogress'
import { formatSeason } from '@/@core/utils/formatters'
import { formatSeasonLabel } from '@/@core/utils/season'

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

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'

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'
@@ -678,23 +678,57 @@ async function saveFolderPluginOrder() {
}
}
/** 将插件市场运行时字段转换为可安全比较的文本。 */
function normalizeMarketText(value: unknown) {
if (typeof value === 'string') return value
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
return ''
}
/** 将插件市场逗号分隔字段转换为去重前的文本数组。 */
function splitMarketValues(value: unknown) {
if (Array.isArray(value)) {
return value.map(normalizeMarketText).map(item => item.trim()).filter(Boolean)
}
return normalizeMarketText(value)
.split(',')
.map(item => item.trim())
.filter(Boolean)
}
/** 判断插件是否来源于本地插件仓库。 */
function isLocalRepoSource(item: Plugin | string | undefined) {
if (!item) return false
const repoUrl = typeof item === 'string' ? item : normalizeMarketText(item.repo_url)
return Boolean((typeof item !== 'string' && item.is_local) || repoUrl.startsWith('local://'))
}
/** 解码本地插件仓库路径,避免异常路径中断市场列表加载。 */
function decodeLocalRepoPath(value: string) {
try {
return decodeURIComponent(value)
} catch (error) {
return value
}
}
// 初始化过滤选项
function initOptions(item: Plugin) {
const optionValue = (options: Array<string>, value: string | undefined, preferred = false) => {
if (!value || options.includes(value)) return
if (preferred) options.unshift(value)
else options.push(value)
const optionValue = (options: Array<string>, value: unknown, preferred = false) => {
const text = normalizeMarketText(value).trim()
if (!text || options.includes(text)) return
if (preferred) options.unshift(text)
else options.push(text)
}
const optionMutipleValue = (options: Array<string>, value: string | undefined) => {
value && value.split(',').forEach(v => !options.includes(v) && options.push(v))
const optionMutipleValue = (options: Array<string>, value: unknown) => {
splitMarketValues(value).forEach(v => !options.includes(v) && options.push(v))
}
optionValue(authorFilterOptions.value, item.plugin_author)
optionMutipleValue(labelFilterOptions.value, item.plugin_label)
optionValue(
repoFilterOptions.value,
handleRepoUrl(item),
Boolean(item.is_local || item.repo_url?.startsWith('local://')),
)
optionValue(repoFilterOptions.value, handleRepoUrl(item), isLocalRepoSource(item))
}
// 关闭插件市场窗口
@@ -775,12 +809,13 @@ function closeSearchDialog() {
// 过滤插件
const filterPlugins = computed(() => {
const all_list = [...dataList.value, ...uninstalledList.value]
const normalizedKeyword = normalizeMarketText(keyword.value).toLowerCase()
return all_list.filter((item: Plugin) => {
// 需要忽略大小写
return (
item.plugin_name?.toLowerCase().includes(keyword.value.toLowerCase()) ||
item.plugin_desc?.toLowerCase().includes(keyword.value.toLowerCase()) ||
!keyword
!normalizedKeyword ||
normalizeMarketText(item.plugin_name).toLowerCase().includes(normalizedKeyword) ||
normalizeMarketText(item.plugin_desc).toLowerCase().includes(normalizedKeyword)
)
})
})
@@ -818,12 +853,13 @@ async function fetchUninstalledPlugins(force: boolean = false, context: KeepAliv
if (showLoading) {
loading.value = true
}
uninstalledList.value = await api.get('plugin/', {
const marketResponse = await api.get('plugin/', {
params: {
state: 'market',
force: force,
},
})
uninstalledList.value = Array.isArray(marketResponse) ? marketResponse : []
// 设置更新状态
for (const uninstalled of uninstalledList.value) {
for (const data of dataList.value) {
@@ -842,6 +878,8 @@ async function fetchUninstalledPlugins(force: boolean = false, context: KeepAliv
// 排除已安装且有更新的,上面的问题在于"本地存在未安装的旧版本插件且云端有更新时"不会在插件市场展示
marketList.value = uninstalledList.value.filter(item => !(item.has_update && item.installed))
// 初始化过滤选项
authorFilterOptions.value = []
labelFilterOptions.value = []
repoFilterOptions.value = []
marketList.value.forEach(initOptions)
// 设置APP市场加载完成
@@ -876,12 +914,18 @@ async function refreshData(context: KeepAliveRefreshContext = {}) {
// 对uninstalledList进行排序到sortedUninstalledList
watch([marketList, filterForm, activeSort, PluginStatistics], () => {
// 匹配过滤函数
const match = (filter: Array<string>, value: string | undefined) =>
filter.length === 0 || (value && filter.includes(value))
const matchMultiple = (filter: Array<string>, value: string | undefined) =>
filter.length === 0 || (value && value.split(',').some(v => filter.includes(v)))
const filterText = (filter: string, value: string | undefined) =>
!filter || (value && value.toLowerCase().includes(filter.toLowerCase()))
const match = (filter: Array<string>, value: unknown) => {
const text = normalizeMarketText(value).trim()
return filter.length === 0 || (!!text && filter.includes(text))
}
const matchMultiple = (filter: Array<string>, value: unknown) =>
filter.length === 0 || splitMarketValues(value).some(v => filter.includes(v))
const filterText = (filter: string, value: unknown) => {
const text = normalizeMarketText(value).toLowerCase()
return !filter || (!!text && text.includes(filter.toLowerCase()))
}
sortedUninstalledList.value = []
@@ -889,7 +933,7 @@ watch([marketList, filterForm, activeSort, PluginStatistics], () => {
marketList.value.forEach(value => {
if (value) {
if (
filterText(filterForm.name, `${value.plugin_name} ${value.plugin_desc}`) &&
filterText(filterForm.name, `${normalizeMarketText(value.plugin_name)} ${normalizeMarketText(value.plugin_desc)}`) &&
match(filterForm.author, value.plugin_author) &&
matchMultiple(filterForm.label, value.plugin_label) &&
match(filterForm.repo, handleRepoUrl(value))
@@ -960,21 +1004,21 @@ async function refreshActiveTabData(context: KeepAliveRefreshContext = {}) {
}
function parseLocalRepoPath(repoUrl: string | undefined) {
if (!repoUrl?.startsWith('local://')) return ''
const text = normalizeMarketText(repoUrl)
if (!text.startsWith('local://')) return ''
try {
return new URL(repoUrl).searchParams.get('path') || ''
return new URL(text).searchParams.get('path') || ''
} catch (error) {
return decodeURIComponent(repoUrl.match(/[?&]path=([^&]+)/)?.[1] || '')
return decodeLocalRepoPath(text.match(/[?&]path=([^&]+)/)?.[1] || '')
}
}
// 处理掉github地址的前缀
function handleRepoUrl(item: Plugin | string | undefined) {
const url = typeof item === 'string' ? item : item?.repo_url
const url = typeof item === 'string' ? item : normalizeMarketText(item?.repo_url)
if (!url) return ''
if (url.startsWith('local://')) return parseLocalRepoPath(url) || localRepoLabel.value
if (typeof item !== 'string' && item?.is_local) return parseLocalRepoPath(url) || localRepoLabel.value
if (isLocalRepoSource(item)) return parseLocalRepoPath(url) || localRepoLabel.value
return url.replace('https://github.com/', '').replace('https://raw.githubusercontent.com/', '')
}

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'

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

View File

@@ -2,6 +2,7 @@
<script lang="ts" setup>
import { useToast } from 'vue-toastification'
import api from '@/api'
import { useGlobalSettingsStore } from '@/stores'
import { DownloaderConf, MediaServerConf } from '@/api/types'
import DownloaderCard from '@/components/cards/DownloaderCard.vue'
import MediaServerCard from '@/components/cards/MediaServerCard.vue'
@@ -17,6 +18,7 @@ const display = useDisplay()
const theme = useTheme()
const isTransparentTheme = computed(() => theme.name.value === 'transparent')
const globalSettingsStore = useGlobalSettingsStore()
// 国际化
const { t } = useI18n()
@@ -47,6 +49,7 @@ const SystemSettings = ref<any>({
CUSTOMIZE_WALLPAPER_API_URL: null,
AI_AGENT_ENABLE: false,
AI_AGENT_GLOBAL: false,
AI_AGENT_HIDE_ENTRY: false,
AI_AGENT_VERBOSE: false,
AI_AGENT_JOB_INTERVAL: 24,
LLM_PROVIDER: 'deepseek',
@@ -59,7 +62,7 @@ const SystemSettings = ref<any>({
LLM_BASE_URL: 'https://api.deepseek.com',
LLM_USE_PROXY: true,
LLM_BASE_URL_PRESET: null,
LLM_MAX_CONTEXT_TOKENS: 64,
LLM_MAX_CONTEXT_TOKENS: 128,
LLM_USER_AGENT: null,
AUDIO_INPUT_PROVIDER: 'openai',
AUDIO_INPUT_API_KEY: null,
@@ -698,6 +701,8 @@ async function saveBasicSettings() {
savingBasic.value = true
try {
if (await saveSystemSetting(SystemSettings.value.Basic)) {
// 更新全局设置store使Web Agent图标实时生效
globalSettingsStore.setData({ ...globalSettingsStore.getData, ...SystemSettings.value.Basic })
$toast.success(t('setting.system.basicSaveSuccess'))
}
} finally {
@@ -1156,7 +1161,7 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
<VExpandTransition>
<VCardText v-show="!aiAgentSettingsCollapsed" class="pt-2">
<VRow>
<VCol cols="12" md="4">
<VCol cols="12" md="6">
<VSwitch
v-model="SystemSettings.Basic.AI_AGENT_ENABLE"
:label="t('setting.system.aiAgentEnable')"
@@ -1164,7 +1169,7 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
persistent-hint
/>
</VCol>
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="4">
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
<VSwitch
v-model="SystemSettings.Basic.AI_AGENT_GLOBAL"
:label="t('setting.system.aiAgentGlobal')"
@@ -1172,7 +1177,7 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
persistent-hint
/>
</VCol>
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="4">
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
<VSwitch
v-model="SystemSettings.Basic.AI_AGENT_VERBOSE"
:label="t('setting.system.aiAgentVerbose')"
@@ -1180,6 +1185,14 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
persistent-hint
/>
</VCol>
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
<VSwitch
v-model="SystemSettings.Basic.AI_AGENT_HIDE_ENTRY"
:label="t('setting.system.aiAgentHideEntry')"
:hint="t('setting.system.aiAgentHideEntryHint')"
persistent-hint
/>
</VCol>
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
<VAutocomplete
v-model="SystemSettings.Basic.LLM_PROVIDER"

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'

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import type { CalendarOptions, EventSourceInput } from '@fullcalendar/core'
import type { CalendarOptions, EventInput, EventSourceInput } from '@fullcalendar/core'
import dayGridPlugin from '@fullcalendar/daygrid'
import interactionPlugin from '@fullcalendar/interaction'
import timeGridPlugin from '@fullcalendar/timegrid'
@@ -15,6 +15,10 @@ import { openSharedDialog } from '@/composables/useSharedDialog'
const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue'))
const COLLAPSED_DAY_CARD_LIMIT = 5
const COLLAPSED_VISIBLE_CARD_LIMIT = COLLAPSED_DAY_CARD_LIMIT
const DAY_GROUP_EVENT_PREFIX = 'calendar-day-group-'
// 国际化
const { t } = useI18n()
@@ -35,6 +39,7 @@ let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
type CalendarLibraryState = 'none' | 'partial' | 'complete'
interface CalendarEventInfo {
id?: string
title: string
subtitle: string
start: Date | null
@@ -49,6 +54,9 @@ interface CalendarEventInfo {
libraryEpisodeNumbers: number[]
libraryState: CalendarLibraryState
libraryUpdateText: string
dateKey?: string
hiddenEventCount?: number
calendarSortIndex?: number
}
// 打开订阅日历共享进度弹窗。
@@ -85,10 +93,12 @@ const calendarOptions: Ref<CalendarOptions> = ref({
center: 'title',
right: 'next',
},
// 日历页需要完整展示每天所有订阅条目,避免折叠成 "+ more" 后隐藏关键信息
// 折叠逻辑由组件自行控制,点击展开时可以直接扩展当前日期格子
dayMaxEvents: false,
dayMaxEventRows: false,
eventDisplay: 'block',
eventOrder: 'start,calendarSortIndex,title',
eventOrderStrict: true,
views: {
week: {
titleFormat: { day: 'numeric' },
@@ -97,6 +107,91 @@ const calendarOptions: Ref<CalendarOptions> = ref({
events: [],
})
// 原始日历事件与已展开日期分离,避免依赖 FullCalendar 的弹窗式 more 链接。
const rawCalendarEvents = ref<CalendarEventInfo[]>([])
const expandedDateKeys = ref(new Set<string>())
const calendarRef = ref<InstanceType<typeof FullCalendar> | null>(null)
function getDateKey(date: Date | null) {
if (!date) return ''
const year = date.getFullYear()
const month = `${date.getMonth() + 1}`.padStart(2, '0')
const day = `${date.getDate()}`.padStart(2, '0')
return `${year}-${month}-${day}`
}
function getDayGroupEventId(dateKey: string) {
return `${DAY_GROUP_EVENT_PREFIX}${dateKey}`
}
function createDayGroupCalendarEvent(dateKey: string, events: CalendarEventInfo[]): EventInput {
const isExpanded = expandedDateKeys.value.has(dateKey)
const visibleEvents = isExpanded ? events : events.slice(0, COLLAPSED_VISIBLE_CARD_LIMIT)
return {
id: getDayGroupEventId(dateKey),
title: '',
start: events[0]?.start || undefined,
allDay: false,
interactive: false,
calendarSortIndex: events[0]?.calendarSortIndex ?? 0,
dateKey,
hiddenEventCount: isExpanded ? 0 : Math.max(events.length - COLLAPSED_VISIBLE_CARD_LIMIT, 0),
isDayGroup: true,
visibleEvents,
}
}
function normalizeCalendarEventOrder(events: CalendarEventInfo[]) {
return events
.sort((first, second) => {
const firstTime = first.start?.getTime() ?? 0
const secondTime = second.start?.getTime() ?? 0
return firstTime - secondTime || first.title.localeCompare(second.title)
})
.map((event, index) => ({
...event,
calendarSortIndex: index,
}))
}
function renderVisibleCalendarEvents() {
const groupedEvents = new Map<string, CalendarEventInfo[]>()
rawCalendarEvents.value.forEach(event => {
const dateKey = getDateKey(event.start)
if (!dateKey) return
groupedEvents.set(dateKey, [...(groupedEvents.get(dateKey) || []), event])
})
calendarOptions.value.events = Array.from(groupedEvents.entries()).map(([dateKey, events]) =>
createDayGroupCalendarEvent(dateKey, events),
) as EventSourceInput
}
function expandCalendarDay(dateKey: string) {
const currentScrollY = window.scrollY
const events = rawCalendarEvents.value.filter(event => getDateKey(event.start) === dateKey)
const calendarApi = calendarRef.value?.getApi()
expandedDateKeys.value = new Set(expandedDateKeys.value).add(dateKey)
// 只更新当天这个聚合事件的内容,避免重置整个 FullCalendar 导致页面回到顶部。
if (calendarApi) {
const event = calendarApi.getEventById(getDayGroupEventId(dateKey))
event?.setExtendedProp('visibleEvents', events)
event?.setExtendedProp('hiddenEventCount', 0)
requestAnimationFrame(() => window.scrollTo({ top: currentScrollY, left: window.scrollX }))
} else {
renderVisibleCalendarEvents()
}
}
function clampEpisodeCount(value: number, total: number) {
return Math.min(Math.max(value, 0), total)
}
@@ -108,7 +203,7 @@ function getLibraryEpisodeCount(subscribe: Subscribe) {
const libraryEpisode =
typeof subscribe.lack_episode === 'number'
? totalEpisode - subscribe.lack_episode
: subscribe.completed_episode ?? 0
: (subscribe.completed_episode ?? 0)
return clampEpisodeCount(libraryEpisode, totalEpisode)
}
@@ -123,9 +218,7 @@ function getLackEpisodeCount(subscribe: Subscribe) {
function normalizeEpisodeNumbers(value: unknown) {
if (!Array.isArray(value)) return []
return value
.map(number => Number(number))
.filter(number => Number.isFinite(number) && number > 0)
return value.map(number => Number(number)).filter(number => Number.isFinite(number) && number > 0)
}
function isEnabledFlag(value: unknown) {
@@ -183,15 +276,20 @@ function buildCalendarEventInfo(
}
}
function getCalendarEventTooltip(event: any) {
function getExpandCalendarEventLabel(event: any) {
const props = event.extendedProps as CalendarEventInfo
return t('calendar.expandDayEvents', { count: props.hiddenEventCount || 0 })
}
function getCalendarEventInfoTooltip(event: CalendarEventInfo) {
const parts = [event.title]
if (props.subtitle) parts.push(t('calendar.episode', { number: props.subtitle }))
if (props.totalEpisode) {
parts.push(t('calendar.libraryProgress', { completed: props.libraryEpisode, total: props.totalEpisode }))
if (event.subtitle) parts.push(t('calendar.episode', { number: event.subtitle }))
if (event.totalEpisode) {
parts.push(t('calendar.libraryProgress', { completed: event.libraryEpisode, total: event.totalEpisode }))
}
if (props.libraryUpdateText) parts.push(t('calendar.libraryUpdatedAt', { time: props.libraryUpdateText }))
if (event.libraryUpdateText) parts.push(t('calendar.libraryUpdatedAt', { time: event.libraryUpdateText }))
return parts.filter(Boolean).join(' · ')
}
@@ -273,7 +371,8 @@ async function getSubscribes() {
loading.value = false
const subEvents = await Promise.allSettled(subscribes.map(async sub => eventsHander(sub)))
const succEvents = subEvents.filter(result => result.status === 'fulfilled').map(result => result.value)
calendarOptions.value.events = succEvents.flat().filter(event => event.start) as EventSourceInput
rawCalendarEvents.value = normalizeCalendarEventOrder(succEvents.flat().filter(event => event.start))
renderVisibleCalendarEvents()
isLoaded.value = true
} catch (error) {
console.error(error)
@@ -295,19 +394,21 @@ onActivated(() => {
</script>
<template>
<FullCalendar :options="calendarOptions">
<FullCalendar ref="calendarRef" :options="calendarOptions">
<template #eventContent="arg">
<div v-if="display.lgAndUp.value">
<div v-if="arg.event.extendedProps.isDayGroup" class="calendar-day-events">
<div
v-for="calendarEvent in arg.event.extendedProps.visibleEvents"
:key="`${calendarEvent.title}-${calendarEvent.subtitle}-${calendarEvent.calendarSortIndex}`"
class="calendar-event-card"
:class="`calendar-event-card--${arg.event.extendedProps.libraryState}`"
:title="getCalendarEventTooltip(arg.event)"
:class="`calendar-event-card--${calendarEvent.libraryState}`"
:title="getCalendarEventInfoTooltip(calendarEvent)"
>
<div class="calendar-event-poster">
<div v-if="display.lgAndUp.value" class="calendar-event-poster">
<VImg
height="74"
width="50"
:src="arg.event.extendedProps.posterPath"
:src="calendarEvent.posterPath"
aspect-ratio="2/3"
class="calendar-event-image object-cover"
cover
@@ -318,71 +419,80 @@ onActivated(() => {
</div>
</template>
</VImg>
<span
v-if="arg.event.extendedProps.libraryState === 'complete'"
class="calendar-library-check"
>
<span v-if="calendarEvent.libraryState === 'complete'" class="calendar-library-check">
<VIcon icon="mdi-check" size="12" />
</span>
</div>
<div class="calendar-event-content">
<VImg
v-else
:src="calendarEvent.posterPath"
aspect-ratio="2/3"
class="calendar-mobile-image object-cover ring-gray-500"
cover
:title="getCalendarEventInfoTooltip(calendarEvent)"
>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
<span
v-if="calendarEvent.libraryState === 'complete'"
class="calendar-library-check calendar-library-check--mobile"
>
<VIcon icon="mdi-check" size="11" />
</span>
<span v-if="calendarEvent.subtitle" class="calendar-mobile-episode">
{{ calendarEvent.subtitle }}
</span>
</VImg>
<div v-if="display.lgAndUp.value" class="calendar-event-content">
<div class="calendar-event-title">
{{ arg.event.title }}
{{ calendarEvent.title }}
</div>
<div v-if="arg.event.extendedProps.subtitle" class="calendar-event-episode">
<div v-if="calendarEvent.subtitle" class="calendar-event-episode">
<VIcon icon="mdi-calendar-blank-outline" size="13" />
{{ t('calendar.episode', { number: arg.event.extendedProps.subtitle }) }}
{{ t('calendar.episode', { number: calendarEvent.subtitle }) }}
</div>
<div v-if="arg.event.extendedProps.totalEpisode" class="calendar-event-library-row">
<div v-if="calendarEvent.totalEpisode" class="calendar-event-library-row">
<span
v-if="arg.event.extendedProps.libraryState !== 'complete'"
v-if="calendarEvent.libraryState !== 'complete'"
class="calendar-event-status"
:class="`calendar-event-status--${arg.event.extendedProps.libraryState}`"
:class="`calendar-event-status--${calendarEvent.libraryState}`"
>
<VIcon :icon="getLibraryStateIcon(arg.event.extendedProps.libraryState)" size="13" />
{{ getLibraryStateText(arg.event.extendedProps.libraryState) }}
<VIcon :icon="getLibraryStateIcon(calendarEvent.libraryState)" size="13" />
{{ getLibraryStateText(calendarEvent.libraryState) }}
</span>
<span class="calendar-event-progress">
<VIcon icon="mdi-library" size="13" />
{{
t('calendar.libraryProgress', {
completed: arg.event.extendedProps.libraryEpisode,
total: arg.event.extendedProps.totalEpisode,
completed: calendarEvent.libraryEpisode,
total: calendarEvent.totalEpisode,
})
}}
</span>
</div>
<div v-if="arg.event.extendedProps.libraryUpdateText" class="calendar-event-time">
<div v-if="calendarEvent.libraryUpdateText" class="calendar-event-time">
<VIcon icon="mdi-clock-outline" size="13" />
{{ t('calendar.libraryUpdatedAtShort', { time: arg.event.extendedProps.libraryUpdateText }) }}
{{ t('calendar.libraryUpdatedAtShort', { time: calendarEvent.libraryUpdateText }) }}
</div>
</div>
</div>
</div>
<div v-else>
<VImg
:src="arg.event.extendedProps.posterPath"
aspect-ratio="2/3"
class="calendar-mobile-image object-cover ring-gray-500"
cover
:title="getCalendarEventTooltip(arg.event)"
<button
v-if="arg.event.extendedProps.hiddenEventCount"
type="button"
class="calendar-expand-card"
:title="getExpandCalendarEventLabel(arg.event)"
:aria-label="getExpandCalendarEventLabel(arg.event)"
@click.stop.prevent="expandCalendarDay(arg.event.extendedProps.dateKey)"
>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
<span
v-if="arg.event.extendedProps.libraryState === 'complete'"
class="calendar-library-check calendar-library-check--mobile"
>
<VIcon icon="mdi-check" size="11" />
</span>
<span v-if="arg.event.extendedProps.subtitle" class="calendar-mobile-episode">
{{ arg.event.extendedProps.subtitle }}
</span>
</VImg>
<VIcon icon="mdi-unfold-more-horizontal" size="18" />
<span class="calendar-expand-count">+{{ arg.event.extendedProps.hiddenEventCount }}</span>
</button>
</div>
</template>
</FullCalendar>
@@ -561,18 +671,18 @@ onActivated(() => {
.v-application .fc .fc-event,
.v-application .fc .fc-h-event,
.v-application .fc .fc-daygrid-event {
padding: 0 !important;
border-color: transparent;
background: transparent !important;
box-shadow: none;
margin-block-end: 0.3rem;
padding: 0 !important;
}
.v-application .fc .fc-event-main {
padding: 0 !important;
color: inherit;
font-size: 0.75rem;
font-weight: 500;
padding: 0 !important;
}
.v-application .fc tbody[role='rowgroup'] > tr > td[role='presentation'] {
@@ -671,12 +781,44 @@ onActivated(() => {
.calendar-event-card {
display: flex;
gap: 0.55rem;
overflow: hidden;
align-items: flex-start;
padding: 0.4rem;
border-radius: 8px;
background: rgba(var(--v-theme-surface), 0.72);
overflow: hidden;
gap: 0.55rem;
}
.calendar-day-events {
display: flex;
flex-direction: column;
gap: 0.3rem;
inline-size: 100%;
}
.calendar-expand-card {
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border: 1px dashed rgba(var(--v-theme-primary), 0.44);
border-radius: 8px;
background: rgba(var(--v-theme-primary), 0.08);
color: rgb(var(--v-theme-primary));
cursor: pointer;
font-size: 0.78rem;
font-weight: 700;
gap: 0.35rem;
inline-size: 100%;
min-block-size: 2.1rem;
}
.calendar-expand-card:hover {
background: rgba(var(--v-theme-primary), 0.14);
}
.calendar-expand-count {
line-height: 1;
}
.calendar-event-poster {
@@ -693,24 +835,24 @@ onActivated(() => {
.calendar-library-check {
position: absolute;
top: 0.18rem;
right: 0.18rem;
display: inline-flex;
align-items: center;
justify-content: center;
border: 2px solid rgb(var(--v-theme-surface));
border: 2px solid rgba(var(--v-theme-surface), 0.5);
border-radius: 50%;
background: rgb(var(--v-theme-success));
block-size: 1.15rem;
color: rgb(var(--v-theme-on-success));
inline-size: 1.15rem;
inset-block-start: 0.18rem;
inset-inline-end: 0.18rem;
}
.calendar-library-check--mobile {
top: 0.12rem;
right: 0.12rem;
block-size: 1rem;
inline-size: 1rem;
inset-block-start: 0.12rem;
inset-inline-end: 0.12rem;
}
.calendar-event-content {
@@ -724,23 +866,23 @@ onActivated(() => {
.calendar-event-title {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
font-size: 0.88rem;
font-weight: 700;
-webkit-line-clamp: 2;
line-clamp: 2;
line-height: 1.28;
max-block-size: calc(0.88rem * 1.28 * 2);
overflow-wrap: anywhere;
white-space: normal;
word-break: break-word;
line-clamp: 2;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.calendar-event-episode {
display: inline-flex;
align-items: center;
overflow: hidden;
align-items: center;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
column-gap: 0.2rem;
font-size: 0.72rem;
@@ -753,8 +895,8 @@ onActivated(() => {
.calendar-event-episode,
.calendar-event-time {
display: inline-flex;
align-items: center;
overflow: hidden;
align-items: center;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
column-gap: 0.2rem;
line-height: 1.25;
@@ -765,8 +907,8 @@ onActivated(() => {
.calendar-event-library-row {
display: flex;
flex-wrap: wrap;
gap: 0.18rem 0.3rem;
align-items: center;
gap: 0.18rem 0.3rem;
min-inline-size: 0;
}
@@ -796,8 +938,8 @@ onActivated(() => {
.calendar-event-status,
.calendar-event-progress,
.calendar-event-time {
max-inline-size: 100%;
overflow: hidden;
max-inline-size: 100%;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -810,15 +952,14 @@ onActivated(() => {
.calendar-mobile-episode {
position: absolute;
right: 0;
bottom: 0;
left: 0;
display: block;
overflow: hidden;
background: rgba(0, 0, 0, 0.58);
background: rgba(0, 0, 0, 58%);
color: #fff;
font-size: 0.62rem;
font-weight: 700;
inset-block-end: 0;
inset-inline: 0;
line-height: 1.25;
padding-block: 0.1rem;
padding-inline: 0.2rem;
@@ -828,10 +969,27 @@ onActivated(() => {
}
@media (width <= 1279px) {
.calendar-day-events {
align-items: center;
}
.calendar-event-card,
.fc-daygrid-event-harness {
display: flex;
align-items: center;
justify-content: center;
}
.calendar-expand-card {
flex-direction: column;
block-size: clamp(60px, 8.7vw, 96px);
gap: 0.12rem;
inline-size: clamp(40px, 5.8vw, 64px);
min-block-size: 0;
}
.calendar-expand-count {
font-size: 0.68rem;
}
}
</style>

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'

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'

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'

View File

@@ -1,426 +0,0 @@
<script lang="ts" setup>
import type { Message } from '@/api/types'
import MessageCard from '@/components/cards/MessageCard.vue'
import api from '@/api'
import { useI18n } from 'vue-i18n'
import { useBackground } from '@/composables/useBackground'
// 国际化
const { t } = useI18n()
const { useSSE } = useBackground()
// 消息列表
const messages = ref<Message[]>([])
// 当前页数据
const currData = ref<Message[]>([])
// 已加载消息的签名集合
// SSE 消息与数据库消息的字段来源不同date vs reg_time, null vs {}),签名已归一化处理。
const messageKeys = new Set<string>()
// 是否完成加载
const isLoaded = ref(false)
// 是否加载中
const loading = ref(false)
// 当前页码
const page = ref(1)
// 存量消息最新时间
const lastTime = ref('')
// 消息列表滚动容器
const messageListRef = ref<any>(null)
// 自动滚动状态
const shouldAutoScroll = ref(true)
const isSyncingScroll = ref(false)
const MESSAGE_AUTO_SCROLL_THRESHOLD = 64
let scrollTimer: number | undefined
let scrollReleaseTimer: number | undefined
let boundScrollContainer: HTMLElement | null = null
// 生成消息去重签名
// SSE 消息只有 date 没有 reg_time数据库消息只有 reg_time 没有 date
// note 在 SSE 侧为 null数据库侧为 {},需要归一化。
function normalizeNote(note: Message['note']): string {
if (note == null) return ''
if (typeof note === 'string') return note
if (typeof note === 'object' && !Array.isArray(note) && Object.keys(note).length === 0) return ''
return JSON.stringify(note)
}
function getMessageKey(message: Message) {
return [
message.action ?? '',
message.userid ?? '',
message.reg_time || message.date || '',
message.title ?? '',
message.text ?? '',
message.image ?? '',
message.link ?? '',
normalizeNote(message.note),
].join('::')
}
// 获取消息时间
function getMessageTime(message: Message) {
return message.reg_time || message.date || ''
}
// 排序消息列表,确保最新消息始终位于底部
function sortMessages(items: Message[]) {
return [...items].sort((a, b) => compareTime(getMessageTime(a), getMessageTime(b)))
}
// 记录最新消息时间
function updateLastTime(message: Message) {
const messageTime = getMessageTime(message)
if (messageTime && compareTime(messageTime, lastTime.value) > 0) {
lastTime.value = messageTime
}
}
/** 判断元素自身是否是真正承载滚动的位置。 */
function isScrollableElement(element: HTMLElement) {
const { overflowY } = window.getComputedStyle(element)
const canScroll = overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay'
return canScroll && element.scrollHeight > element.clientHeight + 1
}
/** 获取消息列表所在的真实滚动容器。 */
function getScrollContainer() {
const element = messageListRef.value?.$el ?? messageListRef.value
if (!(element instanceof HTMLElement)) {
return null
}
let container: HTMLElement | null = element
while (container) {
if (isScrollableElement(container)) {
return container
}
container = container.parentElement
}
const dialogCardText = element.closest('.v-card-text')
return dialogCardText instanceof HTMLElement ? dialogCardText : element
}
function isNearBottom(container: HTMLElement) {
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight
return distanceFromBottom <= Math.max(MESSAGE_AUTO_SCROLL_THRESHOLD, container.clientHeight / 3)
}
function updateAutoScrollState() {
const container = getScrollContainer()
if (!container || isSyncingScroll.value) {
return
}
shouldAutoScroll.value = isNearBottom(container)
}
function handleScroll() {
updateAutoScrollState()
}
function bindScrollListener() {
const container = getScrollContainer()
if (!container) {
return
}
if (boundScrollContainer && boundScrollContainer !== container) {
boundScrollContainer.removeEventListener('scroll', handleScroll)
}
container.removeEventListener('scroll', handleScroll)
container.addEventListener('scroll', handleScroll, { passive: true })
boundScrollContainer = container
updateAutoScrollState()
}
function unbindScrollListener() {
boundScrollContainer?.removeEventListener('scroll', handleScroll)
boundScrollContainer = null
}
/** 滚动到底部,并在布局稳定前连续几帧校正滚动位置。 */
function scrollContainerToEnd(retryCount = 1) {
const container = getScrollContainer()
if (!container) {
return
}
bindScrollListener()
isSyncingScroll.value = true
container.scrollTop = Math.max(0, container.scrollHeight - container.clientHeight)
requestAnimationFrame(() => {
const latestContainer = getScrollContainer()
if (!latestContainer) {
isSyncingScroll.value = false
return
}
latestContainer.scrollTop = Math.max(0, latestContainer.scrollHeight - latestContainer.clientHeight)
shouldAutoScroll.value = true
if (retryCount > 0) {
scrollContainerToEnd(retryCount - 1)
return
}
if (scrollReleaseTimer) {
window.clearTimeout(scrollReleaseTimer)
}
scrollReleaseTimer = window.setTimeout(() => {
isSyncingScroll.value = false
updateAutoScrollState()
}, 80)
})
}
function requestScrollToEnd(force = false) {
if (!force && !shouldAutoScroll.value) {
return
}
if (scrollTimer) {
window.clearTimeout(scrollTimer)
}
scrollTimer = window.setTimeout(() => {
nextTick(() => {
requestAnimationFrame(() => {
scrollContainerToEnd(force ? 6 : 1)
})
})
}, force ? 0 : 80)
}
function forceScrollToEnd() {
requestScrollToEnd(true)
}
// 合并消息到当前列表
function mergeMessages(items: Message[]) {
let hasNewMessage = false
for (const item of sortMessages(items)) {
const messageKey = getMessageKey(item)
if (messageKeys.has(messageKey)) {
continue
}
messageKeys.add(messageKey)
messages.value.push(item)
updateLastTime(item)
hasNewMessage = true
}
if (hasNewMessage) {
messages.value = sortMessages(messages.value)
}
return hasNewMessage
}
// SSE消息处理函数
function handleSSEMessage(event: MessageEvent) {
const message = event.data
if (message) {
const object = JSON.parse(message)
if (mergeMessages([object])) {
requestScrollToEnd() // 新消息到达时触发智能滚动
}
}
}
// 使用SSE连接
const { manager, isConnected } = useSSE(
`${import.meta.env.VITE_API_BASE_URL}system/message?role=user`,
handleSSEMessage,
'message-view',
{
backgroundCloseDelay: 5000,
reconnectDelay: 3000,
maxReconnectAttempts: 3,
},
)
// 调用API加载存量消息
async function loadMessages({ done }: { done: any }) {
// 如果正在加载中,直接返回
if (loading.value) {
done('ok')
return
}
try {
// 设置加载中
loading.value = true
const isFirstPage = page.value === 1
currData.value = await api.get('message/web', {
params: {
page: page.value,
size: 20,
},
})
// 已加载过
isLoaded.value = true
if (currData.value.length > 0) {
mergeMessages(currData.value)
// 页码+1
page.value++
// 完成
done('ok')
// 首次加载完成后再滚动,避免列表尚未完成布局时滚动失效。
if (isFirstPage) {
requestScrollToEnd(true)
}
} else {
// 没有新数据
done('empty')
}
} catch (error) {
console.error('加载消息失败:', error)
done('error')
} finally {
loading.value = false
}
}
// 主动刷新最新一页消息作为SSE偶发丢流时的兜底
async function refreshLatestMessages() {
try {
const latestMessages = (await api.get('message/web', {
params: {
page: 1,
size: 20,
},
})) as Message[]
if (mergeMessages(latestMessages)) {
requestScrollToEnd()
}
} catch (error) {
console.error('刷新最新消息失败:', error)
}
}
// 比较yyyy-MM-dd HH:mm:ss时间大小
function compareTime(time1: string, time2: string) {
if (!time1 && !time2) return 0
if (!time1) return -1
if (!time2) return 1
try {
// 统一时间格式处理,支持多种格式
const normalizeTime = (time: string) => {
// 如果是ISO格式直接使用
if (time.includes('T')) {
return new Date(time).getTime()
}
// 如果是yyyy-MM-dd HH:mm:ss格式替换-为/
return new Date(time.replaceAll(/-/g, '/')).getTime()
}
const timestamp1 = normalizeTime(time1)
const timestamp2 = normalizeTime(time2)
return timestamp1 - timestamp2
} catch (error) {
console.error('时间比较错误:', error, 'time1:', time1, 'time2:', time2)
return 0
}
}
// 图片加载完成时触发智能滚动
function handleImageLoad() {
requestScrollToEnd()
}
// 暂停SSE连接
function pauseSSE() {
if (manager) {
manager.removeMessageListener('message-view')
}
}
// 恢复SSE连接
function resumeSSE() {
if (manager) {
// 先移除再重建监听确保恢复时拿到一条新的SSE连接。
manager.removeMessageListener('message-view')
manager.addMessageListener('message-view', handleSSEMessage)
}
refreshLatestMessages()
}
// 暴露方法给父组件
defineExpose({
pauseSSE,
resumeSSE,
refreshLatestMessages,
forceScrollToEnd,
})
onMounted(() => {
nextTick(() => {
bindScrollListener()
})
})
onBeforeUnmount(() => {
if (scrollTimer) {
window.clearTimeout(scrollTimer)
}
if (scrollReleaseTimer) {
window.clearTimeout(scrollReleaseTimer)
}
unbindScrollListener()
})
</script>
<template>
<VInfiniteScroll
ref="messageListRef"
:mode="!isLoaded ? 'intersect' : 'manual'"
side="start"
:items="messages"
class="overflow-auto h-full"
@load="loadMessages"
:load-more-text="t('message.loadMore') + ' ...'"
>
<template #loading>
<LoadingBanner />
</template>
<template #empty> {{ t('message.noMoreData') }} </template>
<div
v-for="(item, index) in messages"
:key="getMessageKey(item) || index"
class="chat-group d-flex mt-5 mb-8"
:class="item.action == 1 ? 'flex-row align-start' : 'flex-row-reverse align-end'"
>
<div class="d-inline-flex flex-column" :class="item.action == 1 ? 'align-start' : 'align-end'">
<MessageCard :message="item" @imageload="handleImageLoad" />
</div>
</div>
</VInfiniteScroll>
</template>

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'

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'

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'