mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-06-22 08:03:45 +08:00
Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39c250ba09 | ||
|
|
924fcef403 | ||
|
|
e586342b19 | ||
|
|
c795de9b2d | ||
|
|
6fa1cf28f4 | ||
|
|
3f70aafdad | ||
|
|
f8ceee39b3 | ||
|
|
0a22f33e34 | ||
|
|
cf88ed9a58 | ||
|
|
49dfd794c1 | ||
|
|
68f2f010d1 | ||
|
|
9eed2fea87 | ||
|
|
1f170030ee | ||
|
|
e78ed20936 | ||
|
|
b1787b207d | ||
|
|
fdb34732cc | ||
|
|
fc1f163a94 | ||
|
|
a771dc5354 | ||
|
|
d28360a161 | ||
|
|
a730abc437 | ||
|
|
5b72eda4fc | ||
|
|
6c49d7a59e | ||
|
|
8900366faf | ||
|
|
e8e0ac9084 | ||
|
|
c66ee881b1 | ||
|
|
c055740926 | ||
|
|
a5bc4e6baf | ||
|
|
15b4ee5893 | ||
|
|
8868403ff3 | ||
|
|
3abff72e25 | ||
|
|
0c56cf0be7 | ||
|
|
ce12d04648 | ||
|
|
efc0ae4df6 | ||
|
|
2530c3bcd9 | ||
|
|
60e2402aff | ||
|
|
1a478f97fb | ||
|
|
33666703af | ||
|
|
cd69172a99 | ||
|
|
61749e3595 | ||
|
|
b658533262 | ||
|
|
d8015b7def |
@@ -18,7 +18,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<!-- 核心viewport设置 - 针对PWA优化 -->
|
<!-- 核心viewport设置 - 针对PWA优化 -->
|
||||||
<meta name="viewport"
|
<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" />
|
<meta name="format-detection" content="telephone=no, date=no, email=no, address=no" />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "moviepilot",
|
"name": "moviepilot",
|
||||||
"version": "2.13.10",
|
"version": "2.13.13",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": "dist/service.js",
|
"bin": "dist/service.js",
|
||||||
|
|||||||
@@ -35,6 +35,23 @@ http {
|
|||||||
try_files $uri $uri/ /index.html;
|
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)$ {
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||||
# 静态资源
|
# 静态资源
|
||||||
expires 1y;
|
expires 1y;
|
||||||
@@ -44,8 +61,7 @@ http {
|
|||||||
|
|
||||||
location /assets {
|
location /assets {
|
||||||
# 静态资源
|
# 静态资源
|
||||||
expires 1y;
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||||
add_header Cache-Control "public";
|
|
||||||
root html;
|
root html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
10
src/App.vue
10
src/App.vue
@@ -59,8 +59,9 @@ const loginStateKey = computed(() => (isLogin.value ? 'logged-in' : 'logged-out'
|
|||||||
const backgroundImages = ref<string[]>([])
|
const backgroundImages = ref<string[]>([])
|
||||||
const activeImageIndex = ref(0)
|
const activeImageIndex = ref(0)
|
||||||
const isTransparentTheme = computed(() => globalTheme.name.value === 'transparent')
|
const isTransparentTheme = computed(() => globalTheme.name.value === 'transparent')
|
||||||
|
const isLoginWallpaperRoute = computed(() => !isLogin.value && route.path === LOGIN_WALLPAPER_ROUTE)
|
||||||
const shouldLoadBackgroundImages = computed(
|
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 backgroundRetryTimer: number | null = null
|
||||||
let backgroundRequestController: AbortController | null = null
|
let backgroundRequestController: AbortController | null = null
|
||||||
@@ -434,7 +435,7 @@ onUnmounted(() => {
|
|||||||
<div v-if="isLogin && isTransparentTheme" class="global-blur-layer"></div>
|
<div v-if="isLogin && isTransparentTheme" class="global-blur-layer"></div>
|
||||||
</div>
|
</div>
|
||||||
<!-- 页面内容 -->
|
<!-- 页面内容 -->
|
||||||
<VApp>
|
<VApp :class="{ 'app-shell--login-wallpaper': isLoginWallpaperRoute }">
|
||||||
<RouterView />
|
<RouterView />
|
||||||
<!-- 全局共享弹窗入口,列表与卡片按需在这里挂载业务弹窗。 -->
|
<!-- 全局共享弹窗入口,列表与卡片按需在这里挂载业务弹窗。 -->
|
||||||
<SharedDialogHost />
|
<SharedDialogHost />
|
||||||
@@ -504,4 +505,9 @@ onUnmounted(() => {
|
|||||||
inset-block-start: 0;
|
inset-block-start: 0;
|
||||||
inset-inline-start: 0;
|
inset-inline-start: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 登录页壁纸在 VApp 外层渲染,登录页 VApp 需要透明才能露出壁纸。 */
|
||||||
|
.app-shell--login-wallpaper.v-application {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -656,6 +656,8 @@ export interface Plugin {
|
|||||||
system_version_message?: string
|
system_version_message?: string
|
||||||
// 主系统版本限定范围
|
// 主系统版本限定范围
|
||||||
system_version?: string
|
system_version?: string
|
||||||
|
// 是否声明支持通过 GitHub Release 资产安装
|
||||||
|
release?: boolean
|
||||||
// 是否本地插件
|
// 是否本地插件
|
||||||
is_local?: boolean
|
is_local?: boolean
|
||||||
// 插件仓库地址
|
// 插件仓库地址
|
||||||
@@ -668,6 +670,38 @@ export interface Plugin {
|
|||||||
page_open?: boolean
|
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 对齐)
|
// 插件侧栏全页导航项(与后端 PluginSidebarNavItem 对齐)
|
||||||
export interface PluginSidebarNavItem {
|
export interface PluginSidebarNavItem {
|
||||||
plugin_id: string
|
plugin_id: string
|
||||||
@@ -1131,6 +1165,12 @@ export interface MediaServerLibrary {
|
|||||||
|
|
||||||
// 消息通知
|
// 消息通知
|
||||||
export interface Message {
|
export interface Message {
|
||||||
|
// 消息ID
|
||||||
|
id?: number
|
||||||
|
// 消息渠道
|
||||||
|
channel?: string
|
||||||
|
// 消息来源
|
||||||
|
source?: string
|
||||||
// 消息类型
|
// 消息类型
|
||||||
mtype?: string
|
mtype?: string
|
||||||
// 消息标题
|
// 消息标题
|
||||||
@@ -1150,19 +1190,15 @@ export interface Message {
|
|||||||
// 消息方向:0-接收,1-发送
|
// 消息方向:0-接收,1-发送
|
||||||
action?: number
|
action?: number
|
||||||
// JSON
|
// JSON
|
||||||
note?: string
|
note?: string | any[] | Record<string, any>
|
||||||
}
|
}
|
||||||
|
|
||||||
// 系统通知
|
// 系统通知
|
||||||
export interface SystemNotification {
|
export interface SystemNotification extends Message {
|
||||||
// 通知类型 user/system/plugin
|
// 通知类型 user/system/plugin/notification
|
||||||
type: string
|
type?: string
|
||||||
// 通知标题
|
|
||||||
title: string
|
|
||||||
// 通知内容
|
|
||||||
text: string
|
|
||||||
// 通知时间
|
// 通知时间
|
||||||
date: string
|
date?: string
|
||||||
// 是否已读
|
// 是否已读
|
||||||
read?: boolean
|
read?: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
2472
src/components/AgentAssistantWidget.vue
Normal file
2472
src/components/AgentAssistantWidget.vue
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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>
|
|
||||||
@@ -1,16 +1,20 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import api from '@/api'
|
||||||
import type { Plugin } from '@/api/types'
|
import type { Plugin } from '@/api/types'
|
||||||
import { getLogoUrl } from '@/utils/imageUtils'
|
import { getLogoUrl } from '@/utils/imageUtils'
|
||||||
import { getDominantColor } from '@/@core/utils/image'
|
import { getDominantColor } from '@/@core/utils/image'
|
||||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||||
import { formatDownloadCount } from '@/@core/utils/formatters'
|
import { formatDownloadCount } from '@/@core/utils/formatters'
|
||||||
|
import { useToast } from 'vue-toastification'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||||
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
|
|
||||||
const PluginMarketDetailDialog = defineAsyncComponent(() => import('@/components/dialog/PluginMarketDetailDialog.vue'))
|
const PluginMarketDetailDialog = defineAsyncComponent(() => import('@/components/dialog/PluginMarketDetailDialog.vue'))
|
||||||
const PluginVersionHistoryDialog = defineAsyncComponent(
|
const PluginVersionHistoryDialog = defineAsyncComponent(
|
||||||
() => import('@/components/dialog/PluginVersionHistoryDialog.vue'),
|
() => import('@/components/dialog/PluginVersionHistoryDialog.vue'),
|
||||||
)
|
)
|
||||||
|
const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue'))
|
||||||
|
|
||||||
// 输入参数
|
// 输入参数
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -26,6 +30,11 @@ const emit = defineEmits(['install'])
|
|||||||
// 多语言
|
// 多语言
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
// 提示框
|
||||||
|
const $toast = useToast()
|
||||||
|
|
||||||
|
const createConfirm = useConfirm()
|
||||||
|
|
||||||
// 背景颜色
|
// 背景颜色
|
||||||
const backgroundColor = ref('#28A9E1')
|
const backgroundColor = ref('#28A9E1')
|
||||||
|
|
||||||
@@ -48,6 +57,21 @@ const isImageLoaded = ref(false)
|
|||||||
// 图片是否加载失败
|
// 图片是否加载失败
|
||||||
const imageLoadError = 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() {
|
async function imageLoaded() {
|
||||||
isImageLoaded.value = true
|
isImageLoaded.value = true
|
||||||
@@ -96,14 +120,69 @@ function visitPluginPage() {
|
|||||||
|
|
||||||
// 显示更新日志
|
// 显示更新日志
|
||||||
function showUpdateHistory() {
|
function showUpdateHistory() {
|
||||||
openSharedDialog(
|
versionHistoryDialogController?.close()
|
||||||
|
versionHistoryDialogController = openSharedDialog(
|
||||||
PluginVersionHistoryDialog,
|
PluginVersionHistoryDialog,
|
||||||
{ plugin: props.plugin },
|
{ plugin: props.plugin, actionMode: 'install' },
|
||||||
{},
|
{
|
||||||
|
update: installPlugin,
|
||||||
|
},
|
||||||
{ closeOn: ['close', 'update:modelValue'] },
|
{ 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() {
|
function showPluginDetail() {
|
||||||
openSharedDialog(
|
openSharedDialog(
|
||||||
@@ -140,6 +219,11 @@ const dropdownItems = ref([
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
closeInstallProgress()
|
||||||
|
versionHistoryDialogController?.close()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ const imageLoadError = ref(false)
|
|||||||
|
|
||||||
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
|
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||||
let cloneDialogController: ReturnType<typeof openSharedDialog> | null = null
|
let cloneDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||||
|
let versionHistoryDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||||
|
|
||||||
/** 打开插件操作进度弹窗,插件卡片自身不再持有进度弹窗实例。 */
|
/** 打开插件操作进度弹窗,插件卡片自身不再持有进度弹窗实例。 */
|
||||||
function showPluginProgress(text: string) {
|
function showPluginProgress(text: string) {
|
||||||
@@ -103,11 +104,12 @@ async function imageLoaded() {
|
|||||||
|
|
||||||
// 显示更新日志
|
// 显示更新日志
|
||||||
function showUpdateHistory(showUpdateAction: boolean = false) {
|
function showUpdateHistory(showUpdateAction: boolean = false) {
|
||||||
openSharedDialog(
|
versionHistoryDialogController?.close()
|
||||||
|
versionHistoryDialogController = openSharedDialog(
|
||||||
PluginVersionHistoryDialog,
|
PluginVersionHistoryDialog,
|
||||||
{ plugin: props.plugin, showUpdateAction },
|
{ plugin: props.plugin, showUpdateAction },
|
||||||
{ update: updatePlugin },
|
{ update: updatePlugin },
|
||||||
{ closeOn: ['close', 'update', 'update:modelValue'] },
|
{ closeOn: ['close', 'update:modelValue'] },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,19 +221,37 @@ async function resetPlugin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 更新插件
|
// 更新插件
|
||||||
async function updatePlugin() {
|
async function updatePlugin(releaseVersion?: string, repoUrl?: string) {
|
||||||
if (props.plugin?.system_version_compatible === false) {
|
if (!releaseVersion && props.plugin?.system_version_compatible === false) {
|
||||||
$toast.error(props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion'))
|
$toast.error(props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion'))
|
||||||
return
|
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 {
|
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}`, {
|
const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
|
||||||
params: {
|
params: {
|
||||||
repo_url: props.plugin?.repo_url,
|
repo_url: repoUrl || props.plugin?.repo_url,
|
||||||
|
release_version: releaseVersion,
|
||||||
force: true,
|
force: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -241,6 +261,8 @@ async function updatePlugin() {
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
$toast.success(t('plugin.updateSuccess', { name: props.plugin?.plugin_name }))
|
$toast.success(t('plugin.updateSuccess', { name: props.plugin?.plugin_name }))
|
||||||
|
versionHistoryDialogController?.close()
|
||||||
|
versionHistoryDialogController = null
|
||||||
|
|
||||||
// 通知父组件刷新
|
// 通知父组件刷新
|
||||||
emit('save')
|
emit('save')
|
||||||
|
|||||||
@@ -409,7 +409,6 @@ function handleCardClick() {
|
|||||||
:class="{
|
:class="{
|
||||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering && !props.sortable,
|
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering && !props.sortable,
|
||||||
'outline-dotted outline-pink-500 outline-2': props.batchMode && props.selected,
|
'outline-dotted outline-pink-500 outline-2': props.batchMode && props.selected,
|
||||||
'subscribe-card-pending-tint': subscribeState === 'P',
|
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<VCard
|
<VCard
|
||||||
@@ -418,6 +417,7 @@ function handleCardClick() {
|
|||||||
class="flex flex-col h-full overflow-hidden"
|
class="flex flex-col h-full overflow-hidden"
|
||||||
:class="{
|
:class="{
|
||||||
'subscribe-card-paused': subscribeState === 'S',
|
'subscribe-card-paused': subscribeState === 'S',
|
||||||
|
'subscribe-card-pending-tint': subscribeState === 'P',
|
||||||
'cursor-move': props.sortable,
|
'cursor-move': props.sortable,
|
||||||
}"
|
}"
|
||||||
min-height="150"
|
min-height="150"
|
||||||
@@ -588,7 +588,7 @@ function handleCardClick() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 待定:用 ::after 浮层在 VCard 之上渲染 sky 漫反射式内发光
|
* 待定:内发光挂在实际 VCard 上,跟随卡片圆角并被 overflow-hidden 裁剪。
|
||||||
*/
|
*/
|
||||||
.subscribe-card-pending-tint {
|
.subscribe-card-pending-tint {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@@ -99,9 +99,9 @@ function submitCustomCSS() {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.custom-css-dialog {
|
.custom-css-dialog {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
max-block-size: calc(100dvh - 2rem);
|
max-block-size: calc(100dvh - 2rem);
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-css-header {
|
.custom-css-header {
|
||||||
@@ -111,7 +111,7 @@ function submitCustomCSS() {
|
|||||||
|
|
||||||
.custom-css-editor-body {
|
.custom-css-editor-body {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-block-size: 0;
|
min-block-size: 240px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-css-editor {
|
.custom-css-editor {
|
||||||
@@ -141,8 +141,8 @@ function submitCustomCSS() {
|
|||||||
|
|
||||||
.custom-css-editor {
|
.custom-css-editor {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-block-size: 0;
|
|
||||||
block-size: auto;
|
block-size: auto;
|
||||||
|
min-block-size: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-css-actions {
|
.custom-css-actions {
|
||||||
|
|||||||
@@ -6,8 +6,12 @@ import { getLogoUrl } from '@/utils/imageUtils'
|
|||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||||
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
|
|
||||||
const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue'))
|
const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue'))
|
||||||
|
const PluginVersionHistoryDialog = defineAsyncComponent(
|
||||||
|
() => import('@/components/dialog/PluginVersionHistoryDialog.vue'),
|
||||||
|
)
|
||||||
|
|
||||||
// 多语言
|
// 多语言
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -15,6 +19,8 @@ const { t } = useI18n()
|
|||||||
// 提示框
|
// 提示框
|
||||||
const $toast = useToast()
|
const $toast = useToast()
|
||||||
|
|
||||||
|
const createConfirm = useConfirm()
|
||||||
|
|
||||||
// 输入参数
|
// 输入参数
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: {
|
modelValue: {
|
||||||
@@ -47,6 +53,7 @@ const imageRef = ref<any>()
|
|||||||
const imageLoadError = ref(false)
|
const imageLoadError = ref(false)
|
||||||
|
|
||||||
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
|
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||||
|
let versionHistoryDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||||
|
|
||||||
/** 打开插件安装进度弹窗。 */
|
/** 打开插件安装进度弹窗。 */
|
||||||
function showInstallProgress(text: string) {
|
function showInstallProgress(text: string) {
|
||||||
@@ -97,24 +104,38 @@ function visitPluginPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 安装插件并通知父级刷新市场列表。 */
|
/** 安装插件并通知父级刷新市场列表。 */
|
||||||
async function installPlugin() {
|
async function installPlugin(releaseVersion?: string, repoUrl?: string) {
|
||||||
if (props.plugin?.system_version_compatible === false) {
|
if (!releaseVersion && props.plugin?.system_version_compatible === false) {
|
||||||
$toast.error(props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion'))
|
$toast.error(props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion'))
|
||||||
return
|
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 {
|
try {
|
||||||
showInstallProgress(
|
showInstallProgress(
|
||||||
t('plugin.installing', {
|
t('plugin.installing', {
|
||||||
name: props.plugin?.plugin_name,
|
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}`, {
|
const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
|
||||||
params: {
|
params: {
|
||||||
repo_url: props.plugin?.repo_url,
|
repo_url: repoUrl || props.plugin?.repo_url,
|
||||||
force: props.plugin?.has_update,
|
release_version: releaseVersion,
|
||||||
|
force: props.plugin?.has_update || Boolean(releaseVersion),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -122,6 +143,8 @@ async function installPlugin() {
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
$toast.success(t('plugin.installSuccess', { name: props.plugin?.plugin_name }))
|
$toast.success(t('plugin.installSuccess', { name: props.plugin?.plugin_name }))
|
||||||
|
versionHistoryDialogController?.close()
|
||||||
|
versionHistoryDialogController = null
|
||||||
visible.value = false
|
visible.value = false
|
||||||
emit('install')
|
emit('install')
|
||||||
} else {
|
} 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(() => {
|
onUnmounted(() => {
|
||||||
closeInstallProgress()
|
closeInstallProgress()
|
||||||
|
versionHistoryDialogController?.close()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -190,16 +227,21 @@ onUnmounted(() => {
|
|||||||
class="mb-3"
|
class="mb-3"
|
||||||
:text="props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion')"
|
:text="props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion')"
|
||||||
/>
|
/>
|
||||||
<div class="text-center text-md-left">
|
<div class="plugin-market-detail-actions">
|
||||||
<VBtn
|
<div>
|
||||||
color="primary"
|
<VBtn
|
||||||
@click="installPlugin"
|
color="primary"
|
||||||
prepend-icon="mdi-download"
|
@click="installPlugin()"
|
||||||
:disabled="props.plugin?.system_version_compatible === false"
|
prepend-icon="mdi-download"
|
||||||
>
|
:disabled="props.plugin?.system_version_compatible === false"
|
||||||
{{ t('plugin.installToLocal') }}
|
>
|
||||||
</VBtn>
|
{{ t('plugin.installToLocal') }}
|
||||||
<div class="text-xs mt-2" v-if="props.count">
|
</VBtn>
|
||||||
|
<VBtn variant="tonal" @click="showUpdateHistory" prepend-icon="mdi-update" class="ms-2">
|
||||||
|
{{ t('plugin.versionHistory') }}
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
|
<div class="plugin-market-detail-actions__downloads" v-if="props.count">
|
||||||
<VIcon icon="mdi-fire" />
|
<VIcon icon="mdi-fire" />
|
||||||
{{ t('plugin.totalDownloads', { count: formatDownloadCount(props.count) }) }}
|
{{ t('plugin.totalDownloads', { count: formatDownloadCount(props.count) }) }}
|
||||||
</div>
|
</div>
|
||||||
@@ -212,3 +254,30 @@ onUnmounted(() => {
|
|||||||
</VCard>
|
</VCard>
|
||||||
</VDialog>
|
</VDialog>
|
||||||
</template>
|
</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__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__downloads {
|
||||||
|
text-align: start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import api from '@/api'
|
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 VersionHistory from '@/components/misc/VersionHistory.vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
// 多语言
|
// 多语言
|
||||||
const { t } = useI18n()
|
const { t, locale } = useI18n()
|
||||||
|
|
||||||
// 输入参数
|
// 输入参数
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -21,14 +21,25 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
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 loading = ref(false)
|
||||||
const loadError = ref('')
|
const loadError = ref('')
|
||||||
const pluginDetail = ref<Plugin | null>(null)
|
const pluginDetail = ref<Plugin | null>(null)
|
||||||
|
const releaseLoading = ref(false)
|
||||||
|
const releaseError = ref('')
|
||||||
|
const releaseDetail = ref<PluginReleaseVersionsResponse | null>(null)
|
||||||
|
|
||||||
// 弹窗显示状态
|
// 弹窗显示状态
|
||||||
const visible = computed({
|
const visible = computed({
|
||||||
@@ -41,19 +52,73 @@ const visible = computed({
|
|||||||
|
|
||||||
const resolvedPlugin = computed(() => pluginDetail.value ?? props.plugin)
|
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 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() {
|
async function loadPluginHistory() {
|
||||||
if (!props.plugin?.id) {
|
if (!props.plugin?.id) {
|
||||||
pluginDetail.value = null
|
pluginDetail.value = null
|
||||||
loadError.value = ''
|
loadError.value = ''
|
||||||
|
releaseDetail.value = null
|
||||||
|
releaseError.value = ''
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
loadError.value = ''
|
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 {
|
try {
|
||||||
pluginDetail.value = await api.get(`plugin/history/${props.plugin.id}`, {
|
pluginDetail.value = await api.get(`plugin/history/${props.plugin.id}`, {
|
||||||
@@ -61,6 +126,7 @@ async function loadPluginHistory() {
|
|||||||
force: true,
|
force: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
loadPluginReleases(pluginDetail.value ?? props.plugin, true)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
pluginDetail.value = null
|
pluginDetail.value = null
|
||||||
loadError.value = t('plugin.updateHistoryLoadFailed')
|
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() {
|
function handleUpdate(releaseItem?: PluginReleaseVersion) {
|
||||||
emit('update')
|
emit('update', releaseItem?.is_latest ? undefined : releaseItem?.version, resolvedPlugin.value?.repo_url)
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => [visible.value, props.plugin?.id],
|
() => [visible.value, props.plugin?.id],
|
||||||
([isVisible]) => {
|
([isVisible]) => {
|
||||||
if (isVisible) loadPluginHistory()
|
if (isVisible) {
|
||||||
|
loadPluginHistory()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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 })">
|
<VCard :title="t('plugin.updateHistoryTitle', { name: resolvedPlugin?.plugin_name })">
|
||||||
<VDialogCloseBtn v-model="visible" />
|
<VDialogCloseBtn v-model="visible" />
|
||||||
<VDivider />
|
<VDivider />
|
||||||
|
<VProgressLinear v-if="releaseLoading && !loading" indeterminate color="primary" height="2" />
|
||||||
<div v-if="loading" class="plugin-version-history-dialog__loading">
|
<div v-if="loading" class="plugin-version-history-dialog__loading">
|
||||||
<VProgressCircular indeterminate color="primary" />
|
<VProgressCircular indeterminate color="primary" />
|
||||||
</div>
|
</div>
|
||||||
<VCardText v-else-if="loadError && !hasHistory">
|
<VCardText v-else-if="loadError && !hasHistory">
|
||||||
<VAlert type="warning" variant="tonal" density="compact" :text="loadError" />
|
<VAlert type="warning" variant="tonal" density="compact" :text="loadError" />
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardText v-else-if="!hasHistory">
|
<VCardText v-else-if="!hasHistory && !releaseLoading">
|
||||||
<VAlert type="info" variant="tonal" density="compact" :text="t('plugin.updateHistoryEmpty')" />
|
<VAlert type="info" variant="tonal" density="compact" :text="t('plugin.updateHistoryEmpty')" />
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VersionHistory v-else :history="resolvedHistory" />
|
<template v-else>
|
||||||
<template v-if="props.showUpdateAction">
|
<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 />
|
<VDivider />
|
||||||
<VCardItem>
|
<VCardItem>
|
||||||
<VAlert
|
<VAlert
|
||||||
@@ -110,7 +248,11 @@ watch(
|
|||||||
class="mb-3"
|
class="mb-3"
|
||||||
:text="resolvedPlugin?.system_version_message || t('plugin.incompatibleSystemVersion')"
|
: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>
|
<template #prepend>
|
||||||
<VIcon icon="mdi-arrow-up-circle-outline" />
|
<VIcon icon="mdi-arrow-up-circle-outline" />
|
||||||
</template>
|
</template>
|
||||||
@@ -129,4 +271,23 @@ watch(
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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>
|
</style>
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import { useBackground } from '@/composables/useBackground'
|
|||||||
import MediaIdSelector from '../misc/MediaIdSelector.vue'
|
import MediaIdSelector from '../misc/MediaIdSelector.vue'
|
||||||
import ProgressDialog from './ProgressDialog.vue'
|
import ProgressDialog from './ProgressDialog.vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { nextTick } from 'vue'
|
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
import { useGlobalSettingsStore } from '@/stores'
|
import { useGlobalSettingsStore } from '@/stores'
|
||||||
|
|
||||||
@@ -150,13 +149,7 @@ const normalizedItems = computed(() => dedupeFileItems(props.items))
|
|||||||
|
|
||||||
// 分页
|
// 分页
|
||||||
const previewPage = ref(1)
|
const previewPage = ref(1)
|
||||||
const previewPageSize = ref(10)
|
const previewPageSize = ref(20)
|
||||||
|
|
||||||
// 预览列表主体元素
|
|
||||||
const previewFileBodyRef = ref<HTMLElement>()
|
|
||||||
|
|
||||||
// 预览列表尺寸观察器
|
|
||||||
let previewFileBodyResizeObserver: ResizeObserver | undefined
|
|
||||||
|
|
||||||
// 所有存储
|
// 所有存储
|
||||||
const storages = ref<StorageConf[]>([])
|
const storages = ref<StorageConf[]>([])
|
||||||
@@ -419,9 +412,39 @@ watch(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// 过滤后的预览数据
|
// 过滤并排序后的预览数据
|
||||||
const filteredPreviewItems = computed(() => {
|
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
|
previewData.value = mergedPreviewData
|
||||||
previewLoaded.value = true
|
previewLoaded.value = true
|
||||||
nextTick(() => updatePreviewPageSize())
|
|
||||||
|
|
||||||
if (previewHasFailures(mergedPreviewData)) {
|
if (previewHasFailures(mergedPreviewData)) {
|
||||||
$toast.warning(getPreviewResultSummaryMessage(mergedPreviewData))
|
$toast.warning(getPreviewResultSummaryMessage(mergedPreviewData))
|
||||||
@@ -1137,45 +1159,6 @@ async function togglePreview() {
|
|||||||
await previewTransfer()
|
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) {
|
async function handleTransfer(item: FileItem, background: boolean = false) {
|
||||||
try {
|
try {
|
||||||
@@ -1303,7 +1286,6 @@ onMounted(async () => {
|
|||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
stopLoadingProgress()
|
stopLoadingProgress()
|
||||||
if (episodeGroupQueryTimer) clearTimeout(episodeGroupQueryTimer)
|
if (episodeGroupQueryTimer) clearTimeout(episodeGroupQueryTimer)
|
||||||
previewFileBodyResizeObserver?.disconnect()
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -1671,7 +1653,7 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="reorganize-preview-list">
|
<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
|
<div
|
||||||
v-for="(item, index) in pagedPreviewRows"
|
v-for="(item, index) in pagedPreviewRows"
|
||||||
:key="`${item.source}-${item.target}-${index}`"
|
:key="`${item.source}-${item.target}-${index}`"
|
||||||
@@ -1894,6 +1876,8 @@ onUnmounted(() => {
|
|||||||
.preview-overview-card {
|
.preview-overview-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||||
|
border-radius: 0.5rem;
|
||||||
gap: 0.375rem;
|
gap: 0.375rem;
|
||||||
min-inline-size: 0;
|
min-inline-size: 0;
|
||||||
padding-block: 0.875rem;
|
padding-block: 0.875rem;
|
||||||
@@ -1919,6 +1903,8 @@ onUnmounted(() => {
|
|||||||
.preview-custom-words {
|
.preview-custom-words {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||||
|
border-radius: 0.5rem;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
padding-block: 0.875rem;
|
padding-block: 0.875rem;
|
||||||
padding-inline: 1rem;
|
padding-inline: 1rem;
|
||||||
@@ -1970,8 +1956,12 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.preview-custom-words__chip {
|
.preview-custom-words__chip {
|
||||||
|
block-size: auto !important;
|
||||||
max-inline-size: 100%;
|
max-inline-size: 100%;
|
||||||
|
min-block-size: 1.5rem;
|
||||||
|
padding-block: 0.25rem;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reorganize-preview-pane__scroll {
|
.reorganize-preview-pane__scroll {
|
||||||
@@ -2011,9 +2001,9 @@ onUnmounted(() => {
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
margin-block-end: 1.5rem;
|
margin-block-end: 1.5rem;
|
||||||
margin-inline: 1.5rem;
|
|
||||||
min-block-size: 0;
|
min-block-size: 0;
|
||||||
min-inline-size: 0;
|
min-inline-size: 0;
|
||||||
|
padding-inline: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-file-body {
|
.preview-file-body {
|
||||||
@@ -2024,13 +2014,13 @@ onUnmounted(() => {
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
min-block-size: 0;
|
min-block-size: 0;
|
||||||
min-inline-size: 0;
|
min-inline-size: 0;
|
||||||
padding-block: 1rem;
|
|
||||||
padding-inline: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-file-row {
|
.preview-file-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||||
|
border-radius: 0.5rem;
|
||||||
gap: 0.875rem;
|
gap: 0.875rem;
|
||||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||||||
min-block-size: 5.25rem;
|
min-block-size: 5.25rem;
|
||||||
@@ -2039,10 +2029,6 @@ onUnmounted(() => {
|
|||||||
padding-inline: 1rem;
|
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 {
|
.preview-file-row--failed {
|
||||||
background: rgba(var(--v-theme-error), 0.04);
|
background: rgba(var(--v-theme-error), 0.04);
|
||||||
}
|
}
|
||||||
@@ -2168,7 +2154,7 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.reorganize-preview-list {
|
.reorganize-preview-list {
|
||||||
margin-block-end: 1rem;
|
margin-block-end: 1rem;
|
||||||
margin-inline: 1rem;
|
padding-inline: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -39,10 +39,21 @@ const visible = computed({
|
|||||||
if (!value) emit('close')
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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">
|
<VCard :class="props.cardClass">
|
||||||
<VCardItem>
|
<VCardItem>
|
||||||
<VCardTitle>
|
<VCardTitle>
|
||||||
@@ -53,7 +64,7 @@ const visible = computed({
|
|||||||
<VDialogCloseBtn v-model="visible" />
|
<VDialogCloseBtn v-model="visible" />
|
||||||
</VCardItem>
|
</VCardItem>
|
||||||
<VDivider />
|
<VDivider />
|
||||||
<VCardText :class="props.bodyClass">
|
<VCardText :class="bodyClasses">
|
||||||
<Component :is="props.view" v-bind="props.viewProps" />
|
<Component :is="props.view" v-bind="props.viewProps" />
|
||||||
</VCardText>
|
</VCardText>
|
||||||
</VCard>
|
</VCard>
|
||||||
@@ -61,8 +72,6 @@ const visible = computed({
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* stylelint-disable selector-pseudo-class-no-unknown */
|
|
||||||
|
|
||||||
.system-health-dialog-card {
|
.system-health-dialog-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -78,7 +87,7 @@ const visible = computed({
|
|||||||
min-block-size: 0;
|
min-block-size: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.v-dialog--fullscreen) .system-health-dialog-body {
|
.system-health-dialog-body--fullscreen {
|
||||||
block-size: auto;
|
block-size: auto;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -475,26 +475,26 @@ onMounted(() => {
|
|||||||
:items="mobileResourceList"
|
:items="mobileResourceList"
|
||||||
:columns="1"
|
:columns="1"
|
||||||
:gap="12"
|
:gap="12"
|
||||||
:estimated-item-height="320"
|
:estimated-item-height="220"
|
||||||
:overscan-rows="5"
|
:overscan-rows="5"
|
||||||
:get-item-key="getResourceItemKey"
|
:get-item-key="getResourceItemKey"
|
||||||
>
|
>
|
||||||
<template #default="{ item }">
|
<template #default="{ item }">
|
||||||
<VCard>
|
<VCard class="site-resource-card" variant="flat">
|
||||||
<VCardText class="pa-4">
|
<VCardText class="pa-3">
|
||||||
<button type="button" class="site-resource-title-btn text-start" @click="addDownload(item)">
|
<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 }}
|
{{ item.title }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="item.description"
|
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 }}
|
{{ item.description }}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="mt-3">
|
<div class="site-resource-card__chips mt-2">
|
||||||
<VChip
|
<VChip
|
||||||
v-if="item.hit_and_run"
|
v-if="item.hit_and_run"
|
||||||
variant="elevated"
|
variant="elevated"
|
||||||
@@ -533,47 +533,82 @@ onMounted(() => {
|
|||||||
</VChip>
|
</VChip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="site-resource-card__meta mt-4">
|
<!-- 移动端在操作区前展示关键资源指标,方便点击前快速判断。 -->
|
||||||
<div class="site-resource-card__meta-item">
|
<div class="site-resource-card__summary mt-3">
|
||||||
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.timeColumn') }}</div>
|
<div class="site-resource-card__stat">
|
||||||
<div class="text-body-2 font-weight-medium">{{ item.date_elapsed || item.pubdate || '-' }}</div>
|
<VIcon icon="mdi-clock-outline" size="15" />
|
||||||
<div v-if="item.pubdate" class="text-caption text-medium-emphasis mt-1">{{ item.pubdate }}</div>
|
<span>{{ item.date_elapsed || item.pubdate || '-' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="site-resource-card__meta-item">
|
<div class="site-resource-card__stat">
|
||||||
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.sizeColumn') }}</div>
|
<VIcon icon="mdi-harddisk" size="15" />
|
||||||
<div class="text-body-2 font-weight-medium">{{ formatFileSize(item.size) }}</div>
|
<span>{{ formatFileSize(item.size) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="site-resource-card__meta-item">
|
<div class="site-resource-card__stat site-resource-card__stat--success">
|
||||||
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.seedersColumn') }}</div>
|
<VIcon icon="mdi-arrow-up" size="15" />
|
||||||
<div class="text-body-2 font-weight-medium">{{ item.seeders }}</div>
|
<span>{{ item.seeders ?? '-' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="site-resource-card__meta-item">
|
<div class="site-resource-card__stat site-resource-card__stat--warning">
|
||||||
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.peersColumn') }}</div>
|
<VIcon icon="mdi-arrow-down" size="15" />
|
||||||
<div class="text-body-2 font-weight-medium">{{ item.peers }}</div>
|
<span>{{ item.peers ?? '-' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</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') }}
|
{{ t('actionStep.addDownload') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
<div class="site-resource-card__secondary-actions mt-2">
|
<VTooltip :text="t('common.viewDetails')" location="top">
|
||||||
<VBtn
|
<template #activator="{ props: tooltipProps }">
|
||||||
variant="tonal"
|
<VBtn
|
||||||
prepend-icon="mdi-open-in-new"
|
v-bind="tooltipProps"
|
||||||
@click="openTorrentDetail(item.page_url || '')"
|
icon
|
||||||
>
|
variant="tonal"
|
||||||
{{ t('common.viewDetails') }}
|
color="primary"
|
||||||
</VBtn>
|
class="site-resource-card__icon-btn"
|
||||||
<VBtn
|
:aria-label="t('common.viewDetails')"
|
||||||
v-if="item.enclosure?.startsWith('http')"
|
@click="openTorrentDetail(item.page_url || '')"
|
||||||
variant="tonal"
|
>
|
||||||
prepend-icon="mdi-tray-arrow-down"
|
<VIcon icon="mdi-open-in-new" />
|
||||||
@click="downloadTorrentFile(item.enclosure)"
|
</VBtn>
|
||||||
>
|
</template>
|
||||||
{{ t('dialog.siteResource.downloadTorrent') }}
|
</VTooltip>
|
||||||
</VBtn>
|
<VTooltip
|
||||||
</div>
|
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>
|
</div>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
</VCard>
|
</VCard>
|
||||||
@@ -702,44 +737,107 @@ onMounted(() => {
|
|||||||
white-space: nowrap;
|
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 {
|
.site-resource-card__description {
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
-webkit-box-orient: vertical;
|
-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;
|
display: grid;
|
||||||
gap: 0.55rem;
|
gap: 0.45rem;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: minmax(0, 1fr) 2.5rem 2.5rem;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-resource-card__meta-item {
|
.site-resource-card__download-btn {
|
||||||
background: rgba(var(--v-theme-surface), 0.78);
|
min-block-size: 2.5rem;
|
||||||
min-block-size: 0;
|
min-inline-size: 0;
|
||||||
padding-block: 0.55rem;
|
box-shadow: 0 6px 16px rgba(var(--v-theme-primary), 0.17);
|
||||||
padding-inline: 0.65rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-resource-card__meta-item :deep(.text-caption) {
|
.site-resource-card__download-btn :deep(.v-btn__content) {
|
||||||
font-size: 0.72rem !important;
|
overflow: hidden;
|
||||||
line-height: 1.2;
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-resource-card__meta-item :deep(.text-body-2) {
|
.site-resource-card__icon-btn {
|
||||||
font-size: 0.82rem !important;
|
block-size: 2.5rem;
|
||||||
line-height: 1.25;
|
inline-size: 2.5rem;
|
||||||
|
min-inline-size: 2.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-resource-card__secondary-actions {
|
.site-resource-card__icon-btn :deep(.v-btn__content) {
|
||||||
display: flex;
|
font-size: 1.05rem;
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-resource-card__secondary-actions :deep(.v-btn) {
|
|
||||||
flex: 1 1 12rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (width >= 960px) {
|
@media (width >= 960px) {
|
||||||
@@ -761,4 +859,14 @@ onMounted(() => {
|
|||||||
min-block-size: 2.5rem;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -141,4 +141,29 @@ function updateFilter(key: string, values: string[]) {
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
|
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>
|
</style>
|
||||||
|
|||||||
@@ -142,4 +142,24 @@ function handleDetail(item: Context) {
|
|||||||
max-block-size: 60vh;
|
max-block-size: 60vh;
|
||||||
overflow-y: auto;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ function updateFilter(values: string[]) {
|
|||||||
@update:model-value="updateFilter"
|
@update:model-value="updateFilter"
|
||||||
>
|
>
|
||||||
<VChip
|
<VChip
|
||||||
v-for="option in options"
|
v-for="option in options"
|
||||||
:key="option"
|
:key="option"
|
||||||
:value="option"
|
:value="option"
|
||||||
filter
|
filter
|
||||||
@@ -106,3 +106,30 @@ function updateFilter(values: string[]) {
|
|||||||
</VCard>
|
</VCard>
|
||||||
</VDialog>
|
</VDialog>
|
||||||
</template>
|
</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>
|
||||||
|
|||||||
@@ -372,7 +372,7 @@ onMounted(() => {
|
|||||||
:key="key"
|
:key="key"
|
||||||
variant="tonal"
|
variant="tonal"
|
||||||
size="small"
|
size="small"
|
||||||
:color="filterForm[key].length > 0 ? 'primary' : undefined"
|
color="primary"
|
||||||
:prepend-icon="getFilterIcon(key)"
|
:prepend-icon="getFilterIcon(key)"
|
||||||
class="filter-btn"
|
class="filter-btn"
|
||||||
rounded="pill"
|
rounded="pill"
|
||||||
@@ -555,7 +555,7 @@ onMounted(() => {
|
|||||||
v-for="(title, key) in filterTitles"
|
v-for="(title, key) in filterTitles"
|
||||||
v-show="filterOptions[key].length > 0"
|
v-show="filterOptions[key].length > 0"
|
||||||
:key="key"
|
:key="key"
|
||||||
variant="text"
|
variant="tonal"
|
||||||
color="primary"
|
color="primary"
|
||||||
class="filter-btn-mobile"
|
class="filter-btn-mobile"
|
||||||
@click="toggleFilterMenu(key)"
|
@click="toggleFilterMenu(key)"
|
||||||
@@ -575,7 +575,7 @@ onMounted(() => {
|
|||||||
</VBtn>
|
</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>
|
<VIcon icon="mdi-filter-variant" class="filter-icon me-1"></VIcon>
|
||||||
<span class="filter-label">
|
<span class="filter-label">
|
||||||
{{ t('torrent.allFilters') }}
|
{{ t('torrent.allFilters') }}
|
||||||
@@ -665,7 +665,6 @@ onMounted(() => {
|
|||||||
|
|
||||||
.filter-btn {
|
.filter-btn {
|
||||||
min-inline-size: 0;
|
min-inline-size: 0;
|
||||||
background: rgba(var(--v-theme-surface-variant), 0.1);
|
|
||||||
transition: opacity 0.2s;
|
transition: opacity 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -733,7 +732,6 @@ onMounted(() => {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background-color: rgba(var(--v-theme-surface-variant), 0.08);
|
|
||||||
block-size: auto;
|
block-size: auto;
|
||||||
min-block-size: 48px;
|
min-block-size: 48px;
|
||||||
padding-block: 4px;
|
padding-block: 4px;
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import type { PropType } from 'vue'
|
|||||||
import MarkdownIt from 'markdown-it'
|
import MarkdownIt from 'markdown-it'
|
||||||
import mdLinkAttributes from 'markdown-it-link-attributes'
|
import mdLinkAttributes from 'markdown-it-link-attributes'
|
||||||
|
|
||||||
// 初始化 markdown-it
|
// 版本历史可能来自插件市场或 Release 内容,禁止透传原始 HTML,避免外部内容注入脚本或事件属性。
|
||||||
const md = new MarkdownIt({
|
const md = new MarkdownIt({
|
||||||
html: true,
|
html: false,
|
||||||
linkify: true,
|
linkify: true,
|
||||||
typographer: true,
|
typographer: true,
|
||||||
})
|
})
|
||||||
@@ -27,23 +27,100 @@ function renderMarkdown(value: string) {
|
|||||||
// 输入参数
|
// 输入参数
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
history: Object as PropType<{ [key: string]: string }>,
|
history: Object as PropType<{ [key: string]: string }>,
|
||||||
|
hasAction: Function as PropType<(version: string) => boolean>,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function shouldRenderAction(version: string) {
|
||||||
|
return props.hasAction?.(version) ?? true
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VCardText>
|
<VCardText class="version-history">
|
||||||
<VList>
|
<div class="version-history__list">
|
||||||
<VListItem v-for="(value, key) in props.history" :key="key">
|
<section v-for="(value, key) in props.history" :key="key" class="version-history__item">
|
||||||
<VListItemTitle class="font-bold text-lg">
|
<div
|
||||||
{{ key }}
|
class="version-history__top"
|
||||||
</VListItemTitle>
|
:class="{ 'version-history__top--with-action': $slots.action && shouldRenderAction(String(key)) }"
|
||||||
<div class="markdown-body text-gray-500" v-html="renderMarkdown(value)" />
|
>
|
||||||
</VListItem>
|
<div class="version-history__header">
|
||||||
</VList>
|
<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>
|
</VCardText>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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(h1),
|
||||||
.markdown-body :deep(h2),
|
.markdown-body :deep(h2),
|
||||||
.markdown-body :deep(h3) {
|
.markdown-body :deep(h3) {
|
||||||
@@ -112,4 +189,28 @@ const props = defineProps({
|
|||||||
border-inline-start: 3px solid rgba(127, 127, 127, 0.4);
|
border-inline-start: 3px solid rgba(127, 127, 127, 0.4);
|
||||||
color: rgba(127, 127, 127, 0.8);
|
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>
|
</style>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { themeManager } from '@/utils/themeManager'
|
|||||||
|
|
||||||
export const THEME_CUSTOMIZER_STORAGE_KEY = 'moviepilot-theme-customizer'
|
export const THEME_CUSTOMIZER_STORAGE_KEY = 'moviepilot-theme-customizer'
|
||||||
export const THEME_CUSTOMIZER_CHANGE_EVENT = 'moviepilot-theme-customizer-change'
|
export const THEME_CUSTOMIZER_CHANGE_EVENT = 'moviepilot-theme-customizer-change'
|
||||||
|
export const THEME_CUSTOMIZER_OPEN_EVENT = 'moviepilot-theme-customizer-open'
|
||||||
|
|
||||||
export const themeCustomizerPrimaryColors = [
|
export const themeCustomizerPrimaryColors = [
|
||||||
{ name: 'Purple', value: '#9155FD' },
|
{ name: 'Purple', value: '#9155FD' },
|
||||||
@@ -23,9 +24,37 @@ export const themeCustomizerPrimaryColors = [
|
|||||||
{ name: 'Slate', value: '#607D8B' },
|
{ name: 'Slate', value: '#607D8B' },
|
||||||
] as const
|
] 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 ThemeCustomizerLayout = 'collapsed' | 'horizontal' | 'vertical'
|
||||||
export type ThemeCustomizerRadius = 'default' | 'extra' | 'huge' | 'large' | 'small'
|
export type ThemeCustomizerRadius = 'default' | 'extra' | 'large' | 'none' | 'small'
|
||||||
export type ThemeCustomizerShadow = 'none' | 'low' | 'medium' | 'high'
|
export type ThemeCustomizerShadow = (typeof themeCustomizerShadowLevels)[number]
|
||||||
export type ThemeCustomizerSkin = 'bordered' | 'default'
|
export type ThemeCustomizerSkin = 'bordered' | 'default'
|
||||||
export type ThemeCustomizerTheme = 'auto' | 'dark' | 'light' | 'purple' | 'transparent'
|
export type ThemeCustomizerTheme = 'auto' | 'dark' | 'light' | 'purple' | 'transparent'
|
||||||
|
|
||||||
@@ -43,10 +72,16 @@ type VuetifyThemeApi = ReturnType<typeof useTheme>
|
|||||||
|
|
||||||
const defaultPrimaryColor = themeCustomizerPrimaryColors[0].value
|
const defaultPrimaryColor = themeCustomizerPrimaryColors[0].value
|
||||||
const validLayouts: ThemeCustomizerLayout[] = ['vertical', 'collapsed', 'horizontal']
|
const validLayouts: ThemeCustomizerLayout[] = ['vertical', 'collapsed', 'horizontal']
|
||||||
const validRadii: ThemeCustomizerRadius[] = ['small', 'default', 'large', 'extra', 'huge']
|
const validRadii: ThemeCustomizerRadius[] = ['none', 'small', 'default', 'large', 'extra']
|
||||||
const validShadows: ThemeCustomizerShadow[] = ['none', 'low', 'medium', 'high']
|
const validShadows: readonly ThemeCustomizerShadow[] = themeCustomizerShadowLevels
|
||||||
const validSkins: ThemeCustomizerSkin[] = ['default', 'bordered']
|
const validSkins: ThemeCustomizerSkin[] = ['default', 'bordered']
|
||||||
const validThemes: ThemeCustomizerTheme[] = ['auto', 'light', 'dark', 'purple', 'transparent']
|
const validThemes: ThemeCustomizerTheme[] = ['auto', 'light', 'dark', 'purple', 'transparent']
|
||||||
|
const legacyShadowMap: Record<string, ThemeCustomizerShadow> = {
|
||||||
|
high: '24',
|
||||||
|
low: '6',
|
||||||
|
medium: '12',
|
||||||
|
none: '0',
|
||||||
|
}
|
||||||
|
|
||||||
let themeApplyVersion = 0
|
let themeApplyVersion = 0
|
||||||
|
|
||||||
@@ -72,27 +107,35 @@ function getDefaultThemeCustomizerSettings(): ThemeCustomizerSettings {
|
|||||||
primaryColor: defaultPrimaryColor,
|
primaryColor: defaultPrimaryColor,
|
||||||
radius: 'default',
|
radius: 'default',
|
||||||
semiDarkMenu: false,
|
semiDarkMenu: false,
|
||||||
shadow: 'none',
|
shadow: '0',
|
||||||
skin: 'default',
|
skin: 'default',
|
||||||
theme: readStoredThemePreference(),
|
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 {
|
function normalizeThemeCustomizerSettings(settings: Partial<ThemeCustomizerSettings>): ThemeCustomizerSettings {
|
||||||
const fallback = getDefaultThemeCustomizerSettings()
|
const fallback = getDefaultThemeCustomizerSettings()
|
||||||
|
const storedRadius = settings.radius as string | undefined
|
||||||
|
const radius = storedRadius === 'huge' ? 'extra' : storedRadius
|
||||||
|
|
||||||
return {
|
return {
|
||||||
layout: validLayouts.includes(settings.layout as ThemeCustomizerLayout)
|
layout: validLayouts.includes(settings.layout as ThemeCustomizerLayout)
|
||||||
? (settings.layout as ThemeCustomizerLayout)
|
? (settings.layout as ThemeCustomizerLayout)
|
||||||
: fallback.layout,
|
: fallback.layout,
|
||||||
primaryColor: isHexColor(settings.primaryColor) ? settings.primaryColor.toUpperCase() : fallback.primaryColor,
|
primaryColor: isHexColor(settings.primaryColor) ? settings.primaryColor.toUpperCase() : fallback.primaryColor,
|
||||||
radius: validRadii.includes(settings.radius as ThemeCustomizerRadius)
|
radius: validRadii.includes(radius as ThemeCustomizerRadius)
|
||||||
? (settings.radius as ThemeCustomizerRadius)
|
? (radius as ThemeCustomizerRadius)
|
||||||
: fallback.radius,
|
: fallback.radius,
|
||||||
semiDarkMenu: typeof settings.semiDarkMenu === 'boolean' ? settings.semiDarkMenu : fallback.semiDarkMenu,
|
semiDarkMenu: typeof settings.semiDarkMenu === 'boolean' ? settings.semiDarkMenu : fallback.semiDarkMenu,
|
||||||
shadow: validShadows.includes(settings.shadow as ThemeCustomizerShadow)
|
shadow: normalizeThemeCustomizerShadow(settings.shadow),
|
||||||
? (settings.shadow as ThemeCustomizerShadow)
|
|
||||||
: fallback.shadow,
|
|
||||||
skin: validSkins.includes(settings.skin as ThemeCustomizerSkin)
|
skin: validSkins.includes(settings.skin as ThemeCustomizerSkin)
|
||||||
? (settings.skin as ThemeCustomizerSkin)
|
? (settings.skin as ThemeCustomizerSkin)
|
||||||
: fallback.skin,
|
: fallback.skin,
|
||||||
@@ -246,7 +289,7 @@ export function isDefaultThemeCustomizerSettings(settings: ThemeCustomizerSettin
|
|||||||
primaryColor: defaultPrimaryColor,
|
primaryColor: defaultPrimaryColor,
|
||||||
radius: 'default',
|
radius: 'default',
|
||||||
semiDarkMenu: false,
|
semiDarkMenu: false,
|
||||||
shadow: 'none',
|
shadow: '0',
|
||||||
skin: 'default',
|
skin: 'default',
|
||||||
theme: 'auto',
|
theme: 'auto',
|
||||||
})
|
})
|
||||||
@@ -323,7 +366,7 @@ export function useThemeCustomizer() {
|
|||||||
primaryColor: defaultPrimaryColor,
|
primaryColor: defaultPrimaryColor,
|
||||||
radius: 'default',
|
radius: 'default',
|
||||||
semiDarkMenu: false,
|
semiDarkMenu: false,
|
||||||
shadow: 'none',
|
shadow: '0',
|
||||||
skin: 'default',
|
skin: 'default',
|
||||||
theme: 'auto',
|
theme: 'auto',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import ShortcutBar from '@/layouts/components/ShortcutBar.vue'
|
|||||||
import UserProfile from '@/layouts/components/UserProfile.vue'
|
import UserProfile from '@/layouts/components/UserProfile.vue'
|
||||||
import QuickAccess from '@/layouts/components/QuickAccess.vue'
|
import QuickAccess from '@/layouts/components/QuickAccess.vue'
|
||||||
import HeaderTab from '@/layouts/components/HeaderTab.vue'
|
import HeaderTab from '@/layouts/components/HeaderTab.vue'
|
||||||
import { usePluginSidebarNavStore, useUserStore } from '@/stores'
|
import AgentAssistantWidget from '@/components/AgentAssistantWidget.vue'
|
||||||
|
import ThemeCustomizer from '@/components/ThemeCustomizer.vue'
|
||||||
|
import { useGlobalSettingsStore, usePluginSidebarNavStore, useUserStore } from '@/stores'
|
||||||
import { getNavMenus } from '@/router/i18n-menu'
|
import { getNavMenus } from '@/router/i18n-menu'
|
||||||
import { filterPluginSidebarNavEntries } from '@/utils/pluginSidebarNav'
|
import { filterPluginSidebarNavEntries } from '@/utils/pluginSidebarNav'
|
||||||
import { NavMenu } from '@/@layouts/types'
|
import { NavMenu } from '@/@layouts/types'
|
||||||
@@ -31,6 +33,7 @@ import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus'
|
|||||||
import {
|
import {
|
||||||
readThemeCustomizerSettings,
|
readThemeCustomizerSettings,
|
||||||
THEME_CUSTOMIZER_CHANGE_EVENT,
|
THEME_CUSTOMIZER_CHANGE_EVENT,
|
||||||
|
THEME_CUSTOMIZER_OPEN_EVENT,
|
||||||
type ThemeCustomizerSettings,
|
type ThemeCustomizerSettings,
|
||||||
} from '@/composables/useThemeCustomizer'
|
} from '@/composables/useThemeCustomizer'
|
||||||
import logo from '@images/logo.svg?raw'
|
import logo from '@images/logo.svg?raw'
|
||||||
@@ -42,14 +45,19 @@ const { t } = useI18n()
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const themeLayout = ref(readThemeCustomizerSettings().layout)
|
const themeLayout = ref(readThemeCustomizerSettings().layout)
|
||||||
|
const showThemeCustomizer = ref(false)
|
||||||
|
|
||||||
// 用户 Store
|
// 用户 Store
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const pluginSidebarNavStore = usePluginSidebarNavStore()
|
const pluginSidebarNavStore = usePluginSidebarNavStore()
|
||||||
|
const globalSettingsStore = useGlobalSettingsStore()
|
||||||
|
|
||||||
// 获取用户权限信息
|
// 获取用户权限信息
|
||||||
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
|
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
|
||||||
const canAdmin = computed(() => hasPermission(userPermissions.value, 'admin'))
|
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[]>([])
|
const startMenus = ref<NavMenu[]>([])
|
||||||
@@ -279,6 +287,10 @@ function handleThemeCustomizerChange(event: Event) {
|
|||||||
themeLayout.value = (event as CustomEvent<ThemeCustomizerSettings>).detail.layout
|
themeLayout.value = (event as CustomEvent<ThemeCustomizerSettings>).detail.layout
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleThemeCustomizerOpen() {
|
||||||
|
showThemeCustomizer.value = true
|
||||||
|
}
|
||||||
|
|
||||||
function isHorizontalNavActive(item: NavMenu) {
|
function isHorizontalNavActive(item: NavMenu) {
|
||||||
const targetPath = normalizeMenuPath(item.to)
|
const targetPath = normalizeMenuPath(item.to)
|
||||||
if (!targetPath) return false
|
if (!targetPath) return false
|
||||||
@@ -416,6 +428,10 @@ function appendPluginSidebarMenus() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
// 主题定制器由布局统一承载,监听需要尽早注册,避免异步加载菜单期间丢失打开事件。
|
||||||
|
window.addEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange)
|
||||||
|
window.addEventListener(THEME_CUSTOMIZER_OPEN_EVENT, handleThemeCustomizerOpen)
|
||||||
|
|
||||||
// 获取菜单列表
|
// 获取菜单列表
|
||||||
startMenus.value = getMenuList(t('menu.start'))
|
startMenus.value = getMenuList(t('menu.start'))
|
||||||
discoveryMenus.value = getMenuList(t('menu.discovery'))
|
discoveryMenus.value = getMenuList(t('menu.discovery'))
|
||||||
@@ -431,11 +447,10 @@ onMounted(async () => {
|
|||||||
navigator.serviceWorker.addEventListener('message', handleServiceWorkerMessage)
|
navigator.serviceWorker.addEventListener('message', handleServiceWorkerMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange)
|
|
||||||
|
|
||||||
// 组件卸载时清理监听
|
// 组件卸载时清理监听
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange)
|
window.removeEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange)
|
||||||
|
window.removeEventListener(THEME_CUSTOMIZER_OPEN_EVENT, handleThemeCustomizerOpen)
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
navigator.serviceWorker.removeEventListener('message', handleServiceWorkerMessage)
|
navigator.serviceWorker.removeEventListener('message', handleServiceWorkerMessage)
|
||||||
}
|
}
|
||||||
@@ -692,6 +707,12 @@ onMounted(async () => {
|
|||||||
@close="handleClosePluginQuickAccess"
|
@close="handleClosePluginQuickAccess"
|
||||||
@plugin-click="handlePluginClick"
|
@plugin-click="handlePluginClick"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- 👉 Theme Customizer -->
|
||||||
|
<ThemeCustomizer v-if="showThemeCustomizer" @close="showThemeCustomizer = false" />
|
||||||
|
|
||||||
|
<!-- 👉 Agent Assistant -->
|
||||||
|
<AgentAssistantWidget v-if="showAgentAssistant" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ function handleDynamicMenuItemClick(item: DynamicButtonMenuItem) {
|
|||||||
<VCardText class="footer-card-content">
|
<VCardText class="footer-card-content">
|
||||||
<!-- 添加指示器 -->
|
<!-- 添加指示器 -->
|
||||||
<div ref="indicator" class="nav-indicator"></div>
|
<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
|
<VBtn
|
||||||
v-for="menu in footerMenus"
|
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);
|
transition: all 0.5s cubic-bezier(0.25, 1, 0.5, 1);
|
||||||
will-change: transform, max-inline-size, opacity;
|
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 & {
|
.v-theme--transparent & {
|
||||||
backdrop-filter: blur(var(--transparent-blur-heavy, 16px));
|
backdrop-filter: blur(var(--transparent-blur-heavy, 16px));
|
||||||
@@ -361,13 +364,19 @@ function handleDynamicMenuItemClick(item: DynamicButtonMenuItem) {
|
|||||||
padding-inline: 6px;
|
padding-inline: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-btn-group {
|
.footer-nav-card .footer-btn-group.v-btn-group {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-around;
|
justify-content: space-around;
|
||||||
border: none;
|
border: none;
|
||||||
|
border-radius: 9999px !important;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
|
box-shadow: none !important;
|
||||||
inline-size: 100%;
|
inline-size: 100%;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-nav-btn {
|
.footer-nav-btn {
|
||||||
@@ -377,12 +386,15 @@ function handleDynamicMenuItemClick(item: DynamicButtonMenuItem) {
|
|||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
border-radius: 9999px !important;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
block-size: 48px;
|
block-size: 48px;
|
||||||
|
box-shadow: none !important;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
&.v-btn--active {
|
&.v-btn--active {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
box-shadow: none;
|
box-shadow: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-content {
|
.btn-content {
|
||||||
|
|||||||
@@ -810,12 +810,6 @@ function handleBackdropClick(event: MouseEvent) {
|
|||||||
-webkit-user-select: none;
|
-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) {
|
@media (hover: none) and (pointer: coarse) {
|
||||||
.plugin-item:hover {
|
.plugin-item:hover {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { openSharedDialog } from '@/composables/useSharedDialog'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useUserStore } from '@/stores'
|
import { useUserStore } from '@/stores'
|
||||||
import { buildUserPermissionContext, filterItemsByPermission, hasItemPermission, type PermissionProtectedItem } from '@/utils/permission'
|
import { buildUserPermissionContext, filterItemsByPermission, hasItemPermission, type PermissionProtectedItem } from '@/utils/permission'
|
||||||
import { clearUnreadMessages, getUnreadCount, onUnreadMessage } from '@/utils/badge'
|
|
||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -21,7 +20,6 @@ const WordsView = defineAsyncComponent(() => import('@/views/system/WordsView.vu
|
|||||||
const CacheView = defineAsyncComponent(() => import('@/views/system/CacheView.vue'))
|
const CacheView = defineAsyncComponent(() => import('@/views/system/CacheView.vue'))
|
||||||
const AccountSettingService = defineAsyncComponent(() => import('@/views/system/ServiceView.vue'))
|
const AccountSettingService = defineAsyncComponent(() => import('@/views/system/ServiceView.vue'))
|
||||||
const ShortcutLogDialog = defineAsyncComponent(() => import('@/components/dialog/ShortcutLogDialog.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'))
|
const ShortcutToolDialog = defineAsyncComponent(() => import('@/components/dialog/ShortcutToolDialog.vue'))
|
||||||
|
|
||||||
type ShortcutItem = PermissionProtectedItem & {
|
type ShortcutItem = PermissionProtectedItem & {
|
||||||
@@ -44,12 +42,6 @@ const appsMenu = ref(false)
|
|||||||
// 菜单最大宽度
|
// 菜单最大宽度
|
||||||
const menuMaxWidth = ref(420)
|
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[] = [
|
const shortcuts: ShortcutItem[] = [
|
||||||
{
|
{
|
||||||
@@ -123,55 +115,16 @@ const shortcuts: ShortcutItem[] = [
|
|||||||
component: ModuleTestView,
|
component: ModuleTestView,
|
||||||
titleText: t('shortcut.system.subtitle'),
|
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' }))
|
].map(item => ({ ...item, permission: 'admin' }))
|
||||||
|
|
||||||
const visibleShortcuts = computed(() => filterItemsByPermission(shortcuts, userPermissions.value))
|
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]) {
|
function openShortcutDialog(item: (typeof shortcuts)[number]) {
|
||||||
if (!hasItemPermission(item, userPermissions.value)) return
|
if (!hasItemPermission(item, userPermissions.value)) return
|
||||||
|
|
||||||
appsMenu.value = false
|
appsMenu.value = false
|
||||||
|
|
||||||
if (item.dialog === 'message') {
|
|
||||||
clearUnreadMessageState()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.customDialog) {
|
if (item.customDialog) {
|
||||||
openSharedDialog(item.customDialog, {}, {}, { closeOn: ['close', 'update:modelValue'] })
|
openSharedDialog(item.customDialog, {}, {}, { closeOn: ['close', 'update:modelValue'] })
|
||||||
return
|
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(() => {
|
onMounted(() => {
|
||||||
stopUnreadMessageListener = onUnreadMessage(handleUnreadMessage)
|
|
||||||
void syncUnreadMessageStateFromBadge()
|
|
||||||
|
|
||||||
const shortcut = getQueryValue('shortcut')
|
const shortcut = getQueryValue('shortcut')
|
||||||
if (shortcut) {
|
if (shortcut) {
|
||||||
const found = visibleShortcuts.value.find(item => item.dialog === shortcut)
|
const found = visibleShortcuts.value.find(item => item.dialog === shortcut)
|
||||||
@@ -218,10 +157,6 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
stopUnreadMessageListener?.()
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -257,30 +192,20 @@ onBeforeUnmount(() => {
|
|||||||
<div class="grid grid-cols-2 gap-3">
|
<div class="grid grid-cols-2 gap-3">
|
||||||
<!-- 循环渲染快捷方式 -->
|
<!-- 循环渲染快捷方式 -->
|
||||||
<div v-for="(item, index) in visibleShortcuts" :key="index">
|
<div v-for="(item, index) in visibleShortcuts" :key="index">
|
||||||
<VBadge
|
<VCard
|
||||||
:model-value="item.dialog === 'message' && hasUnreadMessages"
|
flat
|
||||||
dot
|
class="pa-2 d-flex align-center cursor-pointer transition-transform duration-300 hover:-translate-y-1 border h-full w-100"
|
||||||
color="error"
|
hover
|
||||||
location="top end"
|
@click="openShortcutDialog(item)"
|
||||||
offset-x="8"
|
|
||||||
offset-y="8"
|
|
||||||
class="d-block h-full w-100"
|
|
||||||
>
|
>
|
||||||
<VCard
|
<VAvatar variant="text" size="48" rounded="lg">
|
||||||
flat
|
<VIcon color="primary" :icon="item.icon" size="24" />
|
||||||
class="pa-2 d-flex align-center cursor-pointer transition-transform duration-300 hover:-translate-y-1 border h-full w-100"
|
</VAvatar>
|
||||||
hover
|
<div>
|
||||||
@click="openShortcutDialog(item)"
|
<div class="text-body-1 text-high-emphasis font-weight-medium">{{ item.title }}</div>
|
||||||
>
|
<div class="text-caption text-medium-emphasis">{{ item.subtitle }}</div>
|
||||||
<VAvatar variant="text" size="48" rounded="lg">
|
</div>
|
||||||
<VIcon color="primary" :icon="item.icon" size="24" />
|
</VCard>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,129 +1,765 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { SystemNotification } from '@/api/types'
|
||||||
|
import api from '@/api'
|
||||||
|
import { clearUnreadMessages } from '@/utils/badge'
|
||||||
import { formatDateDifference } from '@core/utils/formatters'
|
import { formatDateDifference } from '@core/utils/formatters'
|
||||||
import { SystemNotification } from '@/api/types'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import { useBackground } from '@/composables/useBackground'
|
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 }
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { useDelayedSSE } = useBackground()
|
const { useDelayedSSE } = useBackground()
|
||||||
|
const $toast = useToast()
|
||||||
|
const createConfirm = useConfirm()
|
||||||
|
|
||||||
// 是否有新消息
|
const PAGE_SIZE = 20
|
||||||
const hasNewMessage = ref(false)
|
// 虚拟滚动的默认通知项高度,展开后的实际高度由 VVirtualScroll 的 itemRef 动态测量。
|
||||||
|
const NOTIFICATION_ITEM_HEIGHT = 136
|
||||||
|
const CLEAR_NOTIFICATION_ENDPOINTS = ['message/notification', 'message/notification/clear']
|
||||||
|
const NOTIFICATION_CLEAR_BEFORE_STORAGE_KEY = 'moviepilot-notification-clear-before'
|
||||||
|
|
||||||
// 通知列表
|
|
||||||
const notificationList = ref<SystemNotification[]>([])
|
|
||||||
const MAX_NOTIFICATIONS = 100
|
|
||||||
|
|
||||||
// 弹窗
|
|
||||||
const appsMenu = ref(false)
|
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))
|
||||||
|
|
||||||
|
/** 从本地存储读取通知清理时间戳,用于过滤已清理的历史通知。 */
|
||||||
|
function readNotificationClearBefore() {
|
||||||
|
if (typeof localStorage === 'undefined') return 0
|
||||||
|
|
||||||
|
return Number(localStorage.getItem(NOTIFICATION_CLEAR_BEFORE_STORAGE_KEY) || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 写入通知清理时间戳,使清理结果在刷新后仍然生效。 */
|
||||||
|
function writeNotificationClearBefore(value: number) {
|
||||||
|
notificationClearBefore.value = value
|
||||||
|
if (typeof localStorage === 'undefined') return
|
||||||
|
|
||||||
|
localStorage.setItem(NOTIFICATION_CLEAR_BEFORE_STORAGE_KEY, String(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将通知备注统一转换成稳定字符串,用于生成去重 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 clearBefore = notificationClearBefore.value
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通过后端接口清理通知历史,兼容新旧后端可能暴露的清理路径。 */
|
||||||
|
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() {
|
||||||
|
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() {
|
||||||
|
if (clearing.value || notificationList.value.length === 0) return
|
||||||
|
|
||||||
|
const confirmed = await createConfirm({
|
||||||
|
type: 'warn',
|
||||||
|
title: t('notification.clear'),
|
||||||
|
content: t('notification.clearConfirm'),
|
||||||
|
confirmText: t('notification.clear'),
|
||||||
|
})
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
clearing.value = true
|
||||||
|
try {
|
||||||
|
const cleared = await tryDeleteNotificationHistory()
|
||||||
|
if (!cleared) {
|
||||||
|
$toast.error(t('notification.clearFailed'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeNotificationClearBefore(Date.now())
|
||||||
|
resetNotifications()
|
||||||
|
await clearUnreadMessages()
|
||||||
|
appsMenu.value = false
|
||||||
|
hasMore.value = false
|
||||||
|
$toast.success(t('notification.clearSuccess'))
|
||||||
|
} 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
|
||||||
|
const items = (await api.get('message/notification', {
|
||||||
|
params: {
|
||||||
|
page: page.value,
|
||||||
|
count: PAGE_SIZE,
|
||||||
|
},
|
||||||
|
})) as SystemNotification[]
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
hasMore.value = false
|
||||||
|
done('empty')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleItems = items.filter(item => !isClearedHistoryNotification(item))
|
||||||
|
mergeNotifications(visibleItems, { read: true })
|
||||||
|
page.value += 1
|
||||||
|
hasMore.value = visibleItems.length === items.length && items.length >= PAGE_SIZE
|
||||||
|
done(hasMore.value ? 'ok' : 'empty')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载通知失败:', error)
|
||||||
|
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 (mergeNotifications([notification], { prepend: true, read: false })) {
|
||||||
|
hasNewMessage.value = true
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('解析通知失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将通知列表标记为已读,并同步清理应用角标、未读红点和通知弹窗。 */
|
||||||
function markAllAsRead() {
|
function markAllAsRead() {
|
||||||
hasNewMessage.value = false
|
hasNewMessage.value = false
|
||||||
// 标记所有消息为已读
|
|
||||||
notificationList.value.forEach(item => {
|
notificationList.value.forEach(item => {
|
||||||
item.read = true
|
item.read = true
|
||||||
})
|
})
|
||||||
appsMenu.value = false
|
appsMenu.value = false
|
||||||
|
void clearUnreadMessages()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 消息处理函数
|
/** 根据通知分类和业务类型选择列表图标。 */
|
||||||
function handleMessage(event: MessageEvent) {
|
function getNotificationIcon(item: SystemNotification) {
|
||||||
if (event.data) {
|
if (getNotificationKind(item) === 'plugin') return 'mdi-puzzle-outline'
|
||||||
const noti: SystemNotification = JSON.parse(event.data)
|
if (item.mtype === '资源下载') return 'mdi-download'
|
||||||
notificationList.value.unshift(noti)
|
if (item.mtype === '整理入库') return 'mdi-folder-check-outline'
|
||||||
if (notificationList.value.length > MAX_NOTIFICATIONS) {
|
if (item.mtype === '订阅') return 'mdi-rss'
|
||||||
notificationList.value.length = MAX_NOTIFICATIONS
|
if (item.mtype === '智能体') return 'lucide:bot'
|
||||||
}
|
return getNotificationKind(item) === 'system' ? 'mdi-alert-circle-outline' : 'mdi-bell-outline'
|
||||||
hasNewMessage.value = true
|
}
|
||||||
}
|
|
||||||
|
/** 根据通知分类和业务类型选择图标颜色。 */
|
||||||
|
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 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
|
||||||
}
|
}
|
||||||
|
|
||||||
// 延迟3秒启动SSE连接,避免认证信息尚未准备好。
|
|
||||||
useDelayedSSE(
|
useDelayedSSE(
|
||||||
`${import.meta.env.VITE_API_BASE_URL}system/message`,
|
`${import.meta.env.VITE_API_BASE_URL}system/message?role=notification`,
|
||||||
handleMessage,
|
handleMessage,
|
||||||
'user-notification',
|
'user-notification',
|
||||||
3000,
|
3000,
|
||||||
{
|
{
|
||||||
backgroundCloseDelay: 5000,
|
backgroundCloseDelay: 5000,
|
||||||
reconnectDelay: 3000,
|
reconnectDelay: 3000,
|
||||||
maxReconnectAttempts: 3
|
maxReconnectAttempts: 3,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VMenu
|
<VMenu
|
||||||
v-model="appsMenu"
|
v-model="appsMenu"
|
||||||
width="400"
|
width="420"
|
||||||
|
max-width="calc(100vw - 24px)"
|
||||||
transition="scale-transition"
|
transition="scale-transition"
|
||||||
close-on-content-click
|
:close-on-content-click="false"
|
||||||
class="notification-menu"
|
class="notification-menu"
|
||||||
scrim
|
scrim
|
||||||
>
|
>
|
||||||
<!-- Menu Activator -->
|
|
||||||
<template #activator="{ props }">
|
<template #activator="{ props }">
|
||||||
<VBadge v-if="hasNewMessage" dot color="error" :offset-x="5" :offset-y="5" v-bind="props">
|
<VBadge v-if="hasNewMessage" dot color="error" :offset-x="5" :offset-y="5" v-bind="props">
|
||||||
<IconBtn>
|
<IconBtn>
|
||||||
<VIcon icon="mdi-bell-outline" />
|
<VIcon icon="mdi-bell-outline" size="22" />
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
</VBadge>
|
</VBadge>
|
||||||
<IconBtn v-else v-bind="props">
|
<IconBtn v-else v-bind="props">
|
||||||
<VIcon icon="mdi-bell-outline" />
|
<VIcon icon="mdi-bell-outline" size="22" />
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
</template>
|
</template>
|
||||||
<!-- Menu Content -->
|
|
||||||
<VCard>
|
<VCard class="notification-panel">
|
||||||
<VCardItem class="py-3">
|
<VCardItem class="py-3">
|
||||||
<VCardTitle>{{ t('notification.center') }}</VCardTitle>
|
<VCardTitle>{{ t('notification.center') }}</VCardTitle>
|
||||||
<template #append>
|
<template #append>
|
||||||
<VTooltip :text="t('notification.markRead')">
|
<div class="notification-actions">
|
||||||
<template #activator="{ props }">
|
<VTooltip :text="t('notification.clear')">
|
||||||
<IconBtn v-bind="props" @click="markAllAsRead">
|
<template #activator="{ props }">
|
||||||
<VIcon icon="mdi-email-check-outline" size="20" />
|
<IconBtn
|
||||||
</IconBtn>
|
v-bind="props"
|
||||||
</template>
|
:disabled="notificationList.length === 0 || clearing"
|
||||||
</VTooltip>
|
@click.stop="clearNotifications"
|
||||||
|
>
|
||||||
|
<VProgressCircular v-if="clearing" indeterminate size="18" width="2" />
|
||||||
|
<VIcon v-else icon="mdi-trash-can-outline" size="20" />
|
||||||
|
</IconBtn>
|
||||||
|
</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>
|
</template>
|
||||||
</VCardItem>
|
</VCardItem>
|
||||||
<VDivider />
|
<VDivider />
|
||||||
|
|
||||||
<div class="notification-list-container">
|
<div class="notification-list-container">
|
||||||
<div v-if="notificationList.length > 0">
|
<VInfiniteScroll
|
||||||
<VListItem v-for="(item, i) in notificationList" :key="i" lines="two" class="mb-1">
|
mode="intersect"
|
||||||
<template #prepend>
|
side="end"
|
||||||
<VAvatar rounded>
|
:items="notificationList"
|
||||||
<VIcon v-if="item.type === 'user'" icon="mdi-account-alert" size="large"></VIcon>
|
class="notification-list-scroll"
|
||||||
<VIcon v-else-if="item.type === 'plugin'" icon="mdi-robot" size="large"></VIcon>
|
@load="loadNotifications"
|
||||||
<VIcon v-else icon="mdi-laptop" size="large"></VIcon>
|
>
|
||||||
</VAvatar>
|
<template #loading>
|
||||||
</template>
|
<div class="py-3 text-center text-caption text-medium-emphasis">
|
||||||
<div>
|
{{ t('message.loadMore') }}
|
||||||
<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>
|
</div>
|
||||||
</VListItem>
|
</template>
|
||||||
</div>
|
<template #empty>
|
||||||
<div v-else class="py-8 text-center">
|
<div v-if="notificationList.length > 0" class="py-3 text-center text-caption text-medium-emphasis">
|
||||||
<VIcon icon="mdi-bell-sleep-outline" size="40" class="mb-3" />
|
{{ t('message.noMoreData') }}
|
||||||
<div>{{ t('notification.empty') }}</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</VCard>
|
</VCard>
|
||||||
</VMenu>
|
</VMenu>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.notification-panel {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.notification-list-container {
|
.notification-list-container {
|
||||||
max-block-size: 50vh;
|
overflow: hidden;
|
||||||
overflow-y: auto;
|
max-block-size: min(560px, 62vh);
|
||||||
scrollbar-width: thin;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
persistPartialThemeCustomizerSettings,
|
persistPartialThemeCustomizerSettings,
|
||||||
readThemeCustomizerSettings,
|
readThemeCustomizerSettings,
|
||||||
THEME_CUSTOMIZER_CHANGE_EVENT,
|
THEME_CUSTOMIZER_CHANGE_EVENT,
|
||||||
|
THEME_CUSTOMIZER_OPEN_EVENT,
|
||||||
type ThemeCustomizerSettings,
|
type ThemeCustomizerSettings,
|
||||||
} from '@/composables/useThemeCustomizer'
|
} from '@/composables/useThemeCustomizer'
|
||||||
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||||
@@ -30,7 +31,6 @@ const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/Pr
|
|||||||
const TransparencySettingsDialog = defineAsyncComponent(
|
const TransparencySettingsDialog = defineAsyncComponent(
|
||||||
() => import('@/components/dialog/TransparencySettingsDialog.vue'),
|
() => import('@/components/dialog/TransparencySettingsDialog.vue'),
|
||||||
)
|
)
|
||||||
const ThemeCustomizer = defineAsyncComponent(() => import('@/components/ThemeCustomizer.vue'))
|
|
||||||
const UserAuthDialog = defineAsyncComponent(() => import('@/components/dialog/UserAuthDialog.vue'))
|
const UserAuthDialog = defineAsyncComponent(() => import('@/components/dialog/UserAuthDialog.vue'))
|
||||||
|
|
||||||
// 认证 Store
|
// 认证 Store
|
||||||
@@ -50,12 +50,12 @@ const $toast = useToast()
|
|||||||
// UI模式菜单是否显示
|
// UI模式菜单是否显示
|
||||||
const showUIModeMenu = ref(false)
|
const showUIModeMenu = ref(false)
|
||||||
|
|
||||||
|
// 用户头像主菜单是否显示;打开布局级面板前需要主动关闭,避免菜单 overlay 残留。
|
||||||
|
const showUserMenu = ref(false)
|
||||||
|
|
||||||
// 主题菜单是否显示
|
// 主题菜单是否显示
|
||||||
const showThemeMenu = ref(false)
|
const showThemeMenu = ref(false)
|
||||||
|
|
||||||
// 主题定制器面板是否显示
|
|
||||||
const showThemeCustomizer = ref(false)
|
|
||||||
|
|
||||||
// 语言菜单是否显示
|
// 语言菜单是否显示
|
||||||
const showLanguageMenu = ref(false)
|
const showLanguageMenu = ref(false)
|
||||||
|
|
||||||
@@ -442,8 +442,11 @@ function showTransparencySettingsDialog() {
|
|||||||
|
|
||||||
/** 从用户菜单打开主题定制器,App 模式会在面板内部隐藏布局设置。 */
|
/** 从用户菜单打开主题定制器,App 模式会在面板内部隐藏布局设置。 */
|
||||||
function showThemeCustomizerDrawer() {
|
function showThemeCustomizerDrawer() {
|
||||||
|
showUserMenu.value = false
|
||||||
showThemeMenu.value = false
|
showThemeMenu.value = false
|
||||||
showThemeCustomizer.value = true
|
|
||||||
|
// 主题定制器由 DefaultLayout 统一挂载
|
||||||
|
window.dispatchEvent(new CustomEvent(THEME_CUSTOMIZER_OPEN_EVENT))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 保存自定义 CSS。 */
|
/** 保存自定义 CSS。 */
|
||||||
@@ -558,6 +561,7 @@ onUnmounted(() => {
|
|||||||
<VImg :src="avatar" />
|
<VImg :src="avatar" />
|
||||||
|
|
||||||
<VMenu
|
<VMenu
|
||||||
|
v-model="showUserMenu"
|
||||||
activator="parent"
|
activator="parent"
|
||||||
width="15rem"
|
width="15rem"
|
||||||
location="bottom end"
|
location="bottom end"
|
||||||
@@ -777,7 +781,6 @@ onUnmounted(() => {
|
|||||||
</VMenu>
|
</VMenu>
|
||||||
<!-- !SECTION -->
|
<!-- !SECTION -->
|
||||||
</VAvatar>
|
</VAvatar>
|
||||||
<ThemeCustomizer v-model="showThemeCustomizer" />
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|||||||
@@ -170,16 +170,13 @@ export default {
|
|||||||
skinDefault: 'Default',
|
skinDefault: 'Default',
|
||||||
skinBordered: 'Bordered',
|
skinBordered: 'Bordered',
|
||||||
radius: 'Corners',
|
radius: 'Corners',
|
||||||
|
radiusNone: 'Square',
|
||||||
radiusSmall: 'Small',
|
radiusSmall: 'Small',
|
||||||
radiusDefault: 'Default',
|
radiusDefault: 'Default',
|
||||||
radiusLarge: 'Large',
|
radiusLarge: 'Large',
|
||||||
radiusExtra: 'Larger',
|
radiusExtra: 'Larger',
|
||||||
radiusHuge: 'Extra Large',
|
|
||||||
shadow: 'Shadows',
|
shadow: 'Shadows',
|
||||||
shadowNone: 'Flat',
|
shadowLevel: 'Level {level}',
|
||||||
shadowLow: 'Soft',
|
|
||||||
shadowMedium: 'Balanced',
|
|
||||||
shadowHigh: 'Bold',
|
|
||||||
semiDarkMenu: 'Semi Dark Menu',
|
semiDarkMenu: 'Semi Dark Menu',
|
||||||
layout: 'Layout',
|
layout: 'Layout',
|
||||||
layoutVertical: 'Vertical',
|
layoutVertical: 'Vertical',
|
||||||
@@ -462,7 +459,13 @@ export default {
|
|||||||
notification: {
|
notification: {
|
||||||
center: 'Notification Center',
|
center: 'Notification Center',
|
||||||
markRead: 'Mark as Read',
|
markRead: 'Mark as Read',
|
||||||
|
clear: 'Clear Notifications',
|
||||||
|
clearConfirm: 'Clear all notification history from Notification Center?',
|
||||||
|
clearSuccess: 'Notifications cleared',
|
||||||
|
clearFailed: 'Failed to clear notifications',
|
||||||
empty: 'No Notifications',
|
empty: 'No Notifications',
|
||||||
|
systemMessages: 'System Messages',
|
||||||
|
mediaMessages: 'Media Messages',
|
||||||
channel: 'Notification Channel',
|
channel: 'Notification Channel',
|
||||||
name: 'Name',
|
name: 'Name',
|
||||||
nameHint: 'Name of notification channel',
|
nameHint: 'Name of notification channel',
|
||||||
@@ -680,10 +683,6 @@ export default {
|
|||||||
title: 'System',
|
title: 'System',
|
||||||
subtitle: 'Health Check',
|
subtitle: 'Health Check',
|
||||||
},
|
},
|
||||||
message: {
|
|
||||||
title: 'Messages',
|
|
||||||
subtitle: 'Message Center',
|
|
||||||
},
|
|
||||||
words: {
|
words: {
|
||||||
title: 'Words',
|
title: 'Words',
|
||||||
subtitle: 'Word Settings',
|
subtitle: 'Word Settings',
|
||||||
@@ -697,6 +696,39 @@ export default {
|
|||||||
subtitle: 'Scheduled Services',
|
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: {
|
workflow: {
|
||||||
components: 'Action Components',
|
components: 'Action Components',
|
||||||
clickToAdd: 'Click to Add',
|
clickToAdd: 'Click to Add',
|
||||||
@@ -1169,6 +1201,7 @@ export default {
|
|||||||
currentEpisodeNotInLibrary: 'Current not in library',
|
currentEpisodeNotInLibrary: 'Current not in library',
|
||||||
libraryUpdatedAt: 'Updated {time}',
|
libraryUpdatedAt: 'Updated {time}',
|
||||||
libraryUpdatedAtShort: '{time}',
|
libraryUpdatedAtShort: '{time}',
|
||||||
|
expandDayEvents: 'Show {count} more items for this day',
|
||||||
},
|
},
|
||||||
storage: {
|
storage: {
|
||||||
name: 'Name',
|
name: 'Name',
|
||||||
@@ -1586,6 +1619,9 @@ export default {
|
|||||||
'Set the check interval for scheduled wake. Select "Disabled" to disable scheduled tasks.',
|
'Set the check interval for scheduled wake. Select "Disabled" to disable scheduled tasks.',
|
||||||
aiAgentVerbose: 'Verbose Mode',
|
aiAgentVerbose: 'Verbose Mode',
|
||||||
aiAgentVerboseHint: 'When enabled, tool call process will be displayed in AI agent responses',
|
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',
|
aiAgentJobIntervalDisabled: 'Disabled',
|
||||||
aiAgentJobInterval1h: '1 Hour',
|
aiAgentJobInterval1h: '1 Hour',
|
||||||
aiAgentJobInterval3h: '3 Hours',
|
aiAgentJobInterval3h: '3 Hours',
|
||||||
@@ -3006,6 +3042,12 @@ export default {
|
|||||||
projectHome: 'Project Home',
|
projectHome: 'Project Home',
|
||||||
updateHistory: 'Update History',
|
updateHistory: 'Update History',
|
||||||
versionHistory: 'Version 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',
|
local: 'Local',
|
||||||
systemVersion: 'System Version',
|
systemVersion: 'System Version',
|
||||||
incompatibleSystemVersion: 'The current MoviePilot version does not meet this plugin requirement.',
|
incompatibleSystemVersion: 'The current MoviePilot version does not meet this plugin requirement.',
|
||||||
|
|||||||
@@ -170,16 +170,13 @@ export default {
|
|||||||
skinDefault: '默认',
|
skinDefault: '默认',
|
||||||
skinBordered: '边框',
|
skinBordered: '边框',
|
||||||
radius: '圆角',
|
radius: '圆角',
|
||||||
|
radiusNone: '无圆角',
|
||||||
radiusSmall: '小圆角',
|
radiusSmall: '小圆角',
|
||||||
radiusDefault: '默认',
|
radiusDefault: '默认',
|
||||||
radiusLarge: '大圆角',
|
radiusLarge: '大圆角',
|
||||||
radiusExtra: '更大圆角',
|
radiusExtra: '更大圆角',
|
||||||
radiusHuge: '超大圆角',
|
|
||||||
shadow: '阴影',
|
shadow: '阴影',
|
||||||
shadowNone: '无阴影',
|
shadowLevel: '层级 {level}',
|
||||||
shadowLow: '柔和',
|
|
||||||
shadowMedium: '标准',
|
|
||||||
shadowHigh: '强烈',
|
|
||||||
semiDarkMenu: '半暗菜单',
|
semiDarkMenu: '半暗菜单',
|
||||||
layout: '布局',
|
layout: '布局',
|
||||||
layoutVertical: '垂直',
|
layoutVertical: '垂直',
|
||||||
@@ -460,7 +457,13 @@ export default {
|
|||||||
notification: {
|
notification: {
|
||||||
center: '通知中心',
|
center: '通知中心',
|
||||||
markRead: '设为已读',
|
markRead: '设为已读',
|
||||||
|
clear: '清理通知',
|
||||||
|
clearConfirm: '是否确认清理通知中心内的全部历史消息?',
|
||||||
|
clearSuccess: '通知已清理',
|
||||||
|
clearFailed: '通知清理失败',
|
||||||
empty: '暂无通知',
|
empty: '暂无通知',
|
||||||
|
systemMessages: '系统类消息',
|
||||||
|
mediaMessages: '媒体消息',
|
||||||
channel: '通知渠道',
|
channel: '通知渠道',
|
||||||
name: '名称',
|
name: '名称',
|
||||||
nameHint: '通知渠道名称',
|
nameHint: '通知渠道名称',
|
||||||
@@ -676,10 +679,6 @@ export default {
|
|||||||
title: '系统',
|
title: '系统',
|
||||||
subtitle: '健康检查',
|
subtitle: '健康检查',
|
||||||
},
|
},
|
||||||
message: {
|
|
||||||
title: '消息',
|
|
||||||
subtitle: '消息中心',
|
|
||||||
},
|
|
||||||
words: {
|
words: {
|
||||||
title: '词表',
|
title: '词表',
|
||||||
subtitle: '词表设置',
|
subtitle: '词表设置',
|
||||||
@@ -693,6 +692,39 @@ export default {
|
|||||||
subtitle: '定时服务',
|
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: {
|
workflow: {
|
||||||
components: '动作组件',
|
components: '动作组件',
|
||||||
clickToAdd: '点击添加',
|
clickToAdd: '点击添加',
|
||||||
@@ -1164,6 +1196,7 @@ export default {
|
|||||||
currentEpisodeNotInLibrary: '本集未入库',
|
currentEpisodeNotInLibrary: '本集未入库',
|
||||||
libraryUpdatedAt: '最近更新 {time}',
|
libraryUpdatedAt: '最近更新 {time}',
|
||||||
libraryUpdatedAtShort: '{time}',
|
libraryUpdatedAtShort: '{time}',
|
||||||
|
expandDayEvents: '展开当天剩余 {count} 个条目',
|
||||||
},
|
},
|
||||||
storage: {
|
storage: {
|
||||||
name: '名称',
|
name: '名称',
|
||||||
@@ -1572,6 +1605,8 @@ export default {
|
|||||||
aiAgentJobIntervalHint: '设置定时唤醒的检查间隔,选择"不启用"则不执行定时任务',
|
aiAgentJobIntervalHint: '设置定时唤醒的检查间隔,选择"不启用"则不执行定时任务',
|
||||||
aiAgentVerbose: '啰嗦模式',
|
aiAgentVerbose: '啰嗦模式',
|
||||||
aiAgentVerboseHint: '开启后会在智能体回复时显示工具调用过程',
|
aiAgentVerboseHint: '开启后会在智能体回复时显示工具调用过程',
|
||||||
|
aiAgentHideEntry: '隐藏全局入口',
|
||||||
|
aiAgentHideEntryHint: '仅隐藏页面右下角的智能助手浮动入口,不影响消息渠道和后台智能助手功能',
|
||||||
aiAgentJobIntervalDisabled: '不启用',
|
aiAgentJobIntervalDisabled: '不启用',
|
||||||
aiAgentJobInterval1h: '1小时',
|
aiAgentJobInterval1h: '1小时',
|
||||||
aiAgentJobInterval3h: '3小时',
|
aiAgentJobInterval3h: '3小时',
|
||||||
@@ -2956,6 +2991,12 @@ export default {
|
|||||||
projectHome: '项目主页',
|
projectHome: '项目主页',
|
||||||
updateHistory: '更新说明',
|
updateHistory: '更新说明',
|
||||||
versionHistory: '版本历史',
|
versionHistory: '版本历史',
|
||||||
|
releaseVersionsLoadFailed: 'Release 版本加载失败',
|
||||||
|
latestVersion: '最新',
|
||||||
|
currentVersion: '当前',
|
||||||
|
installReleaseVersion: '安装',
|
||||||
|
confirmInstallOldRelease:
|
||||||
|
'是否确认安装 {name} v{version}?该版本缺少主程序兼容元数据,安装后可能无法加载或运行异常。',
|
||||||
local: '本地',
|
local: '本地',
|
||||||
systemVersion: '系统版本',
|
systemVersion: '系统版本',
|
||||||
incompatibleSystemVersion: '当前 MoviePilot 版本不满足插件要求,无法安装',
|
incompatibleSystemVersion: '当前 MoviePilot 版本不满足插件要求,无法安装',
|
||||||
|
|||||||
@@ -170,16 +170,13 @@ export default {
|
|||||||
skinDefault: '默認',
|
skinDefault: '默認',
|
||||||
skinBordered: '邊框',
|
skinBordered: '邊框',
|
||||||
radius: '圓角',
|
radius: '圓角',
|
||||||
|
radiusNone: '無圓角',
|
||||||
radiusSmall: '小圓角',
|
radiusSmall: '小圓角',
|
||||||
radiusDefault: '默認',
|
radiusDefault: '默認',
|
||||||
radiusLarge: '大圓角',
|
radiusLarge: '大圓角',
|
||||||
radiusExtra: '更大圓角',
|
radiusExtra: '更大圓角',
|
||||||
radiusHuge: '超大圓角',
|
|
||||||
shadow: '陰影',
|
shadow: '陰影',
|
||||||
shadowNone: '無陰影',
|
shadowLevel: '層級 {level}',
|
||||||
shadowLow: '柔和',
|
|
||||||
shadowMedium: '標準',
|
|
||||||
shadowHigh: '強烈',
|
|
||||||
semiDarkMenu: '半暗菜單',
|
semiDarkMenu: '半暗菜單',
|
||||||
layout: '佈局',
|
layout: '佈局',
|
||||||
layoutVertical: '垂直',
|
layoutVertical: '垂直',
|
||||||
@@ -460,7 +457,13 @@ export default {
|
|||||||
notification: {
|
notification: {
|
||||||
center: '通知中心',
|
center: '通知中心',
|
||||||
markRead: '設為已讀',
|
markRead: '設為已讀',
|
||||||
|
clear: '清理通知',
|
||||||
|
clearConfirm: '是否確認清理通知中心內的全部歷史消息?',
|
||||||
|
clearSuccess: '通知已清理',
|
||||||
|
clearFailed: '通知清理失敗',
|
||||||
empty: '暫無通知',
|
empty: '暫無通知',
|
||||||
|
systemMessages: '系統類消息',
|
||||||
|
mediaMessages: '媒體消息',
|
||||||
channel: '通知渠道',
|
channel: '通知渠道',
|
||||||
name: '名稱',
|
name: '名稱',
|
||||||
nameHint: '通知渠道名稱',
|
nameHint: '通知渠道名稱',
|
||||||
@@ -676,10 +679,6 @@ export default {
|
|||||||
title: '系統',
|
title: '系統',
|
||||||
subtitle: '健康檢查',
|
subtitle: '健康檢查',
|
||||||
},
|
},
|
||||||
message: {
|
|
||||||
title: '消息',
|
|
||||||
subtitle: '消息中心',
|
|
||||||
},
|
|
||||||
words: {
|
words: {
|
||||||
title: '詞表',
|
title: '詞表',
|
||||||
subtitle: '詞表設置',
|
subtitle: '詞表設置',
|
||||||
@@ -693,6 +692,39 @@ export default {
|
|||||||
subtitle: '定時服務',
|
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: {
|
workflow: {
|
||||||
components: '動作組件',
|
components: '動作組件',
|
||||||
clickToAdd: '點擊添加',
|
clickToAdd: '點擊添加',
|
||||||
@@ -1164,6 +1196,7 @@ export default {
|
|||||||
currentEpisodeNotInLibrary: '本集未入庫',
|
currentEpisodeNotInLibrary: '本集未入庫',
|
||||||
libraryUpdatedAt: '最近更新 {time}',
|
libraryUpdatedAt: '最近更新 {time}',
|
||||||
libraryUpdatedAtShort: '{time}',
|
libraryUpdatedAtShort: '{time}',
|
||||||
|
expandDayEvents: '展開當天剩餘 {count} 個條目',
|
||||||
},
|
},
|
||||||
storage: {
|
storage: {
|
||||||
name: '名稱',
|
name: '名稱',
|
||||||
@@ -1573,6 +1606,8 @@ export default {
|
|||||||
aiAgentJobIntervalHint: '設置定時喚醒的檢查間隔,選擇「不啟用」則不執行定時任務',
|
aiAgentJobIntervalHint: '設置定時喚醒的檢查間隔,選擇「不啟用」則不執行定時任務',
|
||||||
aiAgentVerbose: '囉嗦模式',
|
aiAgentVerbose: '囉嗦模式',
|
||||||
aiAgentVerboseHint: '開啟後會在智能體回覆時顯示工具調用過程',
|
aiAgentVerboseHint: '開啟後會在智能體回覆時顯示工具調用過程',
|
||||||
|
aiAgentHideEntry: '隱藏全域入口',
|
||||||
|
aiAgentHideEntryHint: '僅隱藏頁面右下角的智能助手浮動入口,不影響消息渠道和後台智能助手功能',
|
||||||
aiAgentJobIntervalDisabled: '不啟用',
|
aiAgentJobIntervalDisabled: '不啟用',
|
||||||
aiAgentJobInterval1h: '1小時',
|
aiAgentJobInterval1h: '1小時',
|
||||||
aiAgentJobInterval3h: '3小時',
|
aiAgentJobInterval3h: '3小時',
|
||||||
@@ -2957,6 +2992,12 @@ export default {
|
|||||||
projectHome: '項目主頁',
|
projectHome: '項目主頁',
|
||||||
updateHistory: '更新說明',
|
updateHistory: '更新說明',
|
||||||
versionHistory: '版本歷史',
|
versionHistory: '版本歷史',
|
||||||
|
releaseVersionsLoadFailed: 'Release 版本載入失敗',
|
||||||
|
latestVersion: '最新',
|
||||||
|
currentVersion: '當前',
|
||||||
|
installReleaseVersion: '安裝',
|
||||||
|
confirmInstallOldRelease:
|
||||||
|
'是否確認安裝 {name} v{version}?該版本缺少主程序兼容元數據,安裝後可能無法載入或運行異常。',
|
||||||
local: '本地',
|
local: '本地',
|
||||||
installToLocal: '安裝到本地',
|
installToLocal: '安裝到本地',
|
||||||
totalDownloads: '共 {count} 次下載',
|
totalDownloads: '共 {count} 次下載',
|
||||||
|
|||||||
@@ -894,8 +894,6 @@ onUnmounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
/* stylelint-disable selector-pseudo-class-no-unknown */
|
|
||||||
|
|
||||||
@use '@core/scss/pages/page-auth';
|
@use '@core/scss/pages/page-auth';
|
||||||
|
|
||||||
/* ===================== 布局根容器 ===================== */
|
/* ===================== 布局根容器 ===================== */
|
||||||
@@ -910,11 +908,6 @@ onUnmounted(() => {
|
|||||||
min-block-size: 100dvh;
|
min-block-size: 100dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 登录页需要透出 App.vue 注入的壁纸层。 */
|
|
||||||
:global(.v-application:has(.login-root)) {
|
|
||||||
background: transparent !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===================== 浮动语言切换 ===================== */
|
/* ===================== 浮动语言切换 ===================== */
|
||||||
.lang-switch-btn {
|
.lang-switch-btn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@@ -1,6 +1,30 @@
|
|||||||
|
/* stylelint-disable custom-property-pattern */
|
||||||
/* stylelint-disable no-duplicate-selectors */
|
/* stylelint-disable no-duplicate-selectors */
|
||||||
/* stylelint-disable scss/at-rule-no-unknown */
|
/* stylelint-disable scss/at-rule-no-unknown */
|
||||||
/* stylelint-disable no-descending-specificity */
|
/* 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 base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@@ -17,6 +41,12 @@ html.v-overlay-scroll-blocked body {
|
|||||||
inset-block-start: var(--v-body-scroll-y);
|
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 {
|
@mixin hide-scrollbar {
|
||||||
-ms-overflow-style: none;
|
-ms-overflow-style: none;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
@@ -32,24 +62,36 @@ html.v-overlay-scroll-blocked body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 全局卡片外观 token:圆角和阴影在主题定制器中即时切换。
|
// 全局外观 token:圆角和阴影复用 Vuetify 的 rounded / elevation 分级。
|
||||||
html {
|
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-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-opacity: 0.06;
|
||||||
--app-surface-border: 1px solid rgba(var(--v-theme-on-surface), var(--app-surface-border-opacity));
|
--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: var(--app-elevation-0);
|
||||||
--app-card-rest-shadow: none;
|
--app-card-hover-shadow: var(--app-elevation-0);
|
||||||
--app-card-hover-shadow: none;
|
--app-fab-shadow: var(--app-elevation-0);
|
||||||
--app-fab-shadow: none;
|
--app-fab-shadow-strong: var(--app-elevation-0);
|
||||||
--app-fab-shadow-strong: none;
|
--app-fab-shadow-hover: var(--app-elevation-0);
|
||||||
--app-fab-shadow-hover: none;
|
--app-fab-shadow-strong-hover: var(--app-elevation-0);
|
||||||
--app-fab-shadow-strong-hover: none;
|
--app-fab-shadow-active: var(--app-elevation-0);
|
||||||
--app-fab-shadow-active: none;
|
--app-overlay-shadow: var(--app-elevation-0);
|
||||||
--app-overlay-shadow: none;
|
--app-surface-shadow: var(--app-elevation-0);
|
||||||
--app-surface-shadow: none;
|
--app-surface-hover-shadow: var(--app-elevation-0);
|
||||||
--app-surface-hover-shadow: none;
|
|
||||||
--mp-motion-duration-page: 180ms;
|
--mp-motion-duration-page: 180ms;
|
||||||
--mp-motion-duration-overlay: 160ms;
|
--mp-motion-duration-overlay: 160ms;
|
||||||
--mp-motion-ease-standard: cubic-bezier(0.2, 0.8, 0.2, 1);
|
--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;
|
--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'] {
|
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'] {
|
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'] {
|
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'] {
|
@for $level from 1 through 24 {
|
||||||
--app-theme-surface-radius: 24px;
|
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)});
|
||||||
html[data-theme='dark'],
|
--app-fab-shadow: var(--app-elevation-#{$level});
|
||||||
html[data-theme='purple'],
|
--app-fab-shadow-active: var(--app-elevation-#{app-clamp-elevation($level - 2)});
|
||||||
html[data-theme='transparent'] {
|
--app-fab-shadow-hover: var(--app-elevation-#{app-clamp-elevation($level + 3)});
|
||||||
--app-shadow-rgb: 0, 0, 0;
|
--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)});
|
||||||
html[data-theme-shadow='low'] {
|
--app-surface-shadow: var(--app-elevation-#{$level});
|
||||||
--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-surface-hover-shadow: var(--app-elevation-#{app-clamp-elevation($level + 2)});
|
||||||
--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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 进度条样式
|
// 进度条样式
|
||||||
@@ -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-']) {
|
.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;
|
transition: border-radius 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.v-btn-group:not(.v-btn-group--variant-plain, .v-btn-group--variant-text) {
|
.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;
|
box-shadow: var(--app-surface-shadow) !important;
|
||||||
transition: box-shadow 0.2s ease, border-radius 0.2s ease;
|
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 {
|
.Vue-Toastification__toast {
|
||||||
border-radius: var(--app-surface-radius);
|
border-radius: var(--app-overlay-radius);
|
||||||
box-shadow: var(--app-overlay-shadow);
|
box-shadow: var(--app-overlay-shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -922,6 +946,7 @@ html[data-theme="transparent"] .app-card-colorful,
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
--agent-assistant-fab-offset: 30rem;
|
||||||
--theme-customizer-fab-offset: 420px;
|
--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 {
|
html[data-theme-customizer-open='true'] .global-action-buttons {
|
||||||
inset-inline-end: calc(var(--theme-customizer-fab-offset) + 2rem);
|
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) {
|
@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))
|
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 {
|
.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-bottom-sheet > .v-bottom-sheet__content.v-overlay__content > .v-card,
|
||||||
.v-menu > .v-overlay__content > .v-card,
|
.v-menu > .v-overlay__content > .v-card,
|
||||||
.v-menu > .v-overlay__content > .v-list {
|
.v-menu > .v-overlay__content > .v-list {
|
||||||
|
border-radius: var(--app-overlay-radius) !important;
|
||||||
box-shadow: var(--app-overlay-shadow) !important;
|
box-shadow: var(--app-overlay-shadow) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1170,8 +1210,8 @@ html[data-theme="transparent"] .app-card-colorful,
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-end-end-radius: 0 !important;
|
border-end-end-radius: 0 !important;
|
||||||
border-end-start-radius: 0 !important;
|
border-end-start-radius: 0 !important;
|
||||||
border-start-end-radius: var(--app-theme-surface-radius) !important;
|
border-start-end-radius: var(--app-overlay-radius) !important;
|
||||||
border-start-start-radius: var(--app-theme-surface-radius) !important;
|
border-start-start-radius: var(--app-overlay-radius) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.v-dialog--fullscreen > .v-overlay__content > .v-card,
|
.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 {
|
.v-dialog--fullscreen > .v-overlay__content > form > .v-sheet {
|
||||||
border-end-end-radius: 0 !important;
|
border-end-end-radius: 0 !important;
|
||||||
border-end-start-radius: 0 !important;
|
border-end-start-radius: 0 !important;
|
||||||
border-start-end-radius: var(--app-theme-surface-radius) !important;
|
border-start-end-radius: var(--app-overlay-radius) !important;
|
||||||
border-start-start-radius: var(--app-theme-surface-radius) !important;
|
border-start-start-radius: var(--app-overlay-radius) !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -130,4 +130,34 @@ html[data-theme="transparent"] {
|
|||||||
background-color: rgba(var(--v-theme-surface), var(--transparent-opacity));
|
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 {
|
||||||
|
backdrop-filter: blur(var(--transparent-blur));
|
||||||
|
background-color: rgba(var(--v-theme-surface), var(--transparent-opacity-heavy));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
function initOptions(item: Plugin) {
|
||||||
const optionValue = (options: Array<string>, value: string | undefined, preferred = false) => {
|
const optionValue = (options: Array<string>, value: unknown, preferred = false) => {
|
||||||
if (!value || options.includes(value)) return
|
const text = normalizeMarketText(value).trim()
|
||||||
if (preferred) options.unshift(value)
|
if (!text || options.includes(text)) return
|
||||||
else options.push(value)
|
if (preferred) options.unshift(text)
|
||||||
|
else options.push(text)
|
||||||
}
|
}
|
||||||
const optionMutipleValue = (options: Array<string>, value: string | undefined) => {
|
const optionMutipleValue = (options: Array<string>, value: unknown) => {
|
||||||
value && value.split(',').forEach(v => !options.includes(v) && options.push(v))
|
splitMarketValues(value).forEach(v => !options.includes(v) && options.push(v))
|
||||||
}
|
}
|
||||||
optionValue(authorFilterOptions.value, item.plugin_author)
|
optionValue(authorFilterOptions.value, item.plugin_author)
|
||||||
optionMutipleValue(labelFilterOptions.value, item.plugin_label)
|
optionMutipleValue(labelFilterOptions.value, item.plugin_label)
|
||||||
optionValue(
|
optionValue(repoFilterOptions.value, handleRepoUrl(item), isLocalRepoSource(item))
|
||||||
repoFilterOptions.value,
|
|
||||||
handleRepoUrl(item),
|
|
||||||
Boolean(item.is_local || item.repo_url?.startsWith('local://')),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关闭插件市场窗口
|
// 关闭插件市场窗口
|
||||||
@@ -775,12 +809,13 @@ function closeSearchDialog() {
|
|||||||
// 过滤插件
|
// 过滤插件
|
||||||
const filterPlugins = computed(() => {
|
const filterPlugins = computed(() => {
|
||||||
const all_list = [...dataList.value, ...uninstalledList.value]
|
const all_list = [...dataList.value, ...uninstalledList.value]
|
||||||
|
const normalizedKeyword = normalizeMarketText(keyword.value).toLowerCase()
|
||||||
return all_list.filter((item: Plugin) => {
|
return all_list.filter((item: Plugin) => {
|
||||||
// 需要忽略大小写
|
// 需要忽略大小写
|
||||||
return (
|
return (
|
||||||
item.plugin_name?.toLowerCase().includes(keyword.value.toLowerCase()) ||
|
!normalizedKeyword ||
|
||||||
item.plugin_desc?.toLowerCase().includes(keyword.value.toLowerCase()) ||
|
normalizeMarketText(item.plugin_name).toLowerCase().includes(normalizedKeyword) ||
|
||||||
!keyword
|
normalizeMarketText(item.plugin_desc).toLowerCase().includes(normalizedKeyword)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -818,12 +853,13 @@ async function fetchUninstalledPlugins(force: boolean = false, context: KeepAliv
|
|||||||
if (showLoading) {
|
if (showLoading) {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
}
|
}
|
||||||
uninstalledList.value = await api.get('plugin/', {
|
const marketResponse = await api.get('plugin/', {
|
||||||
params: {
|
params: {
|
||||||
state: 'market',
|
state: 'market',
|
||||||
force: force,
|
force: force,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
uninstalledList.value = Array.isArray(marketResponse) ? marketResponse : []
|
||||||
// 设置更新状态
|
// 设置更新状态
|
||||||
for (const uninstalled of uninstalledList.value) {
|
for (const uninstalled of uninstalledList.value) {
|
||||||
for (const data of dataList.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))
|
marketList.value = uninstalledList.value.filter(item => !(item.has_update && item.installed))
|
||||||
// 初始化过滤选项
|
// 初始化过滤选项
|
||||||
|
authorFilterOptions.value = []
|
||||||
|
labelFilterOptions.value = []
|
||||||
repoFilterOptions.value = []
|
repoFilterOptions.value = []
|
||||||
marketList.value.forEach(initOptions)
|
marketList.value.forEach(initOptions)
|
||||||
// 设置APP市场加载完成
|
// 设置APP市场加载完成
|
||||||
@@ -876,12 +914,18 @@ async function refreshData(context: KeepAliveRefreshContext = {}) {
|
|||||||
// 对uninstalledList进行排序到sortedUninstalledList
|
// 对uninstalledList进行排序到sortedUninstalledList
|
||||||
watch([marketList, filterForm, activeSort, PluginStatistics], () => {
|
watch([marketList, filterForm, activeSort, PluginStatistics], () => {
|
||||||
// 匹配过滤函数
|
// 匹配过滤函数
|
||||||
const match = (filter: Array<string>, value: string | undefined) =>
|
const match = (filter: Array<string>, value: unknown) => {
|
||||||
filter.length === 0 || (value && filter.includes(value))
|
const text = normalizeMarketText(value).trim()
|
||||||
const matchMultiple = (filter: Array<string>, value: string | undefined) =>
|
|
||||||
filter.length === 0 || (value && value.split(',').some(v => filter.includes(v)))
|
return filter.length === 0 || (!!text && filter.includes(text))
|
||||||
const filterText = (filter: string, value: string | undefined) =>
|
}
|
||||||
!filter || (value && value.toLowerCase().includes(filter.toLowerCase()))
|
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 = []
|
sortedUninstalledList.value = []
|
||||||
|
|
||||||
@@ -889,7 +933,7 @@ watch([marketList, filterForm, activeSort, PluginStatistics], () => {
|
|||||||
marketList.value.forEach(value => {
|
marketList.value.forEach(value => {
|
||||||
if (value) {
|
if (value) {
|
||||||
if (
|
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) &&
|
match(filterForm.author, value.plugin_author) &&
|
||||||
matchMultiple(filterForm.label, value.plugin_label) &&
|
matchMultiple(filterForm.label, value.plugin_label) &&
|
||||||
match(filterForm.repo, handleRepoUrl(value))
|
match(filterForm.repo, handleRepoUrl(value))
|
||||||
@@ -960,21 +1004,21 @@ async function refreshActiveTabData(context: KeepAliveRefreshContext = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseLocalRepoPath(repoUrl: string | undefined) {
|
function parseLocalRepoPath(repoUrl: string | undefined) {
|
||||||
if (!repoUrl?.startsWith('local://')) return ''
|
const text = normalizeMarketText(repoUrl)
|
||||||
|
if (!text.startsWith('local://')) return ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return new URL(repoUrl).searchParams.get('path') || ''
|
return new URL(text).searchParams.get('path') || ''
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return decodeURIComponent(repoUrl.match(/[?&]path=([^&]+)/)?.[1] || '')
|
return decodeLocalRepoPath(text.match(/[?&]path=([^&]+)/)?.[1] || '')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理掉github地址的前缀
|
// 处理掉github地址的前缀
|
||||||
function handleRepoUrl(item: Plugin | string | undefined) {
|
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) return ''
|
||||||
if (url.startsWith('local://')) return parseLocalRepoPath(url) || localRepoLabel.value
|
if (isLocalRepoSource(item)) return parseLocalRepoPath(url) || localRepoLabel.value
|
||||||
if (typeof item !== 'string' && item?.is_local) return parseLocalRepoPath(url) || localRepoLabel.value
|
|
||||||
return url.replace('https://github.com/', '').replace('https://raw.githubusercontent.com/', '')
|
return url.replace('https://github.com/', '').replace('https://raw.githubusercontent.com/', '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
|
import { useGlobalSettingsStore } from '@/stores'
|
||||||
import { DownloaderConf, MediaServerConf } from '@/api/types'
|
import { DownloaderConf, MediaServerConf } from '@/api/types'
|
||||||
import DownloaderCard from '@/components/cards/DownloaderCard.vue'
|
import DownloaderCard from '@/components/cards/DownloaderCard.vue'
|
||||||
import MediaServerCard from '@/components/cards/MediaServerCard.vue'
|
import MediaServerCard from '@/components/cards/MediaServerCard.vue'
|
||||||
@@ -17,6 +18,7 @@ const display = useDisplay()
|
|||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
|
|
||||||
const isTransparentTheme = computed(() => theme.name.value === 'transparent')
|
const isTransparentTheme = computed(() => theme.name.value === 'transparent')
|
||||||
|
const globalSettingsStore = useGlobalSettingsStore()
|
||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -47,6 +49,7 @@ const SystemSettings = ref<any>({
|
|||||||
CUSTOMIZE_WALLPAPER_API_URL: null,
|
CUSTOMIZE_WALLPAPER_API_URL: null,
|
||||||
AI_AGENT_ENABLE: false,
|
AI_AGENT_ENABLE: false,
|
||||||
AI_AGENT_GLOBAL: false,
|
AI_AGENT_GLOBAL: false,
|
||||||
|
AI_AGENT_HIDE_ENTRY: false,
|
||||||
AI_AGENT_VERBOSE: false,
|
AI_AGENT_VERBOSE: false,
|
||||||
AI_AGENT_JOB_INTERVAL: 24,
|
AI_AGENT_JOB_INTERVAL: 24,
|
||||||
LLM_PROVIDER: 'deepseek',
|
LLM_PROVIDER: 'deepseek',
|
||||||
@@ -59,7 +62,7 @@ const SystemSettings = ref<any>({
|
|||||||
LLM_BASE_URL: 'https://api.deepseek.com',
|
LLM_BASE_URL: 'https://api.deepseek.com',
|
||||||
LLM_USE_PROXY: true,
|
LLM_USE_PROXY: true,
|
||||||
LLM_BASE_URL_PRESET: null,
|
LLM_BASE_URL_PRESET: null,
|
||||||
LLM_MAX_CONTEXT_TOKENS: 64,
|
LLM_MAX_CONTEXT_TOKENS: 128,
|
||||||
LLM_USER_AGENT: null,
|
LLM_USER_AGENT: null,
|
||||||
AUDIO_INPUT_PROVIDER: 'openai',
|
AUDIO_INPUT_PROVIDER: 'openai',
|
||||||
AUDIO_INPUT_API_KEY: null,
|
AUDIO_INPUT_API_KEY: null,
|
||||||
@@ -698,6 +701,8 @@ async function saveBasicSettings() {
|
|||||||
savingBasic.value = true
|
savingBasic.value = true
|
||||||
try {
|
try {
|
||||||
if (await saveSystemSetting(SystemSettings.value.Basic)) {
|
if (await saveSystemSetting(SystemSettings.value.Basic)) {
|
||||||
|
// 更新全局设置store,使Web Agent图标实时生效
|
||||||
|
globalSettingsStore.setData({ ...globalSettingsStore.getData, ...SystemSettings.value.Basic })
|
||||||
$toast.success(t('setting.system.basicSaveSuccess'))
|
$toast.success(t('setting.system.basicSaveSuccess'))
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1156,7 +1161,7 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
|
|||||||
<VExpandTransition>
|
<VExpandTransition>
|
||||||
<VCardText v-show="!aiAgentSettingsCollapsed" class="pt-2">
|
<VCardText v-show="!aiAgentSettingsCollapsed" class="pt-2">
|
||||||
<VRow>
|
<VRow>
|
||||||
<VCol cols="12" md="4">
|
<VCol cols="12" md="6">
|
||||||
<VSwitch
|
<VSwitch
|
||||||
v-model="SystemSettings.Basic.AI_AGENT_ENABLE"
|
v-model="SystemSettings.Basic.AI_AGENT_ENABLE"
|
||||||
:label="t('setting.system.aiAgentEnable')"
|
:label="t('setting.system.aiAgentEnable')"
|
||||||
@@ -1164,7 +1169,7 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
|
|||||||
persistent-hint
|
persistent-hint
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</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
|
<VSwitch
|
||||||
v-model="SystemSettings.Basic.AI_AGENT_GLOBAL"
|
v-model="SystemSettings.Basic.AI_AGENT_GLOBAL"
|
||||||
:label="t('setting.system.aiAgentGlobal')"
|
:label="t('setting.system.aiAgentGlobal')"
|
||||||
@@ -1172,7 +1177,7 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
|
|||||||
persistent-hint
|
persistent-hint
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</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
|
<VSwitch
|
||||||
v-model="SystemSettings.Basic.AI_AGENT_VERBOSE"
|
v-model="SystemSettings.Basic.AI_AGENT_VERBOSE"
|
||||||
:label="t('setting.system.aiAgentVerbose')"
|
:label="t('setting.system.aiAgentVerbose')"
|
||||||
@@ -1180,6 +1185,14 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
|
|||||||
persistent-hint
|
persistent-hint
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</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">
|
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
|
||||||
<VAutocomplete
|
<VAutocomplete
|
||||||
v-model="SystemSettings.Basic.LLM_PROVIDER"
|
v-model="SystemSettings.Basic.LLM_PROVIDER"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts" setup>
|
<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 dayGridPlugin from '@fullcalendar/daygrid'
|
||||||
import interactionPlugin from '@fullcalendar/interaction'
|
import interactionPlugin from '@fullcalendar/interaction'
|
||||||
import timeGridPlugin from '@fullcalendar/timegrid'
|
import timeGridPlugin from '@fullcalendar/timegrid'
|
||||||
@@ -15,6 +15,10 @@ import { openSharedDialog } from '@/composables/useSharedDialog'
|
|||||||
|
|
||||||
const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue'))
|
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()
|
const { t } = useI18n()
|
||||||
|
|
||||||
@@ -35,6 +39,7 @@ let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
|
|||||||
type CalendarLibraryState = 'none' | 'partial' | 'complete'
|
type CalendarLibraryState = 'none' | 'partial' | 'complete'
|
||||||
|
|
||||||
interface CalendarEventInfo {
|
interface CalendarEventInfo {
|
||||||
|
id?: string
|
||||||
title: string
|
title: string
|
||||||
subtitle: string
|
subtitle: string
|
||||||
start: Date | null
|
start: Date | null
|
||||||
@@ -49,6 +54,9 @@ interface CalendarEventInfo {
|
|||||||
libraryEpisodeNumbers: number[]
|
libraryEpisodeNumbers: number[]
|
||||||
libraryState: CalendarLibraryState
|
libraryState: CalendarLibraryState
|
||||||
libraryUpdateText: string
|
libraryUpdateText: string
|
||||||
|
dateKey?: string
|
||||||
|
hiddenEventCount?: number
|
||||||
|
calendarSortIndex?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// 打开订阅日历共享进度弹窗。
|
// 打开订阅日历共享进度弹窗。
|
||||||
@@ -85,10 +93,12 @@ const calendarOptions: Ref<CalendarOptions> = ref({
|
|||||||
center: 'title',
|
center: 'title',
|
||||||
right: 'next',
|
right: 'next',
|
||||||
},
|
},
|
||||||
// 日历页需要完整展示每天所有订阅条目,避免折叠成 "+ more" 后隐藏关键信息。
|
// 折叠逻辑由组件自行控制,点击展开时可以直接扩展当前日期格子。
|
||||||
dayMaxEvents: false,
|
dayMaxEvents: false,
|
||||||
dayMaxEventRows: false,
|
dayMaxEventRows: false,
|
||||||
eventDisplay: 'block',
|
eventDisplay: 'block',
|
||||||
|
eventOrder: 'start,calendarSortIndex,title',
|
||||||
|
eventOrderStrict: true,
|
||||||
views: {
|
views: {
|
||||||
week: {
|
week: {
|
||||||
titleFormat: { day: 'numeric' },
|
titleFormat: { day: 'numeric' },
|
||||||
@@ -97,6 +107,91 @@ const calendarOptions: Ref<CalendarOptions> = ref({
|
|||||||
events: [],
|
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) {
|
function clampEpisodeCount(value: number, total: number) {
|
||||||
return Math.min(Math.max(value, 0), total)
|
return Math.min(Math.max(value, 0), total)
|
||||||
}
|
}
|
||||||
@@ -108,7 +203,7 @@ function getLibraryEpisodeCount(subscribe: Subscribe) {
|
|||||||
const libraryEpisode =
|
const libraryEpisode =
|
||||||
typeof subscribe.lack_episode === 'number'
|
typeof subscribe.lack_episode === 'number'
|
||||||
? totalEpisode - subscribe.lack_episode
|
? totalEpisode - subscribe.lack_episode
|
||||||
: subscribe.completed_episode ?? 0
|
: (subscribe.completed_episode ?? 0)
|
||||||
|
|
||||||
return clampEpisodeCount(libraryEpisode, totalEpisode)
|
return clampEpisodeCount(libraryEpisode, totalEpisode)
|
||||||
}
|
}
|
||||||
@@ -123,9 +218,7 @@ function getLackEpisodeCount(subscribe: Subscribe) {
|
|||||||
function normalizeEpisodeNumbers(value: unknown) {
|
function normalizeEpisodeNumbers(value: unknown) {
|
||||||
if (!Array.isArray(value)) return []
|
if (!Array.isArray(value)) return []
|
||||||
|
|
||||||
return value
|
return value.map(number => Number(number)).filter(number => Number.isFinite(number) && number > 0)
|
||||||
.map(number => Number(number))
|
|
||||||
.filter(number => Number.isFinite(number) && number > 0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isEnabledFlag(value: unknown) {
|
function isEnabledFlag(value: unknown) {
|
||||||
@@ -183,15 +276,20 @@ function buildCalendarEventInfo(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCalendarEventTooltip(event: any) {
|
function getExpandCalendarEventLabel(event: any) {
|
||||||
const props = event.extendedProps as CalendarEventInfo
|
const props = event.extendedProps as CalendarEventInfo
|
||||||
|
|
||||||
|
return t('calendar.expandDayEvents', { count: props.hiddenEventCount || 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCalendarEventInfoTooltip(event: CalendarEventInfo) {
|
||||||
const parts = [event.title]
|
const parts = [event.title]
|
||||||
|
|
||||||
if (props.subtitle) parts.push(t('calendar.episode', { number: props.subtitle }))
|
if (event.subtitle) parts.push(t('calendar.episode', { number: event.subtitle }))
|
||||||
if (props.totalEpisode) {
|
if (event.totalEpisode) {
|
||||||
parts.push(t('calendar.libraryProgress', { completed: props.libraryEpisode, total: props.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(' · ')
|
return parts.filter(Boolean).join(' · ')
|
||||||
}
|
}
|
||||||
@@ -273,7 +371,8 @@ async function getSubscribes() {
|
|||||||
loading.value = false
|
loading.value = false
|
||||||
const subEvents = await Promise.allSettled(subscribes.map(async sub => eventsHander(sub)))
|
const subEvents = await Promise.allSettled(subscribes.map(async sub => eventsHander(sub)))
|
||||||
const succEvents = subEvents.filter(result => result.status === 'fulfilled').map(result => result.value)
|
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
|
isLoaded.value = true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
@@ -295,19 +394,21 @@ onActivated(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<FullCalendar :options="calendarOptions">
|
<FullCalendar ref="calendarRef" :options="calendarOptions">
|
||||||
<template #eventContent="arg">
|
<template #eventContent="arg">
|
||||||
<div v-if="display.lgAndUp.value">
|
<div v-if="arg.event.extendedProps.isDayGroup" class="calendar-day-events">
|
||||||
<div
|
<div
|
||||||
|
v-for="calendarEvent in arg.event.extendedProps.visibleEvents"
|
||||||
|
:key="`${calendarEvent.title}-${calendarEvent.subtitle}-${calendarEvent.calendarSortIndex}`"
|
||||||
class="calendar-event-card"
|
class="calendar-event-card"
|
||||||
:class="`calendar-event-card--${arg.event.extendedProps.libraryState}`"
|
:class="`calendar-event-card--${calendarEvent.libraryState}`"
|
||||||
:title="getCalendarEventTooltip(arg.event)"
|
:title="getCalendarEventInfoTooltip(calendarEvent)"
|
||||||
>
|
>
|
||||||
<div class="calendar-event-poster">
|
<div v-if="display.lgAndUp.value" class="calendar-event-poster">
|
||||||
<VImg
|
<VImg
|
||||||
height="74"
|
height="74"
|
||||||
width="50"
|
width="50"
|
||||||
:src="arg.event.extendedProps.posterPath"
|
:src="calendarEvent.posterPath"
|
||||||
aspect-ratio="2/3"
|
aspect-ratio="2/3"
|
||||||
class="calendar-event-image object-cover"
|
class="calendar-event-image object-cover"
|
||||||
cover
|
cover
|
||||||
@@ -318,71 +419,80 @@ onActivated(() => {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</VImg>
|
</VImg>
|
||||||
<span
|
<span v-if="calendarEvent.libraryState === 'complete'" class="calendar-library-check">
|
||||||
v-if="arg.event.extendedProps.libraryState === 'complete'"
|
|
||||||
class="calendar-library-check"
|
|
||||||
>
|
|
||||||
<VIcon icon="mdi-check" size="12" />
|
<VIcon icon="mdi-check" size="12" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<div class="calendar-event-title">
|
||||||
{{ arg.event.title }}
|
{{ calendarEvent.title }}
|
||||||
</div>
|
</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" />
|
<VIcon icon="mdi-calendar-blank-outline" size="13" />
|
||||||
{{ t('calendar.episode', { number: arg.event.extendedProps.subtitle }) }}
|
{{ t('calendar.episode', { number: calendarEvent.subtitle }) }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="arg.event.extendedProps.totalEpisode" class="calendar-event-library-row">
|
<div v-if="calendarEvent.totalEpisode" class="calendar-event-library-row">
|
||||||
<span
|
<span
|
||||||
v-if="arg.event.extendedProps.libraryState !== 'complete'"
|
v-if="calendarEvent.libraryState !== 'complete'"
|
||||||
class="calendar-event-status"
|
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" />
|
<VIcon :icon="getLibraryStateIcon(calendarEvent.libraryState)" size="13" />
|
||||||
{{ getLibraryStateText(arg.event.extendedProps.libraryState) }}
|
{{ getLibraryStateText(calendarEvent.libraryState) }}
|
||||||
</span>
|
</span>
|
||||||
<span class="calendar-event-progress">
|
<span class="calendar-event-progress">
|
||||||
<VIcon icon="mdi-library" size="13" />
|
<VIcon icon="mdi-library" size="13" />
|
||||||
{{
|
{{
|
||||||
t('calendar.libraryProgress', {
|
t('calendar.libraryProgress', {
|
||||||
completed: arg.event.extendedProps.libraryEpisode,
|
completed: calendarEvent.libraryEpisode,
|
||||||
total: arg.event.extendedProps.totalEpisode,
|
total: calendarEvent.totalEpisode,
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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" />
|
<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>
|
</div>
|
||||||
</div>
|
|
||||||
<div v-else>
|
<button
|
||||||
<VImg
|
v-if="arg.event.extendedProps.hiddenEventCount"
|
||||||
:src="arg.event.extendedProps.posterPath"
|
type="button"
|
||||||
aspect-ratio="2/3"
|
class="calendar-expand-card"
|
||||||
class="calendar-mobile-image object-cover ring-gray-500"
|
:title="getExpandCalendarEventLabel(arg.event)"
|
||||||
cover
|
:aria-label="getExpandCalendarEventLabel(arg.event)"
|
||||||
:title="getCalendarEventTooltip(arg.event)"
|
@click.stop.prevent="expandCalendarDay(arg.event.extendedProps.dateKey)"
|
||||||
>
|
>
|
||||||
<template #placeholder>
|
<VIcon icon="mdi-unfold-more-horizontal" size="18" />
|
||||||
<div class="w-full h-full">
|
<span class="calendar-expand-count">+{{ arg.event.extendedProps.hiddenEventCount }}</span>
|
||||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
</button>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</FullCalendar>
|
</FullCalendar>
|
||||||
@@ -561,18 +671,18 @@ onActivated(() => {
|
|||||||
.v-application .fc .fc-event,
|
.v-application .fc .fc-event,
|
||||||
.v-application .fc .fc-h-event,
|
.v-application .fc .fc-h-event,
|
||||||
.v-application .fc .fc-daygrid-event {
|
.v-application .fc .fc-daygrid-event {
|
||||||
|
padding: 0 !important;
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
margin-block-end: 0.3rem;
|
margin-block-end: 0.3rem;
|
||||||
padding: 0 !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.v-application .fc .fc-event-main {
|
.v-application .fc .fc-event-main {
|
||||||
|
padding: 0 !important;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
padding: 0 !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.v-application .fc tbody[role='rowgroup'] > tr > td[role='presentation'] {
|
.v-application .fc tbody[role='rowgroup'] > tr > td[role='presentation'] {
|
||||||
@@ -671,12 +781,44 @@ onActivated(() => {
|
|||||||
|
|
||||||
.calendar-event-card {
|
.calendar-event-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.55rem;
|
overflow: hidden;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
padding: 0.4rem;
|
padding: 0.4rem;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: rgba(var(--v-theme-surface), 0.72);
|
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 {
|
.calendar-event-poster {
|
||||||
@@ -693,24 +835,24 @@ onActivated(() => {
|
|||||||
|
|
||||||
.calendar-library-check {
|
.calendar-library-check {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0.18rem;
|
|
||||||
right: 0.18rem;
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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%;
|
border-radius: 50%;
|
||||||
background: rgb(var(--v-theme-success));
|
background: rgb(var(--v-theme-success));
|
||||||
block-size: 1.15rem;
|
block-size: 1.15rem;
|
||||||
color: rgb(var(--v-theme-on-success));
|
color: rgb(var(--v-theme-on-success));
|
||||||
inline-size: 1.15rem;
|
inline-size: 1.15rem;
|
||||||
|
inset-block-start: 0.18rem;
|
||||||
|
inset-inline-end: 0.18rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-library-check--mobile {
|
.calendar-library-check--mobile {
|
||||||
top: 0.12rem;
|
|
||||||
right: 0.12rem;
|
|
||||||
block-size: 1rem;
|
block-size: 1rem;
|
||||||
inline-size: 1rem;
|
inline-size: 1rem;
|
||||||
|
inset-block-start: 0.12rem;
|
||||||
|
inset-inline-end: 0.12rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-event-content {
|
.calendar-event-content {
|
||||||
@@ -724,23 +866,23 @@ onActivated(() => {
|
|||||||
.calendar-event-title {
|
.calendar-event-title {
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||||
font-size: 0.88rem;
|
font-size: 0.88rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
line-clamp: 2;
|
||||||
line-height: 1.28;
|
line-height: 1.28;
|
||||||
max-block-size: calc(0.88rem * 1.28 * 2);
|
max-block-size: calc(0.88rem * 1.28 * 2);
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-event-episode {
|
.calendar-event-episode {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
align-items: center;
|
||||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||||
column-gap: 0.2rem;
|
column-gap: 0.2rem;
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
@@ -753,8 +895,8 @@ onActivated(() => {
|
|||||||
.calendar-event-episode,
|
.calendar-event-episode,
|
||||||
.calendar-event-time {
|
.calendar-event-time {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
align-items: center;
|
||||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||||
column-gap: 0.2rem;
|
column-gap: 0.2rem;
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
@@ -765,8 +907,8 @@ onActivated(() => {
|
|||||||
.calendar-event-library-row {
|
.calendar-event-library-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.18rem 0.3rem;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 0.18rem 0.3rem;
|
||||||
min-inline-size: 0;
|
min-inline-size: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -796,8 +938,8 @@ onActivated(() => {
|
|||||||
.calendar-event-status,
|
.calendar-event-status,
|
||||||
.calendar-event-progress,
|
.calendar-event-progress,
|
||||||
.calendar-event-time {
|
.calendar-event-time {
|
||||||
max-inline-size: 100%;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
max-inline-size: 100%;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -810,15 +952,14 @@ onActivated(() => {
|
|||||||
|
|
||||||
.calendar-mobile-episode {
|
.calendar-mobile-episode {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
display: block;
|
display: block;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: rgba(0, 0, 0, 0.58);
|
background: rgba(0, 0, 0, 58%);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 0.62rem;
|
font-size: 0.62rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
inset-block-end: 0;
|
||||||
|
inset-inline: 0;
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
padding-block: 0.1rem;
|
padding-block: 0.1rem;
|
||||||
padding-inline: 0.2rem;
|
padding-inline: 0.2rem;
|
||||||
@@ -828,10 +969,27 @@ onActivated(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (width <= 1279px) {
|
@media (width <= 1279px) {
|
||||||
|
.calendar-day-events {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-event-card,
|
||||||
.fc-daygrid-event-harness {
|
.fc-daygrid-event-harness {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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>
|
</style>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
Reference in New Issue
Block a user