mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-06-22 16:13:47 +08:00
Compare commits
77 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
33599cc21d | ||
|
|
bf22a4809d | ||
|
|
4a6f7390e6 | ||
|
|
405e460ad6 | ||
|
|
18566c0e9d | ||
|
|
2c471a936f | ||
|
|
2efb07402f | ||
|
|
9434ef71e4 | ||
|
|
e06b9537ff | ||
|
|
2829e3b082 | ||
|
|
1a0fc10559 | ||
|
|
5a1aec3323 | ||
|
|
48913b8811 | ||
|
|
0a7d53b5c7 | ||
|
|
da0cd14af8 | ||
|
|
342c62c085 | ||
|
|
891274cc0e | ||
|
|
889a4b744a | ||
|
|
7fc5b74851 | ||
|
|
785cbcf81d | ||
|
|
364b660390 | ||
|
|
599ca912f4 | ||
|
|
2f66f0f1fc | ||
|
|
cd2f561194 | ||
|
|
c59a555a2d | ||
|
|
4413fedec5 | ||
|
|
d7562ea506 | ||
|
|
951d76481b | ||
|
|
68b0071009 | ||
|
|
0594d1d5b2 | ||
|
|
4f328add1b | ||
|
|
62c9a10377 | ||
|
|
d3d0d847f6 | ||
|
|
b7dd397664 | ||
|
|
c0276fca9f | ||
|
|
4691d12faa | ||
|
|
d0cac34d08 | ||
|
|
2f46c19826 | ||
|
|
b1cb07ae8c |
18
README.md
18
README.md
@@ -11,15 +11,6 @@
|
||||
- 支持多语言(中文/英文)
|
||||
- 完整的插件系统支持,包括远程组件动态加载
|
||||
|
||||
## 模块联邦功能
|
||||
|
||||
MoviePilot 现已支持模块联邦(Module Federation)功能,允许插件开发者创建可动态加载的远程组件,实现更丰富的插件用户界面。
|
||||
|
||||
### 相关文档
|
||||
|
||||
- [模块联邦开发指南](docs/module-federation-guide.md) - 如何开发远程组件插件
|
||||
- [模块联邦问题排查指南](docs/federation-troubleshooting.md) - 常见问题和解决方案
|
||||
- [插件远程组件示例](examples/plugin-component/) - 开发插件组件的完整示例项目
|
||||
|
||||
## 开发部署
|
||||
|
||||
@@ -58,3 +49,12 @@ yarn build
|
||||
```shell
|
||||
node dist/service.js
|
||||
```
|
||||
|
||||
|
||||
### 模块联邦功能
|
||||
|
||||
MoviePilot 现已支持模块联邦(Module Federation)功能,允许插件开发者创建可动态加载的远程组件,实现更丰富的插件用户界面。
|
||||
|
||||
- [模块联邦开发指南](docs/module-federation-guide.md) - 如何开发远程组件插件
|
||||
- [模块联邦问题排查指南](docs/federation-troubleshooting.md) - 常见问题和解决方案
|
||||
- [插件远程组件示例](examples/plugin-component/) - 开发插件组件的完整示例项目
|
||||
|
||||
16
README_EN.md
16
README_EN.md
@@ -11,15 +11,6 @@ Frontend project for [MoviePilot](https://github.com/jxxghp/MoviePilot), NodeJS
|
||||
- Multi-language support (Chinese/English)
|
||||
- Complete plugin system with dynamic remote component loading
|
||||
|
||||
## Module Federation
|
||||
|
||||
MoviePilot now supports Module Federation, allowing plugin developers to create dynamically loadable remote components for richer plugin user interfaces.
|
||||
|
||||
### Documentation
|
||||
|
||||
- [Module Federation Troubleshooting Guide](docs/federation-troubleshooting.md) - Common issues and solutions
|
||||
- [Plugin Remote Component Example](examples/plugin-component/) - Complete example project for developing plugin components
|
||||
|
||||
## Development
|
||||
|
||||
### Recommended IDE Setup
|
||||
@@ -57,3 +48,10 @@ yarn build
|
||||
```shell
|
||||
node dist/service.js
|
||||
```
|
||||
|
||||
### Module Federation
|
||||
|
||||
MoviePilot now supports Module Federation, allowing plugin developers to create dynamically loadable remote components for richer plugin user interfaces.
|
||||
|
||||
- [Module Federation Troubleshooting Guide](docs/federation-troubleshooting.md) - Common issues and solutions
|
||||
- [Plugin Remote Component Example](examples/plugin-component/) - Complete example project for developing plugin components
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<!-- 核心viewport设置 - 针对PWA优化 -->
|
||||
<meta name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover, shrink-to-fit=no, interactive-widget=resizes-content" />
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover, shrink-to-fit=no" />
|
||||
|
||||
<!-- 防止缩放和选择,提供原生应用体验 -->
|
||||
<meta name="format-detection" content="telephone=no, date=no, email=no, address=no" />
|
||||
@@ -121,6 +121,12 @@
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
html[data-launch-loading="true"] .footer-nav-container {
|
||||
opacity: 0 !important;
|
||||
pointer-events: none !important;
|
||||
visibility: hidden !important;
|
||||
}
|
||||
|
||||
#loading-bg {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "moviepilot",
|
||||
"version": "2.13.6",
|
||||
"version": "2.13.12",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": "dist/service.js",
|
||||
|
||||
12
scripts/check-season-label.ts
Normal file
12
scripts/check-season-label.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { formatSeasonLabel } from '../src/@core/utils/season.ts'
|
||||
|
||||
assert.equal(formatSeasonLabel(0, '特别篇'), '特别篇')
|
||||
assert.equal(formatSeasonLabel('0', 'Specials'), 'Specials')
|
||||
assert.equal(formatSeasonLabel(1, '特别篇'), 'S01')
|
||||
assert.equal(formatSeasonLabel('12', '特别篇'), 'S12')
|
||||
assert.equal(formatSeasonLabel(null, '特别篇'), '')
|
||||
assert.equal(formatSeasonLabel(undefined, '特别篇'), '')
|
||||
|
||||
console.log('season label checks passed')
|
||||
15
src/@core/utils/season.ts
Normal file
15
src/@core/utils/season.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* 格式化用户可见的季标签。
|
||||
*
|
||||
* TMDB 使用季号 0 表示特别季;调用方传入当前语言的特别季名称,
|
||||
* 其余季号保持 MoviePilot 现有的 Sxx 展示口径。
|
||||
*/
|
||||
export function formatSeasonLabel(
|
||||
season: number | string | null | undefined,
|
||||
specialsLabel: string,
|
||||
): string {
|
||||
if (season === null || season === undefined || season === '') return ''
|
||||
if (Number(season) === 0) return specialsLabel
|
||||
|
||||
return `S${String(season).padStart(2, '0')}`
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { Transition } from 'vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import VerticalNav from '@layouts/components/VerticalNav.vue'
|
||||
import {
|
||||
@@ -110,9 +109,7 @@ export default defineComponent({
|
||||
const main = h(
|
||||
'main',
|
||||
{ class: 'layout-page-content' },
|
||||
h(Transition, { name: 'fade-slide', mode: 'out-in', appear: true }, () =>
|
||||
h('section', { class: 'page-content-container' }, slots.default?.()),
|
||||
),
|
||||
h('section', { class: 'page-content-container' }, slots.default?.()),
|
||||
)
|
||||
|
||||
// 👉 根据路由 meta 决定 footer 高度
|
||||
@@ -173,6 +170,10 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
.layout-wrapper.layout-nav-type-vertical {
|
||||
--layout-navbar-block-size: calc(
|
||||
env(safe-area-inset-top, 0px) + #{variables.$layout-vertical-nav-navbar-height} + var(--navbar-tab-height)
|
||||
);
|
||||
|
||||
// TODO(v2): Check why we need height in vertical nav & min-height in horizontal nav
|
||||
min-block-size: 100%;
|
||||
|
||||
@@ -188,13 +189,16 @@ export default defineComponent({
|
||||
.layout-navbar {
|
||||
position: fixed;
|
||||
z-index: variables.$layout-vertical-nav-layout-navbar-z-index;
|
||||
// iOS Safari 在地址栏收起和惯性滚动时可能把 fixed 顶栏和页面滚动层合成到一起,
|
||||
// 单独提升顶栏图层可避免导航栏短暂上移到安全区下方。
|
||||
backface-visibility: hidden;
|
||||
block-size: var(--layout-navbar-block-size);
|
||||
inline-size: calc(100vw - variables.$layout-vertical-nav-width - 0.5rem);
|
||||
inset-block-start: 0;
|
||||
transform: translate3d(0, 0, 0);
|
||||
|
||||
.navbar-content-container {
|
||||
block-size: calc(
|
||||
env(safe-area-inset-top) + variables.$layout-vertical-nav-navbar-height + var(--navbar-tab-height)
|
||||
);
|
||||
block-size: var(--layout-navbar-block-size);
|
||||
}
|
||||
|
||||
@at-root {
|
||||
|
||||
@@ -15,7 +15,7 @@ body {
|
||||
background: rgb(var(--v-theme-background));
|
||||
overscroll-behavior-y: contain;
|
||||
|
||||
--webkit-overflow-scrolling: touch;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
body,
|
||||
|
||||
2
src/@layouts/types.d.ts
vendored
2
src/@layouts/types.d.ts
vendored
@@ -1,5 +1,6 @@
|
||||
import type { Component, Ref, VNode } from 'vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
import type { UserPermissionKey } from '@/utils/permission'
|
||||
import { ContentWidth, FooterType, NavbarType } from './enums'
|
||||
|
||||
export interface UserConfig {
|
||||
@@ -119,6 +120,7 @@ export interface NavLink extends NavLinkProps, Partial<AclProperties> {
|
||||
badgeContent?: string
|
||||
badgeClass?: string
|
||||
disable?: boolean
|
||||
permission?: UserPermissionKey
|
||||
}
|
||||
|
||||
export interface NavMenuTabItem {
|
||||
|
||||
43
src/App.vue
43
src/App.vue
@@ -12,6 +12,8 @@ import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundMan
|
||||
import PWAInstallPrompt from '@/components/PWAInstallPrompt.vue'
|
||||
import SharedDialogHost from '@/components/dialog/SharedDialogHost.vue'
|
||||
import { applyStoredThemeCustomizerAppearance } from '@/composables/useThemeCustomizer'
|
||||
import { completeLaunchLoading } from '@/composables/useLaunchLoading'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import { themeManager } from '@/utils/themeManager'
|
||||
import { applyDocumentThemeChrome, resolveThemeName } from '@/utils/themePalette'
|
||||
import { configureApexChartsTheme } from '@/utils/apexCharts'
|
||||
@@ -45,6 +47,7 @@ setI18nLanguage(localeValue as SupportedLocale)
|
||||
const authStore = useAuthStore()
|
||||
const isLogin = computed(() => authStore.token)
|
||||
const route = useRoute()
|
||||
const { initializePWA } = usePWA()
|
||||
|
||||
// 全局设置store
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
@@ -56,8 +59,9 @@ const loginStateKey = computed(() => (isLogin.value ? 'logged-in' : 'logged-out'
|
||||
const backgroundImages = ref<string[]>([])
|
||||
const activeImageIndex = ref(0)
|
||||
const isTransparentTheme = computed(() => globalTheme.name.value === 'transparent')
|
||||
const isLoginWallpaperRoute = computed(() => !isLogin.value && route.path === LOGIN_WALLPAPER_ROUTE)
|
||||
const shouldLoadBackgroundImages = computed(
|
||||
() => (!isLogin.value && route.path === LOGIN_WALLPAPER_ROUTE) || (Boolean(isLogin.value) && isTransparentTheme.value),
|
||||
() => isLoginWallpaperRoute.value || (Boolean(isLogin.value) && isTransparentTheme.value),
|
||||
)
|
||||
let backgroundRetryTimer: number | null = null
|
||||
let backgroundRequestController: AbortController | null = null
|
||||
@@ -98,7 +102,7 @@ const startHeartbeat = () => {
|
||||
heartbeatInterval = window.setInterval(async () => {
|
||||
try {
|
||||
if (isLogin.value) {
|
||||
await api.get('dashboard/cpu')
|
||||
await api.get('system/ping')
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Heartbeat request failed:', error)
|
||||
@@ -245,19 +249,25 @@ function scheduleAuthenticatedStateInitialization() {
|
||||
}
|
||||
|
||||
// 添加logo动画效果并延迟移除加载界面
|
||||
function animateAndRemoveLoader() {
|
||||
async function animateAndRemoveLoader() {
|
||||
const loadingBg = document.querySelector('#loading-bg') as HTMLElement
|
||||
if (loadingBg) {
|
||||
// 只收掉启动内容,背景层保持实色直到节点被移除,避免底部 safe area 先透出页面内容。
|
||||
loadingBg.classList.add('loading-complete')
|
||||
window.setTimeout(() => {
|
||||
removeEl('#loading-bg')
|
||||
await new Promise<void>(resolve => {
|
||||
window.setTimeout(() => {
|
||||
removeEl('#loading-bg')
|
||||
|
||||
// 启动阶段的根节点锁定只在 loader 存在时生效,移除后恢复正常页面与弹窗布局。
|
||||
document.documentElement.removeAttribute('data-launch-loading')
|
||||
document.documentElement.style.removeProperty('overflow')
|
||||
document.body.style.removeProperty('overflow')
|
||||
}, 120)
|
||||
// 启动阶段的根节点锁定只在 loader 存在时生效,移除后恢复正常页面与弹窗布局。
|
||||
document.documentElement.removeAttribute('data-launch-loading')
|
||||
document.documentElement.style.removeProperty('overflow')
|
||||
document.body.style.removeProperty('overflow')
|
||||
completeLaunchLoading()
|
||||
resolve()
|
||||
}, 120)
|
||||
})
|
||||
} else {
|
||||
completeLaunchLoading()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,13 +284,15 @@ async function removeLoadingWithStateCheck() {
|
||||
}
|
||||
globalLoadingStateManager.setLoadingState('pwa-state', false)
|
||||
|
||||
// PWA/App 模式会影响布局和底部导航,必须在启动屏退场前稳定下来。
|
||||
await initializePWA()
|
||||
await initializeAuthenticatedState()
|
||||
|
||||
// 等待所有加载完成
|
||||
await globalLoadingStateManager.waitForAllComplete()
|
||||
|
||||
// 移除加载界面
|
||||
animateAndRemoveLoader()
|
||||
await animateAndRemoveLoader()
|
||||
|
||||
// 检查未读消息
|
||||
if (isLogin.value) {
|
||||
@@ -289,7 +301,7 @@ async function removeLoadingWithStateCheck() {
|
||||
} catch (error) {
|
||||
// 即使出错也要移除加载界面
|
||||
globalLoadingStateManager.reset()
|
||||
animateAndRemoveLoader()
|
||||
await animateAndRemoveLoader()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -423,7 +435,7 @@ onUnmounted(() => {
|
||||
<div v-if="isLogin && isTransparentTheme" class="global-blur-layer"></div>
|
||||
</div>
|
||||
<!-- 页面内容 -->
|
||||
<VApp>
|
||||
<VApp :class="{ 'app-shell--login-wallpaper': isLoginWallpaperRoute }">
|
||||
<RouterView />
|
||||
<!-- 全局共享弹窗入口,列表与卡片按需在这里挂载业务弹窗。 -->
|
||||
<SharedDialogHost />
|
||||
@@ -493,4 +505,9 @@ onUnmounted(() => {
|
||||
inset-block-start: 0;
|
||||
inset-inline-start: 0;
|
||||
}
|
||||
|
||||
/* 登录页壁纸在 VApp 外层渲染,登录页 VApp 需要透明才能露出壁纸。 */
|
||||
.app-shell--login-wallpaper.v-application {
|
||||
background: transparent !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
124
src/api/types.ts
124
src/api/types.ts
@@ -49,7 +49,7 @@ export interface Subscribe {
|
||||
// 已完成集数(普通订阅 = 已入库集数,洗版订阅 = 起始集前 + [start, total] 范围内 priority==100 命中数)
|
||||
completed_episode?: number
|
||||
// 附加信息
|
||||
note?: string
|
||||
note?: string | number[]
|
||||
// 状态:N-新建 R-订阅中 P-待定 S-暂停
|
||||
state: string
|
||||
// 最后更新时间
|
||||
@@ -656,6 +656,8 @@ export interface Plugin {
|
||||
system_version_message?: string
|
||||
// 主系统版本限定范围
|
||||
system_version?: string
|
||||
// 是否声明支持通过 GitHub Release 资产安装
|
||||
release?: boolean
|
||||
// 是否本地插件
|
||||
is_local?: boolean
|
||||
// 插件仓库地址
|
||||
@@ -668,6 +670,38 @@ export interface Plugin {
|
||||
page_open?: boolean
|
||||
}
|
||||
|
||||
// 插件 Release 可安装版本
|
||||
export interface PluginReleaseVersion {
|
||||
// 插件版本
|
||||
version: string
|
||||
// GitHub Release tag
|
||||
tag_name: string
|
||||
// Release 标题
|
||||
name?: string
|
||||
// 发布时间
|
||||
published_at?: string
|
||||
// Release 说明
|
||||
body?: string
|
||||
// 匹配到的资产文件名
|
||||
asset_name?: string
|
||||
// 是否为当前市场最新版本
|
||||
is_latest?: boolean
|
||||
// 是否为本地已安装版本
|
||||
is_current?: boolean
|
||||
}
|
||||
|
||||
// 插件 Release 可安装版本响应
|
||||
export interface PluginReleaseVersionsResponse {
|
||||
// 当前插件是否存在可直接安装的 Release 资产
|
||||
release_supported: boolean
|
||||
// 当前市场 package 声明的最新版本
|
||||
latest_version?: string | null
|
||||
// 本地已安装版本
|
||||
current_version?: string | null
|
||||
// 可安装版本列表
|
||||
items: PluginReleaseVersion[]
|
||||
}
|
||||
|
||||
// 插件侧栏全页导航项(与后端 PluginSidebarNavItem 对齐)
|
||||
export interface PluginSidebarNavItem {
|
||||
plugin_id: string
|
||||
@@ -768,6 +802,58 @@ export interface TorrentInfo {
|
||||
category: string
|
||||
}
|
||||
|
||||
// 字幕信息
|
||||
export interface SubtitleInfo {
|
||||
// 站点ID
|
||||
site?: number
|
||||
// 站点名称
|
||||
site_name?: string
|
||||
// 站点Cookie
|
||||
site_cookie?: string
|
||||
// 站点UA
|
||||
site_ua?: string
|
||||
// 站点是否使用代理
|
||||
site_proxy?: boolean
|
||||
// 站点优先级
|
||||
site_order?: number
|
||||
// 字幕标题
|
||||
title?: string
|
||||
// 字幕描述
|
||||
description?: string
|
||||
// 字幕下载链接
|
||||
enclosure?: string
|
||||
// 详情页面
|
||||
page_url?: string
|
||||
// 语言
|
||||
language?: string
|
||||
// 语言图标
|
||||
language_icon?: string
|
||||
// 字幕大小
|
||||
size?: number
|
||||
// 发布时间
|
||||
pubdate?: string
|
||||
// 已过时间
|
||||
date_elapsed?: string
|
||||
// 点击/下载次数
|
||||
grabs?: number
|
||||
// 上传者
|
||||
uploader?: string
|
||||
// 举报页面
|
||||
report_url?: string
|
||||
// 种子ID
|
||||
torrent_id?: string
|
||||
// 字幕ID
|
||||
subtitle_id?: string
|
||||
// 下载文件名
|
||||
file_name?: string
|
||||
// 识别元数据
|
||||
meta_info?: MetaInfo
|
||||
// SxxExx
|
||||
season_episode?: string
|
||||
// 集列表
|
||||
episode_list?: number[]
|
||||
}
|
||||
|
||||
// 识别元数据
|
||||
export interface MetaInfo {
|
||||
// 是否处理的文件
|
||||
@@ -1079,6 +1165,12 @@ export interface MediaServerLibrary {
|
||||
|
||||
// 消息通知
|
||||
export interface Message {
|
||||
// 消息ID
|
||||
id?: number
|
||||
// 消息渠道
|
||||
channel?: string
|
||||
// 消息来源
|
||||
source?: string
|
||||
// 消息类型
|
||||
mtype?: string
|
||||
// 消息标题
|
||||
@@ -1098,19 +1190,15 @@ export interface Message {
|
||||
// 消息方向:0-接收,1-发送
|
||||
action?: number
|
||||
// JSON
|
||||
note?: string
|
||||
note?: string | any[] | Record<string, any>
|
||||
}
|
||||
|
||||
// 系统通知
|
||||
export interface SystemNotification {
|
||||
// 通知类型 user/system/plugin
|
||||
type: string
|
||||
// 通知标题
|
||||
title: string
|
||||
// 通知内容
|
||||
text: string
|
||||
export interface SystemNotification extends Message {
|
||||
// 通知类型 user/system/plugin/notification
|
||||
type?: string
|
||||
// 通知时间
|
||||
date: string
|
||||
date?: string
|
||||
// 是否已读
|
||||
read?: boolean
|
||||
}
|
||||
@@ -1300,7 +1388,7 @@ export interface TransferForm {
|
||||
// 历史ID
|
||||
logid: number
|
||||
// 目标存储
|
||||
target_storage: string
|
||||
target_storage: string | null
|
||||
// 目标路径
|
||||
target_path: string | null
|
||||
// TMDB ID
|
||||
@@ -1312,7 +1400,7 @@ export interface TransferForm {
|
||||
// 类型
|
||||
type_name?: string
|
||||
// 整理方式
|
||||
transfer_type: string
|
||||
transfer_type: string | null
|
||||
// 自定义格式
|
||||
episode_format?: string
|
||||
// 指定集数
|
||||
@@ -1324,13 +1412,13 @@ export interface TransferForm {
|
||||
// 最小文件大小
|
||||
min_filesize: number
|
||||
// 刮削
|
||||
scrape: boolean
|
||||
scrape: boolean | null
|
||||
// 复用历史识别信息
|
||||
from_history: boolean
|
||||
// 媒体库类型子目录
|
||||
library_type_folder?: boolean
|
||||
library_type_folder?: boolean | null
|
||||
// 媒体库类别子目录
|
||||
library_category_folder?: boolean
|
||||
library_category_folder?: boolean | null
|
||||
// 剧集组编号
|
||||
episode_group?: string | null
|
||||
// 预览模式
|
||||
@@ -1354,11 +1442,11 @@ export interface ManualTransferTargetPathData {
|
||||
// 整理方式
|
||||
transfer_type?: string | null
|
||||
// 刮削
|
||||
scrape?: boolean
|
||||
scrape?: boolean | null
|
||||
// 媒体库类型子目录
|
||||
library_type_folder?: boolean
|
||||
library_type_folder?: boolean | null
|
||||
// 媒体库类别子目录
|
||||
library_category_folder?: boolean
|
||||
library_category_folder?: boolean | null
|
||||
}
|
||||
|
||||
// 手动整理预览统计
|
||||
|
||||
2472
src/components/AgentAssistantWidget.vue
Normal file
2472
src/components/AgentAssistantWidget.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,8 @@ import { storageIconDict } from '@/api/constants'
|
||||
import type { AxiosInstance } from 'axios'
|
||||
import { useDynamicButton } from '@/composables/useDynamicButton'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||
|
||||
// LocalStorage keys
|
||||
const SORT_KEY = 'fileBrowser.sort'
|
||||
@@ -41,6 +43,10 @@ const props = defineProps({
|
||||
const emit = defineEmits(['pathchanged'])
|
||||
const route = useRoute()
|
||||
const { appMode } = usePWA()
|
||||
const userStore = useUserStore()
|
||||
const canManage = computed(() =>
|
||||
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'manage'),
|
||||
)
|
||||
const toolbarRef = ref<InstanceType<typeof FileToolbar> | null>(null)
|
||||
|
||||
const fileIcons = {
|
||||
@@ -136,11 +142,12 @@ function openNewFolderDialog() {
|
||||
toolbarRef.value?.openNewFolderDialog()
|
||||
}
|
||||
|
||||
const showFloatingNewFolderAction = computed(() => route.path === '/filemanager')
|
||||
const showFloatingNewFolderAction = computed(() => route.path === '/filemanager' && canManage.value)
|
||||
|
||||
useDynamicButton({
|
||||
icon: 'mdi-folder-plus-outline',
|
||||
onClick: openNewFolderDialog,
|
||||
permission: 'manage',
|
||||
show: computed(() => appMode.value && showFloatingNewFolderAction.value),
|
||||
})
|
||||
|
||||
|
||||
@@ -163,9 +163,9 @@ const instructions = computed(() => {
|
||||
</VAlert>
|
||||
</VCardText>
|
||||
|
||||
<VCardActions>
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn color="primary" variant="text" @click="showInstructions = false">
|
||||
<VBtn color="primary" variant="flat" class="px-5" @click="showInstructions = false">
|
||||
{{ t('pwa.gotIt') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,7 @@ import router from '@/router'
|
||||
import { useUserStore, useGlobalSettingsStore } from '@/stores'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { mediaTypeDict } from '@/api/constants'
|
||||
import { hasPermission } from '@/utils/permission'
|
||||
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import {
|
||||
getCachedMediaExistsStatus,
|
||||
@@ -45,6 +45,9 @@ const globalSettings = globalSettingsStore.globalSettings
|
||||
|
||||
// 用户 Store
|
||||
const userStore = useUserStore()
|
||||
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
|
||||
const canSearch = computed(() => hasPermission(userPermissions.value, 'search'))
|
||||
const canSubscribe = computed(() => hasPermission(userPermissions.value, 'subscribe'))
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
@@ -143,7 +146,7 @@ async function querySites() {
|
||||
// 查询用户选中的站点
|
||||
async function querySelectedSites() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/IndexerSites')
|
||||
const result: { [key: string]: any } = await api.get('system/setting/public/IndexerSites')
|
||||
selectedSites.value = result.data?.value ?? []
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
@@ -336,12 +339,11 @@ async function checkSubscribe(season: number | null) {
|
||||
|
||||
// 查询订阅弹窗规则
|
||||
async function queryDefaultSubscribeConfig() {
|
||||
// 非管理员不显示
|
||||
if (!userStore.superUser) return false
|
||||
if (!canSubscribe.value) return false
|
||||
try {
|
||||
let subscribe_config_url = ''
|
||||
if (props.media?.type === '电影') subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'
|
||||
else subscribe_config_url = 'system/setting/DefaultTvSubscribeConfig'
|
||||
if (props.media?.type === '电影') subscribe_config_url = 'system/setting/public/DefaultMovieSubscribeConfig'
|
||||
else subscribe_config_url = 'system/setting/public/DefaultTvSubscribeConfig'
|
||||
const result: { [key: string]: any } = await api.get(subscribe_config_url)
|
||||
if (result.data?.value) return result.data.value.show_edit_dialog
|
||||
} catch (error) {
|
||||
@@ -534,7 +536,7 @@ onBeforeUnmount(() => {
|
||||
<div v-if="props.media?.collection_id" class="mb-3" @click.stop=""></div>
|
||||
<div v-else class="flex align-center justify-between">
|
||||
<IconBtn
|
||||
v-if="hasPermission({ is_superuser: userStore.superUser, ...userStore.permissions }, 'search')"
|
||||
v-if="canSearch"
|
||||
icon="mdi-magnify"
|
||||
color="white"
|
||||
size="small"
|
||||
@@ -542,6 +544,7 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
<VSpacer />
|
||||
<IconBtn
|
||||
v-if="canSubscribe"
|
||||
icon="mdi-heart"
|
||||
:color="isSubscribed ? 'error' : 'white'"
|
||||
size="small"
|
||||
@@ -574,6 +577,7 @@ onBeforeUnmount(() => {
|
||||
<!--来源图标-->
|
||||
<VAvatar
|
||||
size="24"
|
||||
variant="plain"
|
||||
density="compact"
|
||||
class="absolute bottom-1 right-1"
|
||||
tile
|
||||
|
||||
@@ -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>
|
||||
import api from '@/api'
|
||||
import type { Plugin } from '@/api/types'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { getDominantColor } from '@/@core/utils/image'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import { formatDownloadCount } from '@/@core/utils/formatters'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
|
||||
const PluginMarketDetailDialog = defineAsyncComponent(() => import('@/components/dialog/PluginMarketDetailDialog.vue'))
|
||||
const PluginVersionHistoryDialog = defineAsyncComponent(
|
||||
() => import('@/components/dialog/PluginVersionHistoryDialog.vue'),
|
||||
)
|
||||
const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue'))
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -26,6 +30,11 @@ const emit = defineEmits(['install'])
|
||||
// 多语言
|
||||
const { t } = useI18n()
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
const createConfirm = useConfirm()
|
||||
|
||||
// 背景颜色
|
||||
const backgroundColor = ref('#28A9E1')
|
||||
|
||||
@@ -48,6 +57,21 @@ const isImageLoaded = ref(false)
|
||||
// 图片是否加载失败
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||
let versionHistoryDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||
|
||||
/** 打开插件安装进度弹窗。 */
|
||||
function showInstallProgress(text: string) {
|
||||
progressDialogController?.close()
|
||||
progressDialogController = openSharedDialog(ProgressDialog, { text }, {}, { closeOn: false })
|
||||
}
|
||||
|
||||
/** 关闭插件安装进度弹窗。 */
|
||||
function closeInstallProgress() {
|
||||
progressDialogController?.close()
|
||||
progressDialogController = null
|
||||
}
|
||||
|
||||
// 图片加载完成
|
||||
async function imageLoaded() {
|
||||
isImageLoaded.value = true
|
||||
@@ -96,14 +120,69 @@ function visitPluginPage() {
|
||||
|
||||
// 显示更新日志
|
||||
function showUpdateHistory() {
|
||||
openSharedDialog(
|
||||
versionHistoryDialogController?.close()
|
||||
versionHistoryDialogController = openSharedDialog(
|
||||
PluginVersionHistoryDialog,
|
||||
{ plugin: props.plugin },
|
||||
{},
|
||||
{ plugin: props.plugin, actionMode: 'install' },
|
||||
{
|
||||
update: installPlugin,
|
||||
},
|
||||
{ closeOn: ['close', 'update:modelValue'] },
|
||||
)
|
||||
}
|
||||
|
||||
/** 从插件市场版本历史安装指定 Release;最新版本走普通安装路径以保留主程序兼容校验。 */
|
||||
async function installPlugin(releaseVersion?: string, repoUrl?: string) {
|
||||
if (!releaseVersion && props.plugin?.system_version_compatible === false) {
|
||||
$toast.error(props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion'))
|
||||
return
|
||||
}
|
||||
|
||||
if (releaseVersion) {
|
||||
const isConfirmed = await createConfirm({
|
||||
title: t('common.confirm'),
|
||||
content: t('plugin.confirmInstallOldRelease', {
|
||||
name: props.plugin?.plugin_name,
|
||||
version: releaseVersion,
|
||||
}),
|
||||
confirmText: t('common.confirm'),
|
||||
})
|
||||
|
||||
if (!isConfirmed) return
|
||||
}
|
||||
|
||||
try {
|
||||
showInstallProgress(
|
||||
t('plugin.installing', {
|
||||
name: props.plugin?.plugin_name,
|
||||
version: releaseVersion || props.plugin?.plugin_version,
|
||||
}),
|
||||
)
|
||||
|
||||
const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
|
||||
params: {
|
||||
repo_url: repoUrl || props.plugin?.repo_url,
|
||||
release_version: releaseVersion,
|
||||
force: props.plugin?.has_update || Boolean(releaseVersion),
|
||||
},
|
||||
})
|
||||
|
||||
closeInstallProgress()
|
||||
|
||||
if (result.success) {
|
||||
$toast.success(t('plugin.installSuccess', { name: props.plugin?.plugin_name }))
|
||||
versionHistoryDialogController?.close()
|
||||
versionHistoryDialogController = null
|
||||
emit('install')
|
||||
} else {
|
||||
$toast.error(t('plugin.installFailed', { name: props.plugin?.plugin_name, message: result.message }))
|
||||
}
|
||||
} catch (error) {
|
||||
closeInstallProgress()
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开共享插件市场详情弹窗。 */
|
||||
function showPluginDetail() {
|
||||
openSharedDialog(
|
||||
@@ -140,6 +219,11 @@ const dropdownItems = ref([
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
onUnmounted(() => {
|
||||
closeInstallProgress()
|
||||
versionHistoryDialogController?.close()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -177,14 +261,14 @@ const dropdownItems = ref([
|
||||
{{ props.plugin?.plugin_desc }}
|
||||
</div>
|
||||
<!-- 插件标签 -->
|
||||
<div v-if="pluginLabels.length > 0" class="plugin-app-card__tags-section px-2">
|
||||
<div v-if="pluginLabels.length > 0" class="plugin-app-card__tags-section px-2 mb-2">
|
||||
<VChip
|
||||
v-for="tag in pluginLabels"
|
||||
:key="tag"
|
||||
size="x-small"
|
||||
variant="tonal"
|
||||
color="info"
|
||||
class="me-1 mb-1"
|
||||
class="plugin-app-card__tag"
|
||||
tile
|
||||
>
|
||||
{{ tag }}
|
||||
@@ -246,3 +330,25 @@ const dropdownItems = ref([
|
||||
</VHover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.plugin-app-card__tags-section {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex-wrap: nowrap;
|
||||
gap: 4px;
|
||||
max-inline-size: 100%;
|
||||
}
|
||||
|
||||
.plugin-app-card__tag {
|
||||
flex: 0 0 auto;
|
||||
max-inline-size: 100%;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.plugin-app-card__tag :deep(.v-chip__content) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -69,6 +69,7 @@ const imageLoadError = ref(false)
|
||||
|
||||
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||
let cloneDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||
let versionHistoryDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||
|
||||
/** 打开插件操作进度弹窗,插件卡片自身不再持有进度弹窗实例。 */
|
||||
function showPluginProgress(text: string) {
|
||||
@@ -103,11 +104,12 @@ async function imageLoaded() {
|
||||
|
||||
// 显示更新日志
|
||||
function showUpdateHistory(showUpdateAction: boolean = false) {
|
||||
openSharedDialog(
|
||||
versionHistoryDialogController?.close()
|
||||
versionHistoryDialogController = openSharedDialog(
|
||||
PluginVersionHistoryDialog,
|
||||
{ plugin: props.plugin, showUpdateAction },
|
||||
{ update: updatePlugin },
|
||||
{ closeOn: ['close', 'update', 'update:modelValue'] },
|
||||
{ closeOn: ['close', 'update:modelValue'] },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -219,19 +221,37 @@ async function resetPlugin() {
|
||||
}
|
||||
|
||||
// 更新插件
|
||||
async function updatePlugin() {
|
||||
if (props.plugin?.system_version_compatible === false) {
|
||||
async function updatePlugin(releaseVersion?: string, repoUrl?: string) {
|
||||
if (!releaseVersion && props.plugin?.system_version_compatible === false) {
|
||||
$toast.error(props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion'))
|
||||
return
|
||||
}
|
||||
|
||||
if (releaseVersion) {
|
||||
const isConfirmed = await createConfirm({
|
||||
title: t('common.confirm'),
|
||||
content: t('plugin.confirmInstallOldRelease', {
|
||||
name: props.plugin?.plugin_name,
|
||||
version: releaseVersion,
|
||||
}),
|
||||
confirmText: t('common.confirm'),
|
||||
})
|
||||
|
||||
if (!isConfirmed) return
|
||||
}
|
||||
|
||||
try {
|
||||
// 显示等待提示框
|
||||
showPluginProgress(t('plugin.updating', { name: props.plugin?.plugin_name }))
|
||||
showPluginProgress(
|
||||
releaseVersion
|
||||
? t('plugin.installing', { name: props.plugin?.plugin_name, version: releaseVersion })
|
||||
: t('plugin.updating', { name: props.plugin?.plugin_name }),
|
||||
)
|
||||
|
||||
const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
|
||||
params: {
|
||||
repo_url: props.plugin?.repo_url,
|
||||
repo_url: repoUrl || props.plugin?.repo_url,
|
||||
release_version: releaseVersion,
|
||||
force: true,
|
||||
},
|
||||
})
|
||||
@@ -241,6 +261,8 @@ async function updatePlugin() {
|
||||
|
||||
if (result.success) {
|
||||
$toast.success(t('plugin.updateSuccess', { name: props.plugin?.plugin_name }))
|
||||
versionHistoryDialogController?.close()
|
||||
versionHistoryDialogController = null
|
||||
|
||||
// 通知父组件刷新
|
||||
emit('save')
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { formatDateDifference, formatSeason } from '@/@core/utils/formatters'
|
||||
import { formatDateDifference } from '@/@core/utils/formatters'
|
||||
import { formatSeasonLabel } from '@/@core/utils/season'
|
||||
import api from '@/api'
|
||||
import type { Subscribe } from '@/api/types'
|
||||
import router from '@/router'
|
||||
@@ -408,7 +409,6 @@ function handleCardClick() {
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering && !props.sortable,
|
||||
'outline-dotted outline-pink-500 outline-2': props.batchMode && props.selected,
|
||||
'subscribe-card-pending-tint': subscribeState === 'P',
|
||||
}"
|
||||
>
|
||||
<VCard
|
||||
@@ -417,6 +417,7 @@ function handleCardClick() {
|
||||
class="flex flex-col h-full overflow-hidden"
|
||||
:class="{
|
||||
'subscribe-card-paused': subscribeState === 'S',
|
||||
'subscribe-card-pending-tint': subscribeState === 'P',
|
||||
'cursor-move': props.sortable,
|
||||
}"
|
||||
min-height="150"
|
||||
@@ -478,7 +479,7 @@ function handleCardClick() {
|
||||
<div class="text-sm font-medium text-white sm:pt-1">{{ props.media?.year }}</div>
|
||||
<div class="mr-2 min-w-0 text-lg font-bold text-white text-ellipsis overflow-hidden line-clamp-2 ...">
|
||||
{{ props.media?.name }}
|
||||
{{ formatSeason(props.media?.season ? props.media?.season.toString() : '') }}
|
||||
{{ formatSeasonLabel(props.media?.season, t('media.specials')) }}
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
@@ -587,7 +588,7 @@ function handleCardClick() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 待定:用 ::after 浮层在 VCard 之上渲染 sky 漫反射式内发光
|
||||
* 待定:内发光挂在实际 VCard 上,跟随卡片圆角并被 overflow-hidden 裁剪。
|
||||
*/
|
||||
.subscribe-card-pending-tint {
|
||||
position: relative;
|
||||
|
||||
213
src/components/cards/SubtitleCard.vue
Normal file
213
src/components/cards/SubtitleCard.vue
Normal file
@@ -0,0 +1,213 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PropType } from 'vue'
|
||||
import { formatDateDifference, formatFileSize } from '@/@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
import type { SubtitleInfo } from '@/api/types'
|
||||
import { getCachedSiteIcon } from '@/utils/siteIconCache'
|
||||
import { downloadedSubtitleMap, markSubtitleDownloaded } from '@/utils/subtitleDownloadCache'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const AddSubtitleDownloadDialog = defineAsyncComponent(() => import('../dialog/AddSubtitleDownloadDialog.vue'))
|
||||
|
||||
// 多语言支持
|
||||
const { t } = useI18n()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
subtitle: Object as PropType<SubtitleInfo>,
|
||||
width: String,
|
||||
})
|
||||
|
||||
// 字幕信息
|
||||
const subtitle = ref(props.subtitle)
|
||||
|
||||
// 站点图标
|
||||
const siteIcon = ref('')
|
||||
|
||||
const isDownloaded = computed(() => Boolean(subtitle.value?.enclosure && downloadedSubtitleMap[subtitle.value.enclosure]))
|
||||
|
||||
// 查询站点图标
|
||||
async function getSiteIcon() {
|
||||
if (!subtitle.value?.site) {
|
||||
siteIcon.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
siteIcon.value = await getCachedSiteIcon(subtitle.value.site, async () => {
|
||||
try {
|
||||
const response = await api.get(`site/icon/${subtitle.value?.site}`)
|
||||
|
||||
return response?.data?.icon || ''
|
||||
} catch (error) {
|
||||
console.error('Failed to load site icon:', error)
|
||||
return ''
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to load site icon:', error)
|
||||
siteIcon.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 添加字幕下载成功
|
||||
function addDownloadSuccess(url: string) {
|
||||
markSubtitleDownloaded(url)
|
||||
}
|
||||
|
||||
// 添加字幕下载失败
|
||||
function addDownloadError(error: string) {
|
||||
console.error(error)
|
||||
}
|
||||
|
||||
// 询问并下载字幕
|
||||
async function handleAddDownload() {
|
||||
openSharedDialog(
|
||||
AddSubtitleDownloadDialog,
|
||||
{
|
||||
title: subtitle.value?.title,
|
||||
subtitle: subtitle.value,
|
||||
},
|
||||
{
|
||||
done: addDownloadSuccess,
|
||||
error: addDownloadError,
|
||||
},
|
||||
{ closeOn: ['close', 'done', 'error'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 打开字幕详情页面
|
||||
function openSubtitleDetail() {
|
||||
if (!subtitle.value?.page_url) return
|
||||
window.open(subtitle.value.page_url, '_blank')
|
||||
}
|
||||
|
||||
// 打开字幕举报页面
|
||||
function openReportPage() {
|
||||
if (!subtitle.value?.report_url) return
|
||||
window.open(subtitle.value.report_url, '_blank')
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.subtitle,
|
||||
value => {
|
||||
subtitle.value = value
|
||||
getSiteIcon()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full">
|
||||
<VCard
|
||||
:width="props.width || '100%'"
|
||||
:variant="isDownloaded ? 'outlined' : 'flat'"
|
||||
@click="handleAddDownload"
|
||||
class="h-full cursor-pointer transition-transform hover:-translate-y-1 duration-300 d-flex flex-column overflow-hidden subtitle-card"
|
||||
:class="{ 'border-success border-2 opacity-85': isDownloaded }"
|
||||
hover
|
||||
>
|
||||
<VCardItem class="pt-3 pb-0">
|
||||
<div class="d-flex justify-space-between align-center flex-wrap gap-2 mb-2">
|
||||
<div class="d-flex align-center min-w-0">
|
||||
<VImg
|
||||
v-if="siteIcon"
|
||||
:src="siteIcon"
|
||||
:alt="subtitle?.site_name"
|
||||
class="mr-2 rounded"
|
||||
width="20"
|
||||
height="20"
|
||||
/>
|
||||
<VAvatar v-else size="20" class="mr-2 text-caption bg-surface-variant" color="surface-variant">
|
||||
{{ subtitle?.site_name?.substring(0, 1) }}
|
||||
</VAvatar>
|
||||
<span class="font-weight-bold text-body-2 text-truncate">{{ subtitle?.site_name }}</span>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-center gap-2">
|
||||
<VChip v-if="subtitle?.season_episode" size="x-small" color="secondary" variant="tonal" class="rounded-sm">
|
||||
{{ subtitle.season_episode }}
|
||||
</VChip>
|
||||
<VChip v-if="subtitle?.language" size="x-small" color="info" variant="tonal" class="rounded-sm">
|
||||
<VImg
|
||||
v-if="subtitle?.language_icon"
|
||||
:src="subtitle.language_icon"
|
||||
:alt="subtitle.language"
|
||||
width="14"
|
||||
height="14"
|
||||
class="me-1"
|
||||
/>
|
||||
{{ subtitle.language }}
|
||||
</VChip>
|
||||
<VChip v-if="isDownloaded" size="x-small" color="success" variant="tonal" class="rounded-sm">
|
||||
{{ t('dialog.addSubtitleDownload.downloaded') }}
|
||||
</VChip>
|
||||
</div>
|
||||
</div>
|
||||
</VCardItem>
|
||||
|
||||
<VCardText class="d-flex flex-column flex-grow-1 pa-3 overflow-hidden">
|
||||
<div class="text-subtitle-2 text-high-emphasis font-weight-medium mb-2 break-all" :title="subtitle?.title">
|
||||
{{ subtitle?.title }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="subtitle?.description"
|
||||
class="text-body-2 text-medium-emphasis mb-2 break-all"
|
||||
:title="subtitle?.description"
|
||||
>
|
||||
{{ subtitle.description }}
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap align-center gap-2 mb-2">
|
||||
<span v-if="subtitle?.pubdate || subtitle?.date_elapsed" class="d-flex align-center text-sm text-medium-emphasis">
|
||||
<VIcon size="small" color="grey" icon="mdi-clock-outline" class="me-1"></VIcon>
|
||||
{{ subtitle?.date_elapsed || formatDateDifference(subtitle.pubdate || '') }}
|
||||
</span>
|
||||
<span v-if="subtitle?.grabs !== undefined" class="d-flex align-center text-sm text-medium-emphasis">
|
||||
<VIcon size="small" color="primary" icon="mdi-download-outline" class="me-1"></VIcon>
|
||||
{{ subtitle.grabs }}
|
||||
</span>
|
||||
<span v-if="subtitle?.uploader" class="d-flex align-center text-sm text-medium-emphasis">
|
||||
<VIcon size="small" color="grey" icon="mdi-account-outline" class="me-1"></VIcon>
|
||||
{{ subtitle.uploader }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap gap-1">
|
||||
<VChip v-if="subtitle?.torrent_id" size="x-small" variant="tonal" class="rounded-sm">
|
||||
TID {{ subtitle.torrent_id }}
|
||||
</VChip>
|
||||
<VChip v-if="subtitle?.subtitle_id" size="x-small" variant="tonal" class="rounded-sm">
|
||||
SID {{ subtitle.subtitle_id }}
|
||||
</VChip>
|
||||
</div>
|
||||
</VCardText>
|
||||
|
||||
<VCardActions class="border-t border-opacity-10 mt-auto pa-2">
|
||||
<VChip v-if="subtitle?.size" color="primary" size="x-small" variant="elevated" class="rounded-sm">
|
||||
{{ formatFileSize(subtitle.size) }}
|
||||
</VChip>
|
||||
<VSpacer />
|
||||
<VBtn v-if="subtitle?.report_url" icon size="small" variant="text" color="warning" @click.stop="openReportPage">
|
||||
<VIcon icon="mdi-alert-outline"></VIcon>
|
||||
</VBtn>
|
||||
<VBtn v-if="subtitle?.page_url" icon size="small" variant="text" color="primary" @click.stop="openSubtitleDetail">
|
||||
<VIcon icon="mdi-information-outline"></VIcon>
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.subtitle-card {
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.subtitle-card:hover {
|
||||
border-color: rgba(var(--v-theme-primary), 0.3);
|
||||
}
|
||||
</style>
|
||||
216
src/components/cards/SubtitleItem.vue
Normal file
216
src/components/cards/SubtitleItem.vue
Normal file
@@ -0,0 +1,216 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PropType } from 'vue'
|
||||
import { formatDateDifference, formatFileSize } from '@/@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
import type { SubtitleInfo } from '@/api/types'
|
||||
import { getCachedSiteIcon } from '@/utils/siteIconCache'
|
||||
import { downloadedSubtitleMap, markSubtitleDownloaded } from '@/utils/subtitleDownloadCache'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const AddSubtitleDownloadDialog = defineAsyncComponent(() => import('../dialog/AddSubtitleDownloadDialog.vue'))
|
||||
|
||||
// 多语言支持
|
||||
const { t } = useI18n()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
subtitle: Object as PropType<SubtitleInfo>,
|
||||
})
|
||||
|
||||
// 字幕信息
|
||||
const subtitle = ref(props.subtitle)
|
||||
|
||||
// 站点图标
|
||||
const siteIcon = ref('')
|
||||
|
||||
const isDownloaded = computed(() => Boolean(subtitle.value?.enclosure && downloadedSubtitleMap[subtitle.value.enclosure]))
|
||||
|
||||
// 查询站点图标
|
||||
async function getSiteIcon() {
|
||||
if (!subtitle.value?.site) {
|
||||
siteIcon.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
siteIcon.value = await getCachedSiteIcon(subtitle.value.site, async () => {
|
||||
try {
|
||||
const response = await api.get(`site/icon/${subtitle.value?.site}`)
|
||||
|
||||
return response?.data?.icon || ''
|
||||
} catch (error) {
|
||||
console.error('Failed to load site icon:', error)
|
||||
return ''
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to load site icon:', error)
|
||||
siteIcon.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 询问并下载字幕
|
||||
async function handleAddDownload() {
|
||||
openSharedDialog(
|
||||
AddSubtitleDownloadDialog,
|
||||
{
|
||||
title: subtitle.value?.title,
|
||||
subtitle: subtitle.value,
|
||||
},
|
||||
{
|
||||
done: addDownloadSuccess,
|
||||
error: addDownloadError,
|
||||
},
|
||||
{ closeOn: ['close', 'done', 'error'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 添加字幕下载成功
|
||||
function addDownloadSuccess(url: string) {
|
||||
markSubtitleDownloaded(url)
|
||||
}
|
||||
|
||||
// 添加字幕下载失败
|
||||
function addDownloadError(error: string) {
|
||||
console.error(error)
|
||||
}
|
||||
|
||||
// 打开字幕详情页面
|
||||
function openSubtitleDetail() {
|
||||
if (!subtitle.value?.page_url) return
|
||||
window.open(subtitle.value.page_url, '_blank')
|
||||
}
|
||||
|
||||
// 打开字幕举报页面
|
||||
function openReportPage() {
|
||||
if (!subtitle.value?.report_url) return
|
||||
window.open(subtitle.value.report_url, '_blank')
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.subtitle,
|
||||
value => {
|
||||
subtitle.value = value
|
||||
getSiteIcon()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-100">
|
||||
<VListItem
|
||||
:value="subtitle?.enclosure"
|
||||
class="pa-3 mb-2 rounded subtitle-item transition-all duration-300 hover:-translate-y-1 overflow-hidden"
|
||||
:class="{ 'border-start border-success border-3 opacity-85': isDownloaded }"
|
||||
@click="handleAddDownload"
|
||||
>
|
||||
<template #prepend>
|
||||
<div class="d-flex flex-column align-center pr-3" :title="subtitle?.site_name">
|
||||
<VImg
|
||||
v-if="siteIcon"
|
||||
:src="siteIcon"
|
||||
:alt="subtitle?.site_name"
|
||||
class="rounded mb-1 site-icon"
|
||||
width="32"
|
||||
height="32"
|
||||
/>
|
||||
<VAvatar
|
||||
v-else
|
||||
size="32"
|
||||
class="mb-1 text-caption bg-primary-lighten-4 text-primary font-weight-bold site-icon"
|
||||
>
|
||||
{{ subtitle?.site_name?.substring(0, 1) }}
|
||||
</VAvatar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<VListItemTitle class="whitespace-normal">
|
||||
<div class="d-flex flex-row flex-wrap align-center gap-2 mb-2">
|
||||
<span class="text-h6 font-weight-bold me-1">{{ subtitle?.site_name }}</span>
|
||||
<VChip v-if="subtitle?.season_episode" size="x-small" color="secondary" variant="tonal" class="rounded-sm">
|
||||
{{ subtitle.season_episode }}
|
||||
</VChip>
|
||||
<VChip v-if="subtitle?.language" size="x-small" color="info" variant="tonal" class="rounded-sm">
|
||||
<VImg
|
||||
v-if="subtitle?.language_icon"
|
||||
:src="subtitle.language_icon"
|
||||
:alt="subtitle.language"
|
||||
width="14"
|
||||
height="14"
|
||||
class="me-1"
|
||||
/>
|
||||
{{ subtitle.language }}
|
||||
</VChip>
|
||||
<VChip v-if="isDownloaded" size="x-small" color="success" variant="tonal" class="rounded-sm">
|
||||
{{ t('dialog.addSubtitleDownload.downloaded') }}
|
||||
</VChip>
|
||||
</div>
|
||||
|
||||
<div class="text-subtitle-2 font-weight-medium mb-2 break-all" :title="subtitle?.title">
|
||||
{{ subtitle?.title }}
|
||||
</div>
|
||||
|
||||
<div v-if="subtitle?.description" class="text-body-2 text-medium-emphasis mb-2 break-all" :title="subtitle.description">
|
||||
{{ subtitle.description }}
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap gap-2 mb-2">
|
||||
<span v-if="subtitle?.pubdate || subtitle?.date_elapsed" class="d-flex align-center text-sm text-medium-emphasis">
|
||||
<VIcon size="small" color="grey" icon="mdi-clock-outline" class="me-1"></VIcon>
|
||||
{{ subtitle?.date_elapsed || formatDateDifference(subtitle.pubdate || '') }}
|
||||
</span>
|
||||
<span v-if="subtitle?.grabs !== undefined" class="d-flex align-center text-sm text-medium-emphasis">
|
||||
<VIcon size="small" color="primary" icon="mdi-download-outline" class="me-1"></VIcon>
|
||||
{{ subtitle.grabs }}
|
||||
</span>
|
||||
<span v-if="subtitle?.uploader" class="d-flex align-center text-sm text-medium-emphasis">
|
||||
<VIcon size="small" color="grey" icon="mdi-account-outline" class="me-1"></VIcon>
|
||||
{{ subtitle.uploader }}
|
||||
</span>
|
||||
</div>
|
||||
</VListItemTitle>
|
||||
|
||||
<template #append>
|
||||
<div class="d-flex flex-column align-end gap-2">
|
||||
<VChip v-if="subtitle?.size" color="primary" size="x-small" variant="elevated" class="rounded-sm">
|
||||
{{ formatFileSize(subtitle.size) }}
|
||||
</VChip>
|
||||
<div class="d-flex align-center">
|
||||
<VBtn
|
||||
v-if="subtitle?.report_url"
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
color="warning"
|
||||
@click.stop="openReportPage"
|
||||
>
|
||||
<VIcon icon="mdi-alert-outline"></VIcon>
|
||||
</VBtn>
|
||||
<VBtn
|
||||
v-if="subtitle?.page_url"
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
color="primary"
|
||||
@click.stop="openSubtitleDetail"
|
||||
>
|
||||
<VIcon icon="mdi-information-outline"></VIcon>
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</VListItem>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.subtitle-item {
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.subtitle-item:hover {
|
||||
border-color: rgba(var(--v-theme-primary), 0.3);
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { formatDateDifference } from '@/@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
import type { Process as SystemProcess } from '@/api/types'
|
||||
import { clearCacheAndReload } from '@/composables/useVersionChecker'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import mdLinkAttributes from 'markdown-it-link-attributes'
|
||||
@@ -37,6 +38,12 @@ md.use(mdLinkAttributes, {
|
||||
// 系统环境变量
|
||||
const systemEnv = ref<any>({})
|
||||
|
||||
// 系统运行时间的基准秒数和同步时间,用于在弹窗打开后实时递增展示。
|
||||
const systemUptimeBaseSeconds = ref<number | null>(null)
|
||||
const systemUptimeSyncedAt = ref(0)
|
||||
const systemUptimeNow = ref(Date.now())
|
||||
let systemUptimeTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// 所有Release
|
||||
const allRelease = ref<any>([])
|
||||
|
||||
@@ -102,6 +109,22 @@ const frontendVersionStatistics = computed(() => versionStatistic.value?.fronten
|
||||
// 活跃用户统计
|
||||
const activeUsers = computed(() => versionStatistic.value?.active_users ?? {})
|
||||
|
||||
// 系统运行秒数
|
||||
const systemUptimeSeconds = computed(() => {
|
||||
if (systemUptimeBaseSeconds.value === null) return null
|
||||
|
||||
const elapsedSeconds = Math.floor((systemUptimeNow.value - systemUptimeSyncedAt.value) / 1000)
|
||||
|
||||
return Math.max(0, systemUptimeBaseSeconds.value + elapsedSeconds)
|
||||
})
|
||||
|
||||
// 友好的系统运行时间文本
|
||||
const systemUptimeText = computed(() => {
|
||||
if (systemUptimeSeconds.value === null) return ''
|
||||
|
||||
return formatUptimeDuration(systemUptimeSeconds.value)
|
||||
})
|
||||
|
||||
/** 格式化版本安装统计数字为千分位展示。 */
|
||||
function formatVersionStatisticNumber(value: unknown) {
|
||||
const numberValue = Number(value ?? 0)
|
||||
@@ -111,6 +134,85 @@ function formatVersionStatisticNumber(value: unknown) {
|
||||
return numberValue.toLocaleString()
|
||||
}
|
||||
|
||||
/** 将秒数保存为运行时间基准,并记录本地同步时间。 */
|
||||
function syncSystemUptime(seconds: number | null) {
|
||||
if (seconds === null) return
|
||||
|
||||
const now = Date.now()
|
||||
|
||||
systemUptimeBaseSeconds.value = seconds
|
||||
systemUptimeSyncedAt.value = now
|
||||
systemUptimeNow.value = now
|
||||
}
|
||||
|
||||
/** 将接口返回值规范化为可展示的秒数。 */
|
||||
function normalizeUptimeSeconds(value: unknown) {
|
||||
const numberValue = Number(value)
|
||||
|
||||
if (!Number.isFinite(numberValue) || numberValue < 0) return null
|
||||
|
||||
return Math.floor(numberValue)
|
||||
}
|
||||
|
||||
/** 从进程创建时间推导运行秒数;兼容秒级和毫秒级时间戳。 */
|
||||
function uptimeSecondsFromCreateTime(value: unknown) {
|
||||
const timestamp = Number(value)
|
||||
|
||||
if (!Number.isFinite(timestamp) || timestamp <= 0) return null
|
||||
|
||||
const timestampMs = timestamp > 1_000_000_000_000 ? timestamp : timestamp * 1000
|
||||
|
||||
return Math.max(0, Math.floor((Date.now() - timestampMs) / 1000))
|
||||
}
|
||||
|
||||
/** 获取单个进程的运行秒数,优先使用创建时间以保留跨天运行时长。 */
|
||||
function getProcessUptimeSeconds(process: SystemProcess) {
|
||||
return uptimeSecondsFromCreateTime(process.create_time) ?? normalizeUptimeSeconds(process.run_time)
|
||||
}
|
||||
|
||||
/** 从进程列表中挑选 MoviePilot 主进程,找不到时使用运行时间最长的进程兜底。 */
|
||||
function resolveSystemUptimeSeconds(processes: SystemProcess[]) {
|
||||
const availableProcesses = processes
|
||||
.map(process => ({
|
||||
process,
|
||||
uptimeSeconds: getProcessUptimeSeconds(process),
|
||||
}))
|
||||
.filter((item): item is { process: SystemProcess; uptimeSeconds: number } => item.uptimeSeconds !== null)
|
||||
|
||||
if (!availableProcesses.length) return null
|
||||
|
||||
const preferredProcesses = availableProcesses.filter(({ process }) =>
|
||||
/moviepilot|python|uvicorn|gunicorn|hypercorn/i.test(process.name ?? ''),
|
||||
)
|
||||
const targetProcesses = preferredProcesses.length ? preferredProcesses : availableProcesses
|
||||
|
||||
return targetProcesses.reduce((max, item) => (item.uptimeSeconds > max.uptimeSeconds ? item : max)).uptimeSeconds
|
||||
}
|
||||
|
||||
/** 格式化单个运行时间单位。 */
|
||||
function formatUptimeUnit(value: number, unit: 'day' | 'hour' | 'minute' | 'second') {
|
||||
const unitKey = value === 1 ? unit : `${unit}s`
|
||||
|
||||
return t(`setting.about.uptimeUnits.${unitKey}`, { count: value })
|
||||
}
|
||||
|
||||
/** 将运行秒数格式化为两段以内的友好文本,例如“3天 2小时”。 */
|
||||
function formatUptimeDuration(totalSeconds: number) {
|
||||
const normalizedSeconds = Math.max(0, Math.floor(totalSeconds))
|
||||
const days = Math.floor(normalizedSeconds / 86400)
|
||||
const hours = Math.floor((normalizedSeconds % 86400) / 3600)
|
||||
const minutes = Math.floor((normalizedSeconds % 3600) / 60)
|
||||
const seconds = normalizedSeconds % 60
|
||||
const parts: string[] = []
|
||||
|
||||
if (days > 0) parts.push(formatUptimeUnit(days, 'day'))
|
||||
if (hours > 0) parts.push(formatUptimeUnit(hours, 'hour'))
|
||||
if (minutes > 0 && parts.length < 2) parts.push(formatUptimeUnit(minutes, 'minute'))
|
||||
if (!parts.length) parts.push(formatUptimeUnit(seconds, 'second'))
|
||||
|
||||
return parts.slice(0, 2).join(' ')
|
||||
}
|
||||
|
||||
// 打开日志对话框
|
||||
function showReleaseDialog(title: string, body: string) {
|
||||
releaseDialogTitle.value = title
|
||||
@@ -151,6 +253,17 @@ async function querySystemEnv() {
|
||||
}
|
||||
}
|
||||
|
||||
// 查询系统运行时间
|
||||
async function querySystemUptime() {
|
||||
try {
|
||||
const processes: SystemProcess[] = await api.get('dashboard/processes')
|
||||
|
||||
syncSystemUptime(resolveSystemUptimeSeconds(processes))
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 查询所有Release
|
||||
async function queryAllRelease() {
|
||||
try {
|
||||
@@ -192,8 +305,17 @@ async function clearCache() {
|
||||
|
||||
onMounted(() => {
|
||||
querySystemEnv()
|
||||
querySystemUptime()
|
||||
queryAllRelease()
|
||||
querySupportingSites()
|
||||
|
||||
systemUptimeTimer = setInterval(() => {
|
||||
if (systemUptimeBaseSeconds.value !== null) systemUptimeNow.value = Date.now()
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (systemUptimeTimer) clearInterval(systemUptimeTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -321,6 +443,16 @@ onMounted(() => {
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="systemUptimeText">
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">{{ t('setting.about.systemUptime') }}</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow flex flex-row items-center truncate">
|
||||
<code class="truncate">{{ systemUptimeText }}</code>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">{{ t('setting.about.supportingSites') }}</dt>
|
||||
|
||||
@@ -71,7 +71,7 @@ const buttonText = computed(() =>
|
||||
// 加载目录设置
|
||||
async function loadDirectories() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/Directories')
|
||||
const result: { [key: string]: any } = await api.get('system/setting/public/Directories')
|
||||
directories.value = result.data?.value ?? []
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
|
||||
270
src/components/dialog/AddSubtitleDownloadDialog.vue
Normal file
270
src/components/dialog/AddSubtitleDownloadDialog.vue
Normal file
@@ -0,0 +1,270 @@
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'vue-toastification'
|
||||
import api from '@/api'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import type { SubtitleInfo, TransferDirectoryConf } from '@/api/types'
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import MediaIdSelector from '../misc/MediaIdSelector.vue'
|
||||
import { numberValidator } from '@/@validators'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
|
||||
// 多语言支持
|
||||
const { t } = useI18n()
|
||||
|
||||
// 从 provide 中获取全局设置
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
const globalSettings = globalSettingsStore.globalSettings
|
||||
|
||||
// 当前识别类型
|
||||
const mediaSource = ref(globalSettings.RECOGNIZE_SOURCE || 'themoviedb')
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
title: String,
|
||||
subtitle: Object as PropType<SubtitleInfo>,
|
||||
})
|
||||
|
||||
// 定义成功和失败事件
|
||||
const emit = defineEmits(['done', 'error', 'close'])
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 选择的保存目录
|
||||
const selectedDirectory = ref<string | null>(null)
|
||||
|
||||
// 所有目录设置
|
||||
const directories = ref<TransferDirectoryConf[]>([])
|
||||
|
||||
// 是否正在加载
|
||||
const loading = ref(false)
|
||||
|
||||
// 是否显示高级选项
|
||||
const showAdvancedOptions = ref(false)
|
||||
|
||||
// TMDB ID
|
||||
const tmdbid = ref<number | undefined>(undefined)
|
||||
|
||||
// 豆瓣ID
|
||||
const doubanId = ref<string | undefined>(undefined)
|
||||
|
||||
// TMDB选择对话框
|
||||
const mediaSelectorDialog = ref(false)
|
||||
|
||||
// 计算按钮图标
|
||||
const icon = computed(() => (loading.value ? 'mdi-progress-download' : 'mdi-download'))
|
||||
|
||||
// 计算按钮文字
|
||||
const buttonText = computed(() =>
|
||||
loading.value ? t('dialog.addSubtitleDownload.downloading') : t('dialog.addSubtitleDownload.startDownload'),
|
||||
)
|
||||
|
||||
// 加载目录设置
|
||||
async function loadDirectories() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/public/Directories')
|
||||
directories.value = result.data?.value ?? []
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
function convertToUri(item: TransferDirectoryConf) {
|
||||
if (!item.download_path) {
|
||||
return undefined
|
||||
}
|
||||
if (item.storage === 'local') {
|
||||
return item.download_path
|
||||
}
|
||||
return item.storage + ':' + item.download_path
|
||||
}
|
||||
|
||||
// 获取保存目录
|
||||
const targetDirectories = computed(() => {
|
||||
const downloadDirectories = directories.value
|
||||
.map(item => convertToUri(item))
|
||||
.filter((item): item is string => item !== undefined)
|
||||
return [...new Set(downloadDirectories)]
|
||||
})
|
||||
|
||||
// 下载字幕
|
||||
async function addSubtitleDownload() {
|
||||
startNProgress()
|
||||
loading.value = true
|
||||
try {
|
||||
const payload: any = {
|
||||
subtitle_in: props.subtitle,
|
||||
save_path: selectedDirectory.value,
|
||||
}
|
||||
|
||||
if (tmdbid.value) {
|
||||
payload.tmdbid = tmdbid.value
|
||||
}
|
||||
if (doubanId.value) {
|
||||
payload.doubanid = doubanId.value
|
||||
}
|
||||
|
||||
const result: { [key: string]: any } = await api.post('download/subtitle', payload)
|
||||
|
||||
if (result && result.success) {
|
||||
$toast.success(
|
||||
t('dialog.addSubtitleDownload.downloadSuccess', {
|
||||
site: props.subtitle?.site_name,
|
||||
title: props.subtitle?.title,
|
||||
}),
|
||||
)
|
||||
emit('done', props.subtitle?.enclosure)
|
||||
} else {
|
||||
$toast.error(
|
||||
t('dialog.addSubtitleDownload.downloadFailed', {
|
||||
site: props.subtitle?.site_name,
|
||||
title: props.subtitle?.title,
|
||||
message: result?.message,
|
||||
}),
|
||||
)
|
||||
emit('error', result?.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
emit('error', String(error))
|
||||
}
|
||||
loading.value = false
|
||||
doneNProgress()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDirectories()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog max-width="35rem" scrollable>
|
||||
<VCard>
|
||||
<VCardItem class="py-2">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-subtitles-outline" class="me-2" />
|
||||
</template>
|
||||
<VCardTitle>{{ t('dialog.addSubtitleDownload.confirmDownload') }}</VCardTitle>
|
||||
<VCardSubtitle>{{ subtitle?.site_name }} - {{ title }}</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VList lines="one">
|
||||
<VListItem>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-web"></VIcon>
|
||||
</template>
|
||||
<VListItemTitle>
|
||||
<span class="whitespace-break-spaces me-2">{{ subtitle?.title }}</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem v-if="subtitle?.description">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-text-box-outline"></VIcon>
|
||||
</template>
|
||||
<VListItemTitle>
|
||||
<span class="text-body-2 whitespace-break-spaces">{{ subtitle?.description }}</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem v-if="subtitle?.language || subtitle?.uploader">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-translate"></VIcon>
|
||||
</template>
|
||||
<VListItemTitle>
|
||||
<span class="text-body-2">
|
||||
{{ subtitle?.language || t('common.unknown') }}
|
||||
<span v-if="subtitle?.uploader" class="text-medium-emphasis ms-2">{{ subtitle.uploader }}</span>
|
||||
</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem v-if="subtitle?.size">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-database"></VIcon>
|
||||
</template>
|
||||
<VListItemTitle>
|
||||
<VChip variant="tonal" label>
|
||||
{{ formatFileSize(subtitle?.size || 0) }}
|
||||
</VChip>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
<VRow class="px-5">
|
||||
<VCol cols="12">
|
||||
<VCombobox
|
||||
v-model="selectedDirectory"
|
||||
:items="targetDirectories"
|
||||
:label="t('dialog.addSubtitleDownload.saveDirectory')"
|
||||
:placeholder="t('dialog.addSubtitleDownload.autoPlaceholder')"
|
||||
variant="underlined"
|
||||
density="comfortable"
|
||||
prepend-inner-icon="mdi-folder"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow class="px-5 mt-2">
|
||||
<VCol cols="12">
|
||||
<VBtn
|
||||
variant="text"
|
||||
:prepend-icon="showAdvancedOptions ? 'mdi-chevron-up' : 'mdi-chevron-down'"
|
||||
@click="showAdvancedOptions = !showAdvancedOptions"
|
||||
>
|
||||
{{
|
||||
showAdvancedOptions
|
||||
? t('dialog.addDownload.hideAdvancedOptions')
|
||||
: t('dialog.addDownload.showAdvancedOptions')
|
||||
}}
|
||||
</VBtn>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-show="showAdvancedOptions" class="px-5">
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-if="mediaSource === 'themoviedb'"
|
||||
v-model="tmdbid"
|
||||
:label="t('dialog.reorganize.tmdbId')"
|
||||
:placeholder="t('dialog.reorganize.mediaIdPlaceholder')"
|
||||
:rules="[numberValidator]"
|
||||
append-inner-icon="mdi-magnify"
|
||||
:hint="t('dialog.reorganize.mediaIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-identifier"
|
||||
variant="underlined"
|
||||
density="comfortable"
|
||||
@click:append-inner="mediaSelectorDialog = true"
|
||||
/>
|
||||
<VTextField
|
||||
v-else
|
||||
v-model="doubanId"
|
||||
:label="t('dialog.reorganize.doubanId')"
|
||||
:placeholder="t('dialog.reorganize.mediaIdPlaceholder')"
|
||||
:rules="[numberValidator]"
|
||||
append-inner-icon="mdi-magnify"
|
||||
:hint="t('dialog.reorganize.mediaIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-identifier"
|
||||
variant="underlined"
|
||||
density="comfortable"
|
||||
@click:append-inner="mediaSelectorDialog = true"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VCardText class="text-center">
|
||||
<VBtn variant="elevated" :disabled="loading" @click="addSubtitleDownload" :prepend-icon="icon" class="px-5">
|
||||
{{ buttonText }}
|
||||
</VBtn>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<VDialog v-model="mediaSelectorDialog" width="40rem" scrollable max-height="85vh">
|
||||
<MediaIdSelector
|
||||
v-if="mediaSource === 'themoviedb'"
|
||||
v-model="tmdbid"
|
||||
@close="mediaSelectorDialog = false"
|
||||
:type="mediaSource"
|
||||
/>
|
||||
<MediaIdSelector v-else v-model="doubanId" @close="mediaSelectorDialog = false" :type="mediaSource" />
|
||||
</VDialog>
|
||||
</VDialog>
|
||||
</template>
|
||||
@@ -133,12 +133,12 @@ async function savaAlistConfig() {
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VBtn color="error" @click="handleReset" prepend-icon="mdi-restore" class="px-5 me-3">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VBtn color="error" variant="tonal" @click="handleReset" prepend-icon="mdi-restore">
|
||||
{{ t('dialog.alistConfig.reset') }}
|
||||
</VBtn>
|
||||
<VSpacer />
|
||||
<VBtn @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3">
|
||||
<VBtn color="primary" variant="flat" @click="handleDone" prepend-icon="mdi-check" class="px-5">
|
||||
{{ t('dialog.alistConfig.complete') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -138,12 +138,12 @@ onUnmounted(() => {
|
||||
</VAlert>
|
||||
</div>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VBtn color="error" @click="handleReset" prepend-icon="mdi-restore" class="px-5 me-3">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VBtn color="error" variant="tonal" @click="handleReset" prepend-icon="mdi-restore">
|
||||
{{ t('dialog.aliyunAuth.reset') }}
|
||||
</VBtn>
|
||||
<VSpacer />
|
||||
<VBtn @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3">
|
||||
<VBtn color="primary" variant="flat" @click="handleDone" prepend-icon="mdi-check" class="px-5">
|
||||
{{ t('dialog.aliyunAuth.complete') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -84,9 +84,16 @@ function submitReidentify() {
|
||||
</VAlert>
|
||||
</VCardText>
|
||||
|
||||
<VCardActions>
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn color="primary" :loading="props.loading" prepend-icon="mdi-check" @click="submitReidentify">
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
:loading="props.loading"
|
||||
prepend-icon="mdi-check"
|
||||
class="px-5"
|
||||
@click="submitReidentify"
|
||||
>
|
||||
{{ t('setting.cache.reidentifyDialog.confirm') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -383,7 +383,7 @@ onMounted(() => {
|
||||
</VTab>
|
||||
</VTabs>
|
||||
|
||||
<div v-if="loading" class="d-flex justify-center align-center" style="min-height: 300px">
|
||||
<div v-if="loading" class="d-flex justify-center align-center" style="min-block-size: 300px">
|
||||
<VProgressCircular indeterminate color="primary" size="64" />
|
||||
</div>
|
||||
|
||||
@@ -610,12 +610,16 @@ onMounted(() => {
|
||||
</VWindow>
|
||||
</VCardText>
|
||||
|
||||
<VCardActions class="pt-3">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn variant="text" @click="emit('close')">
|
||||
{{ t('common.cancel') }}
|
||||
</VBtn>
|
||||
<VBtn color="primary" :loading="saving" prepend-icon="mdi-content-save" class="px-5" @click="saveConfig">
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
:loading="saving"
|
||||
prepend-icon="mdi-content-save"
|
||||
class="px-5"
|
||||
@click="saveConfig"
|
||||
>
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -153,15 +153,15 @@ function submitSettings() {
|
||||
<VSwitch v-model="elevatedValue" :label="props.switchLabel" />
|
||||
</p>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn v-if="props.showBulkActions" variant="text" @click="setAllItems(true)">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VBtn v-if="props.showBulkActions" color="success" variant="tonal" @click="setAllItems(true)">
|
||||
{{ props.selectAllText }}
|
||||
</VBtn>
|
||||
<VBtn v-if="props.showBulkActions" variant="text" @click="setAllItems(false)">
|
||||
<VBtn v-if="props.showBulkActions" color="warning" variant="tonal" @click="setAllItems(false)">
|
||||
{{ props.selectNoneText }}
|
||||
</VBtn>
|
||||
<VSpacer />
|
||||
<VBtn color="primary" class="px-5" @click="submitSettings">
|
||||
<VBtn color="primary" variant="flat" class="px-5" @click="submitSettings">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-content-save" />
|
||||
</template>
|
||||
|
||||
@@ -86,8 +86,9 @@ function submitCustomCSS() {
|
||||
class="custom-css-editor"
|
||||
/>
|
||||
</div>
|
||||
<VCardActions class="custom-css-actions">
|
||||
<VBtn color="primary" prepend-icon="mdi-content-save" class="px-5" @click="submitCustomCSS">
|
||||
<VCardActions class="app-dialog-actions custom-css-actions">
|
||||
<VSpacer />
|
||||
<VBtn color="primary" variant="flat" prepend-icon="mdi-content-save" class="px-5" @click="submitCustomCSS">
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
@@ -98,9 +99,9 @@ function submitCustomCSS() {
|
||||
<style scoped>
|
||||
.custom-css-dialog {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex-direction: column;
|
||||
max-block-size: calc(100dvh - 2rem);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.custom-css-header {
|
||||
@@ -110,7 +111,7 @@ function submitCustomCSS() {
|
||||
|
||||
.custom-css-editor-body {
|
||||
flex: 1 1 auto;
|
||||
min-block-size: 0;
|
||||
min-block-size: 240px;
|
||||
}
|
||||
|
||||
.custom-css-editor {
|
||||
@@ -140,8 +141,8 @@ function submitCustomCSS() {
|
||||
|
||||
.custom-css-editor {
|
||||
flex: 1 1 auto;
|
||||
min-block-size: 0;
|
||||
block-size: auto;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.custom-css-actions {
|
||||
|
||||
@@ -199,8 +199,9 @@ onMounted(() => {
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn @click="saveRuleInfo" prepend-icon="mdi-content-save" class="px-5">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn color="primary" variant="flat" @click="saveRuleInfo" prepend-icon="mdi-content-save" class="px-5">
|
||||
{{ t('customRule.action.confirm') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -88,9 +88,9 @@ function submitOrder() {
|
||||
</template>
|
||||
</draggable>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn @click="submitOrder">
|
||||
<VBtn color="primary" variant="flat" class="px-5" @click="submitOrder">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-content-save" />
|
||||
</template>
|
||||
|
||||
@@ -117,9 +117,20 @@ function generateId() {
|
||||
return Math.random().toString(36).substring(2, 9)
|
||||
}
|
||||
|
||||
/** 初始化下载器新增配置项的兼容默认值。 */
|
||||
function initializeDownloaderConfigDefaults() {
|
||||
if (!['qbittorrent', 'transmission'].includes(downloaderInfo.value.type)) return
|
||||
if (!downloaderInfo.value.config) downloaderInfo.value.config = {}
|
||||
if (downloaderInfo.value.type === 'qbittorrent' && downloaderInfo.value.config.incomplete_files_ext === undefined)
|
||||
downloaderInfo.value.config.incomplete_files_ext = true
|
||||
if (downloaderInfo.value.type === 'transmission' && downloaderInfo.value.config.rename_partial_files === undefined)
|
||||
downloaderInfo.value.config.rename_partial_files = true
|
||||
}
|
||||
|
||||
/** 初始化下载器编辑表单数据。 */
|
||||
function initializeDownloaderInfo() {
|
||||
downloaderInfo.value = cloneDeep(props.downloader)
|
||||
initializeDownloaderConfigDefaults()
|
||||
pathMappingRows.value = (downloaderInfo.value.path_mapping || []).map(item => ({
|
||||
id: generateId(),
|
||||
storage: item[0],
|
||||
@@ -299,6 +310,15 @@ onMounted(() => {
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="downloaderInfo.config.incomplete_files_ext"
|
||||
:label="t('downloader.incomplete_files_ext')"
|
||||
:hint="t('downloader.incomplete_files_extHint')"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="downloaderInfo.type == 'transmission'">
|
||||
<VCol cols="12" md="6">
|
||||
@@ -344,6 +364,15 @@ onMounted(() => {
|
||||
prepend-inner-icon="mdi-lock"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="downloaderInfo.config.rename_partial_files"
|
||||
:label="t('downloader.rename_partial_files')"
|
||||
:hint="t('downloader.rename_partial_filesHint')"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="downloaderInfo.type == 'rtorrent'">
|
||||
<VCol cols="12" md="6">
|
||||
@@ -507,8 +536,9 @@ onMounted(() => {
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn @click="saveDownloaderInfo" prepend-icon="mdi-content-save" class="px-5">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn color="primary" variant="flat" @click="saveDownloaderInfo" prepend-icon="mdi-content-save" class="px-5">
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -52,9 +52,16 @@ function closeDialog() {
|
||||
<VCardText>
|
||||
<VTextField v-model="folderName" :label="t('common.name')" prepend-inner-icon="mdi-format-text" />
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<div class="flex-grow-1" />
|
||||
<VBtn :disabled="!folderName" prepend-icon="mdi-folder-plus" class="px-5 me-3" @click="emit('create')">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
:disabled="!folderName"
|
||||
prepend-icon="mdi-folder-plus"
|
||||
class="px-5"
|
||||
@click="emit('create')"
|
||||
>
|
||||
{{ t('common.create') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -81,11 +81,19 @@ function closeDialog() {
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VBtn color="success" prepend-icon="mdi-magic" class="px-5 me-3" @click="emit('auto-name')">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VBtn color="success" variant="tonal" prepend-icon="mdi-magic" @click="emit('auto-name')">
|
||||
{{ t('file.autoRecognizeName') }}
|
||||
</VBtn>
|
||||
<VBtn :disabled="!renameName" prepend-icon="mdi-check" class="px-5 me-3" @click="emit('rename')">
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
:disabled="!renameName"
|
||||
prepend-icon="mdi-check"
|
||||
class="px-5"
|
||||
@click="emit('rename')"
|
||||
>
|
||||
{{ t('common.confirm') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -294,18 +294,23 @@ onMounted(() => {
|
||||
</Draggable>
|
||||
<div class="text-center" v-if="filterRuleCards.length == 0">{{ t('filterRule.add') }}</div>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn color="primary" @click="addFilterCard">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VBtn color="primary" variant="tonal" class="app-dialog-actions__icon-btn" @click="addFilterCard">
|
||||
<VIcon icon="mdi-plus" />
|
||||
</VBtn>
|
||||
<VBtn color="success" @click="importRules('priority')">
|
||||
<VBtn
|
||||
color="success"
|
||||
variant="tonal"
|
||||
class="app-dialog-actions__icon-btn"
|
||||
@click="importRules('priority')"
|
||||
>
|
||||
<VIcon icon="mdi-import" />
|
||||
</VBtn>
|
||||
<VBtn color="info" @click="shareRules">
|
||||
<VBtn color="info" variant="tonal" class="app-dialog-actions__icon-btn" @click="shareRules">
|
||||
<VIcon icon="mdi-share" />
|
||||
</VBtn>
|
||||
<VSpacer />
|
||||
<VBtn @click="saveGroupInfo" prepend-icon="mdi-content-save" class="px-5">
|
||||
<VBtn color="primary" variant="flat" @click="saveGroupInfo" prepend-icon="mdi-content-save" class="px-5">
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -51,7 +51,7 @@ function toggleExpand() {
|
||||
// 加载follow用户列表
|
||||
async function queryFollowUsers() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/FollowSubscribers')
|
||||
const result: { [key: string]: any } = await api.get('system/setting/public/FollowSubscribers')
|
||||
followUsers.value = result.data?.value ?? []
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
|
||||
@@ -36,9 +36,9 @@ function handleImport() {
|
||||
<VCardText class="pt-2">
|
||||
<VTextarea v-model="codeString" prepend-inner-icon="mdi-code-json" />
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn @click="handleImport" prepend-icon="mdi-import" class="px-5 me-3">
|
||||
<VBtn color="primary" variant="flat" @click="handleImport" prepend-icon="mdi-import" class="px-5">
|
||||
{{ t('dialog.importCode.import') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -43,7 +43,10 @@ function closeDialog() {
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" max-width="560">
|
||||
<VCard>
|
||||
<VCardTitle>{{ t('setting.system.llmProviderAuthDialogTitle') }}</VCardTitle>
|
||||
<VCardItem>
|
||||
<VCardTitle>{{ t('setting.system.llmProviderAuthDialogTitle') }}</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText class="d-flex flex-column ga-4">
|
||||
<VAlert v-if="props.authSession?.instructions" type="info" variant="tonal">
|
||||
{{ props.authSession.instructions }}
|
||||
@@ -71,9 +74,9 @@ function closeDialog() {
|
||||
</VBtn>
|
||||
</div>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn variant="text" @click="closeDialog">
|
||||
<VBtn color="primary" variant="flat" class="px-5" @click="closeDialog">
|
||||
{{ t('common.close') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -591,8 +591,15 @@ onMounted(() => {
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn @click="saveMediaServerInfo" prepend-icon="mdi-content-save" class="px-5">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="saveMediaServerInfo"
|
||||
prepend-icon="mdi-content-save"
|
||||
class="px-5"
|
||||
>
|
||||
{{ t('common.confirm') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -1171,8 +1171,15 @@ onMounted(() => {
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn @click="saveNotificationInfo" prepend-icon="mdi-content-save" class="px-5">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="saveNotificationInfo"
|
||||
prepend-icon="mdi-content-save"
|
||||
class="px-5"
|
||||
>
|
||||
{{ t('common.confirm') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -92,8 +92,9 @@ function submitTemplate() {
|
||||
class="template-ace-editor"
|
||||
/>
|
||||
</div>
|
||||
<VCardActions class="template-editor-actions">
|
||||
<VBtn color="primary" prepend-icon="mdi-content-save" class="px-5" @click="submitTemplate">
|
||||
<VCardActions class="app-dialog-actions template-editor-actions">
|
||||
<VSpacer />
|
||||
<VBtn color="primary" variant="flat" prepend-icon="mdi-content-save" class="px-5" @click="submitTemplate">
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -299,8 +299,9 @@ watch(
|
||||
</VAlert>
|
||||
</VCardText>
|
||||
|
||||
<VCardActions class="justify-end px-6 pb-4">
|
||||
<VBtn variant="outlined" @click="show = false">{{ t('common.close') }}</VBtn>
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn color="primary" variant="flat" class="px-5" @click="show = false">{{ t('common.close') }}</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
|
||||
@@ -154,10 +154,11 @@ onMounted(() => {
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="submitClone"
|
||||
prepend-icon="mdi-content-copy"
|
||||
class="px-5"
|
||||
|
||||
@@ -160,13 +160,26 @@ onBeforeMount(async () => {
|
||||
<div v-if="!pluginFormItems || pluginFormItems.length === 0">此插件没有可配置项</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn v-if="props.plugin?.has_page" @click="emit('switch')" color="info">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VBtn
|
||||
v-if="props.plugin?.has_page"
|
||||
color="info"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-database-eye-outline"
|
||||
@click="emit('switch')"
|
||||
>
|
||||
{{ t('dialog.pluginConfig.viewData') }}
|
||||
</VBtn>
|
||||
<VSpacer />
|
||||
<!-- 只有Vuetify模式显示默认保存按钮,Vue模式由组件内部控制 -->
|
||||
<VBtn v-if="renderMode === 'vuetify'" @click="savePluginConf" prepend-icon="mdi-content-save" class="px-5">
|
||||
<VBtn
|
||||
v-if="renderMode === 'vuetify'"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="savePluginConf"
|
||||
prepend-icon="mdi-content-save"
|
||||
class="px-5"
|
||||
>
|
||||
保存
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -54,9 +54,9 @@ function closeDialog() {
|
||||
@keyup.enter="emit('create')"
|
||||
/>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn color="primary" prepend-icon="mdi-folder-plus" class="px-5" @click="emit('create')">
|
||||
<VBtn color="primary" variant="flat" prepend-icon="mdi-folder-plus" class="px-5" @click="emit('create')">
|
||||
{{ t('plugin.create') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -57,9 +57,9 @@ function confirmRename() {
|
||||
@keyup.enter="confirmRename"
|
||||
/>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn color="primary" prepend-icon="mdi-check" class="px-5" @click="confirmRename">确认</VBtn>
|
||||
<VBtn color="primary" variant="flat" prepend-icon="mdi-check" class="px-5" @click="confirmRename">确认</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
|
||||
@@ -201,9 +201,11 @@ onMounted(() => {
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn color="primary" prepend-icon="mdi-content-save" class="px-5" @click="saveSettings">保存</VBtn>
|
||||
<VBtn color="primary" variant="flat" prepend-icon="mdi-content-save" class="px-5" @click="saveSettings">
|
||||
保存
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
|
||||
@@ -42,6 +42,12 @@ function openLoggerWindow() {
|
||||
}system/logging?length=-1&logfile=plugins/${props.plugin?.id?.toLowerCase()}.log`
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
/** 下载当前插件日志压缩包。 */
|
||||
function downloadLogger() {
|
||||
const url = `${import.meta.env.VITE_API_BASE_URL}system/logging/download/${props.plugin?.id?.toLowerCase()}`
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -52,12 +58,20 @@ function openLoggerWindow() {
|
||||
<VCardTitle class="d-inline-flex">
|
||||
<VIcon icon="mdi-file-document" class="me-2" />
|
||||
{{ t('plugin.logTitle') }}
|
||||
<a class="mx-2 d-inline-flex align-center cursor-pointer" @click="openLoggerWindow">
|
||||
<VChip color="grey-darken-1" size="small" class="ml-2">
|
||||
<VIcon icon="mdi-open-in-new" size="small" start />
|
||||
{{ t('common.openInNewWindow') }}
|
||||
</VChip>
|
||||
</a>
|
||||
<span class="ms-4 d-inline-flex align-center ga-1">
|
||||
<a class="d-inline-flex align-center cursor-pointer" @click="downloadLogger">
|
||||
<VChip color="grey-darken-1" size="small">
|
||||
<VIcon icon="mdi-download" size="small" start />
|
||||
{{ t('common.download') }}
|
||||
</VChip>
|
||||
</a>
|
||||
<a class="d-inline-flex align-center cursor-pointer" @click="openLoggerWindow">
|
||||
<VChip color="grey-darken-1" size="small">
|
||||
<VIcon icon="mdi-open-in-new" size="small" start />
|
||||
{{ t('common.openInNewWindow') }}
|
||||
</VChip>
|
||||
</a>
|
||||
</span>
|
||||
</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
|
||||
@@ -6,8 +6,12 @@ import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
|
||||
const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue'))
|
||||
const PluginVersionHistoryDialog = defineAsyncComponent(
|
||||
() => import('@/components/dialog/PluginVersionHistoryDialog.vue'),
|
||||
)
|
||||
|
||||
// 多语言
|
||||
const { t } = useI18n()
|
||||
@@ -15,6 +19,8 @@ const { t } = useI18n()
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
const createConfirm = useConfirm()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
@@ -47,6 +53,7 @@ const imageRef = ref<any>()
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||
let versionHistoryDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||
|
||||
/** 打开插件安装进度弹窗。 */
|
||||
function showInstallProgress(text: string) {
|
||||
@@ -97,24 +104,38 @@ function visitPluginPage() {
|
||||
}
|
||||
|
||||
/** 安装插件并通知父级刷新市场列表。 */
|
||||
async function installPlugin() {
|
||||
if (props.plugin?.system_version_compatible === false) {
|
||||
async function installPlugin(releaseVersion?: string, repoUrl?: string) {
|
||||
if (!releaseVersion && props.plugin?.system_version_compatible === false) {
|
||||
$toast.error(props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion'))
|
||||
return
|
||||
}
|
||||
|
||||
if (releaseVersion) {
|
||||
const isConfirmed = await createConfirm({
|
||||
title: t('common.confirm'),
|
||||
content: t('plugin.confirmInstallOldRelease', {
|
||||
name: props.plugin?.plugin_name,
|
||||
version: releaseVersion,
|
||||
}),
|
||||
confirmText: t('common.confirm'),
|
||||
})
|
||||
|
||||
if (!isConfirmed) return
|
||||
}
|
||||
|
||||
try {
|
||||
showInstallProgress(
|
||||
t('plugin.installing', {
|
||||
name: props.plugin?.plugin_name,
|
||||
version: props?.plugin?.plugin_version,
|
||||
version: releaseVersion || props?.plugin?.plugin_version,
|
||||
}),
|
||||
)
|
||||
|
||||
const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
|
||||
params: {
|
||||
repo_url: props.plugin?.repo_url,
|
||||
force: props.plugin?.has_update,
|
||||
repo_url: repoUrl || props.plugin?.repo_url,
|
||||
release_version: releaseVersion,
|
||||
force: props.plugin?.has_update || Boolean(releaseVersion),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -122,6 +143,8 @@ async function installPlugin() {
|
||||
|
||||
if (result.success) {
|
||||
$toast.success(t('plugin.installSuccess', { name: props.plugin?.plugin_name }))
|
||||
versionHistoryDialogController?.close()
|
||||
versionHistoryDialogController = null
|
||||
visible.value = false
|
||||
emit('install')
|
||||
} else {
|
||||
@@ -133,8 +156,22 @@ async function installPlugin() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开版本历史并支持从 Release 资产安装指定版本。 */
|
||||
function showUpdateHistory() {
|
||||
versionHistoryDialogController?.close()
|
||||
versionHistoryDialogController = openSharedDialog(
|
||||
PluginVersionHistoryDialog,
|
||||
{ plugin: props.plugin, actionMode: 'install' },
|
||||
{
|
||||
update: installPlugin,
|
||||
},
|
||||
{ closeOn: ['close', 'update:modelValue'] },
|
||||
)
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
closeInstallProgress()
|
||||
versionHistoryDialogController?.close()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -190,16 +227,21 @@ onUnmounted(() => {
|
||||
class="mb-3"
|
||||
:text="props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion')"
|
||||
/>
|
||||
<div class="text-center text-md-left">
|
||||
<VBtn
|
||||
color="primary"
|
||||
@click="installPlugin"
|
||||
prepend-icon="mdi-download"
|
||||
:disabled="props.plugin?.system_version_compatible === false"
|
||||
>
|
||||
{{ t('plugin.installToLocal') }}
|
||||
</VBtn>
|
||||
<div class="text-xs mt-2" v-if="props.count">
|
||||
<div class="plugin-market-detail-actions">
|
||||
<div>
|
||||
<VBtn
|
||||
color="primary"
|
||||
@click="installPlugin()"
|
||||
prepend-icon="mdi-download"
|
||||
:disabled="props.plugin?.system_version_compatible === false"
|
||||
>
|
||||
{{ t('plugin.installToLocal') }}
|
||||
</VBtn>
|
||||
<VBtn variant="tonal" @click="showUpdateHistory" prepend-icon="mdi-update" class="ms-2">
|
||||
{{ t('plugin.versionHistory') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
<div class="plugin-market-detail-actions__downloads" v-if="props.count">
|
||||
<VIcon icon="mdi-fire" />
|
||||
{{ t('plugin.totalDownloads', { count: formatDownloadCount(props.count) }) }}
|
||||
</div>
|
||||
@@ -212,3 +254,30 @@ onUnmounted(() => {
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.plugin-market-detail-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.plugin-market-detail-actions__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>
|
||||
|
||||
@@ -24,11 +24,14 @@ const repoText = ref('')
|
||||
const newRepoUrl = ref('')
|
||||
const editingIndex = ref<number | null>(null)
|
||||
const editingUrl = ref('')
|
||||
const syncingWiki = ref(false)
|
||||
|
||||
const emit = defineEmits(['save', 'close'])
|
||||
|
||||
const parsedTextRepos = computed(() => parseRepoInput(repoText.value))
|
||||
const activeRepoCount = computed(() => (editorMode.value === 'text' ? parsedTextRepos.value.repos.length : repoList.value.length))
|
||||
const activeRepoCount = computed(() =>
|
||||
editorMode.value === 'text' ? parsedTextRepos.value.repos.length : repoList.value.length,
|
||||
)
|
||||
const saveDisabled = computed(
|
||||
() => activeRepoCount.value === 0 || (editorMode.value === 'text' && parsedTextRepos.value.invalidRepos.length > 0),
|
||||
)
|
||||
@@ -108,7 +111,7 @@ function switchEditorMode(mode: EditorMode | undefined) {
|
||||
/** 加载插件市场仓库配置。 */
|
||||
async function queryMarketRepoSetting() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/PLUGIN_MARKET')
|
||||
const result: { [key: string]: any } = await api.get('system/setting/public/PLUGIN_MARKET')
|
||||
if (result && result.data && result.data.value) {
|
||||
repoList.value = parseRepoInput(result.data.value).repos
|
||||
syncTextFromList()
|
||||
@@ -136,6 +139,35 @@ async function saveHandle() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 从 Wiki 同步公开插件仓库清单并写入配置。 */
|
||||
async function syncWikiRepos() {
|
||||
try {
|
||||
syncingWiki.value = true
|
||||
const result: { [key: string]: any } = await api.post('system/setting/PLUGIN_MARKET/sync-wiki', {})
|
||||
|
||||
if (result.success) {
|
||||
const repos = Array.isArray(result.data?.repos)
|
||||
? result.data.repos
|
||||
: parseRepoInput(result.data?.value || '').repos
|
||||
repoList.value = repos
|
||||
syncTextFromList()
|
||||
$toast.success(
|
||||
t('dialog.pluginMarketSetting.syncSuccess', {
|
||||
added: result.data?.added_count ?? 0,
|
||||
total: result.data?.total_count ?? repos.length,
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
$toast.error(t('dialog.pluginMarketSetting.syncFailed', { message: result?.message }))
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
$toast.error(t('dialog.pluginMarketSetting.syncFailed', { message: error instanceof Error ? error.message : '' }))
|
||||
} finally {
|
||||
syncingWiki.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取当前维护模式下可保存的仓库地址。 */
|
||||
function normalizeCurrentRepos() {
|
||||
if (editorMode.value === 'text') {
|
||||
@@ -224,8 +256,8 @@ function formatRepoDisplay(url: string) {
|
||||
const pathSegments = parsedUrl.pathname.split('/').filter(Boolean)
|
||||
|
||||
if (
|
||||
['github.com', 'www.github.com', 'raw.githubusercontent.com'].includes(parsedUrl.hostname)
|
||||
&& pathSegments.length >= 2
|
||||
['github.com', 'www.github.com', 'raw.githubusercontent.com'].includes(parsedUrl.hostname) &&
|
||||
pathSegments.length >= 2
|
||||
) {
|
||||
return `${pathSegments[0]}/${pathSegments[1].replace(/\.git$/, '')}`
|
||||
}
|
||||
@@ -258,25 +290,47 @@ onMounted(() => {
|
||||
</div>
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
</VCardItem>
|
||||
|
||||
<VDivider />
|
||||
<VCardText class="plugin-market-dialog-body pt-4">
|
||||
<div class="plugin-market-toolbar">
|
||||
<VBtnToggle
|
||||
:model-value="editorMode"
|
||||
mandatory
|
||||
color="primary"
|
||||
density="comfortable"
|
||||
variant="tonal"
|
||||
class="plugin-market-mode-toggle"
|
||||
@update:model-value="switchEditorMode"
|
||||
>
|
||||
<VBtn value="list" prepend-icon="mdi-format-list-bulleted">
|
||||
{{ t('dialog.pluginMarketSetting.listMode') }}
|
||||
</VBtn>
|
||||
<VBtn value="text" prepend-icon="mdi-text-box-edit-outline">
|
||||
{{ t('dialog.pluginMarketSetting.textMode') }}
|
||||
</VBtn>
|
||||
</VBtnToggle>
|
||||
<div class="plugin-market-toolbar-hint">
|
||||
<VIcon icon="mdi-information-outline" size="18" />
|
||||
<span>{{ t('dialog.pluginMarketSetting.repoCountHint', { count: activeRepoCount }) }}</span>
|
||||
</div>
|
||||
<div class="plugin-market-mode-switch" role="tablist" :aria-label="t('dialog.pluginMarketSetting.title')">
|
||||
<VTooltip :text="t('dialog.pluginMarketSetting.listMode')" location="top">
|
||||
<template #activator="{ props }">
|
||||
<button
|
||||
v-bind="props"
|
||||
type="button"
|
||||
class="plugin-market-mode-button"
|
||||
:class="{ 'is-active': editorMode === 'list' }"
|
||||
role="tab"
|
||||
:aria-label="t('dialog.pluginMarketSetting.listMode')"
|
||||
:aria-selected="editorMode === 'list'"
|
||||
@click="switchEditorMode('list')"
|
||||
>
|
||||
<VIcon icon="mdi-format-list-bulleted" size="20" />
|
||||
</button>
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VTooltip :text="t('dialog.pluginMarketSetting.textMode')" location="top">
|
||||
<template #activator="{ props }">
|
||||
<button
|
||||
v-bind="props"
|
||||
type="button"
|
||||
class="plugin-market-mode-button"
|
||||
:class="{ 'is-active': editorMode === 'text' }"
|
||||
role="tab"
|
||||
:aria-label="t('dialog.pluginMarketSetting.textMode')"
|
||||
:aria-selected="editorMode === 'text'"
|
||||
@click="switchEditorMode('text')"
|
||||
>
|
||||
<VIcon icon="mdi-text-box-edit-outline" size="20" />
|
||||
</button>
|
||||
</template>
|
||||
</VTooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="editorMode === 'list'" class="plugin-market-list-panel">
|
||||
@@ -424,7 +478,17 @@ onMounted(() => {
|
||||
</div>
|
||||
</VCardText>
|
||||
|
||||
<VCardActions class="plugin-market-actions">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VBtn
|
||||
color="success"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-cloud-sync-outline"
|
||||
:loading="syncingWiki"
|
||||
:disabled="syncingWiki"
|
||||
@click="syncWikiRepos"
|
||||
>
|
||||
{{ t('dialog.pluginMarketSetting.syncWiki') }}
|
||||
</VBtn>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
color="primary"
|
||||
@@ -478,14 +542,70 @@ onMounted(() => {
|
||||
.plugin-market-toolbar {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
min-block-size: 2.25rem;
|
||||
}
|
||||
|
||||
.plugin-market-mode-toggle {
|
||||
inline-size: 100%;
|
||||
.plugin-market-toolbar-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 0.375rem;
|
||||
background: rgba(var(--v-theme-info), 0.08);
|
||||
color: rgb(var(--v-theme-info));
|
||||
font-size: 0.875rem;
|
||||
gap: 0.5rem;
|
||||
min-inline-size: 0;
|
||||
padding-block: 0.5rem;
|
||||
padding-inline: 1rem;
|
||||
|
||||
:deep(.v-btn) {
|
||||
flex: 1;
|
||||
min-inline-size: 0;
|
||||
span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-market-mode-switch {
|
||||
display: inline-flex;
|
||||
padding: 0.125rem;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
border-radius: 0.375rem;
|
||||
background: rgba(var(--v-theme-surface), 0.72);
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.plugin-market-mode-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0.375rem;
|
||||
background: transparent;
|
||||
block-size: 2.25rem;
|
||||
color: rgba(var(--v-theme-on-surface), 0.68);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
inline-size: 2.25rem;
|
||||
transition:
|
||||
background-color 0.16s ease,
|
||||
color 0.16s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(var(--v-theme-primary), 0.07);
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid rgba(var(--v-theme-primary), 0.48);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background: rgba(var(--v-theme-primary), 0.12);
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -529,8 +649,8 @@ onMounted(() => {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
line-break: anywhere;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
@@ -550,20 +670,22 @@ onMounted(() => {
|
||||
|
||||
.plugin-market-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
min-block-size: 14rem;
|
||||
}
|
||||
|
||||
.plugin-market-textarea-field {
|
||||
position: relative;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
background: rgba(var(--v-theme-surface), 0.72);
|
||||
min-block-size: 0;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
|
||||
&:focus-within {
|
||||
border-color: rgb(var(--v-theme-primary));
|
||||
@@ -586,13 +708,14 @@ onMounted(() => {
|
||||
background: transparent;
|
||||
block-size: 100%;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace;
|
||||
font-size: 1rem;
|
||||
line-height: 1.6;
|
||||
min-block-size: 0;
|
||||
outline: none;
|
||||
overflow-y: auto;
|
||||
padding: 1rem 1rem 1rem 3.25rem;
|
||||
padding-block: 1rem;
|
||||
padding-inline: 3.25rem 1rem;
|
||||
resize: none;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
@@ -612,19 +735,14 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-market-actions {
|
||||
flex: 0 0 auto;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
@media (width <= 600px) {
|
||||
.plugin-market-dialog-card {
|
||||
block-size: 100dvh;
|
||||
}
|
||||
|
||||
.plugin-market-card-item {
|
||||
padding: 0.75rem 1rem 0.625rem;
|
||||
padding-block: 0.75rem 0.625rem;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
.plugin-market-header {
|
||||
@@ -640,16 +758,22 @@ onMounted(() => {
|
||||
|
||||
.plugin-market-dialog-body {
|
||||
gap: 0.625rem;
|
||||
padding: 0.75rem 1rem !important;
|
||||
padding-block: 0.75rem !important;
|
||||
padding-inline: 1rem !important;
|
||||
}
|
||||
|
||||
.plugin-market-mode-toggle {
|
||||
inline-size: 100%;
|
||||
.plugin-market-toolbar {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
:deep(.v-btn) {
|
||||
flex: 1;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
.plugin-market-mode-switch {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.plugin-market-toolbar-hint {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.plugin-market-list-panel,
|
||||
@@ -664,9 +788,5 @@ onMounted(() => {
|
||||
.plugin-market-empty {
|
||||
min-block-size: 10rem;
|
||||
}
|
||||
|
||||
.plugin-market-actions {
|
||||
padding: 0.75rem 1rem calc(0.75rem + env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import api from '@/api'
|
||||
import type { Plugin } from '@/api/types'
|
||||
import type { Plugin, PluginReleaseVersion, PluginReleaseVersionsResponse } from '@/api/types'
|
||||
import VersionHistory from '@/components/misc/VersionHistory.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// 多语言
|
||||
const { t } = useI18n()
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -21,14 +21,25 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
actionMode: {
|
||||
type: String as PropType<'install' | 'update'>,
|
||||
default: 'update',
|
||||
},
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['update:modelValue', 'close', 'update'])
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
(event: 'close'): void
|
||||
(event: 'update', releaseVersion?: string, repoUrl?: string): void
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
const loadError = ref('')
|
||||
const pluginDetail = ref<Plugin | null>(null)
|
||||
const releaseLoading = ref(false)
|
||||
const releaseError = ref('')
|
||||
const releaseDetail = ref<PluginReleaseVersionsResponse | null>(null)
|
||||
|
||||
// 弹窗显示状态
|
||||
const visible = computed({
|
||||
@@ -41,19 +52,73 @@ const visible = computed({
|
||||
|
||||
const resolvedPlugin = computed(() => pluginDetail.value ?? props.plugin)
|
||||
|
||||
const resolvedHistory = computed(() => resolvedPlugin.value?.history || {})
|
||||
const resolvedHistory = computed(() => {
|
||||
const history = { ...(resolvedPlugin.value?.history || {}) }
|
||||
releaseItems.value.forEach(item => {
|
||||
const key = normalizeHistoryVersion(item.version)
|
||||
if (!(key in history)) history[key] = item.body || ''
|
||||
})
|
||||
return history
|
||||
})
|
||||
|
||||
const hasHistory = computed(() => Object.keys(resolvedHistory.value).length > 0)
|
||||
|
||||
const latestActionText = computed(() => props.actionMode === 'install' ? t('plugin.installReleaseVersion') : t('plugin.updateToLatest'))
|
||||
|
||||
const releaseItems = computed(() => releaseDetail.value?.items || [])
|
||||
|
||||
const shouldShowUpdatePanel = computed(() => props.showUpdateAction)
|
||||
|
||||
const releaseByHistoryVersion = computed(() => {
|
||||
const releaseMap = new Map<string, PluginReleaseVersion>()
|
||||
releaseItems.value.forEach(item => {
|
||||
releaseMap.set(normalizeHistoryVersion(item.version), item)
|
||||
})
|
||||
return releaseMap
|
||||
})
|
||||
|
||||
function normalizeHistoryVersion(version: string) {
|
||||
return version.startsWith('v') ? version : `v${version}`
|
||||
}
|
||||
|
||||
function formatReleaseDate(value?: string) {
|
||||
if (!value) return ''
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
return date.toLocaleDateString(locale.value)
|
||||
}
|
||||
|
||||
function releaseItemByHistoryVersion(version: string) {
|
||||
return releaseByHistoryVersion.value.get(version)
|
||||
}
|
||||
|
||||
function shouldShowReleaseButton(item?: PluginReleaseVersion) {
|
||||
if (!item || item.is_current) return false
|
||||
return !(item.is_latest && shouldShowUpdatePanel.value && props.actionMode === 'update')
|
||||
}
|
||||
|
||||
async function loadPluginHistory() {
|
||||
if (!props.plugin?.id) {
|
||||
pluginDetail.value = null
|
||||
loadError.value = ''
|
||||
releaseDetail.value = null
|
||||
releaseError.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
loadError.value = ''
|
||||
releaseDetail.value = null
|
||||
releaseError.value = ''
|
||||
|
||||
// 插件市场条目已经携带远端信息;history 接口只查询已安装插件,
|
||||
// 未安装插件打开版本历史时只能基于传入的市场数据和 Release 列表展示。
|
||||
if (props.actionMode === 'install' && props.plugin?.repo_url) {
|
||||
pluginDetail.value = null
|
||||
loading.value = false
|
||||
loadPluginReleases(props.plugin, false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
pluginDetail.value = await api.get(`plugin/history/${props.plugin.id}`, {
|
||||
@@ -61,6 +126,7 @@ async function loadPluginHistory() {
|
||||
force: true,
|
||||
},
|
||||
})
|
||||
loadPluginReleases(pluginDetail.value ?? props.plugin, true)
|
||||
} catch (error) {
|
||||
pluginDetail.value = null
|
||||
loadError.value = t('plugin.updateHistoryLoadFailed')
|
||||
@@ -70,36 +136,108 @@ async function loadPluginHistory() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPluginReleases(plugin: Plugin | null | undefined = resolvedPlugin.value, force = false) {
|
||||
if (!plugin?.id || !plugin?.repo_url || !plugin?.release) {
|
||||
releaseDetail.value = null
|
||||
releaseError.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
releaseLoading.value = true
|
||||
releaseError.value = ''
|
||||
|
||||
try {
|
||||
releaseDetail.value = await api.get(`plugin/releases/${plugin.id}`, {
|
||||
params: {
|
||||
repo_url: plugin.repo_url,
|
||||
force,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
releaseDetail.value = null
|
||||
releaseError.value = t('plugin.releaseVersionsLoadFailed')
|
||||
console.error(error)
|
||||
} finally {
|
||||
releaseLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 触发插件更新操作。 */
|
||||
function handleUpdate() {
|
||||
emit('update')
|
||||
function handleUpdate(releaseItem?: PluginReleaseVersion) {
|
||||
emit('update', releaseItem?.is_latest ? undefined : releaseItem?.version, resolvedPlugin.value?.repo_url)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [visible.value, props.plugin?.id],
|
||||
([isVisible]) => {
|
||||
if (isVisible) loadPluginHistory()
|
||||
if (isVisible) {
|
||||
loadPluginHistory()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" width="600" max-height="85vh" scrollable>
|
||||
<VDialog v-if="visible" v-model="visible" width="680" max-height="85vh" scrollable>
|
||||
<VCard :title="t('plugin.updateHistoryTitle', { name: resolvedPlugin?.plugin_name })">
|
||||
<VDialogCloseBtn v-model="visible" />
|
||||
<VDivider />
|
||||
<VProgressLinear v-if="releaseLoading && !loading" indeterminate color="primary" height="2" />
|
||||
<div v-if="loading" class="plugin-version-history-dialog__loading">
|
||||
<VProgressCircular indeterminate color="primary" />
|
||||
</div>
|
||||
<VCardText v-else-if="loadError && !hasHistory">
|
||||
<VAlert type="warning" variant="tonal" density="compact" :text="loadError" />
|
||||
</VCardText>
|
||||
<VCardText v-else-if="!hasHistory">
|
||||
<VCardText v-else-if="!hasHistory && !releaseLoading">
|
||||
<VAlert type="info" variant="tonal" density="compact" :text="t('plugin.updateHistoryEmpty')" />
|
||||
</VCardText>
|
||||
<VersionHistory v-else :history="resolvedHistory" />
|
||||
<template v-if="props.showUpdateAction">
|
||||
<template v-else>
|
||||
<VCardText v-if="releaseError" class="pb-0">
|
||||
<VAlert type="warning" variant="tonal" density="compact" :text="releaseError" />
|
||||
</VCardText>
|
||||
<VersionHistory
|
||||
:history="resolvedHistory"
|
||||
:has-action="version => shouldShowReleaseButton(releaseItemByHistoryVersion(version))"
|
||||
>
|
||||
<template #meta="{ version }">
|
||||
<div v-if="releaseItemByHistoryVersion(version)" class="plugin-release-meta">
|
||||
<span v-if="formatReleaseDate(releaseItemByHistoryVersion(version)?.published_at)" class="plugin-release-meta__date">
|
||||
{{ formatReleaseDate(releaseItemByHistoryVersion(version)?.published_at) }}
|
||||
</span>
|
||||
<VChip v-if="releaseItemByHistoryVersion(version)?.is_latest" size="x-small" color="primary" variant="tonal">
|
||||
{{ t('plugin.latestVersion') }}
|
||||
</VChip>
|
||||
<VChip v-if="releaseItemByHistoryVersion(version)?.is_current" size="x-small" color="success" variant="tonal">
|
||||
{{ t('plugin.currentVersion') }}
|
||||
</VChip>
|
||||
</div>
|
||||
</template>
|
||||
<template #action="{ version }">
|
||||
<VBtn
|
||||
v-if="shouldShowReleaseButton(releaseItemByHistoryVersion(version))"
|
||||
class="plugin-release-button"
|
||||
size="small"
|
||||
min-width="5rem"
|
||||
:color="releaseItemByHistoryVersion(version)?.is_latest ? 'primary' : undefined"
|
||||
:variant="releaseItemByHistoryVersion(version)?.is_latest ? 'flat' : 'tonal'"
|
||||
:disabled="
|
||||
releaseItemByHistoryVersion(version)?.is_current ||
|
||||
(releaseItemByHistoryVersion(version)?.is_latest && resolvedPlugin?.system_version_compatible === false)
|
||||
"
|
||||
@click.stop="handleUpdate(releaseItemByHistoryVersion(version))"
|
||||
>
|
||||
{{
|
||||
releaseItemByHistoryVersion(version)?.is_latest
|
||||
? latestActionText
|
||||
: t('plugin.installReleaseVersion')
|
||||
}}
|
||||
</VBtn>
|
||||
</template>
|
||||
</VersionHistory>
|
||||
</template>
|
||||
<template v-if="shouldShowUpdatePanel">
|
||||
<VDivider />
|
||||
<VCardItem>
|
||||
<VAlert
|
||||
@@ -110,7 +248,11 @@ watch(
|
||||
class="mb-3"
|
||||
:text="resolvedPlugin?.system_version_message || t('plugin.incompatibleSystemVersion')"
|
||||
/>
|
||||
<VBtn @click="handleUpdate" block :disabled="resolvedPlugin?.system_version_compatible === false">
|
||||
<VBtn
|
||||
@click="handleUpdate()"
|
||||
block
|
||||
:disabled="resolvedPlugin?.system_version_compatible === false"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-arrow-up-circle-outline" />
|
||||
</template>
|
||||
@@ -129,4 +271,23 @@ watch(
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.plugin-release-button {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.plugin-release-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.plugin-release-meta__date {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-disabled-opacity));
|
||||
font-size: 0.875rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -89,12 +89,12 @@ async function handleReset() {
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VBtn color="error" @click="handleReset" prepend-icon="mdi-restore" class="px-5 me-3">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VBtn color="error" variant="tonal" @click="handleReset" prepend-icon="mdi-restore">
|
||||
{{ t('dialog.rcloneConfig.reset') }}
|
||||
</VBtn>
|
||||
<VSpacer />
|
||||
<VBtn @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3">
|
||||
<VBtn color="primary" variant="flat" @click="handleDone" prepend-icon="mdi-check" class="px-5">
|
||||
{{ t('dialog.rcloneConfig.complete') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
ManualTransferPayload,
|
||||
ManualTransferPreviewData,
|
||||
ManualTransferPreviewItem,
|
||||
ManualTransferTargetPathData,
|
||||
StorageConf,
|
||||
TransferDirectoryConf,
|
||||
TransferForm,
|
||||
@@ -19,7 +18,6 @@ import { useBackground } from '@/composables/useBackground'
|
||||
import MediaIdSelector from '../misc/MediaIdSelector.vue'
|
||||
import ProgressDialog from './ProgressDialog.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { nextTick } from 'vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
|
||||
@@ -118,14 +116,13 @@ const episodeFormatRecommendState = reactive<{
|
||||
|
||||
const episodeFormatRuleConfigured = ref<boolean | undefined>(undefined)
|
||||
|
||||
interface ManualTransferTargetPathRequest {
|
||||
fileitem?: FileItem
|
||||
fileitems?: FileItem[]
|
||||
logid?: number
|
||||
logids?: number[]
|
||||
target_storage?: string | null
|
||||
interface TargetDirectoryOption {
|
||||
title: string
|
||||
value: string
|
||||
}
|
||||
|
||||
const AUTO_TARGET_PATH_VALUE = '__moviepilot_auto_target_path__'
|
||||
|
||||
// 生成文件项稳定键,用于去重和状态同步。
|
||||
function getFileItemKey(item?: FileItem) {
|
||||
return [item?.storage ?? '', item?.type ?? '', item?.path ?? ''].join('|')
|
||||
@@ -152,13 +149,7 @@ const normalizedItems = computed(() => dedupeFileItems(props.items))
|
||||
|
||||
// 分页
|
||||
const previewPage = ref(1)
|
||||
const previewPageSize = ref(10)
|
||||
|
||||
// 预览列表主体元素
|
||||
const previewFileBodyRef = ref<HTMLElement>()
|
||||
|
||||
// 预览列表尺寸观察器
|
||||
let previewFileBodyResizeObserver: ResizeObserver | undefined
|
||||
const previewPageSize = ref(20)
|
||||
|
||||
// 所有存储
|
||||
const storages = ref<StorageConf[]>([])
|
||||
@@ -175,7 +166,7 @@ let episodeGroupQueryTimer: ReturnType<typeof setTimeout> | undefined
|
||||
// 查询存储
|
||||
async function loadStorages() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/Storages')
|
||||
const result: { [key: string]: any } = await api.get('system/setting/public/Storages')
|
||||
|
||||
storages.value = result.data?.value ?? []
|
||||
} catch (error) {
|
||||
@@ -185,10 +176,27 @@ async function loadStorages() {
|
||||
|
||||
// 存储字典
|
||||
const storageOptions = computed(() => {
|
||||
return storages.value.map(item => ({
|
||||
title: item.name,
|
||||
value: item.type,
|
||||
}))
|
||||
return [
|
||||
{
|
||||
title: t('dialog.reorganize.auto'),
|
||||
value: null,
|
||||
},
|
||||
...storages.value.map(item => ({
|
||||
title: item.name,
|
||||
value: item.type,
|
||||
})),
|
||||
]
|
||||
})
|
||||
|
||||
// 整理方式选项,包含可提交 null 的自动项。
|
||||
const manualTransferTypeOptions = computed(() => {
|
||||
return [
|
||||
{
|
||||
title: t('dialog.reorganize.auto'),
|
||||
value: null,
|
||||
},
|
||||
...transferTypeOptions,
|
||||
]
|
||||
})
|
||||
|
||||
// 剧集组选项属性
|
||||
@@ -273,16 +281,20 @@ const disableEpisodeDetail = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const initialTargetPath = normalizeTargetPath(props.target_path)
|
||||
|
||||
// 表单
|
||||
const transferForm = reactive<TransferForm>({
|
||||
fileitem: {} as FileItem,
|
||||
logid: 0,
|
||||
target_storage: props.target_storage ?? 'local',
|
||||
target_path: normalizeTargetPath(props.target_path),
|
||||
transfer_type: '',
|
||||
target_storage: initialTargetPath ? (props.target_storage ?? 'local') : null,
|
||||
target_path: initialTargetPath,
|
||||
transfer_type: null,
|
||||
min_filesize: 0,
|
||||
scrape: false,
|
||||
scrape: initialTargetPath ? false : null,
|
||||
from_history: false,
|
||||
library_type_folder: null,
|
||||
library_category_folder: null,
|
||||
episode_group: null,
|
||||
})
|
||||
|
||||
@@ -292,90 +304,52 @@ const directories = ref<TransferDirectoryConf[]>([])
|
||||
// 查询目录
|
||||
async function loadDirectories() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/Directories')
|
||||
const result: { [key: string]: any } = await api.get('system/setting/public/Directories')
|
||||
directories.value = result.data?.value ?? []
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 目的目录下拉框
|
||||
const targetDirectories = computed(() => {
|
||||
const libraryDirectories = directories.value.map(item => item.library_path)
|
||||
return [...new Set(libraryDirectories)]
|
||||
// 目的目录下拉框,第一项用于把目标路径显式重置为后端自动匹配。
|
||||
const targetDirectoryOptions = computed<TargetDirectoryOption[]>(() => {
|
||||
const libraryDirectories = directories.value.map(item => item.library_path).filter(Boolean) as string[]
|
||||
return [
|
||||
{
|
||||
title: t('dialog.reorganize.auto'),
|
||||
value: AUTO_TARGET_PATH_VALUE,
|
||||
},
|
||||
...[...new Set(libraryDirectories)].map(path => ({
|
||||
title: path,
|
||||
value: path,
|
||||
})),
|
||||
]
|
||||
})
|
||||
|
||||
// 构造目的路径自动匹配请求,只传用户真实上下文,避免用默认存储误导后端匹配。
|
||||
function createTargetPathMatchRequest(): ManualTransferTargetPathRequest | undefined {
|
||||
const payload: ManualTransferTargetPathRequest = {}
|
||||
|
||||
if (props.target_storage) {
|
||||
payload.target_storage = props.target_storage
|
||||
}
|
||||
|
||||
if (normalizedItems.value.length === 1) {
|
||||
payload.fileitem = normalizedItems.value[0]
|
||||
return payload
|
||||
}
|
||||
|
||||
if (normalizedItems.value.length > 1) {
|
||||
payload.fileitems = normalizedItems.value
|
||||
return payload
|
||||
}
|
||||
|
||||
if (props.logids?.length) {
|
||||
if (props.logids.length > 1) {
|
||||
payload.logids = props.logids
|
||||
return payload
|
||||
}
|
||||
|
||||
payload.logid = props.logids[0]
|
||||
return payload
|
||||
}
|
||||
}
|
||||
|
||||
// 应用后端匹配到的目的路径配置,未匹配时保持 null 等待用户手工选择。
|
||||
function applyMatchedTargetPath(data?: ManualTransferTargetPathData) {
|
||||
const matchedTargetPath = normalizeTargetPath(data?.target_path)
|
||||
if (!matchedTargetPath) {
|
||||
transferForm.target_path = null
|
||||
return
|
||||
}
|
||||
|
||||
transferForm.target_storage = data?.target_storage || transferForm.target_storage || 'local'
|
||||
transferForm.transfer_type = data?.transfer_type || transferForm.transfer_type
|
||||
transferForm.scrape = data?.scrape ?? false
|
||||
transferForm.library_type_folder = data?.library_type_folder ?? false
|
||||
transferForm.library_category_folder = data?.library_category_folder ?? false
|
||||
transferForm.target_path = matchedTargetPath
|
||||
}
|
||||
|
||||
// 请求后端按源目录匹配最合适的手动整理目的路径。
|
||||
async function autoSelectTargetPath() {
|
||||
if (normalizeTargetPath(props.target_path) || transferForm.target_path) return
|
||||
|
||||
const payload = createTargetPathMatchRequest()
|
||||
if (!payload) {
|
||||
transferForm.target_path = null
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await api.post<ApiResponse<ManualTransferTargetPathData>, ApiResponse<ManualTransferTargetPathData>>(
|
||||
'transfer/manual/target-path',
|
||||
payload,
|
||||
)
|
||||
|
||||
if (!result.success) {
|
||||
transferForm.target_path = null
|
||||
// 目标路径选择值,用哨兵值把界面上的“自动”和接口里的 null 解耦。
|
||||
const targetPathSelection = computed({
|
||||
get() {
|
||||
return transferForm.target_path ?? AUTO_TARGET_PATH_VALUE
|
||||
},
|
||||
set(value: string | null) {
|
||||
const targetPath = normalizeTargetPath(value)
|
||||
if (!targetPath || targetPath === AUTO_TARGET_PATH_VALUE) {
|
||||
resetAutomaticTargetConfig()
|
||||
return
|
||||
}
|
||||
|
||||
applyMatchedTargetPath(result.data)
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
transferForm.target_path = null
|
||||
}
|
||||
transferForm.target_path = targetPath
|
||||
},
|
||||
})
|
||||
|
||||
// 重置为完全自动匹配状态,提交时不携带目标路径及其派生配置。
|
||||
function resetAutomaticTargetConfig() {
|
||||
transferForm.target_storage = null
|
||||
transferForm.target_path = null
|
||||
transferForm.transfer_type = null
|
||||
transferForm.scrape = null
|
||||
transferForm.library_type_folder = null
|
||||
transferForm.library_category_folder = null
|
||||
}
|
||||
|
||||
// 监听目的路径变化,配置默认值
|
||||
@@ -391,6 +365,7 @@ watch(
|
||||
transferForm.library_category_folder = directory.library_category_folder ?? false
|
||||
transferForm.library_type_folder = directory.library_type_folder ?? false
|
||||
} else {
|
||||
transferForm.target_storage = transferForm.target_storage || 'local'
|
||||
transferForm.transfer_type = transferForm.transfer_type || 'copy'
|
||||
transferForm.scrape = false
|
||||
transferForm.library_category_folder = false
|
||||
@@ -398,9 +373,9 @@ watch(
|
||||
}
|
||||
} else {
|
||||
// 路径为空时, 恢复到`自动`条件
|
||||
transferForm.transfer_type = ''
|
||||
transferForm.library_type_folder = undefined
|
||||
transferForm.library_category_folder = undefined
|
||||
transferForm.transfer_type = null
|
||||
transferForm.library_type_folder = null
|
||||
transferForm.library_category_folder = null
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -437,9 +412,39 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
// 过滤后的预览数据
|
||||
// 过滤并排序后的预览数据
|
||||
const filteredPreviewItems = computed(() => {
|
||||
return previewData.value?.items ?? []
|
||||
const items = [...(previewData.value?.items ?? [])]
|
||||
|
||||
return items.sort((a, b) => {
|
||||
// 1. 获取季号(如果有的话优先按季号排)
|
||||
const seasonA = getPreviewSeasonNumber(a)
|
||||
const seasonB = getPreviewSeasonNumber(b)
|
||||
if (seasonA !== seasonB) {
|
||||
if (seasonA === undefined) return 1
|
||||
if (seasonB === undefined) return -1
|
||||
return seasonA - seasonB
|
||||
}
|
||||
|
||||
// 2. 获取集数
|
||||
const epA = toPreviewNumber(a.episode)
|
||||
const epB = toPreviewNumber(b.episode)
|
||||
|
||||
// 如果都有集数,按集数排序
|
||||
if (epA !== undefined && epB !== undefined) {
|
||||
if (epA !== epB) return epA - epB
|
||||
// 集数相同(可能是同集的视频、字幕等),退化到按文件名排序,保证相关文件挨在一起
|
||||
}
|
||||
|
||||
// 3. 有集数的排前面,没集数的(通常是其他文件)排后面
|
||||
if (epA !== undefined && epB === undefined) return -1
|
||||
if (epA === undefined && epB !== undefined) return 1
|
||||
|
||||
// 4. 如果都没集数,或者集数完全相同,则按照目标路径(或源路径)的字母顺序排
|
||||
const nameA = a.target || a.source || ''
|
||||
const nameB = b.target || b.source || ''
|
||||
return nameA.localeCompare(nameB, undefined, { numeric: true })
|
||||
})
|
||||
})
|
||||
|
||||
// 分页后的预览数据(含文件名解析)
|
||||
@@ -496,6 +501,12 @@ function normalizeTargetPath(path?: string | null) {
|
||||
return normalizedPath || null
|
||||
}
|
||||
|
||||
// 归一化可选文本参数,保证自动项提交 null 而不是空字符串。
|
||||
function normalizeOptionalText(value?: string | null) {
|
||||
const normalizedValue = value?.trim()
|
||||
return normalizedValue || null
|
||||
}
|
||||
|
||||
// 归一化剧集组值,兼容历史对象态值。
|
||||
function normalizeEpisodeGroup(episodeGroup?: string | { value?: string | null } | null) {
|
||||
if (!episodeGroup) return null
|
||||
@@ -822,7 +833,9 @@ function createTransferPayload(options: { item?: FileItem; items?: FileItem[]; l
|
||||
...transferForm,
|
||||
fileitem: sourceItem,
|
||||
logid: options.logid ?? 0,
|
||||
target_storage: normalizeOptionalText(transferForm.target_storage),
|
||||
target_path: normalizeTargetPath(transferForm.target_path),
|
||||
transfer_type: normalizeOptionalText(transferForm.transfer_type),
|
||||
episode_group: normalizeEpisodeGroup(transferForm.episode_group),
|
||||
}
|
||||
|
||||
@@ -848,7 +861,7 @@ async function requestManualTransfer<T = any>(
|
||||
// 加载剧集格式规则配置状态,用于决定是否允许自动推荐。
|
||||
async function loadEpisodeFormatRuleConfiguration() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/EpisodeFormatRuleTable')
|
||||
const result: { [key: string]: any } = await api.get('system/setting/public/EpisodeFormatRuleTable')
|
||||
episodeFormatRuleConfigured.value = Boolean(result.data?.value?.length)
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
@@ -1120,7 +1133,6 @@ async function previewTransfer() {
|
||||
|
||||
previewData.value = mergedPreviewData
|
||||
previewLoaded.value = true
|
||||
nextTick(() => updatePreviewPageSize())
|
||||
|
||||
if (previewHasFailures(mergedPreviewData)) {
|
||||
$toast.warning(getPreviewResultSummaryMessage(mergedPreviewData))
|
||||
@@ -1147,45 +1159,6 @@ async function togglePreview() {
|
||||
await previewTransfer()
|
||||
}
|
||||
|
||||
// 根据可用高度自动计算每页条数,保持统一行高
|
||||
function updatePreviewPageSize() {
|
||||
const bodyHeight = previewFileBodyRef.value?.clientHeight ?? 0
|
||||
if (bodyHeight <= 0) return
|
||||
|
||||
const firstRow = previewFileBodyRef.value?.querySelector('.preview-file-row')
|
||||
const rowHeight = firstRow?.getBoundingClientRect().height ?? 46
|
||||
const pageSize = Math.max(1, Math.floor(bodyHeight / rowHeight))
|
||||
previewPageSize.value = pageSize
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filteredPreviewItems.value.length / pageSize))
|
||||
if (previewPage.value > totalPages) {
|
||||
previewPage.value = totalPages
|
||||
}
|
||||
}
|
||||
|
||||
// 启动预览列表高度监听
|
||||
function setupPreviewFileBodyObserver() {
|
||||
previewFileBodyResizeObserver?.disconnect()
|
||||
|
||||
if (!previewFileBodyRef.value || typeof ResizeObserver === 'undefined') return
|
||||
|
||||
previewFileBodyResizeObserver = new ResizeObserver(() => {
|
||||
updatePreviewPageSize()
|
||||
})
|
||||
previewFileBodyResizeObserver.observe(previewFileBodyRef.value)
|
||||
}
|
||||
|
||||
watch([() => previewLoaded.value, () => previewVisible.value], ([loaded, visible]) => {
|
||||
if (loaded && visible) {
|
||||
nextTick(() => {
|
||||
setupPreviewFileBodyObserver()
|
||||
updatePreviewPageSize()
|
||||
})
|
||||
} else {
|
||||
previewFileBodyResizeObserver?.disconnect()
|
||||
}
|
||||
})
|
||||
|
||||
// 整理文件
|
||||
async function handleTransfer(item: FileItem, background: boolean = false) {
|
||||
try {
|
||||
@@ -1306,7 +1279,6 @@ async function transfer(background: boolean = false) {
|
||||
|
||||
onMounted(async () => {
|
||||
await loadDirectories()
|
||||
await autoSelectTargetPath()
|
||||
loadStorages()
|
||||
loadEpisodeFormatRuleConfiguration()
|
||||
})
|
||||
@@ -1314,7 +1286,6 @@ onMounted(async () => {
|
||||
onUnmounted(() => {
|
||||
stopLoadingProgress()
|
||||
if (episodeGroupQueryTimer) clearTimeout(episodeGroupQueryTimer)
|
||||
previewFileBodyResizeObserver?.disconnect()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1356,20 +1327,19 @@ onUnmounted(() => {
|
||||
<VSelect
|
||||
v-model="transferForm.transfer_type"
|
||||
:label="t('dialog.reorganize.transferType')"
|
||||
:items="transferTypeOptions"
|
||||
:items="manualTransferTypeOptions"
|
||||
:hint="t('dialog.reorganize.transferTypeHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-swap-horizontal"
|
||||
>
|
||||
<template v-slot:selection="{ item }">
|
||||
{{ transferForm.transfer_type === '' ? t('dialog.reorganize.auto') : item.title }}
|
||||
</template>
|
||||
</VSelect>
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VCombobox
|
||||
v-model="transferForm.target_path"
|
||||
:items="targetDirectories"
|
||||
v-model="targetPathSelection"
|
||||
:items="targetDirectoryOptions"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
:return-object="false"
|
||||
:label="t('dialog.reorganize.targetPath')"
|
||||
:placeholder="t('dialog.reorganize.targetPathPlaceholder')"
|
||||
:hint="t('dialog.reorganize.targetPathHint')"
|
||||
@@ -1528,7 +1498,7 @@ onUnmounted(() => {
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6" v-if="transferForm.target_path">
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="transferForm.library_type_folder"
|
||||
:label="t('dialog.reorganize.typeFolderOption')"
|
||||
@@ -1536,7 +1506,7 @@ onUnmounted(() => {
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6" v-if="transferForm.target_path">
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="transferForm.library_category_folder"
|
||||
:label="t('dialog.reorganize.categoryFolderOption')"
|
||||
@@ -1562,35 +1532,39 @@ onUnmounted(() => {
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
<VCardActions class="reorganize-form-pane__actions pt-3 px-0 pb-0">
|
||||
<VBtn
|
||||
color="info"
|
||||
:variant="previewVisible ? 'tonal' : 'text'"
|
||||
@click="togglePreview"
|
||||
:prepend-icon="previewToggleIcon"
|
||||
class="reorganize-action-btn reorganize-action-btn--preview"
|
||||
:class="{ 'reorganize-action-btn--active': previewVisible }"
|
||||
:loading="previewLoading"
|
||||
>
|
||||
{{ t('dialog.reorganize.previewResult') }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
color="success"
|
||||
@click="transfer(true)"
|
||||
prepend-icon="mdi-plus"
|
||||
class="reorganize-action-btn reorganize-action-btn--queue"
|
||||
>
|
||||
{{ t('dialog.reorganize.addToQueue') }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
@click="transfer(false)"
|
||||
prepend-icon="mdi-arrow-right-bold"
|
||||
class="reorganize-action-btn reorganize-action-btn--primary"
|
||||
>
|
||||
{{ t('dialog.reorganize.reorganizeNow') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</div>
|
||||
<VCardActions class="app-dialog-actions reorganize-form-pane__actions">
|
||||
<VBtn
|
||||
color="info"
|
||||
variant="tonal"
|
||||
@click="togglePreview"
|
||||
:prepend-icon="previewToggleIcon"
|
||||
class="reorganize-action-btn reorganize-action-btn--preview"
|
||||
:class="{ 'reorganize-action-btn--active': previewVisible }"
|
||||
:loading="previewLoading"
|
||||
>
|
||||
{{ t('dialog.reorganize.previewResult') }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
color="success"
|
||||
variant="tonal"
|
||||
@click="transfer(true)"
|
||||
prepend-icon="mdi-plus"
|
||||
class="reorganize-action-btn reorganize-action-btn--queue"
|
||||
>
|
||||
{{ t('dialog.reorganize.addToQueue') }}
|
||||
</VBtn>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="transfer(false)"
|
||||
prepend-icon="mdi-arrow-right-bold"
|
||||
class="reorganize-action-btn reorganize-action-btn--primary"
|
||||
>
|
||||
{{ t('dialog.reorganize.reorganizeNow') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</div>
|
||||
<div v-show="previewVisible" class="reorganize-preview-pane">
|
||||
<div class="reorganize-preview-pane__header">
|
||||
@@ -1679,7 +1653,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
<div class="reorganize-preview-list">
|
||||
<div v-if="pagedPreviewRows.length" ref="previewFileBodyRef" class="preview-file-body">
|
||||
<div v-if="pagedPreviewRows.length" class="preview-file-body">
|
||||
<div
|
||||
v-for="(item, index) in pagedPreviewRows"
|
||||
:key="`${item.source}-${item.target}-${index}`"
|
||||
@@ -1823,17 +1797,9 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.reorganize-form-pane__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
margin-block-start: auto;
|
||||
}
|
||||
|
||||
.reorganize-action-btn {
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.reorganize-action-btn--active {
|
||||
background: rgba(var(--v-theme-info), 0.12);
|
||||
}
|
||||
@@ -1910,6 +1876,8 @@ onUnmounted(() => {
|
||||
.preview-overview-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-radius: 0.5rem;
|
||||
gap: 0.375rem;
|
||||
min-inline-size: 0;
|
||||
padding-block: 0.875rem;
|
||||
@@ -1935,6 +1903,8 @@ onUnmounted(() => {
|
||||
.preview-custom-words {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-radius: 0.5rem;
|
||||
gap: 0.75rem;
|
||||
padding-block: 0.875rem;
|
||||
padding-inline: 1rem;
|
||||
@@ -1986,8 +1956,12 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.preview-custom-words__chip {
|
||||
block-size: auto !important;
|
||||
max-inline-size: 100%;
|
||||
min-block-size: 1.5rem;
|
||||
padding-block: 0.25rem;
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.reorganize-preview-pane__scroll {
|
||||
@@ -2027,9 +2001,9 @@ onUnmounted(() => {
|
||||
flex: 0 0 auto;
|
||||
flex-direction: column;
|
||||
margin-block-end: 1.5rem;
|
||||
margin-inline: 1.5rem;
|
||||
min-block-size: 0;
|
||||
min-inline-size: 0;
|
||||
padding-inline: 1.5rem;
|
||||
}
|
||||
|
||||
.preview-file-body {
|
||||
@@ -2040,13 +2014,13 @@ onUnmounted(() => {
|
||||
gap: 0.75rem;
|
||||
min-block-size: 0;
|
||||
min-inline-size: 0;
|
||||
padding-block: 1rem;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
.preview-file-row {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-radius: 0.5rem;
|
||||
gap: 0.875rem;
|
||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||||
min-block-size: 5.25rem;
|
||||
@@ -2055,10 +2029,6 @@ onUnmounted(() => {
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
.preview-file-row + .preview-file-row {
|
||||
border-block-start: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
}
|
||||
|
||||
.preview-file-row--failed {
|
||||
background: rgba(var(--v-theme-error), 0.04);
|
||||
}
|
||||
@@ -2173,15 +2143,9 @@ onUnmounted(() => {
|
||||
border-inline-end: none;
|
||||
}
|
||||
|
||||
.reorganize-form-pane__actions {
|
||||
display: grid;
|
||||
justify-content: stretch;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.reorganize-action-btn {
|
||||
inline-size: 100%;
|
||||
min-block-size: 2.75rem;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
.reorganize-preview-pane__summary {
|
||||
@@ -2190,20 +2154,16 @@ onUnmounted(() => {
|
||||
|
||||
.reorganize-preview-list {
|
||||
margin-block-end: 1rem;
|
||||
margin-inline: 1rem;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 640px) {
|
||||
.reorganize-form-pane__actions {
|
||||
justify-content: stretch;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.reorganize-action-btn {
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.reorganize-action-btn--primary {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useUserStore, useGlobalSettingsStore } from '@/stores'
|
||||
import SearchSiteDialog from '@/components/dialog/SearchSiteDialog.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { hasPermission, filterMenusByPermission } from '@/utils/permission'
|
||||
import { buildUserPermissionContext, hasPermission, filterMenusByPermission } from '@/utils/permission'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
@@ -30,41 +30,29 @@ const userStore = useUserStore()
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
const globalSettings = globalSettingsStore.globalSettings
|
||||
|
||||
// 超级用户
|
||||
const superUser = userStore.superUser
|
||||
|
||||
// 当前用户名
|
||||
const userName = userStore.userName
|
||||
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
|
||||
|
||||
// 权限检查
|
||||
const hasSearchPermission = computed(() => {
|
||||
return hasPermission(
|
||||
{
|
||||
is_superuser: userStore.superUser,
|
||||
...userStore.permissions,
|
||||
},
|
||||
'search',
|
||||
)
|
||||
return hasPermission(userPermissions.value, 'search')
|
||||
})
|
||||
|
||||
const hasDiscoveryPermission = computed(() => {
|
||||
return hasPermission(userPermissions.value, 'discovery')
|
||||
})
|
||||
|
||||
const hasSubscribePermission = computed(() => {
|
||||
return hasPermission(
|
||||
{
|
||||
is_superuser: userStore.superUser,
|
||||
...userStore.permissions,
|
||||
},
|
||||
'subscribe',
|
||||
)
|
||||
return hasPermission(userPermissions.value, 'subscribe')
|
||||
})
|
||||
|
||||
const hasManagePermission = computed(() => {
|
||||
return hasPermission(
|
||||
{
|
||||
is_superuser: userStore.superUser,
|
||||
...userStore.permissions,
|
||||
},
|
||||
'manage',
|
||||
)
|
||||
return hasPermission(userPermissions.value, 'manage')
|
||||
})
|
||||
|
||||
const hasAdminPermission = computed(() => {
|
||||
return hasPermission(userPermissions.value, 'admin')
|
||||
})
|
||||
|
||||
// 是否显示合集搜索项(当SEARCH_SOURCE包含themoviedb时显示)
|
||||
@@ -79,6 +67,7 @@ const SubscribeItems = ref<Subscribe[]>([])
|
||||
const chooseSiteDialog = ref(false)
|
||||
const selectedSites = ref<number[]>([])
|
||||
const allSites = ref<Site[]>([])
|
||||
const siteSearchType = ref<'torrent' | 'subtitle'>('torrent')
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['close', 'update:modelValue'])
|
||||
@@ -139,6 +128,7 @@ function getMenus(): NavMenu[] {
|
||||
to: item.to,
|
||||
header: item.header,
|
||||
admin: item.admin,
|
||||
permission: item.permission,
|
||||
}),
|
||||
)
|
||||
// 设置标签页
|
||||
@@ -151,6 +141,7 @@ function getMenus(): NavMenu[] {
|
||||
to: `/setting?tab=${item.tab}`,
|
||||
header: '',
|
||||
admin: true,
|
||||
permission: 'admin',
|
||||
description: item.description,
|
||||
}),
|
||||
)
|
||||
@@ -158,12 +149,6 @@ function getMenus(): NavMenu[] {
|
||||
return menus
|
||||
}
|
||||
|
||||
// 获取用户权限信息
|
||||
const userPermissions = computed(() => ({
|
||||
is_superuser: userStore.superUser,
|
||||
...userStore.permissions,
|
||||
}))
|
||||
|
||||
// 匹配的菜单列表
|
||||
const matchedMenuItems = computed(() => {
|
||||
if (!searchWord.value) return []
|
||||
@@ -201,7 +186,7 @@ async function fetchInstalledPlugins() {
|
||||
// 匹配的插件列表
|
||||
const matchedPluginItems = computed(() => {
|
||||
if (!searchWord.value) return []
|
||||
if (!hasManagePermission.value) return []
|
||||
if (!hasAdminPermission.value) return []
|
||||
const lowerWord = (searchWord.value as string).toLowerCase()
|
||||
return pluginItems.value.filter((item: Plugin) => {
|
||||
if (!item.plugin_name && !item.plugin_desc) return false
|
||||
@@ -221,7 +206,7 @@ async function fetchSubscribes() {
|
||||
// 从接口加载用户站点偏好设置
|
||||
const loadUserSitePreferences = async () => {
|
||||
try {
|
||||
const result = await api.get('system/setting/IndexerSites')
|
||||
const result = await api.get('system/setting/public/IndexerSites')
|
||||
if (result && result.data && result.data.value) {
|
||||
selectedSites.value = result.data.value
|
||||
return
|
||||
@@ -247,7 +232,8 @@ async function queryAllSites() {
|
||||
}
|
||||
|
||||
// 打开站点选择对话框
|
||||
const openSiteDialog = () => {
|
||||
const openSiteDialog = (type: 'torrent' | 'subtitle' = 'torrent') => {
|
||||
siteSearchType.value = type
|
||||
chooseSiteDialog.value = true
|
||||
}
|
||||
|
||||
@@ -257,7 +243,7 @@ const matchedSubscribeItems = computed(() => {
|
||||
if (!hasSubscribePermission.value) return []
|
||||
const lowerWord = (searchWord.value as string).toLowerCase()
|
||||
return SubscribeItems.value.filter((item: Subscribe) => {
|
||||
return (item.name.toLowerCase().includes(lowerWord) && (superUser || userName === item.username)) || false
|
||||
return (item.name.toLowerCase().includes(lowerWord) && (userStore.superUser || userName === item.username)) || false
|
||||
})
|
||||
})
|
||||
|
||||
@@ -265,12 +251,16 @@ const matchedSubscribeItems = computed(() => {
|
||||
function searchSites(sites: number[]) {
|
||||
chooseSiteDialog.value = false
|
||||
selectedSites.value = sites
|
||||
if (siteSearchType.value === 'subtitle') {
|
||||
searchSubtitle()
|
||||
return
|
||||
}
|
||||
searchTorrent()
|
||||
}
|
||||
|
||||
// 搜索资源
|
||||
function searchTorrent() {
|
||||
if (!searchWord.value) return
|
||||
if (!searchWord.value || !hasSearchPermission.value) return
|
||||
// 记录搜索词
|
||||
saveRecentSearches(searchWord.value)
|
||||
// 跳转到搜索页面
|
||||
@@ -279,6 +269,7 @@ function searchTorrent() {
|
||||
query: {
|
||||
keyword: searchWord.value,
|
||||
area: 'title',
|
||||
result_type: 'torrent',
|
||||
sites: selectedSites.value.join(','),
|
||||
},
|
||||
})
|
||||
@@ -287,10 +278,27 @@ function searchTorrent() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// 搜索字幕资源
|
||||
function searchSubtitle() {
|
||||
if (!searchWord.value || !hasSearchPermission.value) return
|
||||
saveRecentSearches(searchWord.value)
|
||||
router.push({
|
||||
path: '/resource',
|
||||
query: {
|
||||
keyword: searchWord.value,
|
||||
area: 'title',
|
||||
result_type: 'subtitle',
|
||||
sites: selectedSites.value.join(','),
|
||||
},
|
||||
})
|
||||
dialog.value = false
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// 跳转媒体搜索页面
|
||||
function searchMedia(searchType: string) {
|
||||
// 搜索类型 media/person
|
||||
if (!searchWord.value) return
|
||||
if (!searchWord.value || !hasDiscoveryPermission.value) return
|
||||
saveRecentSearches(searchWord.value)
|
||||
router.push({
|
||||
path: '/browse/media/search',
|
||||
@@ -371,7 +379,7 @@ onMounted(() => {
|
||||
searchWordInput.value?.focus()
|
||||
}, 500)
|
||||
// 根据权限加载不同的数据
|
||||
if (hasManagePermission.value) {
|
||||
if (hasAdminPermission.value) {
|
||||
fetchInstalledPlugins()
|
||||
}
|
||||
if (hasSubscribePermission.value) {
|
||||
@@ -413,58 +421,60 @@ onMounted(() => {
|
||||
<!-- 有搜索词时显示搜索入口和匹配结果 -->
|
||||
<VList lines="two" v-if="searchWord" class="search-list pa-0 py-2">
|
||||
<!-- 媒体搜索入口 -->
|
||||
<VListSubheader class="font-weight-medium text-uppercase px-4">
|
||||
{{ t('common.media') }}
|
||||
</VListSubheader>
|
||||
<template v-if="hasDiscoveryPermission">
|
||||
<VListSubheader class="font-weight-medium text-uppercase px-4">
|
||||
{{ t('common.media') }}
|
||||
</VListSubheader>
|
||||
|
||||
<VListItem density="comfortable" link @click="searchMedia('media')" class="search-result-item mx-2 my-1">
|
||||
<template #prepend>
|
||||
<div class="result-icon-wrapper">
|
||||
<VIcon icon="mdi-movie-search" size="small" color="medium-emphasis" />
|
||||
</div>
|
||||
</template>
|
||||
<VListItemTitle class="font-weight-medium text-body-2">
|
||||
{{ t('recommend.categoryMovie') }}、{{ t('recommend.categoryTV') }}
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle class="text-caption text-medium-emphasis">
|
||||
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
|
||||
{{ t('resource.title') }}
|
||||
</VListItemSubtitle>
|
||||
</VListItem>
|
||||
<VListItem density="comfortable" link @click="searchMedia('media')" class="search-result-item mx-2 my-1">
|
||||
<template #prepend>
|
||||
<div class="result-icon-wrapper">
|
||||
<VIcon icon="mdi-movie-search" size="small" color="medium-emphasis" />
|
||||
</div>
|
||||
</template>
|
||||
<VListItemTitle class="font-weight-medium text-body-2">
|
||||
{{ t('recommend.categoryMovie') }}、{{ t('recommend.categoryTV') }}
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle class="text-caption text-medium-emphasis">
|
||||
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
|
||||
{{ t('resource.title') }}
|
||||
</VListItemSubtitle>
|
||||
</VListItem>
|
||||
|
||||
<VListItem
|
||||
v-if="showCollectionSearch"
|
||||
density="comfortable"
|
||||
link
|
||||
@click="searchMedia('collection')"
|
||||
class="search-result-item mx-2 my-1"
|
||||
>
|
||||
<template #prepend>
|
||||
<div class="result-icon-wrapper">
|
||||
<VIcon icon="mdi-movie-filter" size="small" color="medium-emphasis" />
|
||||
</div>
|
||||
</template>
|
||||
<VListItemTitle class="font-weight-medium text-body-2">{{
|
||||
t('dialog.searchBar.collections')
|
||||
}}</VListItemTitle>
|
||||
<VListItemSubtitle class="text-caption text-medium-emphasis">
|
||||
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
|
||||
{{ t('dialog.searchBar.collectionSearch') }}
|
||||
</VListItemSubtitle>
|
||||
</VListItem>
|
||||
<VListItem
|
||||
v-if="showCollectionSearch"
|
||||
density="comfortable"
|
||||
link
|
||||
@click="searchMedia('collection')"
|
||||
class="search-result-item mx-2 my-1"
|
||||
>
|
||||
<template #prepend>
|
||||
<div class="result-icon-wrapper">
|
||||
<VIcon icon="mdi-movie-filter" size="small" color="medium-emphasis" />
|
||||
</div>
|
||||
</template>
|
||||
<VListItemTitle class="font-weight-medium text-body-2">{{
|
||||
t('dialog.searchBar.collections')
|
||||
}}</VListItemTitle>
|
||||
<VListItemSubtitle class="text-caption text-medium-emphasis">
|
||||
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
|
||||
{{ t('dialog.searchBar.collectionSearch') }}
|
||||
</VListItemSubtitle>
|
||||
</VListItem>
|
||||
|
||||
<VListItem density="comfortable" link @click="searchMedia('person')" class="search-result-item mx-2 my-1">
|
||||
<template #prepend>
|
||||
<div class="result-icon-wrapper">
|
||||
<VIcon icon="mdi-account-search" size="small" color="medium-emphasis" />
|
||||
</div>
|
||||
</template>
|
||||
<VListItemTitle class="font-weight-medium text-body-2">{{ t('browse.actor') }}</VListItemTitle>
|
||||
<VListItemSubtitle class="text-caption text-medium-emphasis">
|
||||
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
|
||||
{{ t('dialog.searchBar.actorSearch') }}
|
||||
</VListItemSubtitle>
|
||||
</VListItem>
|
||||
<VListItem density="comfortable" link @click="searchMedia('person')" class="search-result-item mx-2 my-1">
|
||||
<template #prepend>
|
||||
<div class="result-icon-wrapper">
|
||||
<VIcon icon="mdi-account-search" size="small" color="medium-emphasis" />
|
||||
</div>
|
||||
</template>
|
||||
<VListItemTitle class="font-weight-medium text-body-2">{{ t('browse.actor') }}</VListItemTitle>
|
||||
<VListItemSubtitle class="text-caption text-medium-emphasis">
|
||||
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
|
||||
{{ t('dialog.searchBar.actorSearch') }}
|
||||
</VListItemSubtitle>
|
||||
</VListItem>
|
||||
</template>
|
||||
|
||||
<VListItem
|
||||
v-if="hasSubscribePermission"
|
||||
@@ -622,7 +632,34 @@ onMounted(() => {
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
rounded="pill"
|
||||
@click.stop="openSiteDialog"
|
||||
@click.stop="openSiteDialog('torrent')"
|
||||
>
|
||||
{{ t('dialog.searchBar.selectSites') }}
|
||||
</VBtn>
|
||||
</template>
|
||||
</VListItem>
|
||||
|
||||
<VListItem density="comfortable" link @click="searchSubtitle" class="search-result-item mx-2 my-1">
|
||||
<template #prepend>
|
||||
<div class="result-icon-wrapper">
|
||||
<VIcon icon="mdi-subtitles-outline" size="small" color="medium-emphasis" />
|
||||
</div>
|
||||
</template>
|
||||
<VListItemTitle class="font-weight-medium text-body-2">{{
|
||||
t('dialog.searchBar.searchSubtitlesInSites')
|
||||
}}</VListItemTitle>
|
||||
<VListItemSubtitle class="text-caption text-medium-emphasis">
|
||||
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
|
||||
{{ t('dialog.searchBar.relatedSubtitles') }}
|
||||
</VListItemSubtitle>
|
||||
<template #append>
|
||||
<VBtn
|
||||
v-if="hasManagePermission"
|
||||
size="x-small"
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
rounded="pill"
|
||||
@click.stop="openSiteDialog('subtitle')"
|
||||
>
|
||||
{{ t('dialog.searchBar.selectSites') }}
|
||||
</VBtn>
|
||||
|
||||
@@ -175,10 +175,11 @@ const filteredSites = computed(() => {
|
||||
</div>
|
||||
</VCardText>
|
||||
|
||||
<VCardActions class="pt-3">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
:disabled="selectedSites.length === 0"
|
||||
@click="confirmSearch"
|
||||
prepend-icon="mdi-magnify"
|
||||
|
||||
@@ -34,6 +34,11 @@ const visible = computed({
|
||||
function allLoggingUrl() {
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/logging?length=-1`
|
||||
}
|
||||
|
||||
/** 拼接主程序日志下载 URL。 */
|
||||
function allLoggingDownloadUrl() {
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/logging/download/moviepilot`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -44,12 +49,20 @@ function allLoggingUrl() {
|
||||
<VCardTitle class="d-inline-flex">
|
||||
<VIcon icon="mdi-file-document" class="me-2" />
|
||||
{{ t('shortcut.log.subtitle') }}
|
||||
<a class="mx-2 d-inline-flex align-center" :href="allLoggingUrl()" target="_blank">
|
||||
<VChip color="grey-darken-1" size="small" class="ml-2">
|
||||
<VIcon icon="mdi-open-in-new" size="small" start />
|
||||
{{ t('common.openInNewWindow') }}
|
||||
</VChip>
|
||||
</a>
|
||||
<span class="ms-4 d-inline-flex align-center ga-1">
|
||||
<a class="d-inline-flex align-center" :href="allLoggingDownloadUrl()" target="_blank">
|
||||
<VChip color="grey-darken-1" size="small">
|
||||
<VIcon icon="mdi-download" size="small" start />
|
||||
{{ t('common.download') }}
|
||||
</VChip>
|
||||
</a>
|
||||
<a class="d-inline-flex align-center" :href="allLoggingUrl()" target="_blank">
|
||||
<VChip color="grey-darken-1" size="small">
|
||||
<VIcon icon="mdi-open-in-new" size="small" start />
|
||||
{{ t('common.openInNewWindow') }}
|
||||
</VChip>
|
||||
</a>
|
||||
</span>
|
||||
</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import api from '@/api'
|
||||
import { clearAppBadge } 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
|
||||
}
|
||||
}
|
||||
|
||||
watch(visible, async newValue => {
|
||||
if (newValue) {
|
||||
await nextTick()
|
||||
messageViewRef.value?.resumeSSE?.()
|
||||
messageViewRef.value?.forceScrollToEnd?.()
|
||||
|
||||
window.setTimeout(() => {
|
||||
void clearAppBadge()
|
||||
}, 500)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
messageViewRef.value?.pauseSSE?.()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
messageViewRef.value?.resumeSSE?.()
|
||||
messageViewRef.value?.forceScrollToEnd?.()
|
||||
|
||||
window.setTimeout(() => {
|
||||
void clearAppBadge()
|
||||
}, 500)
|
||||
})
|
||||
|
||||
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')
|
||||
},
|
||||
})
|
||||
|
||||
const isFullscreen = computed(() => !display.mdAndUp.value)
|
||||
|
||||
// 仅系统健康检查弹窗需要在全屏时取消固定高度,避免其它快捷弹窗被误伤。
|
||||
const bodyClasses = computed(() => [
|
||||
props.bodyClass,
|
||||
{
|
||||
'system-health-dialog-body--fullscreen':
|
||||
isFullscreen.value && props.bodyClass.split(/\s+/).includes('system-health-dialog-body'),
|
||||
},
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" :max-width="props.maxWidth" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VDialog v-if="visible" v-model="visible" :max-width="props.maxWidth" scrollable :fullscreen="isFullscreen">
|
||||
<VCard :class="props.cardClass">
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
@@ -53,7 +64,7 @@ const visible = computed({
|
||||
<VDialogCloseBtn v-model="visible" />
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText :class="props.bodyClass">
|
||||
<VCardText :class="bodyClasses">
|
||||
<Component :is="props.view" v-bind="props.viewProps" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
@@ -61,8 +72,6 @@ const visible = computed({
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* stylelint-disable selector-pseudo-class-no-unknown */
|
||||
|
||||
.system-health-dialog-card {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
@@ -78,7 +87,7 @@ const visible = computed({
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
:global(.v-dialog--fullscreen) .system-health-dialog-body {
|
||||
.system-health-dialog-body--fullscreen {
|
||||
block-size: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -340,12 +340,26 @@ onMounted(async () => {
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn v-if="props.oper === 'add'" color="primary" @click="addSite" prepend-icon="mdi-plus" class="px-5">
|
||||
<VBtn
|
||||
v-if="props.oper === 'add'"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="addSite"
|
||||
prepend-icon="mdi-plus"
|
||||
class="px-5"
|
||||
>
|
||||
{{ t('site.actions.add') }}
|
||||
</VBtn>
|
||||
<VBtn v-else color="primary" @click="updateSiteInfo" prepend-icon="mdi-content-save" class="px-5">
|
||||
<VBtn
|
||||
v-else
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="updateSiteInfo"
|
||||
prepend-icon="mdi-content-save"
|
||||
class="px-5"
|
||||
>
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -110,9 +110,11 @@ async function updateSiteCookie() {
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="mx-auto">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
size="large"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="updateSiteCookie"
|
||||
:disabled="updateButtonDisable"
|
||||
:loading="updateButtonDisable"
|
||||
|
||||
@@ -475,26 +475,26 @@ onMounted(() => {
|
||||
:items="mobileResourceList"
|
||||
:columns="1"
|
||||
:gap="12"
|
||||
:estimated-item-height="320"
|
||||
:estimated-item-height="220"
|
||||
:overscan-rows="5"
|
||||
:get-item-key="getResourceItemKey"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<VCard>
|
||||
<VCardText class="pa-4">
|
||||
<VCard class="site-resource-card" variant="flat">
|
||||
<VCardText class="pa-3">
|
||||
<button type="button" class="site-resource-title-btn text-start" @click="addDownload(item)">
|
||||
<div class="text-body-1 font-weight-medium text-high-emphasis">
|
||||
<div class="site-resource-card__title text-body-1 font-weight-medium text-high-emphasis">
|
||||
{{ item.title }}
|
||||
</div>
|
||||
<div
|
||||
v-if="item.description"
|
||||
class="site-resource-card__description mt-2 text-body-2 text-medium-emphasis"
|
||||
class="site-resource-card__description mt-1 text-body-2 text-medium-emphasis"
|
||||
>
|
||||
{{ item.description }}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="site-resource-card__chips mt-2">
|
||||
<VChip
|
||||
v-if="item.hit_and_run"
|
||||
variant="elevated"
|
||||
@@ -533,47 +533,82 @@ onMounted(() => {
|
||||
</VChip>
|
||||
</div>
|
||||
|
||||
<div class="site-resource-card__meta mt-4">
|
||||
<div class="site-resource-card__meta-item">
|
||||
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.timeColumn') }}</div>
|
||||
<div class="text-body-2 font-weight-medium">{{ item.date_elapsed || item.pubdate || '-' }}</div>
|
||||
<div v-if="item.pubdate" class="text-caption text-medium-emphasis mt-1">{{ item.pubdate }}</div>
|
||||
<!-- 移动端在操作区前展示关键资源指标,方便点击前快速判断。 -->
|
||||
<div class="site-resource-card__summary mt-3">
|
||||
<div class="site-resource-card__stat">
|
||||
<VIcon icon="mdi-clock-outline" size="15" />
|
||||
<span>{{ item.date_elapsed || item.pubdate || '-' }}</span>
|
||||
</div>
|
||||
<div class="site-resource-card__meta-item">
|
||||
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.sizeColumn') }}</div>
|
||||
<div class="text-body-2 font-weight-medium">{{ formatFileSize(item.size) }}</div>
|
||||
<div class="site-resource-card__stat">
|
||||
<VIcon icon="mdi-harddisk" size="15" />
|
||||
<span>{{ formatFileSize(item.size) }}</span>
|
||||
</div>
|
||||
<div class="site-resource-card__meta-item">
|
||||
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.seedersColumn') }}</div>
|
||||
<div class="text-body-2 font-weight-medium">{{ item.seeders }}</div>
|
||||
<div class="site-resource-card__stat site-resource-card__stat--success">
|
||||
<VIcon icon="mdi-arrow-up" size="15" />
|
||||
<span>{{ item.seeders ?? '-' }}</span>
|
||||
</div>
|
||||
<div class="site-resource-card__meta-item">
|
||||
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.peersColumn') }}</div>
|
||||
<div class="text-body-2 font-weight-medium">{{ item.peers }}</div>
|
||||
<div class="site-resource-card__stat site-resource-card__stat--warning">
|
||||
<VIcon icon="mdi-arrow-down" size="15" />
|
||||
<span>{{ item.peers ?? '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="site-resource-card__actions mt-4">
|
||||
<VBtn color="primary" variant="flat" block prepend-icon="mdi-download" @click="addDownload(item)">
|
||||
<!-- 下载保留文本,其它低频操作改为图标按钮并保持同一行。 -->
|
||||
<div class="site-resource-card__actions mt-2">
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
class="site-resource-card__download-btn"
|
||||
prepend-icon="mdi-download"
|
||||
@click="addDownload(item)"
|
||||
>
|
||||
{{ t('actionStep.addDownload') }}
|
||||
</VBtn>
|
||||
<div class="site-resource-card__secondary-actions mt-2">
|
||||
<VBtn
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-open-in-new"
|
||||
@click="openTorrentDetail(item.page_url || '')"
|
||||
>
|
||||
{{ t('common.viewDetails') }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
v-if="item.enclosure?.startsWith('http')"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-tray-arrow-down"
|
||||
@click="downloadTorrentFile(item.enclosure)"
|
||||
>
|
||||
{{ t('dialog.siteResource.downloadTorrent') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
<VTooltip :text="t('common.viewDetails')" location="top">
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<VBtn
|
||||
v-bind="tooltipProps"
|
||||
icon
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
class="site-resource-card__icon-btn"
|
||||
:aria-label="t('common.viewDetails')"
|
||||
@click="openTorrentDetail(item.page_url || '')"
|
||||
>
|
||||
<VIcon icon="mdi-open-in-new" />
|
||||
</VBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VTooltip
|
||||
v-if="item.enclosure?.startsWith('http')"
|
||||
:text="t('dialog.siteResource.downloadTorrent')"
|
||||
location="top"
|
||||
>
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<VBtn
|
||||
v-bind="tooltipProps"
|
||||
icon
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
class="site-resource-card__icon-btn"
|
||||
:aria-label="t('dialog.siteResource.downloadTorrent')"
|
||||
@click="downloadTorrentFile(item.enclosure)"
|
||||
>
|
||||
<VIcon icon="mdi-file-download-outline" />
|
||||
</VBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VBtn
|
||||
v-else
|
||||
icon
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
disabled
|
||||
class="site-resource-card__icon-btn"
|
||||
:aria-label="t('dialog.siteResource.downloadTorrent')"
|
||||
>
|
||||
<VIcon icon="mdi-file-download-outline" />
|
||||
</VBtn>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
@@ -702,44 +737,107 @@ onMounted(() => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.site-resource-card {
|
||||
--site-resource-card-bg:
|
||||
linear-gradient(180deg, rgba(var(--v-theme-surface), 0.98), rgba(var(--v-theme-surface), 0.94)),
|
||||
radial-gradient(circle at top right, rgba(var(--v-theme-primary), 0.08), transparent 34%);
|
||||
|
||||
border: 1px solid rgba(var(--v-border-color), calc(var(--v-border-opacity) * 0.9));
|
||||
background: var(--site-resource-card-bg);
|
||||
}
|
||||
|
||||
:global(html[data-theme="transparent"]) .site-resource-card {
|
||||
--site-resource-card-bg: rgba(var(--v-theme-surface), var(--transparent-opacity));
|
||||
|
||||
backdrop-filter: blur(var(--transparent-blur));
|
||||
}
|
||||
|
||||
.site-resource-card__summary {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) minmax(2.5rem, 0.62fr) minmax(2.5rem, 0.62fr);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.site-resource-card__stat {
|
||||
display: inline-flex;
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.22rem;
|
||||
border-radius: 6px;
|
||||
background: rgba(var(--v-theme-on-surface), 0.05);
|
||||
color: rgba(var(--v-theme-on-surface), 0.72);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
min-block-size: 1.65rem;
|
||||
min-inline-size: 0;
|
||||
padding-inline: 0.4rem;
|
||||
}
|
||||
|
||||
.site-resource-card__stat span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.site-resource-card__stat--success {
|
||||
color: rgb(var(--v-theme-success));
|
||||
}
|
||||
|
||||
.site-resource-card__stat--warning {
|
||||
color: rgb(var(--v-theme-warning));
|
||||
}
|
||||
|
||||
.site-resource-card__title {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
line-height: 1.38;
|
||||
}
|
||||
|
||||
.site-resource-card__description {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-line-clamp: 2;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.site-resource-card__meta {
|
||||
.site-resource-card__chips {
|
||||
max-block-size: 4.75rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.site-resource-card__actions {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.45rem;
|
||||
grid-template-columns: minmax(0, 1fr) 2.5rem 2.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.site-resource-card__meta-item {
|
||||
background: rgba(var(--v-theme-surface), 0.78);
|
||||
min-block-size: 0;
|
||||
padding-block: 0.55rem;
|
||||
padding-inline: 0.65rem;
|
||||
.site-resource-card__download-btn {
|
||||
min-block-size: 2.5rem;
|
||||
min-inline-size: 0;
|
||||
box-shadow: 0 6px 16px rgba(var(--v-theme-primary), 0.17);
|
||||
}
|
||||
|
||||
.site-resource-card__meta-item :deep(.text-caption) {
|
||||
font-size: 0.72rem !important;
|
||||
line-height: 1.2;
|
||||
.site-resource-card__download-btn :deep(.v-btn__content) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.site-resource-card__meta-item :deep(.text-body-2) {
|
||||
font-size: 0.82rem !important;
|
||||
line-height: 1.25;
|
||||
.site-resource-card__icon-btn {
|
||||
block-size: 2.5rem;
|
||||
inline-size: 2.5rem;
|
||||
min-inline-size: 2.5rem;
|
||||
}
|
||||
|
||||
.site-resource-card__secondary-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.site-resource-card__secondary-actions :deep(.v-btn) {
|
||||
flex: 1 1 12rem;
|
||||
.site-resource-card__icon-btn :deep(.v-btn__content) {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
@media (width >= 960px) {
|
||||
@@ -761,4 +859,14 @@ onMounted(() => {
|
||||
min-block-size: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 420px) {
|
||||
.site-resource-card__summary {
|
||||
grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.95fr) minmax(2.3rem, 0.55fr) minmax(2.3rem, 0.55fr);
|
||||
}
|
||||
|
||||
.site-resource-card__stat {
|
||||
padding-inline: 0.3rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -117,12 +117,12 @@ async function saveSmbConfig() {
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VBtn color="error" @click="handleReset" prepend-icon="mdi-restore" class="px-5 me-3">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VBtn color="error" variant="tonal" @click="handleReset" prepend-icon="mdi-restore">
|
||||
{{ t('dialog.smbConfig.reset') }}
|
||||
</VBtn>
|
||||
<VSpacer />
|
||||
<VBtn @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3">
|
||||
<VBtn color="primary" variant="flat" @click="handleDone" prepend-icon="mdi-check" class="px-5">
|
||||
{{ t('dialog.smbConfig.complete') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -89,8 +89,9 @@ function handleDone() {
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn @click="handleDone" prepend-icon="mdi-content-save" class="px-5">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn color="primary" variant="flat" @click="handleDone" prepend-icon="mdi-content-save" class="px-5">
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -7,8 +7,14 @@ import { useDisplay } from 'vuetify'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { qualityOptions, resolutionOptions, effectOptions } from '@/api/constants'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||
// i18n
|
||||
const { t } = useI18n()
|
||||
const userStore = useUserStore()
|
||||
const canAdmin = computed(() =>
|
||||
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'admin'),
|
||||
)
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
@@ -128,6 +134,8 @@ async function loadDownloaderSetting() {
|
||||
|
||||
// 加载规则组
|
||||
async function queryFilterRuleGroups() {
|
||||
if (!canAdmin.value) return
|
||||
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/UserFilterRuleGroups')
|
||||
filterRuleGroups.value = result.data?.value ?? []
|
||||
@@ -163,6 +171,8 @@ async function updateSubscribeInfo() {
|
||||
|
||||
// 设置用户设置的默认订阅规则
|
||||
async function saveDefaultSubscribeConfig() {
|
||||
if (!canAdmin.value) return
|
||||
|
||||
try {
|
||||
let subscribe_config_url = ''
|
||||
if (props.type === '电影') subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'
|
||||
@@ -183,8 +193,8 @@ async function saveDefaultSubscribeConfig() {
|
||||
async function queryDefaultSubscribeConfig() {
|
||||
try {
|
||||
let subscribe_config_url = ''
|
||||
if (props.type === '电影') subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'
|
||||
else subscribe_config_url = 'system/setting/DefaultTvSubscribeConfig'
|
||||
if (props.type === '电影') subscribe_config_url = 'system/setting/public/DefaultMovieSubscribeConfig'
|
||||
else subscribe_config_url = 'system/setting/public/DefaultTvSubscribeConfig'
|
||||
|
||||
const result: { [key: string]: any } = await api.get(subscribe_config_url)
|
||||
|
||||
@@ -260,7 +270,7 @@ async function removeSubscribe() {
|
||||
// 查询下载目录
|
||||
async function loadDownloadDirectories() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/Directories')
|
||||
const result: { [key: string]: any } = await api.get('system/setting/public/Directories')
|
||||
if (result.success && result.data?.value) {
|
||||
downloadDirectories.value = result.data.value
|
||||
}
|
||||
@@ -549,12 +559,14 @@ onMounted(() => {
|
||||
</VWindow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn v-if="!props.default" color="error" @click="removeSubscribe" class="me-3">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VBtn v-if="!props.default" color="error" variant="tonal" @click="removeSubscribe">
|
||||
{{ t('dialog.subscribeEdit.cancelSubscribe') }}
|
||||
</VBtn>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click=";`${props.default ? saveDefaultSubscribeConfig() : updateSubscribeInfo()}`"
|
||||
prepend-icon="mdi-content-save"
|
||||
class="px-5"
|
||||
|
||||
@@ -105,9 +105,17 @@ const $toast = useToast()
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn :disabled="shareDoing" @click="doShare" prepend-icon="mdi-share" class="px-5" :loading="shareDoing">
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
:disabled="shareDoing"
|
||||
@click="doShare"
|
||||
prepend-icon="mdi-share"
|
||||
class="px-5"
|
||||
:loading="shareDoing"
|
||||
>
|
||||
{{ t('dialog.subscribeShare.confirmShare') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -141,4 +141,29 @@ function updateFilter(key: string, values: string[]) {
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
|
||||
}
|
||||
|
||||
.filter-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-chip {
|
||||
border: 1px solid rgba(var(--v-theme-primary), 0.2);
|
||||
margin: 4px;
|
||||
background-color: rgba(var(--v-theme-primary), 0.1) !important;
|
||||
color: rgba(var(--v-theme-on-surface), 0.9) !important;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.filter-chip:hover {
|
||||
background-color: rgba(var(--v-theme-primary), 0.15) !important;
|
||||
}
|
||||
|
||||
.filter-chip.v-chip--selected {
|
||||
background-color: rgba(var(--v-theme-primary), 0.85) !important;
|
||||
box-shadow: 0 2px 4px rgba(var(--v-theme-primary), 0.3);
|
||||
color: rgb(var(--v-theme-on-primary)) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -142,4 +142,24 @@ function handleDetail(item: Context) {
|
||||
max-block-size: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.chip-season {
|
||||
background-color: #3f51b5;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.chip-free {
|
||||
background-color: #4caf50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.chip-discount {
|
||||
background-color: #ff5722;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.chip-bonus {
|
||||
background-color: #9c27b0;
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -85,7 +85,7 @@ function updateFilter(values: string[]) {
|
||||
@update:model-value="updateFilter"
|
||||
>
|
||||
<VChip
|
||||
v-for="option in options"
|
||||
v-for="option in options"
|
||||
:key="option"
|
||||
:value="option"
|
||||
filter
|
||||
@@ -97,12 +97,39 @@ function updateFilter(values: string[]) {
|
||||
</VChip>
|
||||
</VChipGroup>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn color="primary" prepend-icon="mdi-check" class="px-5" @click="visible = false">
|
||||
<VBtn color="primary" variant="flat" prepend-icon="mdi-check" class="px-5" @click="visible = false">
|
||||
{{ t('torrent.confirm') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-chip {
|
||||
border: 1px solid rgba(var(--v-theme-primary), 0.2);
|
||||
margin: 4px;
|
||||
background-color: rgba(var(--v-theme-primary), 0.1) !important;
|
||||
color: rgba(var(--v-theme-on-surface), 0.9) !important;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.filter-chip:hover {
|
||||
background-color: rgba(var(--v-theme-primary), 0.15) !important;
|
||||
}
|
||||
|
||||
.filter-chip.v-chip--selected {
|
||||
background-color: rgba(var(--v-theme-primary), 0.85) !important;
|
||||
box-shadow: 0 2px 4px rgba(var(--v-theme-primary), 0.3);
|
||||
color: rgb(var(--v-theme-on-primary)) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -225,11 +225,11 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</VCardText>
|
||||
|
||||
<VCardActions>
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VBtn
|
||||
color="error"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-restore"
|
||||
class="px-5 me-3"
|
||||
@click="handleReset"
|
||||
>
|
||||
{{ t('dialog.u115Auth.reset') }}
|
||||
@@ -238,8 +238,10 @@ onUnmounted(() => {
|
||||
<VSpacer />
|
||||
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
prepend-icon="mdi-check"
|
||||
class="px-5 me-3"
|
||||
class="px-5"
|
||||
@click="handleDone"
|
||||
>
|
||||
{{ t('dialog.u115Auth.complete') }}
|
||||
|
||||
@@ -612,12 +612,13 @@ onMounted(() => {
|
||||
</div>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
v-if="props.oper === 'add'"
|
||||
:disabled="isAdding"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="addUser"
|
||||
prepend-icon="mdi-plus"
|
||||
class="px-5"
|
||||
@@ -629,6 +630,7 @@ onMounted(() => {
|
||||
v-else
|
||||
:disabled="isUpdating"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="updateUser"
|
||||
prepend-icon="mdi-content-save"
|
||||
class="px-5"
|
||||
|
||||
@@ -312,12 +312,19 @@ onMounted(() => {
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn v-if="workflow" color="primary" @click="editWorkflow" prepend-icon="mdi-content-save" class="px-5">
|
||||
<VBtn
|
||||
v-if="workflow"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="editWorkflow"
|
||||
prepend-icon="mdi-content-save"
|
||||
class="px-5"
|
||||
>
|
||||
{{ t('dialog.workflowAddEdit.confirm') }}
|
||||
</VBtn>
|
||||
<VBtn v-else color="primary" @click="addWorkflow" prepend-icon="mdi-plus" class="px-5">
|
||||
<VBtn v-else color="primary" variant="flat" @click="addWorkflow" prepend-icon="mdi-plus" class="px-5">
|
||||
{{ t('dialog.workflowAddEdit.confirm') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -125,9 +125,17 @@ const $toast = useToast()
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn :disabled="shareDoing" @click="doShare" prepend-icon="mdi-share" class="px-5" :loading="shareDoing">
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
:disabled="shareDoing"
|
||||
@click="doShare"
|
||||
prepend-icon="mdi-share"
|
||||
class="px-5"
|
||||
:loading="shareDoing"
|
||||
>
|
||||
{{ t('dialog.workflowShare.confirmShare') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -372,7 +372,7 @@ onMounted(() => {
|
||||
:key="key"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
:color="filterForm[key].length > 0 ? 'primary' : undefined"
|
||||
color="primary"
|
||||
:prepend-icon="getFilterIcon(key)"
|
||||
class="filter-btn"
|
||||
rounded="pill"
|
||||
@@ -555,7 +555,7 @@ onMounted(() => {
|
||||
v-for="(title, key) in filterTitles"
|
||||
v-show="filterOptions[key].length > 0"
|
||||
:key="key"
|
||||
variant="text"
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
class="filter-btn-mobile"
|
||||
@click="toggleFilterMenu(key)"
|
||||
@@ -575,7 +575,7 @@ onMounted(() => {
|
||||
</VBtn>
|
||||
|
||||
<!-- 全部筛选按钮 -->
|
||||
<VBtn variant="text" color="primary" class="filter-btn-mobile" @click="toggleAllFilterMenu">
|
||||
<VBtn variant="tonal" color="primary" class="filter-btn-mobile" @click="toggleAllFilterMenu">
|
||||
<VIcon icon="mdi-filter-variant" class="filter-icon me-1"></VIcon>
|
||||
<span class="filter-label">
|
||||
{{ t('torrent.allFilters') }}
|
||||
@@ -665,7 +665,6 @@ onMounted(() => {
|
||||
|
||||
.filter-btn {
|
||||
min-inline-size: 0;
|
||||
background: rgba(var(--v-theme-surface-variant), 0.1);
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
@@ -733,7 +732,6 @@ onMounted(() => {
|
||||
justify-content: center;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
border-radius: 8px;
|
||||
background-color: rgba(var(--v-theme-surface-variant), 0.08);
|
||||
block-size: auto;
|
||||
min-block-size: 48px;
|
||||
padding-block: 4px;
|
||||
|
||||
@@ -3,9 +3,9 @@ import type { PropType } from 'vue'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import mdLinkAttributes from 'markdown-it-link-attributes'
|
||||
|
||||
// 初始化 markdown-it
|
||||
// 版本历史可能来自插件市场或 Release 内容,禁止透传原始 HTML,避免外部内容注入脚本或事件属性。
|
||||
const md = new MarkdownIt({
|
||||
html: true,
|
||||
html: false,
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
})
|
||||
@@ -27,23 +27,100 @@ function renderMarkdown(value: string) {
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
history: Object as PropType<{ [key: string]: string }>,
|
||||
hasAction: Function as PropType<(version: string) => boolean>,
|
||||
})
|
||||
|
||||
function shouldRenderAction(version: string) {
|
||||
return props.hasAction?.(version) ?? true
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCardText>
|
||||
<VList>
|
||||
<VListItem v-for="(value, key) in props.history" :key="key">
|
||||
<VListItemTitle class="font-bold text-lg">
|
||||
{{ key }}
|
||||
</VListItemTitle>
|
||||
<div class="markdown-body text-gray-500" v-html="renderMarkdown(value)" />
|
||||
</VListItem>
|
||||
</VList>
|
||||
<VCardText class="version-history">
|
||||
<div class="version-history__list">
|
||||
<section v-for="(value, key) in props.history" :key="key" class="version-history__item">
|
||||
<div
|
||||
class="version-history__top"
|
||||
:class="{ 'version-history__top--with-action': $slots.action && shouldRenderAction(String(key)) }"
|
||||
>
|
||||
<div class="version-history__header">
|
||||
<div class="version-history__version">
|
||||
{{ key }}
|
||||
</div>
|
||||
<div v-if="$slots.meta" class="version-history__meta">
|
||||
<slot name="meta" :version="String(key)" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="$slots.action && shouldRenderAction(String(key))" class="version-history__action">
|
||||
<slot name="action" :version="String(key)" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="markdown-body text-medium-emphasis" v-html="renderMarkdown(value)" />
|
||||
</section>
|
||||
</div>
|
||||
</VCardText>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.version-history {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.version-history__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.version-history__item {
|
||||
padding: 1.25rem 2rem;
|
||||
}
|
||||
|
||||
.version-history__item + .version-history__item {
|
||||
border-block-start: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
}
|
||||
|
||||
.version-history__top {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-areas: "main";
|
||||
gap: 0;
|
||||
align-items: center;
|
||||
margin-block-end: 0.5rem;
|
||||
}
|
||||
|
||||
.version-history__top--with-action {
|
||||
grid-template-columns: minmax(0, 1fr) max-content;
|
||||
grid-template-areas: "main action";
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.version-history__header {
|
||||
grid-area: main;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.version-history__version {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.version-history__meta {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.version-history__action {
|
||||
grid-area: action;
|
||||
align-self: center;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.markdown-body :deep(h1),
|
||||
.markdown-body :deep(h2),
|
||||
.markdown-body :deep(h3) {
|
||||
@@ -112,4 +189,28 @@ const props = defineProps({
|
||||
border-inline-start: 3px solid rgba(127, 127, 127, 0.4);
|
||||
color: rgba(127, 127, 127, 0.8);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.version-history {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.version-history__item {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.version-history__top--with-action {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.version-history__header {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.version-history__version {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,8 +4,14 @@ import { FilterRuleGroup } from '@/api/types'
|
||||
import { Handle, Position } from '@vue-flow/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { qualityOptions, resolutionOptions, effectOptions } from '@/api/constants'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||
|
||||
const { t } = useI18n()
|
||||
const userStore = useUserStore()
|
||||
const canAdmin = computed(() =>
|
||||
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'admin'),
|
||||
)
|
||||
|
||||
defineProps({
|
||||
id: {
|
||||
@@ -23,6 +29,8 @@ const filterRuleGroups = ref<FilterRuleGroup[]>([])
|
||||
|
||||
// 加载规则组
|
||||
async function queryFilterRuleGroups() {
|
||||
if (!canAdmin.value) return
|
||||
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/UserFilterRuleGroups')
|
||||
filterRuleGroups.value = result.data?.value ?? []
|
||||
|
||||
@@ -22,7 +22,7 @@ const storages = ref<StorageConf[]>([])
|
||||
|
||||
// 查询存储
|
||||
async function loadStorages() {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/Storages')
|
||||
const result: { [key: string]: any } = await api.get('system/setting/public/Storages')
|
||||
storages.value = result.data?.value ?? []
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,14 @@ import api from '@/api'
|
||||
import { NotificationConf } from '@/api/types'
|
||||
import { Handle, Position } from '@vue-flow/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||
|
||||
const { t } = useI18n()
|
||||
const userStore = useUserStore()
|
||||
const canAdmin = computed(() =>
|
||||
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'admin'),
|
||||
)
|
||||
|
||||
defineProps({
|
||||
id: {
|
||||
@@ -22,6 +28,8 @@ const notifications = ref<NotificationConf[]>([])
|
||||
|
||||
// 调用API查询通知渠道设置
|
||||
async function loadNotificationSetting() {
|
||||
if (!canAdmin.value) return
|
||||
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/Notifications')
|
||||
notifications.value = result.data?.value ?? []
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type ComputedRef,
|
||||
type Ref,
|
||||
} from 'vue'
|
||||
import type { UserPermissionKey } from '@/utils/permission'
|
||||
|
||||
// 声明全局变量类型
|
||||
declare global {
|
||||
@@ -29,6 +30,7 @@ export interface DynamicButtonMenuItem {
|
||||
titleParams?: Record<string, unknown>
|
||||
icon?: string
|
||||
color?: string
|
||||
permission?: UserPermissionKey
|
||||
action: () => void
|
||||
}
|
||||
|
||||
@@ -57,11 +59,12 @@ export function useDynamicButton(options: {
|
||||
icon: MaybeRefValue<string>
|
||||
onClick?: () => void
|
||||
menuItems?: MaybeRefValue<DynamicButtonMenuItem[] | undefined>
|
||||
permission?: UserPermissionKey
|
||||
show?: MaybeRefValue<boolean>
|
||||
autoRegister?: boolean // 是否自动注册,默认为true
|
||||
}) {
|
||||
// 提取配置
|
||||
const { icon, onClick, menuItems, show, autoRegister = true } = options
|
||||
const { icon, onClick, menuItems, permission, show, autoRegister = true } = options
|
||||
|
||||
// 动态按钮相关
|
||||
const registerDynamicButton = inject<((button: any) => void) | null>('registerDynamicButton', null)
|
||||
@@ -81,6 +84,7 @@ export function useDynamicButton(options: {
|
||||
return {
|
||||
icon: resolvedIcon.value,
|
||||
action: onClick || (() => {}),
|
||||
permission,
|
||||
show: resolvedShow.value,
|
||||
menuItems: buttonMenuItems && buttonMenuItems.length > 0 ? buttonMenuItems : undefined,
|
||||
}
|
||||
@@ -174,7 +178,7 @@ export function useDynamicButton(options: {
|
||||
cleanupDynamicButton()
|
||||
})
|
||||
|
||||
watch([resolvedIcon, resolvedShow, resolvedMenuItems], () => {
|
||||
watch([resolvedIcon, resolvedShow, resolvedMenuItems, () => permission], () => {
|
||||
if (!componentActive.value) return
|
||||
|
||||
setupDynamicButton()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
import { useTabStateRestore } from '@/composables/useStateRestore'
|
||||
import type { UserPermissionKey } from '@/utils/permission'
|
||||
|
||||
// 动态标签页相关类型
|
||||
interface DynamicHeaderTabButton {
|
||||
@@ -9,6 +10,7 @@ interface DynamicHeaderTabButton {
|
||||
size?: string
|
||||
class?: string
|
||||
action?: () => void
|
||||
permission?: UserPermissionKey
|
||||
show?: boolean | ComputedRef<boolean>
|
||||
loading?: boolean | ComputedRef<boolean>
|
||||
dataAttr?: string // 用于VMenu定位的data属性
|
||||
|
||||
20
src/composables/useLaunchLoading.ts
Normal file
20
src/composables/useLaunchLoading.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { readonly, ref } from 'vue'
|
||||
|
||||
function detectInitialLaunchLoading() {
|
||||
if (typeof document === 'undefined') return true
|
||||
|
||||
return document.documentElement.getAttribute('data-launch-loading') === 'true' || Boolean(document.getElementById('loading-bg'))
|
||||
}
|
||||
|
||||
// 启动屏的全局状态,供 Teleport 到 body 的组件避开 iOS PWA 启动阶段的固定层闪烁。
|
||||
const isLaunchLoading = ref(detectInitialLaunchLoading())
|
||||
|
||||
export function completeLaunchLoading() {
|
||||
isLaunchLoading.value = false
|
||||
}
|
||||
|
||||
export function useLaunchLoading() {
|
||||
return {
|
||||
isLaunchLoading: readonly(isLaunchLoading),
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { checkPWAStatus, isPWADisplayMode } from '@/@core/utils/navigator'
|
||||
import { checkPWAStatus, isMobileDevice, isPWADisplayMode } from '@/@core/utils/navigator'
|
||||
|
||||
// 全局PWA状态,确保只初始化一次
|
||||
const globalPwaStatus = ref<{
|
||||
@@ -34,11 +34,14 @@ async function initializePWAGlobally() {
|
||||
globalPwaStatus.value = await checkPWAStatus()
|
||||
} catch (error) {
|
||||
console.error('Failed to detect PWA status', error)
|
||||
const isStandaloneMode = isPWADisplayMode()
|
||||
|
||||
// 即使检测失败,也设置一个合理的默认值
|
||||
globalPwaStatus.value = {
|
||||
hasPWAFeatures: false,
|
||||
isStandaloneMode: isPWADisplayMode(),
|
||||
isPWAEnvironment: isPWADisplayMode(),
|
||||
isStandaloneMode,
|
||||
// iOS Safari 浏览器模式可能取不到 Service Worker 注册信息,但移动端仍应使用 App 交互。
|
||||
isPWAEnvironment: isStandaloneMode || isMobileDevice(),
|
||||
isFullPWA: false,
|
||||
}
|
||||
} finally {
|
||||
@@ -56,7 +59,8 @@ export function usePWA() {
|
||||
|
||||
// 基于新的PWA状态结构
|
||||
const pwaMode = computed(() => {
|
||||
return globalPwaStatus.value?.isPWAEnvironment ?? false
|
||||
// PWA 状态异步恢复前先用移动端特征兜底,避免 Safari 浏览器首屏阶段缺少移动端交互。
|
||||
return globalPwaStatus.value?.isPWAEnvironment ?? isMobileDevice()
|
||||
})
|
||||
|
||||
const appMode = computed(() => {
|
||||
|
||||
@@ -85,7 +85,10 @@ export function usePullDownGesture(options: PullDownOptions = {}) {
|
||||
})
|
||||
|
||||
const indicatorTransform = computed(() => {
|
||||
return `translate(-50%, ${Math.min(60 + pullDistance.value - config.SHOW_INDICATOR, 70)}px)`
|
||||
// 顶部基准位置由布局 CSS 负责,这里只让指示器跟随下拉手势轻微移动。
|
||||
const followOffset = Math.min(Math.max(pullDistance.value - config.SHOW_INDICATOR, 0), 16)
|
||||
|
||||
return `translate3d(-50%, ${followOffset}px, 0)`
|
||||
})
|
||||
|
||||
// 弹窗检测函数
|
||||
|
||||
@@ -1619,7 +1619,7 @@ export function useSetupWizard() {
|
||||
// 加载存储设置
|
||||
async function loadStorageSettings() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/Directories')
|
||||
const result: { [key: string]: any } = await api.get('system/setting/public/Directories')
|
||||
if (result.success && result.data?.value && result.data.value.length > 0) {
|
||||
const directory = result.data.value[0]
|
||||
wizardData.value.storage.downloadPath = directory.download_path || ''
|
||||
|
||||
@@ -7,6 +7,7 @@ import { themeManager } from '@/utils/themeManager'
|
||||
|
||||
export const THEME_CUSTOMIZER_STORAGE_KEY = 'moviepilot-theme-customizer'
|
||||
export const THEME_CUSTOMIZER_CHANGE_EVENT = 'moviepilot-theme-customizer-change'
|
||||
export const THEME_CUSTOMIZER_OPEN_EVENT = 'moviepilot-theme-customizer-open'
|
||||
|
||||
export const themeCustomizerPrimaryColors = [
|
||||
{ name: 'Purple', value: '#9155FD' },
|
||||
@@ -23,9 +24,37 @@ export const themeCustomizerPrimaryColors = [
|
||||
{ name: 'Slate', value: '#607D8B' },
|
||||
] as const
|
||||
|
||||
export const themeCustomizerShadowLevels = [
|
||||
'0',
|
||||
'1',
|
||||
'2',
|
||||
'3',
|
||||
'4',
|
||||
'5',
|
||||
'6',
|
||||
'7',
|
||||
'8',
|
||||
'9',
|
||||
'10',
|
||||
'11',
|
||||
'12',
|
||||
'13',
|
||||
'14',
|
||||
'15',
|
||||
'16',
|
||||
'17',
|
||||
'18',
|
||||
'19',
|
||||
'20',
|
||||
'21',
|
||||
'22',
|
||||
'23',
|
||||
'24',
|
||||
] as const
|
||||
|
||||
export type ThemeCustomizerLayout = 'collapsed' | 'horizontal' | 'vertical'
|
||||
export type ThemeCustomizerRadius = 'default' | 'extra' | 'huge' | 'large' | 'small'
|
||||
export type ThemeCustomizerShadow = 'none' | 'low' | 'medium' | 'high'
|
||||
export type ThemeCustomizerRadius = 'default' | 'extra' | 'large' | 'none' | 'small'
|
||||
export type ThemeCustomizerShadow = (typeof themeCustomizerShadowLevels)[number]
|
||||
export type ThemeCustomizerSkin = 'bordered' | 'default'
|
||||
export type ThemeCustomizerTheme = 'auto' | 'dark' | 'light' | 'purple' | 'transparent'
|
||||
|
||||
@@ -43,10 +72,16 @@ type VuetifyThemeApi = ReturnType<typeof useTheme>
|
||||
|
||||
const defaultPrimaryColor = themeCustomizerPrimaryColors[0].value
|
||||
const validLayouts: ThemeCustomizerLayout[] = ['vertical', 'collapsed', 'horizontal']
|
||||
const validRadii: ThemeCustomizerRadius[] = ['small', 'default', 'large', 'extra', 'huge']
|
||||
const validShadows: ThemeCustomizerShadow[] = ['none', 'low', 'medium', 'high']
|
||||
const validRadii: ThemeCustomizerRadius[] = ['none', 'small', 'default', 'large', 'extra']
|
||||
const validShadows: readonly ThemeCustomizerShadow[] = themeCustomizerShadowLevels
|
||||
const validSkins: ThemeCustomizerSkin[] = ['default', 'bordered']
|
||||
const validThemes: ThemeCustomizerTheme[] = ['auto', 'light', 'dark', 'purple', 'transparent']
|
||||
const legacyShadowMap: Record<string, ThemeCustomizerShadow> = {
|
||||
high: '24',
|
||||
low: '6',
|
||||
medium: '12',
|
||||
none: '0',
|
||||
}
|
||||
|
||||
let themeApplyVersion = 0
|
||||
|
||||
@@ -72,27 +107,35 @@ function getDefaultThemeCustomizerSettings(): ThemeCustomizerSettings {
|
||||
primaryColor: defaultPrimaryColor,
|
||||
radius: 'default',
|
||||
semiDarkMenu: false,
|
||||
shadow: 'none',
|
||||
shadow: '0',
|
||||
skin: 'default',
|
||||
theme: readStoredThemePreference(),
|
||||
}
|
||||
}
|
||||
|
||||
/** 将旧版语义阴影档位迁移到 Vuetify elevation 数值档位。 */
|
||||
function normalizeThemeCustomizerShadow(shadow: unknown): ThemeCustomizerShadow {
|
||||
if (validShadows.includes(shadow as ThemeCustomizerShadow)) return shadow as ThemeCustomizerShadow
|
||||
if (typeof shadow === 'string' && legacyShadowMap[shadow]) return legacyShadowMap[shadow]
|
||||
|
||||
return getDefaultThemeCustomizerSettings().shadow
|
||||
}
|
||||
|
||||
function normalizeThemeCustomizerSettings(settings: Partial<ThemeCustomizerSettings>): ThemeCustomizerSettings {
|
||||
const fallback = getDefaultThemeCustomizerSettings()
|
||||
const storedRadius = settings.radius as string | undefined
|
||||
const radius = storedRadius === 'huge' ? 'extra' : storedRadius
|
||||
|
||||
return {
|
||||
layout: validLayouts.includes(settings.layout as ThemeCustomizerLayout)
|
||||
? (settings.layout as ThemeCustomizerLayout)
|
||||
: fallback.layout,
|
||||
primaryColor: isHexColor(settings.primaryColor) ? settings.primaryColor.toUpperCase() : fallback.primaryColor,
|
||||
radius: validRadii.includes(settings.radius as ThemeCustomizerRadius)
|
||||
? (settings.radius as ThemeCustomizerRadius)
|
||||
radius: validRadii.includes(radius as ThemeCustomizerRadius)
|
||||
? (radius as ThemeCustomizerRadius)
|
||||
: fallback.radius,
|
||||
semiDarkMenu: typeof settings.semiDarkMenu === 'boolean' ? settings.semiDarkMenu : fallback.semiDarkMenu,
|
||||
shadow: validShadows.includes(settings.shadow as ThemeCustomizerShadow)
|
||||
? (settings.shadow as ThemeCustomizerShadow)
|
||||
: fallback.shadow,
|
||||
shadow: normalizeThemeCustomizerShadow(settings.shadow),
|
||||
skin: validSkins.includes(settings.skin as ThemeCustomizerSkin)
|
||||
? (settings.skin as ThemeCustomizerSkin)
|
||||
: fallback.skin,
|
||||
@@ -246,7 +289,7 @@ export function isDefaultThemeCustomizerSettings(settings: ThemeCustomizerSettin
|
||||
primaryColor: defaultPrimaryColor,
|
||||
radius: 'default',
|
||||
semiDarkMenu: false,
|
||||
shadow: 'none',
|
||||
shadow: '0',
|
||||
skin: 'default',
|
||||
theme: 'auto',
|
||||
})
|
||||
@@ -323,7 +366,7 @@ export function useThemeCustomizer() {
|
||||
primaryColor: defaultPrimaryColor,
|
||||
radius: 'default',
|
||||
semiDarkMenu: false,
|
||||
shadow: 'none',
|
||||
shadow: '0',
|
||||
skin: 'default',
|
||||
theme: 'auto',
|
||||
})
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
<script lang="ts" setup>
|
||||
const route = useRoute()
|
||||
|
||||
// 空白布局用于登录、初始化与 404,复用全局页面动效保持切换手感一致。
|
||||
const routeTransitionKey = computed(() => route.fullPath)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="layout-wrapper layout-blank">
|
||||
<RouterView />
|
||||
<RouterView v-slot="{ Component }">
|
||||
<transition name="mp-page" mode="out-in" appear>
|
||||
<div :key="routeTransitionKey" class="mp-page-route">
|
||||
<component :is="Component" />
|
||||
</div>
|
||||
</transition>
|
||||
</RouterView>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -9,15 +9,23 @@ import ShortcutBar from '@/layouts/components/ShortcutBar.vue'
|
||||
import UserProfile from '@/layouts/components/UserProfile.vue'
|
||||
import QuickAccess from '@/layouts/components/QuickAccess.vue'
|
||||
import HeaderTab from '@/layouts/components/HeaderTab.vue'
|
||||
import { usePluginSidebarNavStore, useUserStore } from '@/stores'
|
||||
import AgentAssistantWidget from '@/components/AgentAssistantWidget.vue'
|
||||
import ThemeCustomizer from '@/components/ThemeCustomizer.vue'
|
||||
import { useGlobalSettingsStore, usePluginSidebarNavStore, useUserStore } from '@/stores'
|
||||
import { getNavMenus } from '@/router/i18n-menu'
|
||||
import { filterPluginSidebarNavEntries } from '@/utils/pluginSidebarNav'
|
||||
import { NavMenu } from '@/@layouts/types'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { filterMenusByPermission } from '@/utils/permission'
|
||||
import { onUnreadMessage } from '@/utils/badge'
|
||||
import {
|
||||
buildUserPermissionContext,
|
||||
filterItemsByPermission,
|
||||
filterMenusByPermission,
|
||||
hasItemPermission,
|
||||
hasPermission,
|
||||
type UserPermissionKey,
|
||||
} from '@/utils/permission'
|
||||
import { usePullDownGesture } from '@/composables/usePullDownGesture'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import OfflinePage from '@/layouts/components/OfflinePage.vue'
|
||||
@@ -25,6 +33,7 @@ import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus'
|
||||
import {
|
||||
readThemeCustomizerSettings,
|
||||
THEME_CUSTOMIZER_CHANGE_EVENT,
|
||||
THEME_CUSTOMIZER_OPEN_EVENT,
|
||||
type ThemeCustomizerSettings,
|
||||
} from '@/composables/useThemeCustomizer'
|
||||
import logo from '@images/logo.svg?raw'
|
||||
@@ -36,22 +45,19 @@ const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const themeLayout = ref(readThemeCustomizerSettings().layout)
|
||||
const showThemeCustomizer = ref(false)
|
||||
|
||||
// 用户 Store
|
||||
const userStore = useUserStore()
|
||||
const pluginSidebarNavStore = usePluginSidebarNavStore()
|
||||
|
||||
// 响应式的超级用户状态
|
||||
const superUser = computed(() => userStore.superUser)
|
||||
|
||||
// ShortcutBar 引用
|
||||
const shortcutBarRef = ref<InstanceType<typeof ShortcutBar> | null>(null)
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
// 获取用户权限信息
|
||||
const userPermissions = computed(() => ({
|
||||
is_superuser: userStore.superUser,
|
||||
...userStore.permissions,
|
||||
}))
|
||||
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
|
||||
const canAdmin = computed(() => hasPermission(userPermissions.value, 'admin'))
|
||||
const showAgentAssistant = computed(
|
||||
() => globalSettingsStore.get('AI_AGENT_ENABLE') === true && globalSettingsStore.get('AI_AGENT_HIDE_ENTRY') !== true,
|
||||
)
|
||||
|
||||
// 开始菜单项
|
||||
const startMenus = ref<NavMenu[]>([])
|
||||
@@ -84,14 +90,14 @@ const horizontalNavGroups = computed(() =>
|
||||
)
|
||||
|
||||
const navbarExtraHeight = computed(() => {
|
||||
const dynamicTabHeight = showDynamicHeaderTab.value ? 2.5 : 0
|
||||
const dynamicTabHeight = showDynamicHeaderTab.value ? 2.75 : 0
|
||||
const horizontalNavHeight = showHorizontalThemeNav.value ? 3.25 : 0
|
||||
|
||||
return `${dynamicTabHeight + horizontalNavHeight}rem`
|
||||
})
|
||||
|
||||
const mainContentPaddingTop = computed(() => {
|
||||
const dynamicTabPadding = showDynamicHeaderTab.value ? 3 : 0
|
||||
const dynamicTabPadding = showDynamicHeaderTab.value ? 3.25 : 0
|
||||
const horizontalNavPadding = showHorizontalThemeNav.value ? 3.5 : 0
|
||||
|
||||
return `${dynamicTabPadding + horizontalNavPadding}rem`
|
||||
@@ -112,6 +118,7 @@ interface DynamicHeaderTabButton {
|
||||
size?: string
|
||||
class?: string
|
||||
action?: () => void
|
||||
permission?: UserPermissionKey
|
||||
show?: boolean | ComputedRef<boolean>
|
||||
loading?: boolean | ComputedRef<boolean>
|
||||
dataAttr?: string
|
||||
@@ -196,10 +203,19 @@ const hasDynamicHeaderTab = computed(() => {
|
||||
// 水平布局下动态标签页会并入顶部导航三级菜单,不再额外显示标签页栏。
|
||||
const showDynamicHeaderTab = computed(() => hasDynamicHeaderTab.value && !showHorizontalThemeNav.value)
|
||||
|
||||
const visibleHorizontalHeaderButtons = computed(() => {
|
||||
if (!showHorizontalThemeNav.value || !hasDynamicHeaderTab.value) return []
|
||||
const visibleDynamicHeaderButtons = computed(() => {
|
||||
if (!hasDynamicHeaderTab.value) return []
|
||||
|
||||
return (dynamicHeaderTab.value?.appendButtons ?? []).filter(button => resolveMaybeRefValue(button.show, true) !== false)
|
||||
const visibleButtons = (dynamicHeaderTab.value?.appendButtons ?? []).filter(
|
||||
button => resolveMaybeRefValue(button.show, true) !== false,
|
||||
)
|
||||
return filterItemsByPermission(visibleButtons, userPermissions.value)
|
||||
})
|
||||
|
||||
const visibleHorizontalHeaderButtons = computed(() => {
|
||||
if (!showHorizontalThemeNav.value) return []
|
||||
|
||||
return visibleDynamicHeaderButtons.value
|
||||
})
|
||||
|
||||
// 在组件销毁时清理
|
||||
@@ -227,7 +243,7 @@ const canUsePullGesture = () => {
|
||||
// 检查是否在dashboard页面
|
||||
const isDashboard = route.path === '/dashboard' || route.path === '/'
|
||||
// 检查是否是管理员
|
||||
const isAdmin = superUser.value
|
||||
const isAdmin = canAdmin.value
|
||||
// 检查插件快速访问面板是否已显示
|
||||
const quickAccessOpen = showPluginQuickAccess.value
|
||||
// 检查是否离线
|
||||
@@ -271,6 +287,10 @@ function handleThemeCustomizerChange(event: Event) {
|
||||
themeLayout.value = (event as CustomEvent<ThemeCustomizerSettings>).detail.layout
|
||||
}
|
||||
|
||||
function handleThemeCustomizerOpen() {
|
||||
showThemeCustomizer.value = true
|
||||
}
|
||||
|
||||
function isHorizontalNavActive(item: NavMenu) {
|
||||
const targetPath = normalizeMenuPath(item.to)
|
||||
if (!targetPath) return false
|
||||
@@ -312,7 +332,7 @@ function closeHorizontalNavGroup() {
|
||||
}
|
||||
|
||||
function resolveMaybeRefValue<T>(value: T | ComputedRef<T> | undefined, fallback: T): T {
|
||||
return isRef(value) ? value.value : value ?? fallback
|
||||
return isRef(value) ? value.value : (value ?? fallback)
|
||||
}
|
||||
|
||||
function resolveHeaderButtonColor(button: DynamicHeaderTabButton) {
|
||||
@@ -323,6 +343,12 @@ function resolveHeaderButtonLoading(button: DynamicHeaderTabButton) {
|
||||
return resolveMaybeRefValue(button.loading, false)
|
||||
}
|
||||
|
||||
function handleHeaderButtonClick(button: DynamicHeaderTabButton) {
|
||||
if (!hasItemPermission(button, userPermissions.value)) return
|
||||
|
||||
button.action?.()
|
||||
}
|
||||
|
||||
function getHorizontalTabIcon(tab: DynamicHeaderTabItem) {
|
||||
const icon = tab.icon?.trim()
|
||||
|
||||
@@ -364,18 +390,6 @@ function applyPendingHorizontalTab() {
|
||||
pendingHorizontalTab.value = null
|
||||
}
|
||||
|
||||
// 处理未读消息事件
|
||||
function handleUnreadMessage(count: number) {
|
||||
if (superUser.value && count > 0) {
|
||||
// 延迟一点时间确保组件已渲染
|
||||
setTimeout(() => {
|
||||
if (shortcutBarRef.value && typeof shortcutBarRef.value.openMessageDialog === 'function') {
|
||||
shortcutBarRef.value.openMessageDialog()
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭插件快速访问
|
||||
function handleClosePluginQuickAccess() {
|
||||
showPluginQuickAccess.value = false
|
||||
@@ -414,6 +428,10 @@ function appendPluginSidebarMenus() {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 主题定制器由布局统一承载,监听需要尽早注册,避免异步加载菜单期间丢失打开事件。
|
||||
window.addEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange)
|
||||
window.addEventListener(THEME_CUSTOMIZER_OPEN_EVENT, handleThemeCustomizerOpen)
|
||||
|
||||
// 获取菜单列表
|
||||
startMenus.value = getMenuList(t('menu.start'))
|
||||
discoveryMenus.value = getMenuList(t('menu.discovery'))
|
||||
@@ -424,20 +442,15 @@ onMounted(async () => {
|
||||
await pluginSidebarNavStore.ensureSidebarNav()
|
||||
appendPluginSidebarMenus()
|
||||
|
||||
// 监听全局未读消息事件
|
||||
const unsubscribe = onUnreadMessage(handleUnreadMessage)
|
||||
|
||||
// 监听Service Worker消息
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.addEventListener('message', handleServiceWorkerMessage)
|
||||
}
|
||||
|
||||
window.addEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange)
|
||||
|
||||
// 组件卸载时清理监听
|
||||
onBeforeUnmount(() => {
|
||||
unsubscribe()
|
||||
window.removeEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange)
|
||||
window.removeEventListener(THEME_CUSTOMIZER_OPEN_EVENT, handleThemeCustomizerOpen)
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.removeEventListener('message', handleServiceWorkerMessage)
|
||||
}
|
||||
@@ -454,6 +467,7 @@ onMounted(async () => {
|
||||
v-if="appMode && showPullIndicator"
|
||||
class="pull-indicator"
|
||||
:style="{
|
||||
'--pull-indicator-navbar-extra-height': navbarExtraHeight,
|
||||
opacity: indicatorOpacity,
|
||||
transform: indicatorTransform,
|
||||
}"
|
||||
@@ -477,10 +491,10 @@ onMounted(async () => {
|
||||
<!-- 👉 Navbar -->
|
||||
<template #navbar="{ toggleVerticalOverlayNavActive }">
|
||||
<div
|
||||
class="theme-navbar-row d-flex h-14 align-center mx-1"
|
||||
class="theme-navbar-row d-flex h-16 align-center mx-1"
|
||||
:class="{ 'theme-navbar-row--horizontal': showHorizontalThemeNav }"
|
||||
>
|
||||
<RouterLink v-if="showHorizontalThemeNav" to="/dashboard" class="theme-horizontal-logo">
|
||||
<RouterLink v-if="showHorizontalThemeNav" :to="canAdmin ? '/dashboard' : '/apps'" class="theme-horizontal-logo">
|
||||
<span class="theme-horizontal-logo__mark" v-html="logo" />
|
||||
<span class="theme-horizontal-logo__text">MOVIEPILOT</span>
|
||||
</RouterLink>
|
||||
@@ -503,7 +517,7 @@ onMounted(async () => {
|
||||
<!-- 👉 Horizontal Search Icon -->
|
||||
<SearchBar v-if="showHorizontalThemeNav" icon-only />
|
||||
<!-- 👉 Shortcuts -->
|
||||
<ShortcutBar v-if="superUser" ref="shortcutBarRef" />
|
||||
<ShortcutBar v-if="canAdmin" />
|
||||
<!-- 👉 Notification -->
|
||||
<UserNofification />
|
||||
<!-- 👉 UserProfile -->
|
||||
@@ -597,7 +611,7 @@ onMounted(async () => {
|
||||
:class="button.class || 'settings-icon-button'"
|
||||
:loading="resolveHeaderButtonLoading(button)"
|
||||
:data-menu-activator="button.dataAttr"
|
||||
@click="button.action"
|
||||
@click="handleHeaderButtonClick(button)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -650,17 +664,16 @@ onMounted(async () => {
|
||||
@update:model-value="handleTabChange"
|
||||
>
|
||||
<template #append>
|
||||
<template v-for="button in dynamicHeaderTab!.appendButtons" :key="button.icon">
|
||||
<template v-for="button in visibleDynamicHeaderButtons" :key="button.icon">
|
||||
<VBtn
|
||||
v-if="typeof button.show === 'boolean' ? button.show !== false : (button.show as any)?.value !== false"
|
||||
:icon="button.icon"
|
||||
:variant="button.variant || 'text'"
|
||||
:color="typeof button.color === 'string' ? button.color : (button.color as any)?.value || 'gray'"
|
||||
:color="resolveHeaderButtonColor(button)"
|
||||
:size="button.size || 'default'"
|
||||
:class="button.class || 'settings-icon-button'"
|
||||
:loading="typeof button.loading === 'boolean' ? button.loading : (button.loading as any)?.value || false"
|
||||
:loading="resolveHeaderButtonLoading(button)"
|
||||
:data-menu-activator="button.dataAttr"
|
||||
@click="button.action"
|
||||
@click="handleHeaderButtonClick(button)"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
@@ -694,9 +707,17 @@ onMounted(async () => {
|
||||
@close="handleClosePluginQuickAccess"
|
||||
@plugin-click="handlePluginClick"
|
||||
/>
|
||||
|
||||
<!-- 👉 Theme Customizer -->
|
||||
<ThemeCustomizer v-if="showThemeCustomizer" @close="showThemeCustomizer = false" />
|
||||
|
||||
<!-- 👉 Agent Assistant -->
|
||||
<AgentAssistantWidget v-if="showAgentAssistant" />
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* stylelint-disable selector-pseudo-class-no-unknown */
|
||||
|
||||
.main-content-wrapper {
|
||||
backface-visibility: hidden;
|
||||
block-size: 100%;
|
||||
@@ -710,6 +731,10 @@ onMounted(async () => {
|
||||
margin-inline: 0 !important;
|
||||
}
|
||||
|
||||
:deep(.layout-dynamic-header-tab) {
|
||||
padding-block-end: 0.25rem;
|
||||
}
|
||||
|
||||
.theme-horizontal-logo {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
@@ -772,10 +797,10 @@ onMounted(async () => {
|
||||
|
||||
.theme-horizontal-nav {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
align-items: center;
|
||||
block-size: 3.25rem;
|
||||
gap: 0.25rem;
|
||||
overflow-x: auto;
|
||||
padding-block: 0.25rem 0.5rem;
|
||||
padding-inline: 0.5rem;
|
||||
scrollbar-width: none;
|
||||
@@ -804,6 +829,7 @@ onMounted(async () => {
|
||||
|
||||
.pull-indicator {
|
||||
position: fixed;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -811,12 +837,19 @@ onMounted(async () => {
|
||||
border-radius: 50%;
|
||||
backdrop-filter: blur(20px);
|
||||
background: rgba(var(--v-theme-surface), 0.3);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 10%), 0 1px 3px rgba(0, 0, 0, 6%);
|
||||
inset-block-start: 80px;
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 10%),
|
||||
0 1px 3px rgba(0, 0, 0, 6%);
|
||||
inset-block-start: calc(
|
||||
env(safe-area-inset-top, 0px) + 4rem + var(--pull-indicator-navbar-extra-height, 0rem) + 0.75rem
|
||||
);
|
||||
inset-inline-start: 50%;
|
||||
pointer-events: none;
|
||||
transform: translateX(-50%);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transform: translate3d(-50%, 0, 0);
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
.indicator-icon {
|
||||
@@ -836,7 +869,9 @@ html[class*='mica'] .pull-indicator,
|
||||
html[class*='acrylic'] .pull-indicator {
|
||||
border: 1px solid rgba(255, 255, 255, 20%);
|
||||
background: rgba(255, 255, 255, 95%);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 12%), 0 4px 16px rgba(0, 0, 0, 8%);
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0, 0, 0, 12%),
|
||||
0 4px 16px rgba(0, 0, 0, 8%);
|
||||
}
|
||||
|
||||
html[class*='transparent'] .indicator-icon,
|
||||
@@ -850,7 +885,9 @@ html[data-theme='dark'][class*='mica'] .pull-indicator,
|
||||
html[data-theme='dark'][class*='acrylic'] .pull-indicator {
|
||||
border: 1px solid rgba(255, 255, 255, 10%);
|
||||
background: rgba(18, 18, 18, 95%);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 30%), 0 4px 16px rgba(0, 0, 0, 20%);
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0, 0, 0, 30%),
|
||||
0 4px 16px rgba(0, 0, 0, 20%);
|
||||
}
|
||||
|
||||
html[data-theme='dark'][class*='transparent'] .indicator-icon,
|
||||
|
||||
@@ -4,12 +4,13 @@ import { useDisplay } from 'vuetify'
|
||||
import { NavMenu } from '@/@layouts/types'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { filterMenusByPermission } from '@/utils/permission'
|
||||
import { buildUserPermissionContext, filterItemsByPermission, filterMenusByPermission, hasItemPermission } from '@/utils/permission'
|
||||
import { useLaunchLoading } from '@/composables/useLaunchLoading'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import type { DynamicButtonMenuItem } from '@/composables/useDynamicButton'
|
||||
|
||||
// 是否显示的输入参数
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
showNav: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
@@ -19,6 +20,7 @@ defineProps({
|
||||
const display = useDisplay()
|
||||
// PWA模式检测
|
||||
const { appMode } = usePWA()
|
||||
const { isLaunchLoading } = useLaunchLoading()
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
// 判断当前是否为英文环境
|
||||
@@ -42,10 +44,7 @@ const userPermissions = computed(() => {
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
is_superuser: userStore.superUser,
|
||||
...userStore.permissions,
|
||||
}
|
||||
return buildUserPermissionContext(userStore.superUser, userStore.permissions)
|
||||
})
|
||||
|
||||
// 获取导航菜单
|
||||
@@ -119,6 +118,7 @@ watch(
|
||||
interface DynamicButton {
|
||||
icon: string
|
||||
action: () => void
|
||||
permission?: DynamicButtonMenuItem['permission']
|
||||
show: boolean
|
||||
routePath?: string // 添加路径属性,用于标识哪个路由注册的
|
||||
menuItems?: DynamicButtonMenuItem[]
|
||||
@@ -166,12 +166,19 @@ const showDynamicButton = computed(() => {
|
||||
return (
|
||||
dynamicButton.value &&
|
||||
dynamicButton.value.show &&
|
||||
hasItemPermission(dynamicButton.value, userPermissions.value) &&
|
||||
// 确保只在注册的路由路径下显示按钮
|
||||
(!dynamicButton.value.routePath || dynamicButton.value.routePath === route.path)
|
||||
)
|
||||
})
|
||||
|
||||
const hasDynamicButtonMenu = computed(() => Boolean(dynamicButton.value?.menuItems?.length))
|
||||
const visibleDynamicButtonMenuItems = computed(() => {
|
||||
return filterItemsByPermission(dynamicButton.value?.menuItems ?? [], userPermissions.value)
|
||||
})
|
||||
|
||||
const hasDynamicButtonMenu = computed(() => visibleDynamicButtonMenuItems.value.length > 0)
|
||||
const shouldRenderFooterNav = computed(() => appMode.value && props.showNav)
|
||||
const shouldRevealFooterNav = computed(() => shouldRenderFooterNav.value && !isLaunchLoading.value)
|
||||
|
||||
const legacyDynamicMenuTitleKeyMap: Record<string, string> = {
|
||||
'components.subscribeHistory.title': 'dialog.subscribeHistory.title',
|
||||
@@ -194,17 +201,29 @@ function resolveDynamicMenuItemTitle(item: DynamicButtonMenuItem) {
|
||||
|
||||
return looksLikeI18nKey ? t(normalizedTitleKey, item.titleParams as any) : item.title
|
||||
}
|
||||
|
||||
function handleDynamicButtonClick() {
|
||||
if (!dynamicButton.value || !hasItemPermission(dynamicButton.value, userPermissions.value)) return
|
||||
|
||||
dynamicButton.value.action()
|
||||
}
|
||||
|
||||
function handleDynamicMenuItemClick(item: DynamicButtonMenuItem) {
|
||||
if (!hasItemPermission(item, userPermissions.value)) return
|
||||
|
||||
item.action()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport v-if="appMode && showNav" to="body">
|
||||
<div class="footer-nav-container">
|
||||
<Teleport v-if="shouldRenderFooterNav" to="body">
|
||||
<div v-show="shouldRevealFooterNav" class="footer-nav-container">
|
||||
<TransitionGroup name="footer-nav" tag="div" class="footer-nav-group">
|
||||
<VCard key="main-nav" elevation="3" class="footer-nav-card border" rounded="pill">
|
||||
<VCardText class="footer-card-content">
|
||||
<!-- 添加指示器 -->
|
||||
<div ref="indicator" class="nav-indicator"></div>
|
||||
<VBtnToggle class="footer-btn-group" :mandatory="true" v-model="currentMenu">
|
||||
<VBtnToggle class="footer-btn-group" :mandatory="true" variant="plain" v-model="currentMenu">
|
||||
<!-- 遍历底部菜单项 -->
|
||||
<VBtn
|
||||
v-for="menu in footerMenus"
|
||||
@@ -257,7 +276,7 @@ function resolveDynamicMenuItemTitle(item: DynamicButtonMenuItem) {
|
||||
icon
|
||||
variant="text"
|
||||
:ripple="false"
|
||||
@click="!hasDynamicButtonMenu && dynamicButton?.action()"
|
||||
@click="!hasDynamicButtonMenu && handleDynamicButtonClick()"
|
||||
rounded="pill"
|
||||
class="footer-nav-btn"
|
||||
>
|
||||
@@ -270,10 +289,10 @@ function resolveDynamicMenuItemTitle(item: DynamicButtonMenuItem) {
|
||||
<VMenu v-if="hasDynamicButtonMenu" activator="parent" location="top end" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem
|
||||
v-for="(item, index) in dynamicButton?.menuItems"
|
||||
v-for="(item, index) in visibleDynamicButtonMenuItems"
|
||||
:key="item.titleKey || item.title || index"
|
||||
:base-color="item.color"
|
||||
@click="item.action()"
|
||||
@click="handleDynamicMenuItemClick(item)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon v-if="item.icon" :icon="item.icon" />
|
||||
@@ -324,6 +343,9 @@ function resolveDynamicMenuItemTitle(item: DynamicButtonMenuItem) {
|
||||
transition: all 0.5s cubic-bezier(0.25, 1, 0.5, 1);
|
||||
will-change: transform, max-inline-size, opacity;
|
||||
|
||||
--app-control-radius: var(--app-vuetify-rounded-pill);
|
||||
--app-surface-radius: var(--app-vuetify-rounded-pill);
|
||||
|
||||
// 透明主题下的特殊样式
|
||||
.v-theme--transparent & {
|
||||
backdrop-filter: blur(var(--transparent-blur-heavy, 16px));
|
||||
@@ -342,13 +364,19 @@ function resolveDynamicMenuItemTitle(item: DynamicButtonMenuItem) {
|
||||
padding-inline: 6px;
|
||||
}
|
||||
|
||||
.footer-btn-group {
|
||||
.footer-nav-card .footer-btn-group.v-btn-group {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
border: none;
|
||||
border-radius: 9999px !important;
|
||||
background-color: transparent;
|
||||
box-shadow: none !important;
|
||||
inline-size: 100%;
|
||||
|
||||
&:hover {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.footer-nav-btn {
|
||||
@@ -358,12 +386,15 @@ function resolveDynamicMenuItemTitle(item: DynamicButtonMenuItem) {
|
||||
flex-grow: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9999px !important;
|
||||
background-color: transparent;
|
||||
block-size: 48px;
|
||||
box-shadow: none !important;
|
||||
|
||||
&:hover,
|
||||
&.v-btn--active {
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.btn-content {
|
||||
|
||||
@@ -810,12 +810,6 @@ function handleBackdropClick(event: MouseEvent) {
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
:global(html.quick-access-scroll-locked),
|
||||
:global(html.quick-access-scroll-locked body) {
|
||||
overflow: hidden !important;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.plugin-item:hover {
|
||||
background: transparent;
|
||||
|
||||
@@ -37,30 +37,45 @@ const showIconOnly = computed(() => props.iconOnly || !display.mdAndUp.value)
|
||||
|
||||
<template>
|
||||
<!-- 小屏或水平导航右侧工具区:仅显示搜索图标。 -->
|
||||
<IconBtn v-if="showIconOnly" class="search-icon-trigger" @click="openSearchDialog">
|
||||
<IconBtn
|
||||
v-if="showIconOnly"
|
||||
class="search-icon-trigger"
|
||||
:aria-label="t('dialog.searchBar.openSearch')"
|
||||
@click="openSearchDialog"
|
||||
>
|
||||
<VIcon class="search-icon-trigger__icon" icon="mdi-magnify" />
|
||||
</IconBtn>
|
||||
|
||||
<!-- 中屏及以上:胶囊搜索触发栏 -->
|
||||
<div v-else class="search-trigger" @click="openSearchDialog">
|
||||
<VIcon icon="mdi-magnify" size="30" class="search-trigger-icon" />
|
||||
<span class="search-trigger-text">{{ t('common.search') }}</span>
|
||||
<kbd class="search-trigger-kbd">{{ metaKey }}</kbd>
|
||||
</div>
|
||||
<!-- 中屏及以上:与用户头像同尺寸的搜索入口。 -->
|
||||
<VTooltip v-else :text="`${t('dialog.searchBar.openSearch')} (${metaKey})`">
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<button
|
||||
v-bind="tooltipProps"
|
||||
class="search-trigger"
|
||||
type="button"
|
||||
:aria-label="t('dialog.searchBar.openSearch')"
|
||||
@click="openSearchDialog"
|
||||
>
|
||||
<VIcon icon="mdi-magnify" size="24" class="search-trigger-icon" />
|
||||
</button>
|
||||
</template>
|
||||
</VTooltip>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.search-trigger {
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
|
||||
border-radius: 999px;
|
||||
border-radius: 50%;
|
||||
background: rgba(var(--v-theme-surface), 0.44);
|
||||
block-size: 44px;
|
||||
block-size: 40px;
|
||||
color: rgba(var(--v-theme-on-surface), 0.72);
|
||||
cursor: pointer;
|
||||
gap: 12px;
|
||||
min-inline-size: 168px;
|
||||
padding-inline: 18px 10px;
|
||||
flex: 0 0 auto;
|
||||
inline-size: 40px;
|
||||
padding: 0;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
background-color 0.2s ease,
|
||||
@@ -76,29 +91,6 @@ const showIconOnly = computed(() => props.iconOnly || !display.mdAndUp.value)
|
||||
|
||||
.search-trigger-icon {
|
||||
flex-shrink: 0;
|
||||
color: rgba(var(--v-theme-on-surface), 0.72);
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.search-trigger-text {
|
||||
color: rgba(var(--v-theme-on-surface), 0.42);
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.search-trigger-kbd {
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||
border-radius: 8px;
|
||||
background-color: rgba(var(--v-theme-surface), 0.5);
|
||||
color: rgba(var(--v-theme-on-surface), 0.42);
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
margin-inline-start: 4px;
|
||||
padding-block: 6px;
|
||||
padding-inline: 8px;
|
||||
}
|
||||
|
||||
html[data-theme='transparent'] .search-trigger,
|
||||
|
||||
@@ -3,9 +3,13 @@ import type { Component } from 'vue'
|
||||
import { getQueryValue } from '@/@core/utils'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { buildUserPermissionContext, filterItemsByPermission, hasItemPermission, type PermissionProtectedItem } from '@/utils/permission'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
const userStore = useUserStore()
|
||||
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
|
||||
|
||||
// 快捷工具只在弹窗打开时使用,按需加载避免默认布局首屏带上所有 system 视图。
|
||||
const NameTestView = defineAsyncComponent(() => import('@/views/system/NameTestView.vue'))
|
||||
@@ -16,10 +20,9 @@ const WordsView = defineAsyncComponent(() => import('@/views/system/WordsView.vu
|
||||
const CacheView = defineAsyncComponent(() => import('@/views/system/CacheView.vue'))
|
||||
const AccountSettingService = defineAsyncComponent(() => import('@/views/system/ServiceView.vue'))
|
||||
const ShortcutLogDialog = defineAsyncComponent(() => import('@/components/dialog/ShortcutLogDialog.vue'))
|
||||
const ShortcutMessageDialog = defineAsyncComponent(() => import('@/components/dialog/ShortcutMessageDialog.vue'))
|
||||
const ShortcutToolDialog = defineAsyncComponent(() => import('@/components/dialog/ShortcutToolDialog.vue'))
|
||||
|
||||
type ShortcutItem = {
|
||||
type ShortcutItem = PermissionProtectedItem & {
|
||||
bodyClass?: string
|
||||
cardClass?: string
|
||||
component?: Component
|
||||
@@ -112,17 +115,14 @@ const shortcuts: ShortcutItem[] = [
|
||||
component: ModuleTestView,
|
||||
titleText: t('shortcut.system.subtitle'),
|
||||
},
|
||||
{
|
||||
title: t('shortcut.message.title'),
|
||||
subtitle: t('shortcut.message.subtitle'),
|
||||
icon: 'mdi-message',
|
||||
dialog: 'message',
|
||||
customDialog: ShortcutMessageDialog,
|
||||
},
|
||||
]
|
||||
].map(item => ({ ...item, permission: 'admin' }))
|
||||
|
||||
const visibleShortcuts = computed(() => filterItemsByPermission(shortcuts, userPermissions.value))
|
||||
|
||||
/** 打开快捷工具对应的共享弹窗。 */
|
||||
function openShortcutDialog(item: (typeof shortcuts)[number]) {
|
||||
if (!hasItemPermission(item, userPermissions.value)) return
|
||||
|
||||
appsMenu.value = false
|
||||
|
||||
if (item.customDialog) {
|
||||
@@ -148,21 +148,10 @@ function openShortcutDialog(item: (typeof shortcuts)[number]) {
|
||||
)
|
||||
}
|
||||
|
||||
/** 供外部调用的打开消息弹窗方法。 */
|
||||
function openMessageDialogFromExternal() {
|
||||
const messageShortcut = shortcuts.find(item => item.dialog === 'message')
|
||||
if (messageShortcut) openShortcutDialog(messageShortcut)
|
||||
}
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
openMessageDialog: openMessageDialogFromExternal,
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
const shortcut = getQueryValue('shortcut')
|
||||
if (shortcut) {
|
||||
const found = shortcuts.find(item => item.dialog === shortcut)
|
||||
const found = visibleShortcuts.value.find(item => item.dialog === shortcut)
|
||||
if (found) {
|
||||
openShortcutDialog(found)
|
||||
}
|
||||
@@ -202,10 +191,10 @@ onMounted(() => {
|
||||
<div class="pa-3">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<!-- 循环渲染快捷方式 -->
|
||||
<div v-for="(item, index) in shortcuts" :key="index">
|
||||
<div v-for="(item, index) in visibleShortcuts" :key="index">
|
||||
<VCard
|
||||
flat
|
||||
class="pa-2 d-flex align-center cursor-pointer transition-transform duration-300 hover:-translate-y-1 border h-full"
|
||||
class="pa-2 d-flex align-center cursor-pointer transition-transform duration-300 hover:-translate-y-1 border h-full w-100"
|
||||
hover
|
||||
@click="openShortcutDialog(item)"
|
||||
>
|
||||
|
||||
@@ -1,129 +1,765 @@
|
||||
<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 { SystemNotification } from '@/api/types'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
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 { useDelayedSSE } = useBackground()
|
||||
const $toast = useToast()
|
||||
const createConfirm = useConfirm()
|
||||
|
||||
// 是否有新消息
|
||||
const hasNewMessage = ref(false)
|
||||
const PAGE_SIZE = 20
|
||||
// 虚拟滚动的默认通知项高度,展开后的实际高度由 VVirtualScroll 的 itemRef 动态测量。
|
||||
const NOTIFICATION_ITEM_HEIGHT = 136
|
||||
const CLEAR_NOTIFICATION_ENDPOINTS = ['message/notification', 'message/notification/clear']
|
||||
const NOTIFICATION_CLEAR_BEFORE_STORAGE_KEY = 'moviepilot-notification-clear-before'
|
||||
|
||||
// 通知列表
|
||||
const notificationList = ref<SystemNotification[]>([])
|
||||
const MAX_NOTIFICATIONS = 100
|
||||
|
||||
// 弹窗
|
||||
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() {
|
||||
hasNewMessage.value = false
|
||||
// 标记所有消息为已读
|
||||
notificationList.value.forEach(item => {
|
||||
item.read = true
|
||||
})
|
||||
appsMenu.value = false
|
||||
void clearUnreadMessages()
|
||||
}
|
||||
|
||||
// 消息处理函数
|
||||
function handleMessage(event: MessageEvent) {
|
||||
if (event.data) {
|
||||
const noti: SystemNotification = JSON.parse(event.data)
|
||||
notificationList.value.unshift(noti)
|
||||
if (notificationList.value.length > MAX_NOTIFICATIONS) {
|
||||
notificationList.value.length = MAX_NOTIFICATIONS
|
||||
}
|
||||
hasNewMessage.value = true
|
||||
}
|
||||
/** 根据通知分类和业务类型选择列表图标。 */
|
||||
function getNotificationIcon(item: SystemNotification) {
|
||||
if (getNotificationKind(item) === 'plugin') return 'mdi-puzzle-outline'
|
||||
if (item.mtype === '资源下载') return 'mdi-download'
|
||||
if (item.mtype === '整理入库') return 'mdi-folder-check-outline'
|
||||
if (item.mtype === '订阅') return 'mdi-rss'
|
||||
if (item.mtype === '智能体') return 'lucide:bot'
|
||||
return getNotificationKind(item) === 'system' ? 'mdi-alert-circle-outline' : 'mdi-bell-outline'
|
||||
}
|
||||
|
||||
/** 根据通知分类和业务类型选择图标颜色。 */
|
||||
function getNotificationColor(item: SystemNotification) {
|
||||
if (getNotificationKind(item) === 'system') return 'error'
|
||||
if (getNotificationKind(item) === 'plugin') return 'warning'
|
||||
if (item.mtype === '资源下载') return 'info'
|
||||
if (item.mtype === '整理入库') return 'success'
|
||||
if (item.mtype === '订阅') return 'primary'
|
||||
return 'secondary'
|
||||
}
|
||||
|
||||
/** 判断通知是否有真实媒体图,决定是否使用媒体缩略图样式。 */
|
||||
function isMediaNotification(item: SystemNotification) {
|
||||
return Boolean(item.image)
|
||||
}
|
||||
|
||||
/** 按系统类消息和媒体消息生成带分组标题的虚拟列表数据。 */
|
||||
function 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(
|
||||
`${import.meta.env.VITE_API_BASE_URL}system/message`,
|
||||
`${import.meta.env.VITE_API_BASE_URL}system/message?role=notification`,
|
||||
handleMessage,
|
||||
'user-notification',
|
||||
3000,
|
||||
{
|
||||
backgroundCloseDelay: 5000,
|
||||
reconnectDelay: 3000,
|
||||
maxReconnectAttempts: 3
|
||||
}
|
||||
maxReconnectAttempts: 3,
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VMenu
|
||||
v-model="appsMenu"
|
||||
width="400"
|
||||
width="420"
|
||||
max-width="calc(100vw - 24px)"
|
||||
transition="scale-transition"
|
||||
close-on-content-click
|
||||
:close-on-content-click="false"
|
||||
class="notification-menu"
|
||||
scrim
|
||||
>
|
||||
<!-- Menu Activator -->
|
||||
<template #activator="{ props }">
|
||||
<VBadge v-if="hasNewMessage" dot color="error" :offset-x="5" :offset-y="5" v-bind="props">
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-bell-outline" />
|
||||
<VIcon icon="mdi-bell-outline" size="22" />
|
||||
</IconBtn>
|
||||
</VBadge>
|
||||
<IconBtn v-else v-bind="props">
|
||||
<VIcon icon="mdi-bell-outline" />
|
||||
<VIcon icon="mdi-bell-outline" size="22" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
<!-- Menu Content -->
|
||||
<VCard>
|
||||
|
||||
<VCard class="notification-panel">
|
||||
<VCardItem class="py-3">
|
||||
<VCardTitle>{{ t('notification.center') }}</VCardTitle>
|
||||
<template #append>
|
||||
<VTooltip :text="t('notification.markRead')">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn v-bind="props" @click="markAllAsRead">
|
||||
<VIcon icon="mdi-email-check-outline" size="20" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
<div class="notification-actions">
|
||||
<VTooltip :text="t('notification.clear')">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn
|
||||
v-bind="props"
|
||||
:disabled="notificationList.length === 0 || clearing"
|
||||
@click.stop="clearNotifications"
|
||||
>
|
||||
<VProgressCircular v-if="clearing" indeterminate size="18" width="2" />
|
||||
<VIcon v-else icon="mdi-trash-can-outline" size="20" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VTooltip :text="t('notification.markRead')">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn v-bind="props" :disabled="!hasUnreadNotifications" @click.stop="markAllAsRead">
|
||||
<VIcon icon="mdi-email-check-outline" size="20" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
</div>
|
||||
</template>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
|
||||
<div class="notification-list-container">
|
||||
<div v-if="notificationList.length > 0">
|
||||
<VListItem v-for="(item, i) in notificationList" :key="i" lines="two" class="mb-1">
|
||||
<template #prepend>
|
||||
<VAvatar rounded>
|
||||
<VIcon v-if="item.type === 'user'" icon="mdi-account-alert" size="large"></VIcon>
|
||||
<VIcon v-else-if="item.type === 'plugin'" icon="mdi-robot" size="large"></VIcon>
|
||||
<VIcon v-else icon="mdi-laptop" size="large"></VIcon>
|
||||
</VAvatar>
|
||||
</template>
|
||||
<div>
|
||||
<div class="text-body-1 text-high-emphasis break-words whitespace-break-spaces">
|
||||
{{ item.title }}
|
||||
</div>
|
||||
<div class="text-caption mt-1.5">
|
||||
{{ item.text }}
|
||||
</div>
|
||||
<div class="text-sm text-primary mt-1.5">
|
||||
{{ formatDateDifference(item.date) }}
|
||||
</div>
|
||||
<VInfiniteScroll
|
||||
mode="intersect"
|
||||
side="end"
|
||||
:items="notificationList"
|
||||
class="notification-list-scroll"
|
||||
@load="loadNotifications"
|
||||
>
|
||||
<template #loading>
|
||||
<div class="py-3 text-center text-caption text-medium-emphasis">
|
||||
{{ t('message.loadMore') }}
|
||||
</div>
|
||||
</VListItem>
|
||||
</div>
|
||||
<div v-else class="py-8 text-center">
|
||||
<VIcon icon="mdi-bell-sleep-outline" size="40" class="mb-3" />
|
||||
<div>{{ t('notification.empty') }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #empty>
|
||||
<div v-if="notificationList.length > 0" class="py-3 text-center text-caption text-medium-emphasis">
|
||||
{{ t('message.noMoreData') }}
|
||||
</div>
|
||||
<div v-else class="notification-empty">
|
||||
<div class="notification-empty__icon">
|
||||
<VIcon icon="mdi-bell-sleep-outline" size="22" />
|
||||
</div>
|
||||
<div>{{ t('notification.empty') }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<VVirtualScroll
|
||||
v-if="notificationList.length > 0"
|
||||
renderless
|
||||
:items="notificationDisplayList"
|
||||
:item-height="NOTIFICATION_ITEM_HEIGHT"
|
||||
>
|
||||
<template #default="{ item, itemRef }">
|
||||
<div
|
||||
:ref="itemRef"
|
||||
:key="item.key"
|
||||
class="notification-virtual-item"
|
||||
:class="{ 'notification-virtual-item--section': item.kind === 'section' }"
|
||||
>
|
||||
<div v-if="item.kind === 'section'" class="notification-section-heading">
|
||||
<span class="notification-section-heading__title">{{ item.title }}</span>
|
||||
<span class="notification-section-heading__count">{{ item.count }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="notification-row"
|
||||
:class="{
|
||||
'notification-row--unread': item.notification.read === false,
|
||||
'notification-row--media': isMediaNotification(item.notification),
|
||||
}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-expanded="item.notification.text ? isNotificationExpanded(item.notification) : undefined"
|
||||
@click="toggleNotificationExpanded(item.notification)"
|
||||
@keydown.enter.prevent="toggleNotificationExpanded(item.notification)"
|
||||
@keydown.space.prevent="toggleNotificationExpanded(item.notification)"
|
||||
>
|
||||
<div v-if="item.notification.image" class="notification-media">
|
||||
<VImg
|
||||
v-if="item.notification.image"
|
||||
:src="item.notification.image"
|
||||
cover
|
||||
class="notification-media__image"
|
||||
>
|
||||
<template #placeholder>
|
||||
<VSkeletonLoader class="h-100 w-100" />
|
||||
</template>
|
||||
</VImg>
|
||||
</div>
|
||||
<div v-else class="notification-icon" :class="`text-${getNotificationColor(item.notification)}`">
|
||||
<VIcon :icon="getNotificationIcon(item.notification)" size="22" />
|
||||
</div>
|
||||
|
||||
<div class="notification-content">
|
||||
<div class="notification-title-row">
|
||||
<span class="notification-title">{{ item.notification.title }}</span>
|
||||
<span v-if="item.notification.read === false" class="notification-unread-dot" />
|
||||
</div>
|
||||
<div
|
||||
v-if="item.notification.text"
|
||||
class="notification-text"
|
||||
:class="{ 'notification-text--expanded': isNotificationExpanded(item.notification) }"
|
||||
>
|
||||
{{ item.notification.text }}
|
||||
</div>
|
||||
<div class="notification-meta">
|
||||
<span v-if="item.notification.mtype" class="notification-type">{{ item.notification.mtype }}</span>
|
||||
<span>{{ formatDateDifference(getNotificationTime(item.notification)) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</VVirtualScroll>
|
||||
</VInfiniteScroll>
|
||||
</div>
|
||||
</VCard>
|
||||
</VMenu>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.notification-panel {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.notification-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.notification-list-container {
|
||||
max-block-size: 50vh;
|
||||
overflow-y: auto;
|
||||
overflow: hidden;
|
||||
max-block-size: min(560px, 62vh);
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.notification-list-scroll {
|
||||
max-block-size: min(560px, 62vh);
|
||||
min-block-size: 160px;
|
||||
}
|
||||
|
||||
.notification-virtual-item {
|
||||
padding-block: 4px;
|
||||
padding-inline: 8px;
|
||||
}
|
||||
|
||||
.notification-virtual-item--section {
|
||||
padding-block: 10px 2px;
|
||||
}
|
||||
|
||||
.notification-section-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: rgba(var(--v-theme-on-surface), 0.42);
|
||||
gap: 8px;
|
||||
letter-spacing: 0;
|
||||
padding-inline: 10px;
|
||||
}
|
||||
|
||||
.notification-section-heading__title {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.notification-section-heading__count {
|
||||
color: rgba(var(--v-theme-on-surface), 0.34);
|
||||
font-size: 0.625rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.notification-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 10px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
gap: 12px;
|
||||
inline-size: 100%;
|
||||
text-align: start;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
|
||||
.notification-row:hover {
|
||||
background: rgba(var(--v-theme-primary), 0.08);
|
||||
}
|
||||
|
||||
.notification-row--unread {
|
||||
background: rgba(var(--v-theme-error), 0.07);
|
||||
}
|
||||
|
||||
.notification-row--media {
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.notification-media {
|
||||
overflow: hidden;
|
||||
flex: 0 0 56px;
|
||||
border-radius: 6px;
|
||||
background: rgba(var(--v-theme-on-surface), 0.06);
|
||||
block-size: 84px;
|
||||
}
|
||||
|
||||
.notification-media__image {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.notification-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.notification-icon {
|
||||
flex: 0 0 40px;
|
||||
border-radius: 8px;
|
||||
background: rgba(var(--v-theme-on-surface), 0.06);
|
||||
block-size: 40px;
|
||||
}
|
||||
|
||||
.notification-content {
|
||||
flex: 1;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.notification-title-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
min-block-size: 24px;
|
||||
}
|
||||
|
||||
.notification-title {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
flex: 1 1 auto;
|
||||
-webkit-box-orient: vertical;
|
||||
font-size: 0.925rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
-webkit-line-clamp: 2;
|
||||
text-overflow: ellipsis;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.notification-unread-dot {
|
||||
flex: 0 0 7px;
|
||||
border-radius: 999px;
|
||||
background: rgb(var(--v-theme-error));
|
||||
block-size: 7px;
|
||||
inline-size: 7px;
|
||||
margin-block-start: 0.45rem;
|
||||
}
|
||||
|
||||
.notification-text {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
block-size: auto;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
cursor: pointer;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.45;
|
||||
margin-block-start: 4px;
|
||||
max-block-size: calc(0.8125rem * 1.45 * 3);
|
||||
text-align: start;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.notification-text--expanded {
|
||||
max-block-size: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.notification-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-disabled-opacity));
|
||||
font-size: 0.75rem;
|
||||
gap: 6px;
|
||||
line-height: 1.2;
|
||||
margin-block-start: 6px;
|
||||
}
|
||||
|
||||
.notification-type {
|
||||
border-radius: 999px;
|
||||
background: rgba(var(--v-theme-primary), 0.1);
|
||||
color: rgb(var(--v-theme-primary));
|
||||
padding-block: 2px;
|
||||
padding-inline: 6px;
|
||||
}
|
||||
|
||||
.notification-empty {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
padding-block: 32px;
|
||||
padding-inline: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.notification-empty__icon {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border-radius: 8px;
|
||||
background: rgba(var(--v-theme-on-surface), 0.06);
|
||||
block-size: 40px;
|
||||
inline-size: 40px;
|
||||
margin-block-end: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -20,8 +20,10 @@ import {
|
||||
persistPartialThemeCustomizerSettings,
|
||||
readThemeCustomizerSettings,
|
||||
THEME_CUSTOMIZER_CHANGE_EVENT,
|
||||
THEME_CUSTOMIZER_OPEN_EVENT,
|
||||
type ThemeCustomizerSettings,
|
||||
} from '@/composables/useThemeCustomizer'
|
||||
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||
|
||||
const AboutDialog = defineAsyncComponent(() => import('@/components/dialog/AboutDialog.vue'))
|
||||
const CustomCssDialog = defineAsyncComponent(() => import('@/components/dialog/CustomCssDialog.vue'))
|
||||
@@ -29,7 +31,6 @@ const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/Pr
|
||||
const TransparencySettingsDialog = defineAsyncComponent(
|
||||
() => import('@/components/dialog/TransparencySettingsDialog.vue'),
|
||||
)
|
||||
const ThemeCustomizer = defineAsyncComponent(() => import('@/components/ThemeCustomizer.vue'))
|
||||
const UserAuthDialog = defineAsyncComponent(() => import('@/components/dialog/UserAuthDialog.vue'))
|
||||
|
||||
// 认证 Store
|
||||
@@ -49,12 +50,12 @@ const $toast = useToast()
|
||||
// UI模式菜单是否显示
|
||||
const showUIModeMenu = ref(false)
|
||||
|
||||
// 用户头像主菜单是否显示;打开布局级面板前需要主动关闭,避免菜单 overlay 残留。
|
||||
const showUserMenu = ref(false)
|
||||
|
||||
// 主题菜单是否显示
|
||||
const showThemeMenu = ref(false)
|
||||
|
||||
// 主题定制器面板是否显示
|
||||
const showThemeCustomizer = ref(false)
|
||||
|
||||
// 语言菜单是否显示
|
||||
const showLanguageMenu = ref(false)
|
||||
|
||||
@@ -104,7 +105,7 @@ function closeRestartProgress() {
|
||||
// 检测服务状态
|
||||
async function checkServiceStatus(): Promise<boolean> {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/env', { timeout: 3000 })
|
||||
const result: { [key: string]: any } = await api.get('system/ping', { timeout: 3000 })
|
||||
return result?.success === true
|
||||
} catch (error) {
|
||||
return false
|
||||
@@ -163,6 +164,8 @@ async function pollServiceStatus() {
|
||||
|
||||
// 执行重启操作
|
||||
async function restart() {
|
||||
if (!canAdmin.value) return
|
||||
|
||||
// 设置重启状态
|
||||
isRestarting.value = true
|
||||
|
||||
@@ -194,6 +197,8 @@ async function restart() {
|
||||
|
||||
// 显示重启确认对话框
|
||||
async function showRestartDialog() {
|
||||
if (!canAdmin.value) return
|
||||
|
||||
const isConfirmed = await createConfirm({
|
||||
type: 'warn',
|
||||
title: t('app.confirmRestart'),
|
||||
@@ -207,6 +212,8 @@ async function showRestartDialog() {
|
||||
|
||||
/** 显示站点认证共享弹窗。 */
|
||||
function showSiteAuthDialog() {
|
||||
if (!canAdmin.value || userLevel.value >= 2) return
|
||||
|
||||
siteAuthDialogController?.close()
|
||||
siteAuthDialogController = openSharedDialog(
|
||||
UserAuthDialog,
|
||||
@@ -220,6 +227,8 @@ function showSiteAuthDialog() {
|
||||
|
||||
/** 显示关于共享弹窗。 */
|
||||
function showAboutDialog() {
|
||||
if (!canAdmin.value) return
|
||||
|
||||
openSharedDialog(AboutDialog, {}, {}, { closeOn: ['close', 'update:modelValue'] })
|
||||
}
|
||||
|
||||
@@ -232,6 +241,8 @@ function siteAuthDone() {
|
||||
|
||||
// 从用户 Store中获取信息
|
||||
const superUser = computed(() => userStore.superUser)
|
||||
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
|
||||
const canAdmin = computed(() => hasPermission(userPermissions.value, 'admin'))
|
||||
const userName = computed(() => userStore.userName)
|
||||
const avatar = computed(() => userStore.avatar || avatar1)
|
||||
const userLevel = computed(() => userStore.level)
|
||||
@@ -384,6 +395,8 @@ function handleThemeCustomizerSettingsChange(event: Event) {
|
||||
|
||||
// 获取自定义 CSS
|
||||
async function getCustomCSS() {
|
||||
if (!canAdmin.value) return
|
||||
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/UserCustomCSS')
|
||||
if (result && result.success && result.data?.value) {
|
||||
@@ -401,6 +414,8 @@ async function getCustomCSS() {
|
||||
|
||||
/** 打开自定义 CSS 共享弹窗。 */
|
||||
function showCustomCssDialog() {
|
||||
if (!canAdmin.value) return
|
||||
|
||||
customCssDialogController?.close()
|
||||
customCssDialogController = openSharedDialog(
|
||||
CustomCssDialog,
|
||||
@@ -427,12 +442,17 @@ function showTransparencySettingsDialog() {
|
||||
|
||||
/** 从用户菜单打开主题定制器,App 模式会在面板内部隐藏布局设置。 */
|
||||
function showThemeCustomizerDrawer() {
|
||||
showUserMenu.value = false
|
||||
showThemeMenu.value = false
|
||||
showThemeCustomizer.value = true
|
||||
|
||||
// 主题定制器由 DefaultLayout 统一挂载
|
||||
window.dispatchEvent(new CustomEvent(THEME_CUSTOMIZER_OPEN_EVENT))
|
||||
}
|
||||
|
||||
/** 保存自定义 CSS。 */
|
||||
async function saveCustomCSS(css: string) {
|
||||
if (!canAdmin.value) return
|
||||
|
||||
customCSS.value = css
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post('system/setting/UserCustomCSS', css, {
|
||||
@@ -512,7 +532,7 @@ const getThemeIcon = computed(() => {
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
getCustomCSS()
|
||||
if (canAdmin.value) getCustomCSS()
|
||||
window.addEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerSettingsChange)
|
||||
|
||||
// 初始化透明度设置
|
||||
@@ -541,6 +561,7 @@ onUnmounted(() => {
|
||||
<VImg :src="avatar" />
|
||||
|
||||
<VMenu
|
||||
v-model="showUserMenu"
|
||||
activator="parent"
|
||||
width="15rem"
|
||||
location="bottom end"
|
||||
@@ -577,7 +598,7 @@ onUnmounted(() => {
|
||||
</VListItem>
|
||||
|
||||
<VListItem
|
||||
v-if="superUser"
|
||||
v-if="canAdmin"
|
||||
link
|
||||
@click="isAdvancedMode ? router.push('/setting') : router.push('/setup-wizard')"
|
||||
class="mb-1 rounded-lg"
|
||||
@@ -590,7 +611,7 @@ onUnmounted(() => {
|
||||
</VListItem>
|
||||
|
||||
<!-- 👉 Site Auth -->
|
||||
<VListItem v-if="userLevel < 2 && superUser" link @click="showSiteAuthDialog" class="mb-1 rounded-lg" hover>
|
||||
<VListItem v-if="userLevel < 2 && canAdmin" link @click="showSiteAuthDialog" class="mb-1 rounded-lg" hover>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-lock-check-outline" />
|
||||
</template>
|
||||
@@ -674,7 +695,7 @@ onUnmounted(() => {
|
||||
</VListItem>
|
||||
</template>
|
||||
|
||||
<VListItem @click="showCustomCssDialog">
|
||||
<VListItem v-if="canAdmin" @click="showCustomCssDialog">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-palette" />
|
||||
</template>
|
||||
@@ -729,7 +750,7 @@ onUnmounted(() => {
|
||||
</VListItem>
|
||||
|
||||
<!-- 👉 About -->
|
||||
<VListItem @click="showAboutDialog" class="mb-1 rounded-lg" hover>
|
||||
<VListItem v-if="canAdmin" @click="showAboutDialog" class="mb-1 rounded-lg" hover>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-information-outline" />
|
||||
</template>
|
||||
@@ -737,10 +758,10 @@ onUnmounted(() => {
|
||||
</VListItem>
|
||||
|
||||
<!-- Divider -->
|
||||
<VDivider v-if="superUser" class="my-3" />
|
||||
<VDivider v-if="canAdmin" class="my-3" />
|
||||
|
||||
<!-- 👉 restart -->
|
||||
<VListItem v-if="superUser" @click="showRestartDialog" class="mb-1 rounded-lg" hover>
|
||||
<VListItem v-if="canAdmin" @click="showRestartDialog" class="mb-1 rounded-lg" hover>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-restart" />
|
||||
</template>
|
||||
@@ -760,7 +781,6 @@ onUnmounted(() => {
|
||||
</VMenu>
|
||||
<!-- !SECTION -->
|
||||
</VAvatar>
|
||||
<ThemeCustomizer v-model="showThemeCustomizer" />
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -5,15 +5,55 @@ const route = useRoute()
|
||||
|
||||
// keep-alive 缓存按页面身份命中,避免 query 变化导致同一页面反复新建实例。
|
||||
const routeCacheKey = computed(() => route.meta.keepAliveKey?.toString() || route.path)
|
||||
|
||||
// 页面过渡按实际页面身份触发;keep-alive 页面避免 query 变化时反复入场。
|
||||
const routeTransitionKey = computed(() => (route.meta.keepAlive ? routeCacheKey.value : route.fullPath))
|
||||
const isPageEntering = ref(false)
|
||||
let pageMotionTimer: number | null = null
|
||||
let pageMotionFrame: number | null = null
|
||||
|
||||
// 使用稳定容器触发轻量入场动画,避免重建 keep-alive 导致页面缓存失效。
|
||||
function playPageEnterMotion() {
|
||||
if (pageMotionTimer) {
|
||||
window.clearTimeout(pageMotionTimer)
|
||||
pageMotionTimer = null
|
||||
}
|
||||
|
||||
if (pageMotionFrame) {
|
||||
window.cancelAnimationFrame(pageMotionFrame)
|
||||
pageMotionFrame = null
|
||||
}
|
||||
|
||||
isPageEntering.value = false
|
||||
pageMotionFrame = window.requestAnimationFrame(() => {
|
||||
isPageEntering.value = true
|
||||
pageMotionFrame = null
|
||||
pageMotionTimer = window.setTimeout(() => {
|
||||
isPageEntering.value = false
|
||||
pageMotionTimer = null
|
||||
}, 220)
|
||||
})
|
||||
}
|
||||
|
||||
watch(routeTransitionKey, playPageEnterMotion, { flush: 'post' })
|
||||
|
||||
onMounted(playPageEnterMotion)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (pageMotionTimer) window.clearTimeout(pageMotionTimer)
|
||||
if (pageMotionFrame) window.cancelAnimationFrame(pageMotionFrame)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DefaultLayout>
|
||||
<router-view v-slot="{ Component }">
|
||||
<keep-alive :max="24">
|
||||
<component :is="Component" v-if="route.meta.keepAlive" :key="routeCacheKey" />
|
||||
</keep-alive>
|
||||
<component :is="Component" v-if="!route.meta.keepAlive" :key="route.fullPath" />
|
||||
<div class="mp-page-route" :class="{ 'mp-page-route--entering': isPageEntering }">
|
||||
<keep-alive :max="24">
|
||||
<component :is="Component" v-if="route.meta.keepAlive" :key="routeCacheKey" />
|
||||
</keep-alive>
|
||||
<component :is="Component" v-if="!route.meta.keepAlive" :key="route.fullPath" />
|
||||
</div>
|
||||
</router-view>
|
||||
</DefaultLayout>
|
||||
</template>
|
||||
|
||||
@@ -14,6 +14,7 @@ export default {
|
||||
success: 'Success',
|
||||
error: 'Error',
|
||||
openInNewWindow: 'Open in new window',
|
||||
download: 'Download',
|
||||
inputMessage: 'Enter message or command',
|
||||
send: 'Send',
|
||||
noData: 'No data',
|
||||
@@ -169,16 +170,13 @@ export default {
|
||||
skinDefault: 'Default',
|
||||
skinBordered: 'Bordered',
|
||||
radius: 'Corners',
|
||||
radiusNone: 'Square',
|
||||
radiusSmall: 'Small',
|
||||
radiusDefault: 'Default',
|
||||
radiusLarge: 'Large',
|
||||
radiusExtra: 'Larger',
|
||||
radiusHuge: 'Extra Large',
|
||||
shadow: 'Shadows',
|
||||
shadowNone: 'Flat',
|
||||
shadowLow: 'Soft',
|
||||
shadowMedium: 'Balanced',
|
||||
shadowHigh: 'Bold',
|
||||
shadowLevel: 'Level {level}',
|
||||
semiDarkMenu: 'Semi Dark Menu',
|
||||
layout: 'Layout',
|
||||
layoutVertical: 'Vertical',
|
||||
@@ -280,6 +278,8 @@ export default {
|
||||
},
|
||||
login: {
|
||||
wallpapers: 'Wallpapers',
|
||||
tagline: 'Your smart media library',
|
||||
copyright: '© {year} MoviePilot',
|
||||
username: 'Username',
|
||||
password: 'Password',
|
||||
otpCode: 'Verification Code',
|
||||
@@ -459,7 +459,13 @@ export default {
|
||||
notification: {
|
||||
center: 'Notification Center',
|
||||
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',
|
||||
systemMessages: 'System Messages',
|
||||
mediaMessages: 'Media Messages',
|
||||
channel: 'Notification Channel',
|
||||
name: 'Name',
|
||||
nameHint: 'Name of notification channel',
|
||||
@@ -677,10 +683,6 @@ export default {
|
||||
title: 'System',
|
||||
subtitle: 'Health Check',
|
||||
},
|
||||
message: {
|
||||
title: 'Messages',
|
||||
subtitle: 'Message Center',
|
||||
},
|
||||
words: {
|
||||
title: 'Words',
|
||||
subtitle: 'Word Settings',
|
||||
@@ -694,6 +696,39 @@ export default {
|
||||
subtitle: 'Scheduled Services',
|
||||
},
|
||||
},
|
||||
agentAssistant: {
|
||||
title: 'AI Assistant',
|
||||
assistant: 'Assistant',
|
||||
ready: 'Ready',
|
||||
thinking: 'Thinking',
|
||||
newChat: 'New Chat',
|
||||
history: 'Chat History',
|
||||
historyLoading: 'Loading chat history...',
|
||||
historyLoadFailed: 'Failed to load chat history',
|
||||
noHistory: 'No chat history yet',
|
||||
deleteHistory: 'Delete chat history',
|
||||
unknownChannel: 'Unknown channel',
|
||||
webAgentChannel: 'Web Assistant',
|
||||
untitledSession: 'Untitled chat',
|
||||
emptyTitle: 'What should we handle today?',
|
||||
emptySubtitle: 'Ask about sites, subscriptions, downloads, or organization tasks.',
|
||||
placeholder: 'Ask MoviePilot...',
|
||||
stop: 'Stop generating',
|
||||
download: 'Download',
|
||||
attachFile: 'Choose image or file',
|
||||
recordVoice: 'Record voice',
|
||||
stopRecording: 'Stop recording ({time})',
|
||||
attachmentMessage: 'Attachment message',
|
||||
removeAttachment: 'Remove attachment',
|
||||
uploadFailed: 'Attachment upload failed',
|
||||
recordUnsupported: 'Voice recording is not supported by this browser',
|
||||
recordPermissionDenied: 'Cannot access the microphone. Please check browser permissions.',
|
||||
recordFailed: 'Voice recording failed. Please try again.',
|
||||
choiceSelected: 'Selected: {option}',
|
||||
choiceExpired: 'This choice expired. Please ask again.',
|
||||
error: 'Assistant response failed',
|
||||
noStream: 'This browser cannot read streaming responses',
|
||||
},
|
||||
workflow: {
|
||||
components: 'Action Components',
|
||||
clickToAdd: 'Click to Add',
|
||||
@@ -949,10 +984,12 @@ export default {
|
||||
minutes: 'minutes',
|
||||
overview: 'Overview',
|
||||
seasons: 'Seasons',
|
||||
specials: 'Specials',
|
||||
seasonNumber: 'Season {number}',
|
||||
episodeCount: '{count} Episodes',
|
||||
actions: {
|
||||
searchResource: 'Search Resource',
|
||||
searchSubtitle: 'Search Subtitle',
|
||||
subscribe: 'Subscribe',
|
||||
playOnline: 'Play Online',
|
||||
playInApp: 'Play in App',
|
||||
@@ -1107,6 +1144,7 @@ export default {
|
||||
},
|
||||
resource: {
|
||||
searchResults: 'Resource Search Results',
|
||||
subtitleSearchResults: 'Subtitle Search Results',
|
||||
keyword: 'Keyword',
|
||||
title: 'Title',
|
||||
year: 'Year',
|
||||
@@ -1157,6 +1195,13 @@ export default {
|
||||
},
|
||||
calendar: {
|
||||
episode: 'Episode {number}',
|
||||
libraryProgress: 'In library {completed}/{total}',
|
||||
currentEpisodeInLibrary: 'Current in library',
|
||||
currentEpisodePartiallyInLibrary: 'Partially in library',
|
||||
currentEpisodeNotInLibrary: 'Current not in library',
|
||||
libraryUpdatedAt: 'Updated {time}',
|
||||
libraryUpdatedAtShort: '{time}',
|
||||
expandDayEvents: 'Show {count} more items for this day',
|
||||
},
|
||||
storage: {
|
||||
name: 'Name',
|
||||
@@ -1404,6 +1449,7 @@ export default {
|
||||
title: 'About MoviePilot',
|
||||
softwareVersion: 'Software Version',
|
||||
frontendVersion: 'Frontend Version',
|
||||
systemUptime: 'System Uptime',
|
||||
browserVersion: 'Browser Cached Version',
|
||||
authVersion: 'Auth Resource Version',
|
||||
indexerVersion: 'Indexer Resource Version',
|
||||
@@ -1437,6 +1483,16 @@ export default {
|
||||
users: 'Users',
|
||||
lastUpdated: 'Updated At',
|
||||
noVersionStatisticData: 'No statistics data',
|
||||
uptimeUnits: {
|
||||
day: '{count} day',
|
||||
days: '{count} days',
|
||||
hour: '{count} hour',
|
||||
hours: '{count} hours',
|
||||
minute: '{count} minute',
|
||||
minutes: '{count} minutes',
|
||||
second: '{count} second',
|
||||
seconds: '{count} seconds',
|
||||
},
|
||||
},
|
||||
system: {
|
||||
custom: 'Custom',
|
||||
@@ -1557,12 +1613,15 @@ export default {
|
||||
llmTestFailedToastWithMessage: 'LLM test call failed: {message}',
|
||||
aiAgentGlobal: 'Global AI Assistant',
|
||||
aiAgentGlobalHint:
|
||||
'Enable global AI assistant functionality, all message conversations will be answered by the AI agent without using the /ai command',
|
||||
'Global AI Assistant On: AI assistant by default. Use /noai for traditional search; Global AI Assistant Off: Traditional search by default. Use /ai for AI assistant.',
|
||||
aiAgentJobInterval: 'Scheduled Wake',
|
||||
aiAgentJobIntervalHint:
|
||||
'Set the check interval for scheduled wake. Select "Disabled" to disable scheduled tasks.',
|
||||
aiAgentVerbose: 'Verbose Mode',
|
||||
aiAgentVerboseHint: 'When enabled, tool call process will be displayed in AI agent responses',
|
||||
aiAgentHideEntry: 'Hide Global Entry',
|
||||
aiAgentHideEntryHint:
|
||||
'Only hide the floating AI assistant entry in the bottom-right corner. Message channels and background assistant features are not affected.',
|
||||
aiAgentJobIntervalDisabled: 'Disabled',
|
||||
aiAgentJobInterval1h: '1 Hour',
|
||||
aiAgentJobInterval3h: '3 Hours',
|
||||
@@ -2318,7 +2377,9 @@ export default {
|
||||
subscribeShareSearch: 'Related subscription shares',
|
||||
siteResources: 'Site Resources',
|
||||
searchInSites: 'Search for torrent resources in sites',
|
||||
searchSubtitlesInSites: 'Search for subtitle resources in sites',
|
||||
relatedResources: 'Related Resources',
|
||||
relatedSubtitles: 'Related Subtitles',
|
||||
searchTip: 'You can search for movies, TV shows, actors, resources, etc.',
|
||||
emptySearchHint: 'Enter keywords to search',
|
||||
escClose: 'Close',
|
||||
@@ -2350,6 +2411,16 @@ export default {
|
||||
showAdvancedOptions: 'Show Advanced Options',
|
||||
hideAdvancedOptions: 'Hide Advanced Options',
|
||||
},
|
||||
addSubtitleDownload: {
|
||||
confirmDownload: 'Confirm Subtitle Download',
|
||||
saveDirectory: 'Save Directory (Auto)',
|
||||
autoPlaceholder: 'Leave empty for auto-match',
|
||||
downloading: 'Downloading...',
|
||||
startDownload: 'Download Subtitle',
|
||||
downloaded: 'Downloaded',
|
||||
downloadSuccess: '{site} {title} subtitle downloaded successfully!',
|
||||
downloadFailed: '{site} {title} subtitle download failed: {message}!',
|
||||
},
|
||||
subscribeShare: {
|
||||
shareSubscription: 'Share Subscription',
|
||||
season: 'Season {number}',
|
||||
@@ -2556,6 +2627,7 @@ export default {
|
||||
repoHint: 'Separate multiple URLs with new lines or commas',
|
||||
urlPlaceholder: 'Enter plugin repository URL',
|
||||
textPlaceholder: 'https://github.com/jxxghp/MoviePilot-Plugins/\nhttps://github.com/xxxx/xxxxxx/',
|
||||
repoCountHint: '{count} plugin repository URLs maintained',
|
||||
listMode: 'List',
|
||||
textMode: 'Text',
|
||||
textHint: 'Paste repository URLs one per line or separated by commas.',
|
||||
@@ -2566,6 +2638,9 @@ export default {
|
||||
invalidText: 'There are {count} invalid URLs in the text. Fix them before saving.',
|
||||
invalidTextIgnored: '{count} invalid URLs ignored',
|
||||
duplicateTextIgnored: 'Duplicate URLs will be removed automatically when saving.',
|
||||
syncWiki: 'Sync Wiki',
|
||||
syncSuccess: 'Plugin repositories synced from Wiki. {added} added, {total} total.',
|
||||
syncFailed: 'Failed to sync Wiki: {message}!',
|
||||
close: 'Close',
|
||||
save: 'Save',
|
||||
saveSuccess: 'Plugin repository saved successfully',
|
||||
@@ -2639,12 +2714,12 @@ export default {
|
||||
multipleItemsTitle: '{count} Items',
|
||||
singleItemTitle: '{path}',
|
||||
targetStorage: 'Target Storage',
|
||||
targetStorageHint: 'Organization target storage',
|
||||
targetStorageHint: 'Organization target storage. Choose Auto to let the backend match it.',
|
||||
transferType: 'Organization Method',
|
||||
transferTypeHint: 'File operation organization method',
|
||||
transferTypeHint: 'File operation method. Choose Auto to use the backend match or default rule.',
|
||||
targetPath: 'Target Path',
|
||||
targetPathHint: 'Organization target path, leave empty for auto-match',
|
||||
targetPathPlaceholder: 'Leave empty for auto',
|
||||
targetPathHint: 'Organization target path. Choose Auto to match by source path.',
|
||||
targetPathPlaceholder: 'Choose Auto or enter a path',
|
||||
mediaType: 'Type',
|
||||
mediaTypeHint: 'File media type',
|
||||
tmdbId: 'TheMovieDb ID',
|
||||
@@ -2967,6 +3042,12 @@ export default {
|
||||
projectHome: 'Project Home',
|
||||
updateHistory: 'Update History',
|
||||
versionHistory: 'Version History',
|
||||
releaseVersionsLoadFailed: 'Failed to load Release versions',
|
||||
latestVersion: 'Latest',
|
||||
currentVersion: 'Current',
|
||||
installReleaseVersion: 'Install',
|
||||
confirmInstallOldRelease:
|
||||
'Install {name} v{version}? This version has no MoviePilot compatibility metadata and may fail to load or run.',
|
||||
local: 'Local',
|
||||
systemVersion: 'System Version',
|
||||
incompatibleSystemVersion: 'The current MoviePilot version does not meet this plugin requirement.',
|
||||
@@ -3242,6 +3323,10 @@ export default {
|
||||
sequentail: 'Sequential Download',
|
||||
force_resume: 'Force Resume',
|
||||
first_last_piece: 'First/Last Piece Priority',
|
||||
incomplete_files_ext: 'Incomplete File Suffix',
|
||||
incomplete_files_extHint: 'Append .!qB while downloading and remove it after completion',
|
||||
rename_partial_files: 'Incomplete File Suffix',
|
||||
rename_partial_filesHint: 'Append .part while downloading and remove it after completion',
|
||||
saveSuccess: 'Downloader settings saved successfully',
|
||||
saveFailed: 'Failed to save downloader settings',
|
||||
nameRequired: 'Name cannot be empty',
|
||||
|
||||
@@ -14,6 +14,7 @@ export default {
|
||||
success: '成功',
|
||||
error: '错误',
|
||||
openInNewWindow: '在新窗口中打开',
|
||||
download: '下载',
|
||||
inputMessage: '输入消息或命令',
|
||||
send: '发送',
|
||||
noData: '暂无数据',
|
||||
@@ -169,16 +170,13 @@ export default {
|
||||
skinDefault: '默认',
|
||||
skinBordered: '边框',
|
||||
radius: '圆角',
|
||||
radiusNone: '无圆角',
|
||||
radiusSmall: '小圆角',
|
||||
radiusDefault: '默认',
|
||||
radiusLarge: '大圆角',
|
||||
radiusExtra: '更大圆角',
|
||||
radiusHuge: '超大圆角',
|
||||
shadow: '阴影',
|
||||
shadowNone: '无阴影',
|
||||
shadowLow: '柔和',
|
||||
shadowMedium: '标准',
|
||||
shadowHigh: '强烈',
|
||||
shadowLevel: '层级 {level}',
|
||||
semiDarkMenu: '半暗菜单',
|
||||
layout: '布局',
|
||||
layoutVertical: '垂直',
|
||||
@@ -279,6 +277,8 @@ export default {
|
||||
},
|
||||
login: {
|
||||
wallpapers: '壁纸',
|
||||
tagline: '你的智能影视媒体库',
|
||||
copyright: '© {year} MoviePilot',
|
||||
username: '用户名',
|
||||
password: '密码',
|
||||
otpCode: '验证码',
|
||||
@@ -457,7 +457,13 @@ export default {
|
||||
notification: {
|
||||
center: '通知中心',
|
||||
markRead: '设为已读',
|
||||
clear: '清理通知',
|
||||
clearConfirm: '是否确认清理通知中心内的全部历史消息?',
|
||||
clearSuccess: '通知已清理',
|
||||
clearFailed: '通知清理失败',
|
||||
empty: '暂无通知',
|
||||
systemMessages: '系统类消息',
|
||||
mediaMessages: '媒体消息',
|
||||
channel: '通知渠道',
|
||||
name: '名称',
|
||||
nameHint: '通知渠道名称',
|
||||
@@ -673,10 +679,6 @@ export default {
|
||||
title: '系统',
|
||||
subtitle: '健康检查',
|
||||
},
|
||||
message: {
|
||||
title: '消息',
|
||||
subtitle: '消息中心',
|
||||
},
|
||||
words: {
|
||||
title: '词表',
|
||||
subtitle: '词表设置',
|
||||
@@ -690,6 +692,39 @@ export default {
|
||||
subtitle: '定时服务',
|
||||
},
|
||||
},
|
||||
agentAssistant: {
|
||||
title: '智能助手',
|
||||
assistant: '助手',
|
||||
ready: '随时待命',
|
||||
thinking: '思考中',
|
||||
newChat: '新会话',
|
||||
history: '历史会话',
|
||||
historyLoading: '正在加载历史会话...',
|
||||
historyLoadFailed: '历史会话加载失败',
|
||||
noHistory: '暂无历史会话',
|
||||
deleteHistory: '删除历史会话',
|
||||
unknownChannel: '未知渠道',
|
||||
webAgentChannel: '网页助手',
|
||||
untitledSession: '未命名会话',
|
||||
emptyTitle: '今天想处理什么?',
|
||||
emptySubtitle: '站点、订阅、下载、整理任务,都可以直接问我。',
|
||||
placeholder: '询问 MoviePilot...',
|
||||
stop: '停止生成',
|
||||
download: '下载',
|
||||
attachFile: '选择图片或文件',
|
||||
recordVoice: '录制语音',
|
||||
stopRecording: '停止录音({time})',
|
||||
attachmentMessage: '附件消息',
|
||||
removeAttachment: '移除附件',
|
||||
uploadFailed: '附件上传失败',
|
||||
recordUnsupported: '当前浏览器不支持录音',
|
||||
recordPermissionDenied: '无法访问麦克风,请检查浏览器权限',
|
||||
recordFailed: '录音失败,请重试',
|
||||
choiceSelected: '已选择:{option}',
|
||||
choiceExpired: '该选择已失效,请重新发起选择',
|
||||
error: '智能助手响应失败',
|
||||
noStream: '当前浏览器无法读取流式响应',
|
||||
},
|
||||
workflow: {
|
||||
components: '动作组件',
|
||||
clickToAdd: '点击添加',
|
||||
@@ -945,10 +980,12 @@ export default {
|
||||
minutes: '分钟',
|
||||
overview: '简介',
|
||||
seasons: '季',
|
||||
specials: '特别篇',
|
||||
seasonNumber: '第 {number} 季',
|
||||
episodeCount: '{count}集',
|
||||
actions: {
|
||||
searchResource: '搜索资源',
|
||||
searchSubtitle: '搜索字幕',
|
||||
subscribe: '订阅',
|
||||
playOnline: '在线播放',
|
||||
playInApp: 'APP播放',
|
||||
@@ -1102,6 +1139,7 @@ export default {
|
||||
},
|
||||
resource: {
|
||||
searchResults: '资源搜索结果',
|
||||
subtitleSearchResults: '字幕搜索结果',
|
||||
keyword: '关键词',
|
||||
title: '标题',
|
||||
year: '年份',
|
||||
@@ -1152,6 +1190,13 @@ export default {
|
||||
},
|
||||
calendar: {
|
||||
episode: '第{number}集',
|
||||
libraryProgress: '已入库 {completed}/{total}',
|
||||
currentEpisodeInLibrary: '本集已入库',
|
||||
currentEpisodePartiallyInLibrary: '本集部分入库',
|
||||
currentEpisodeNotInLibrary: '本集未入库',
|
||||
libraryUpdatedAt: '最近更新 {time}',
|
||||
libraryUpdatedAtShort: '{time}',
|
||||
expandDayEvents: '展开当天剩余 {count} 个条目',
|
||||
},
|
||||
storage: {
|
||||
name: '名称',
|
||||
@@ -1399,6 +1444,7 @@ export default {
|
||||
title: '关于 MoviePilot',
|
||||
softwareVersion: '软件版本',
|
||||
frontendVersion: '前端版本',
|
||||
systemUptime: '系统已运行',
|
||||
browserVersion: '浏览器缓存版本',
|
||||
authVersion: '认证资源版本',
|
||||
indexerVersion: '站点资源版本',
|
||||
@@ -1432,6 +1478,16 @@ export default {
|
||||
users: '用户数',
|
||||
lastUpdated: '更新时间',
|
||||
noVersionStatisticData: '暂无统计数据',
|
||||
uptimeUnits: {
|
||||
day: '{count}天',
|
||||
days: '{count}天',
|
||||
hour: '{count}小时',
|
||||
hours: '{count}小时',
|
||||
minute: '{count}分钟',
|
||||
minutes: '{count}分钟',
|
||||
second: '{count}秒',
|
||||
seconds: '{count}秒',
|
||||
},
|
||||
},
|
||||
system: {
|
||||
custom: '自定义',
|
||||
@@ -1544,11 +1600,13 @@ export default {
|
||||
llmTestFailedToast: 'LLM 调用测试失败',
|
||||
llmTestFailedToastWithMessage: 'LLM 调用测试失败:{message}',
|
||||
aiAgentGlobal: '全局智能助手',
|
||||
aiAgentGlobalHint: '启用全局智能助手功能,所有消息对话均使用智能体回答而不用使用/ai命令',
|
||||
aiAgentGlobalHint: '启用全局智能助手:默认使用智能体交互,使用 /noai 临时使用传统交互;关闭全局智能助手:默认使用传统交互,使用 /ai 临时使用智能体交互',
|
||||
aiAgentJobInterval: '定时唤醒',
|
||||
aiAgentJobIntervalHint: '设置定时唤醒的检查间隔,选择"不启用"则不执行定时任务',
|
||||
aiAgentVerbose: '啰嗦模式',
|
||||
aiAgentVerboseHint: '开启后会在智能体回复时显示工具调用过程',
|
||||
aiAgentHideEntry: '隐藏全局入口',
|
||||
aiAgentHideEntryHint: '仅隐藏页面右下角的智能助手浮动入口,不影响消息渠道和后台智能助手功能',
|
||||
aiAgentJobIntervalDisabled: '不启用',
|
||||
aiAgentJobInterval1h: '1小时',
|
||||
aiAgentJobInterval3h: '3小时',
|
||||
@@ -2274,7 +2332,9 @@ export default {
|
||||
subscribeShareSearch: '相关的订阅分享',
|
||||
siteResources: '站点资源',
|
||||
searchInSites: '在站点中搜索种子资源',
|
||||
searchSubtitlesInSites: '在站点中搜索字幕资源',
|
||||
relatedResources: '相关资源',
|
||||
relatedSubtitles: '相关字幕',
|
||||
searchTip: '可搜索电影、电视剧、演员、资源等',
|
||||
emptySearchHint: '输入关键字开始搜索',
|
||||
escClose: '关闭',
|
||||
@@ -2306,6 +2366,16 @@ export default {
|
||||
showAdvancedOptions: '显示高级选项',
|
||||
hideAdvancedOptions: '隐藏高级选项',
|
||||
},
|
||||
addSubtitleDownload: {
|
||||
confirmDownload: '确认下载字幕',
|
||||
saveDirectory: '保存目录(自动)',
|
||||
autoPlaceholder: '留空自动匹配',
|
||||
downloading: '下载中...',
|
||||
startDownload: '下载字幕',
|
||||
downloaded: '已下载',
|
||||
downloadSuccess: '{site} {title} 字幕下载成功!',
|
||||
downloadFailed: '{site} {title} 字幕下载失败:{message}!',
|
||||
},
|
||||
subscribeShare: {
|
||||
shareSubscription: '分享订阅',
|
||||
season: '第 {number} 季',
|
||||
@@ -2508,6 +2578,7 @@ export default {
|
||||
repoHint: '多个地址可使用换行或英文逗号分隔',
|
||||
urlPlaceholder: '输入插件仓库地址',
|
||||
textPlaceholder: 'https://github.com/jxxghp/MoviePilot-Plugins/\nhttps://github.com/xxxx/xxxxxx/',
|
||||
repoCountHint: '当前已维护 {count} 个插件仓库地址',
|
||||
listMode: '列表维护',
|
||||
textMode: '文本维护',
|
||||
textHint: '直接粘贴仓库地址串,一行一个或使用英文逗号分隔。',
|
||||
@@ -2518,6 +2589,9 @@ export default {
|
||||
invalidText: '文本中有 {count} 个无效地址,请修正后保存。',
|
||||
invalidTextIgnored: '已忽略 {count} 个无效地址',
|
||||
duplicateTextIgnored: '重复地址会在保存时自动去重。',
|
||||
syncWiki: '同步 Wiki',
|
||||
syncSuccess: '已从 Wiki 同步插件仓库,新增 {added} 个,共 {total} 个',
|
||||
syncFailed: '同步 Wiki 失败:{message}!',
|
||||
close: '关闭',
|
||||
save: '保存',
|
||||
saveSuccess: '插件仓库保存成功',
|
||||
@@ -2591,12 +2665,12 @@ export default {
|
||||
multipleItemsTitle: '共 {count} 项',
|
||||
singleItemTitle: '{path}',
|
||||
targetStorage: '目的存储',
|
||||
targetStorageHint: '整理目的存储',
|
||||
targetStorageHint: '整理目的存储,选择自动时由后端匹配',
|
||||
transferType: '整理方式',
|
||||
transferTypeHint: '文件操作整理方式',
|
||||
transferTypeHint: '文件操作整理方式,选择自动时使用后端匹配结果或默认规则',
|
||||
targetPath: '目的路径',
|
||||
targetPathHint: '整理目的路径,留空将自动匹配',
|
||||
targetPathPlaceholder: '留空自动',
|
||||
targetPathHint: '整理目的路径,选择自动将由后端按源路径匹配',
|
||||
targetPathPlaceholder: '选择自动或输入路径',
|
||||
mediaType: '类型',
|
||||
mediaTypeHint: '文件的媒体类型',
|
||||
tmdbId: 'TheMovieDb编号',
|
||||
@@ -2917,6 +2991,12 @@ export default {
|
||||
projectHome: '项目主页',
|
||||
updateHistory: '更新说明',
|
||||
versionHistory: '版本历史',
|
||||
releaseVersionsLoadFailed: 'Release 版本加载失败',
|
||||
latestVersion: '最新',
|
||||
currentVersion: '当前',
|
||||
installReleaseVersion: '安装',
|
||||
confirmInstallOldRelease:
|
||||
'是否确认安装 {name} v{version}?该版本缺少主程序兼容元数据,安装后可能无法加载或运行异常。',
|
||||
local: '本地',
|
||||
systemVersion: '系统版本',
|
||||
incompatibleSystemVersion: '当前 MoviePilot 版本不满足插件要求,无法安装',
|
||||
@@ -3185,6 +3265,10 @@ export default {
|
||||
sequentail: '顺序下载',
|
||||
force_resume: '强制继续',
|
||||
first_last_piece: '优先首尾文件',
|
||||
incomplete_files_ext: '未完成文件后缀',
|
||||
incomplete_files_extHint: '下载未完成时追加 .!qB 后缀,完成后自动移除',
|
||||
rename_partial_files: '未完成文件后缀',
|
||||
rename_partial_filesHint: '下载未完成时追加 .part 后缀,完成后自动移除',
|
||||
saveSuccess: '下载器设置保存成功',
|
||||
saveFailed: '下载器设置保存失败',
|
||||
nameRequired: '不能为空,且不能重名',
|
||||
|
||||
@@ -14,6 +14,7 @@ export default {
|
||||
success: '成功',
|
||||
error: '錯誤',
|
||||
openInNewWindow: '在新窗口中打開',
|
||||
download: '下載',
|
||||
inputMessage: '輸入消息或命令',
|
||||
send: '發送',
|
||||
noData: '暫無數據',
|
||||
@@ -169,16 +170,13 @@ export default {
|
||||
skinDefault: '默認',
|
||||
skinBordered: '邊框',
|
||||
radius: '圓角',
|
||||
radiusNone: '無圓角',
|
||||
radiusSmall: '小圓角',
|
||||
radiusDefault: '默認',
|
||||
radiusLarge: '大圓角',
|
||||
radiusExtra: '更大圓角',
|
||||
radiusHuge: '超大圓角',
|
||||
shadow: '陰影',
|
||||
shadowNone: '無陰影',
|
||||
shadowLow: '柔和',
|
||||
shadowMedium: '標準',
|
||||
shadowHigh: '強烈',
|
||||
shadowLevel: '層級 {level}',
|
||||
semiDarkMenu: '半暗菜單',
|
||||
layout: '佈局',
|
||||
layoutVertical: '垂直',
|
||||
@@ -279,6 +277,8 @@ export default {
|
||||
},
|
||||
login: {
|
||||
wallpapers: '壁紙',
|
||||
tagline: '你的智能影視媒體庫',
|
||||
copyright: '© {year} MoviePilot',
|
||||
username: '用戶名',
|
||||
password: '密碼',
|
||||
otpCode: '驗證碼',
|
||||
@@ -457,7 +457,13 @@ export default {
|
||||
notification: {
|
||||
center: '通知中心',
|
||||
markRead: '設為已讀',
|
||||
clear: '清理通知',
|
||||
clearConfirm: '是否確認清理通知中心內的全部歷史消息?',
|
||||
clearSuccess: '通知已清理',
|
||||
clearFailed: '通知清理失敗',
|
||||
empty: '暫無通知',
|
||||
systemMessages: '系統類消息',
|
||||
mediaMessages: '媒體消息',
|
||||
channel: '通知渠道',
|
||||
name: '名稱',
|
||||
nameHint: '通知渠道名稱',
|
||||
@@ -673,10 +679,6 @@ export default {
|
||||
title: '系統',
|
||||
subtitle: '健康檢查',
|
||||
},
|
||||
message: {
|
||||
title: '消息',
|
||||
subtitle: '消息中心',
|
||||
},
|
||||
words: {
|
||||
title: '詞表',
|
||||
subtitle: '詞表設置',
|
||||
@@ -690,6 +692,39 @@ export default {
|
||||
subtitle: '定時服務',
|
||||
},
|
||||
},
|
||||
agentAssistant: {
|
||||
title: '智能助手',
|
||||
assistant: '助手',
|
||||
ready: '隨時待命',
|
||||
thinking: '思考中',
|
||||
newChat: '新會話',
|
||||
history: '歷史會話',
|
||||
historyLoading: '正在載入歷史會話...',
|
||||
historyLoadFailed: '歷史會話載入失敗',
|
||||
noHistory: '暫無歷史會話',
|
||||
deleteHistory: '刪除歷史會話',
|
||||
unknownChannel: '未知渠道',
|
||||
webAgentChannel: '網頁助手',
|
||||
untitledSession: '未命名會話',
|
||||
emptyTitle: '今天想處理什麼?',
|
||||
emptySubtitle: '站點、訂閱、下載、整理任務,都可以直接問我。',
|
||||
placeholder: '詢問 MoviePilot...',
|
||||
stop: '停止生成',
|
||||
download: '下載',
|
||||
attachFile: '選擇圖片或文件',
|
||||
recordVoice: '錄製語音',
|
||||
stopRecording: '停止錄音({time})',
|
||||
attachmentMessage: '附件消息',
|
||||
removeAttachment: '移除附件',
|
||||
uploadFailed: '附件上傳失敗',
|
||||
recordUnsupported: '目前瀏覽器不支援錄音',
|
||||
recordPermissionDenied: '無法存取麥克風,請檢查瀏覽器權限',
|
||||
recordFailed: '錄音失敗,請重試',
|
||||
choiceSelected: '已選擇:{option}',
|
||||
choiceExpired: '該選擇已失效,請重新發起選擇',
|
||||
error: '智能助手響應失敗',
|
||||
noStream: '目前瀏覽器無法讀取串流響應',
|
||||
},
|
||||
workflow: {
|
||||
components: '動作組件',
|
||||
clickToAdd: '點擊添加',
|
||||
@@ -945,10 +980,12 @@ export default {
|
||||
minutes: '分鐘',
|
||||
overview: '簡介',
|
||||
seasons: '季',
|
||||
specials: '特別篇',
|
||||
seasonNumber: '第 {number} 季',
|
||||
episodeCount: '{count}集',
|
||||
actions: {
|
||||
searchResource: '搜索資源',
|
||||
searchSubtitle: '搜索字幕',
|
||||
subscribe: '訂閱',
|
||||
playOnline: '線上播放',
|
||||
playInApp: 'APP播放',
|
||||
@@ -1102,6 +1139,7 @@ export default {
|
||||
},
|
||||
resource: {
|
||||
searchResults: '資源搜索結果',
|
||||
subtitleSearchResults: '字幕搜索結果',
|
||||
keyword: '關鍵詞',
|
||||
title: '標題',
|
||||
year: '年份',
|
||||
@@ -1152,6 +1190,13 @@ export default {
|
||||
},
|
||||
calendar: {
|
||||
episode: '第{number}集',
|
||||
libraryProgress: '已入庫 {completed}/{total}',
|
||||
currentEpisodeInLibrary: '本集已入庫',
|
||||
currentEpisodePartiallyInLibrary: '本集部分入庫',
|
||||
currentEpisodeNotInLibrary: '本集未入庫',
|
||||
libraryUpdatedAt: '最近更新 {time}',
|
||||
libraryUpdatedAtShort: '{time}',
|
||||
expandDayEvents: '展開當天剩餘 {count} 個條目',
|
||||
},
|
||||
storage: {
|
||||
name: '名稱',
|
||||
@@ -1400,6 +1445,7 @@ export default {
|
||||
title: '關於 MoviePilot',
|
||||
softwareVersion: '軟件版本',
|
||||
frontendVersion: '前端版本',
|
||||
systemUptime: '系統已運行',
|
||||
browserVersion: '瀏覽器緩存版本',
|
||||
authVersion: '認證資源版本',
|
||||
indexerVersion: '站點資源版本',
|
||||
@@ -1433,6 +1479,16 @@ export default {
|
||||
users: '用戶數',
|
||||
lastUpdated: '更新時間',
|
||||
noVersionStatisticData: '暫無統計數據',
|
||||
uptimeUnits: {
|
||||
day: '{count}天',
|
||||
days: '{count}天',
|
||||
hour: '{count}小時',
|
||||
hours: '{count}小時',
|
||||
minute: '{count}分鐘',
|
||||
minutes: '{count}分鐘',
|
||||
second: '{count}秒',
|
||||
seconds: '{count}秒',
|
||||
},
|
||||
},
|
||||
system: {
|
||||
custom: '自定義',
|
||||
@@ -1545,11 +1601,13 @@ export default {
|
||||
llmTestFailedToast: 'LLM 調用測試失敗',
|
||||
llmTestFailedToastWithMessage: 'LLM 調用測試失敗:{message}',
|
||||
aiAgentGlobal: '全局智能助手',
|
||||
aiAgentGlobalHint: '啟用全局智能助手功能,所有消息對話均使用智能體回答而不用使用/ai命令',
|
||||
aiAgentGlobalHint: '啟用全域智慧助手:預設使用智慧體互動,使用 /noai 暫時切換為傳統互動;停用全域智慧助手:預設使用傳統互動,使用 /ai 暫時切換為智慧體互動',
|
||||
aiAgentJobInterval: '定時喚醒',
|
||||
aiAgentJobIntervalHint: '設置定時喚醒的檢查間隔,選擇「不啟用」則不執行定時任務',
|
||||
aiAgentVerbose: '囉嗦模式',
|
||||
aiAgentVerboseHint: '開啟後會在智能體回覆時顯示工具調用過程',
|
||||
aiAgentHideEntry: '隱藏全域入口',
|
||||
aiAgentHideEntryHint: '僅隱藏頁面右下角的智能助手浮動入口,不影響消息渠道和後台智能助手功能',
|
||||
aiAgentJobIntervalDisabled: '不啟用',
|
||||
aiAgentJobInterval1h: '1小時',
|
||||
aiAgentJobInterval3h: '3小時',
|
||||
@@ -2275,7 +2333,9 @@ export default {
|
||||
subscribeShareSearch: '相關的訂閱分享',
|
||||
siteResources: '站點資源',
|
||||
searchInSites: '在站點中搜索種子資源',
|
||||
searchSubtitlesInSites: '在站點中搜索字幕資源',
|
||||
relatedResources: '相關資源',
|
||||
relatedSubtitles: '相關字幕',
|
||||
searchTip: '可搜索電影、電視劇、演員、資源等',
|
||||
emptySearchHint: '輸入關鍵字開始搜索',
|
||||
escClose: '關閉',
|
||||
@@ -2307,6 +2367,16 @@ export default {
|
||||
showAdvancedOptions: '顯示高級選項',
|
||||
hideAdvancedOptions: '隱藏高級選項',
|
||||
},
|
||||
addSubtitleDownload: {
|
||||
confirmDownload: '確認下載字幕',
|
||||
saveDirectory: '保存目錄(自動)',
|
||||
autoPlaceholder: '留空自動匹配',
|
||||
downloading: '下載中...',
|
||||
startDownload: '下載字幕',
|
||||
downloaded: '已下載',
|
||||
downloadSuccess: '{site} {title} 字幕下載成功!',
|
||||
downloadFailed: '{site} {title} 字幕下載失敗:{message}!',
|
||||
},
|
||||
subscribeShare: {
|
||||
shareSubscription: '分享訂閱',
|
||||
season: '第 {number} 季',
|
||||
@@ -2509,6 +2579,7 @@ export default {
|
||||
repoHint: '多個地址可使用換行或英文逗號分隔',
|
||||
urlPlaceholder: '輸入插件倉庫地址',
|
||||
textPlaceholder: 'https://github.com/jxxghp/MoviePilot-Plugins/\nhttps://github.com/xxxx/xxxxxx/',
|
||||
repoCountHint: '目前已維護 {count} 個插件倉庫地址',
|
||||
listMode: '列表維護',
|
||||
textMode: '文字維護',
|
||||
textHint: '直接貼上倉庫地址串,一行一個或使用英文逗號分隔。',
|
||||
@@ -2519,6 +2590,9 @@ export default {
|
||||
invalidText: '文字中有 {count} 個無效地址,請修正後儲存。',
|
||||
invalidTextIgnored: '已忽略 {count} 個無效地址',
|
||||
duplicateTextIgnored: '重複地址會在儲存時自動去重。',
|
||||
syncWiki: '同步 Wiki',
|
||||
syncSuccess: '已從 Wiki 同步插件倉庫,新增 {added} 個,共 {total} 個',
|
||||
syncFailed: '同步 Wiki 失敗:{message}!',
|
||||
close: '關閉',
|
||||
save: '儲存',
|
||||
saveSuccess: '插件倉庫儲存成功',
|
||||
@@ -2592,12 +2666,12 @@ export default {
|
||||
multipleItemsTitle: '共 {count} 項',
|
||||
singleItemTitle: '{path}',
|
||||
targetStorage: '目的存儲',
|
||||
targetStorageHint: '整理目的存儲',
|
||||
targetStorageHint: '整理目的存儲,選擇自動時由後端匹配',
|
||||
transferType: '整理方式',
|
||||
transferTypeHint: '文件操作整理方式',
|
||||
transferTypeHint: '文件操作整理方式,選擇自動時使用後端匹配結果或預設規則',
|
||||
targetPath: '目的路徑',
|
||||
targetPathHint: '整理目的路徑,留空將自動匹配',
|
||||
targetPathPlaceholder: '留空自動',
|
||||
targetPathHint: '整理目的路徑,選擇自動將由後端按源路徑匹配',
|
||||
targetPathPlaceholder: '選擇自動或輸入路徑',
|
||||
mediaType: '類型',
|
||||
mediaTypeHint: '文件的媒體類型',
|
||||
tmdbId: 'TheMovieDb編號',
|
||||
@@ -2918,6 +2992,12 @@ export default {
|
||||
projectHome: '項目主頁',
|
||||
updateHistory: '更新說明',
|
||||
versionHistory: '版本歷史',
|
||||
releaseVersionsLoadFailed: 'Release 版本載入失敗',
|
||||
latestVersion: '最新',
|
||||
currentVersion: '當前',
|
||||
installReleaseVersion: '安裝',
|
||||
confirmInstallOldRelease:
|
||||
'是否確認安裝 {name} v{version}?該版本缺少主程序兼容元數據,安裝後可能無法載入或運行異常。',
|
||||
local: '本地',
|
||||
installToLocal: '安裝到本地',
|
||||
totalDownloads: '共 {count} 次下載',
|
||||
@@ -3184,6 +3264,10 @@ export default {
|
||||
sequentail: '順序下載',
|
||||
force_resume: '強制繼續',
|
||||
first_last_piece: '優先首尾文件',
|
||||
incomplete_files_ext: '未完成文件後綴',
|
||||
incomplete_files_extHint: '下載未完成時追加 .!qB 後綴,完成後自動移除',
|
||||
rename_partial_files: '未完成文件後綴',
|
||||
rename_partial_filesHint: '下載未完成時追加 .part 後綴,完成後自動移除',
|
||||
saveSuccess: '下載器設置保存成功',
|
||||
saveFailed: '下載器設置保存失敗',
|
||||
nameRequired: '名稱不能為空',
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getNavMenus } from '@/router/i18n-menu'
|
||||
import { usePluginSidebarNavStore, useUserStore } from '@/stores'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { filterPluginSidebarNavEntries } from '@/utils/pluginSidebarNav'
|
||||
import { filterMenusByPermission } from '@/utils/permission'
|
||||
import { buildUserPermissionContext, filterMenusByPermission } from '@/utils/permission'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -13,10 +13,7 @@ const userStore = useUserStore()
|
||||
const pluginSidebarNavStore = usePluginSidebarNavStore()
|
||||
|
||||
// 获取用户权限信息
|
||||
const userPermissions = computed(() => ({
|
||||
is_superuser: userStore.superUser,
|
||||
...userStore.permissions,
|
||||
}))
|
||||
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
|
||||
|
||||
// 应用分组(以header分组)
|
||||
const appGroups = ref<Record<string, NavMenu[]>>({})
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { GridStack } from 'gridstack'
|
||||
import type { GridItemHTMLElement, GridStackWidget } from 'gridstack'
|
||||
import type { ColumnOptions, GridItemHTMLElement, GridStackWidget } from 'gridstack'
|
||||
import 'gridstack/dist/gridstack.min.css'
|
||||
import api from '@/api'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import type { DashboardItem } from '@/api/types'
|
||||
import { useUserStore } from '@/stores'
|
||||
import DashboardElement from '@/components/misc/DashboardElement.vue'
|
||||
import { useDynamicButton, type DynamicButtonMenuItem } from '@/composables/useDynamicButton'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import { getItemColor, initializeItemColors } from '@/utils/colorUtils'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
const ContentToggleSettingsDialog = defineAsyncComponent(
|
||||
() => import('@/components/dialog/ContentToggleSettingsDialog.vue'),
|
||||
@@ -22,19 +24,37 @@ const { t } = useI18n()
|
||||
|
||||
// PWA模式检测
|
||||
const { appMode } = usePWA()
|
||||
const display = useDisplay()
|
||||
const userStore = useUserStore()
|
||||
const canAdmin = computed(() =>
|
||||
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'admin'),
|
||||
)
|
||||
|
||||
// 路由
|
||||
const route = useRoute()
|
||||
|
||||
// 从用户 Store 中获取superuser信息
|
||||
const superUser = useUserStore().superUser
|
||||
|
||||
const DASHBOARD_GRID_COLUMNS = 12
|
||||
const DASHBOARD_GRID_DESKTOP_BREAKPOINT = 1280
|
||||
const DASHBOARD_GRID_TABLET_BREAKPOINT = 960
|
||||
const DASHBOARD_GRID_MOBILE_BREAKPOINT = 640
|
||||
const DASHBOARD_GRID_CELL_HEIGHT = 16
|
||||
const DASHBOARD_GRID_FALLBACK_ROWS = 4
|
||||
const DASHBOARD_GRID_MARGIN = 8
|
||||
const DASHBOARD_GRID_CONTENT_RESIZE_THRESHOLD = 4
|
||||
const DASHBOARD_GRID_LAYOUT_STORAGE_KEY = 'MP_DASHBOARD_GRID_LAYOUT'
|
||||
const DASHBOARD_ENABLE_STORAGE_KEY = 'MP_DASHBOARD'
|
||||
const DASHBOARD_ORDER_STORAGE_KEY = 'MP_DASHBOARD_ORDER'
|
||||
const DASHBOARD_GRID_LAYOUT_STORAGE_KEY_PREFIX = 'MP_DASHBOARD_GRID_LAYOUT'
|
||||
const DASHBOARD_ENABLE_CONFIG_KEY = 'Dashboard'
|
||||
const DASHBOARD_ORDER_CONFIG_KEY = 'DashboardOrder'
|
||||
const DASHBOARD_GRID_LAYOUT_CONFIG_KEY = 'DashboardGridLayout'
|
||||
const DASHBOARD_GRID_LAYOUT_CONFIG_KEY_PREFIX = 'DashboardGridLayout'
|
||||
|
||||
type DashboardEnableConfig = Record<string, boolean>
|
||||
type DashboardOrderConfig = { id: string; key: string }[]
|
||||
type DashboardGridLayoutConfig = Record<string, DashboardGridLayoutItem>
|
||||
type DashboardConfigNormalizer<T> = (value: unknown) => T | undefined
|
||||
type DashboardConfigRemoteValueBuilder<T> = (value: T) => unknown
|
||||
type DashboardLayoutProfile = 'desktop' | 'tablet' | 'mobile'
|
||||
|
||||
interface DashboardGridLayoutItem {
|
||||
x?: number
|
||||
@@ -64,9 +84,6 @@ const dashboardGrid = shallowRef<GridStack | null>(null)
|
||||
// 仪表板配置是否已完成首次加载,包含插件仪表板配置。
|
||||
const isDashboardConfigLoaded = ref(false)
|
||||
|
||||
// 仪表板是否已完成首次整体渐现。
|
||||
const isDashboardRevealed = ref(false)
|
||||
|
||||
// 已完成组件模块加载的仪表板项目 ID。
|
||||
const loadedDashboardGridItemIds = ref<Set<string>>(new Set())
|
||||
|
||||
@@ -76,6 +93,9 @@ const isSyncingDashboardGrid = ref(false)
|
||||
// 仪表板本地布局覆盖配置
|
||||
const dashboardGridLayout = ref<Record<string, DashboardGridLayoutItem>>({})
|
||||
|
||||
// 当前仪表板布局档位,按 GridStack 响应式列数拆分跨端配置。
|
||||
const dashboardLayoutProfile = ref<DashboardLayoutProfile>('desktop')
|
||||
|
||||
// 是否刚恢复过默认布局,用于避免退出编辑时立即把默认布局写回本地覆盖。
|
||||
const isDashboardGridLayoutResetPending = ref(false)
|
||||
|
||||
@@ -265,7 +285,6 @@ function isDashboardGridReadyForReveal() {
|
||||
// 在配置、组件和 GridStack 都就绪后安排仪表板整体渐现。
|
||||
function scheduleDashboardReveal() {
|
||||
if (
|
||||
isDashboardRevealed.value ||
|
||||
isDashboardRevealPending ||
|
||||
dashboardRevealFrame !== null ||
|
||||
!isDashboardConfigLoaded.value ||
|
||||
@@ -278,25 +297,18 @@ function scheduleDashboardReveal() {
|
||||
isDashboardRevealPending = true
|
||||
void nextTick(() => {
|
||||
isDashboardRevealPending = false
|
||||
if (
|
||||
isDashboardRevealed.value ||
|
||||
!isDashboardConfigLoaded.value ||
|
||||
!isDashboardGridReadyForReveal() ||
|
||||
!areDashboardGridItemsLoaded()
|
||||
) {
|
||||
if (!isDashboardConfigLoaded.value || !isDashboardGridReadyForReveal() || !areDashboardGridItemsLoaded()) {
|
||||
return
|
||||
}
|
||||
|
||||
resizeAutoDashboardItemsToContent()
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
isDashboardRevealed.value = true
|
||||
return
|
||||
}
|
||||
|
||||
dashboardRevealFrame = window.requestAnimationFrame(() => {
|
||||
dashboardRevealFrame = null
|
||||
isDashboardRevealed.value = true
|
||||
notifyDashboardContentResize()
|
||||
})
|
||||
})
|
||||
@@ -318,42 +330,182 @@ function clampGridNumber(value: unknown, min: number, max: number, fallback: num
|
||||
return Math.min(max, Math.max(min, Math.round(numericValue)))
|
||||
}
|
||||
|
||||
// 读取并校验本地仪表板布局覆盖配置。
|
||||
function readDashboardGridLayout() {
|
||||
const rawLayout = localStorage.getItem(DASHBOARD_GRID_LAYOUT_STORAGE_KEY)
|
||||
if (!rawLayout) return {}
|
||||
// 校验并归一化仪表板显示配置,避免异常用户配置影响页面渲染。
|
||||
function normalizeDashboardEnableConfig(value: unknown): DashboardEnableConfig | undefined {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
|
||||
|
||||
try {
|
||||
const parsedLayout = JSON.parse(rawLayout) as Record<string, DashboardGridLayoutItem>
|
||||
const normalizedLayout: Record<string, DashboardGridLayoutItem> = {}
|
||||
return Object.entries(value).reduce<DashboardEnableConfig>((config, [key, enabled]) => {
|
||||
config[key] = Boolean(enabled)
|
||||
|
||||
Object.entries(parsedLayout).forEach(([id, layout]) => {
|
||||
if (!layout || typeof layout !== 'object') return
|
||||
const width = clampGridNumber(layout.w, 1, DASHBOARD_GRID_COLUMNS, DASHBOARD_GRID_COLUMNS)
|
||||
const normalizedItemLayout: DashboardGridLayoutItem = {
|
||||
x: clampGridNumber(layout.x, 0, DASHBOARD_GRID_COLUMNS - width, 0),
|
||||
y: clampGridNumber(layout.y, 0, 999, 0),
|
||||
w: width,
|
||||
}
|
||||
return config
|
||||
}, {})
|
||||
}
|
||||
|
||||
if (layout.h !== undefined) {
|
||||
normalizedItemLayout.h = clampGridNumber(layout.h, 1, 96, getDefaultDashboardGridRows())
|
||||
}
|
||||
// 校验并归一化仪表板顺序配置,只保留具备组件 ID 的项目。
|
||||
function normalizeDashboardOrderConfig(value: unknown): DashboardOrderConfig | undefined {
|
||||
if (!Array.isArray(value)) return undefined
|
||||
|
||||
normalizedLayout[id] = normalizedItemLayout
|
||||
return value.reduce<DashboardOrderConfig>((config, item) => {
|
||||
if (!item || typeof item !== 'object') return config
|
||||
|
||||
const rawItem = item as { id?: unknown; key?: unknown }
|
||||
if (typeof rawItem.id !== 'string' || !rawItem.id) return config
|
||||
|
||||
config.push({
|
||||
id: rawItem.id,
|
||||
key: typeof rawItem.key === 'string' ? rawItem.key : '',
|
||||
})
|
||||
|
||||
return normalizedLayout
|
||||
return config
|
||||
}, [])
|
||||
}
|
||||
|
||||
// 校验并归一化仪表板 Grid 布局覆盖配置,兼容旧版裸布局和新版服务端包装结构。
|
||||
function normalizeDashboardGridLayout(value: unknown): DashboardGridLayoutConfig | undefined {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
|
||||
|
||||
const configValue = value as { items?: unknown }
|
||||
const layoutValue = configValue.items && typeof configValue.items === 'object' ? configValue.items : value
|
||||
const normalizedLayout: DashboardGridLayoutConfig = {}
|
||||
|
||||
Object.entries(layoutValue).forEach(([id, layout]) => {
|
||||
if (!layout || typeof layout !== 'object') return
|
||||
|
||||
const rawLayout = layout as DashboardGridLayoutItem
|
||||
const width = clampGridNumber(rawLayout.w, 1, DASHBOARD_GRID_COLUMNS, DASHBOARD_GRID_COLUMNS)
|
||||
const normalizedItemLayout: DashboardGridLayoutItem = {
|
||||
x: clampGridNumber(rawLayout.x, 0, DASHBOARD_GRID_COLUMNS - width, 0),
|
||||
y: clampGridNumber(rawLayout.y, 0, 999, 0),
|
||||
w: width,
|
||||
}
|
||||
|
||||
if (rawLayout.h !== undefined) {
|
||||
normalizedItemLayout.h = clampGridNumber(rawLayout.h, 1, 96, getDefaultDashboardGridRows())
|
||||
}
|
||||
|
||||
normalizedLayout[id] = normalizedItemLayout
|
||||
})
|
||||
|
||||
return normalizedLayout
|
||||
}
|
||||
|
||||
// 构造服务端 Grid 布局配置,避免空布局被后端按空值删除后又被其他浏览器旧缓存回填。
|
||||
function buildRemoteDashboardGridLayout(layout: DashboardGridLayoutConfig) {
|
||||
return { items: layout }
|
||||
}
|
||||
|
||||
// 根据当前视口判断仪表板布局档位,避免手机和桌面共用 Grid 坐标。
|
||||
function resolveDashboardLayoutProfile(): DashboardLayoutProfile {
|
||||
const width = display.width.value || (typeof window === 'undefined' ? DASHBOARD_GRID_DESKTOP_BREAKPOINT : window.innerWidth)
|
||||
|
||||
if (width <= DASHBOARD_GRID_MOBILE_BREAKPOINT) return 'mobile'
|
||||
if (width <= DASHBOARD_GRID_TABLET_BREAKPOINT) return 'tablet'
|
||||
|
||||
return 'desktop'
|
||||
}
|
||||
|
||||
// 获取当前布局档位对应的 GridStack 列数。
|
||||
function getDashboardGridColumnsForProfile(profile: DashboardLayoutProfile) {
|
||||
if (profile === 'mobile') return 1
|
||||
if (profile === 'tablet') return 6
|
||||
|
||||
return DASHBOARD_GRID_COLUMNS
|
||||
}
|
||||
|
||||
// 获取当前 Grid 实际列数,用于按布局档位保存当前坐标。
|
||||
function getCurrentDashboardGridColumns() {
|
||||
return dashboardGrid.value?.getColumn() ?? getDashboardGridColumnsForProfile(dashboardLayoutProfile.value)
|
||||
}
|
||||
|
||||
// 获取当前布局档位的 GridStack 列变化策略。
|
||||
function getDashboardGridColumnLayout(profile: DashboardLayoutProfile): ColumnOptions {
|
||||
return profile === 'mobile' ? 'list' : 'moveScale'
|
||||
}
|
||||
|
||||
// 获取布局档位对应的本地存储键,桌面沿用旧键以兼容已有配置。
|
||||
function getDashboardGridLayoutStorageKey(profile: DashboardLayoutProfile) {
|
||||
if (profile === 'desktop') return DASHBOARD_GRID_LAYOUT_STORAGE_KEY_PREFIX
|
||||
|
||||
return `${DASHBOARD_GRID_LAYOUT_STORAGE_KEY_PREFIX}_${profile.toUpperCase()}`
|
||||
}
|
||||
|
||||
// 获取布局档位对应的用户配置键,桌面沿用旧键以兼容已同步配置。
|
||||
function getDashboardGridLayoutConfigKey(profile: DashboardLayoutProfile) {
|
||||
if (profile === 'desktop') return DASHBOARD_GRID_LAYOUT_CONFIG_KEY
|
||||
|
||||
return `${DASHBOARD_GRID_LAYOUT_CONFIG_KEY_PREFIX}${profile === 'mobile' ? 'Mobile' : 'Tablet'}`
|
||||
}
|
||||
|
||||
// 加载指定布局档位的 Grid 布局配置。
|
||||
async function loadDashboardGridLayoutConfig(profile: DashboardLayoutProfile) {
|
||||
return await loadSharedDashboardConfig(
|
||||
getDashboardGridLayoutConfigKey(profile),
|
||||
getDashboardGridLayoutStorageKey(profile),
|
||||
normalizeDashboardGridLayout,
|
||||
buildRemoteDashboardGridLayout,
|
||||
)
|
||||
}
|
||||
|
||||
// 从本地存储读取并归一化指定的仪表板配置。
|
||||
function readLocalDashboardConfig<T>(storageKey: string, normalize: DashboardConfigNormalizer<T>) {
|
||||
const rawConfig = localStorage.getItem(storageKey)
|
||||
if (!rawConfig) return undefined
|
||||
|
||||
try {
|
||||
return normalize(JSON.parse(rawConfig))
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
|
||||
return {}
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
// 将当前仪表板布局覆盖配置保存到本地。
|
||||
// 将仪表板配置写入本地存储,保留离线和接口失败时的兜底能力。
|
||||
function saveLocalDashboardConfig(storageKey: string, value: unknown) {
|
||||
localStorage.setItem(storageKey, JSON.stringify(value))
|
||||
}
|
||||
|
||||
// 将仪表板配置写入用户配置,用于跨浏览器共享。
|
||||
async function saveUserDashboardConfig(configKey: string, value: unknown) {
|
||||
await api.post(`/user/config/${configKey}`, value)
|
||||
}
|
||||
|
||||
// 优先加载用户配置;服务端缺失时使用本地历史配置并回填到用户配置。
|
||||
async function loadSharedDashboardConfig<T>(
|
||||
configKey: string,
|
||||
storageKey: string,
|
||||
normalize: DashboardConfigNormalizer<T>,
|
||||
buildRemoteValue: DashboardConfigRemoteValueBuilder<T> = value => value,
|
||||
) {
|
||||
const localConfig = readLocalDashboardConfig(storageKey, normalize)
|
||||
|
||||
try {
|
||||
const response = await api.get(`/user/config/${configKey}`)
|
||||
const remoteConfig = normalize(response?.data?.value)
|
||||
|
||||
if (remoteConfig !== undefined) {
|
||||
saveLocalDashboardConfig(storageKey, remoteConfig)
|
||||
|
||||
return remoteConfig
|
||||
}
|
||||
|
||||
if (localConfig !== undefined) {
|
||||
await saveUserDashboardConfig(configKey, buildRemoteValue(localConfig))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
|
||||
return localConfig
|
||||
}
|
||||
|
||||
// 将当前仪表板布局覆盖配置保存到本地和用户配置。
|
||||
function saveDashboardGridLayout(layout: Record<string, DashboardGridLayoutItem>) {
|
||||
localStorage.setItem(DASHBOARD_GRID_LAYOUT_STORAGE_KEY, JSON.stringify(layout))
|
||||
const profile = dashboardLayoutProfile.value
|
||||
saveLocalDashboardConfig(getDashboardGridLayoutStorageKey(profile), layout)
|
||||
void saveUserDashboardConfig(getDashboardGridLayoutConfigKey(profile), buildRemoteDashboardGridLayout(layout)).catch(
|
||||
error => console.error(error),
|
||||
)
|
||||
}
|
||||
|
||||
// 获取仪表板组件的默认宽度,优先兼容插件旧版 cols.md / cols.cols 配置。
|
||||
@@ -369,9 +521,10 @@ function getDefaultDashboardGridRows(item?: DashboardItem) {
|
||||
// 合并插件/内置组件默认尺寸与用户本地布局覆盖。
|
||||
function buildDashboardGridWidget(item: DashboardItem, id: string): GridStackWidget {
|
||||
const savedLayout = dashboardGridLayout.value[id]
|
||||
const gridColumns = getDashboardGridColumnsForProfile(dashboardLayoutProfile.value)
|
||||
const width = savedLayout?.w ?? getDefaultDashboardGridWidth(item)
|
||||
const height = savedLayout?.h ?? getDefaultDashboardGridRows(item)
|
||||
const normalizedWidth = clampGridNumber(width, 1, DASHBOARD_GRID_COLUMNS, DASHBOARD_GRID_COLUMNS)
|
||||
const normalizedWidth = clampGridNumber(width, 1, gridColumns, gridColumns)
|
||||
const widget: GridStackWidget = {
|
||||
id,
|
||||
w: normalizedWidth,
|
||||
@@ -381,7 +534,7 @@ function buildDashboardGridWidget(item: DashboardItem, id: string): GridStackWid
|
||||
}
|
||||
|
||||
if (savedLayout?.x !== undefined && savedLayout?.y !== undefined) {
|
||||
widget.x = clampGridNumber(savedLayout.x, 0, DASHBOARD_GRID_COLUMNS - normalizedWidth, 0)
|
||||
widget.x = clampGridNumber(savedLayout.x, 0, gridColumns - normalizedWidth, 0)
|
||||
widget.y = clampGridNumber(savedLayout.y, 0, 999, 0)
|
||||
} else {
|
||||
widget.autoPosition = true
|
||||
@@ -443,7 +596,7 @@ function exitDashboardLayoutEditing() {
|
||||
// 清除用户本地布局覆盖,并恢复内置组件和插件声明的默认占位,然后退出编辑模式。
|
||||
async function resetDashboardGridLayout() {
|
||||
dashboardGridLayout.value = {}
|
||||
localStorage.removeItem(DASHBOARD_GRID_LAYOUT_STORAGE_KEY)
|
||||
saveDashboardGridLayout({})
|
||||
dashboardGrid.value?.removeAll(false, false)
|
||||
isDashboardGridLayoutResetPending.value = true
|
||||
await syncDashboardGrid()
|
||||
@@ -461,6 +614,7 @@ const dashboardDynamicButtonMenuItems = computed<DynamicButtonMenuItem[] | undef
|
||||
title: isLayoutEditing.value ? t('dashboard.exitEditMode') : t('dashboard.editLayout'),
|
||||
icon: isLayoutEditing.value ? 'mdi-check' : 'mdi-view-dashboard-edit',
|
||||
color: 'primary',
|
||||
permission: 'admin',
|
||||
action: toggleDashboardLayoutEditing,
|
||||
},
|
||||
]
|
||||
@@ -470,6 +624,7 @@ const dashboardDynamicButtonMenuItems = computed<DynamicButtonMenuItem[] | undef
|
||||
title: t('dashboard.resetLayout'),
|
||||
icon: 'mdi-restore',
|
||||
color: 'warning',
|
||||
permission: 'admin',
|
||||
action: resetDashboardGridLayout,
|
||||
})
|
||||
}
|
||||
@@ -478,6 +633,7 @@ const dashboardDynamicButtonMenuItems = computed<DynamicButtonMenuItem[] | undef
|
||||
title: t('dashboard.settings'),
|
||||
icon: 'mdi-tune',
|
||||
color: 'info',
|
||||
permission: 'admin',
|
||||
action: openDashboardSettings,
|
||||
})
|
||||
|
||||
@@ -487,6 +643,7 @@ const dashboardDynamicButtonMenuItems = computed<DynamicButtonMenuItem[] | undef
|
||||
useDynamicButton({
|
||||
icon: 'mdi-view-dashboard-edit',
|
||||
menuItems: dashboardDynamicButtonMenuItems,
|
||||
permission: 'admin',
|
||||
show: computed(() => appMode.value && route.path === '/dashboard'),
|
||||
})
|
||||
|
||||
@@ -502,32 +659,31 @@ function toggleDashboardLayoutEditing() {
|
||||
nextTick(syncDashboardGrid)
|
||||
}
|
||||
|
||||
// 加载用户监控面板配置(本地无配置时才加载)
|
||||
// 加载用户监控面板配置,优先使用服务端用户配置以支持跨浏览器同步。
|
||||
async function loadDashboardConfig() {
|
||||
dashboardLayoutProfile.value = resolveDashboardLayoutProfile()
|
||||
// 显示配置
|
||||
const local_enable = localStorage.getItem('MP_DASHBOARD')
|
||||
if (local_enable) {
|
||||
enableConfig.value = JSON.parse(local_enable)
|
||||
} else {
|
||||
const response = await api.get('/user/config/Dashboard')
|
||||
if (response && response.data && response.data.value) {
|
||||
enableConfig.value = response.data.value
|
||||
localStorage.setItem('MP_DASHBOARD', JSON.stringify(response.data.value))
|
||||
}
|
||||
const enable = await loadSharedDashboardConfig(
|
||||
DASHBOARD_ENABLE_CONFIG_KEY,
|
||||
DASHBOARD_ENABLE_STORAGE_KEY,
|
||||
normalizeDashboardEnableConfig,
|
||||
)
|
||||
if (enable !== undefined) {
|
||||
enableConfig.value = enable
|
||||
}
|
||||
// 顺序配置
|
||||
const local_order = localStorage.getItem('MP_DASHBOARD_ORDER')
|
||||
if (local_order) {
|
||||
orderConfig.value = JSON.parse(local_order)
|
||||
} else {
|
||||
const response2 = await api.get('/user/config/DashboardOrder')
|
||||
if (response2 && response2.data && response2.data.value) {
|
||||
orderConfig.value = response2.data.value
|
||||
localStorage.setItem('MP_DASHBOARD_ORDER', JSON.stringify(orderConfig.value))
|
||||
}
|
||||
const order = await loadSharedDashboardConfig(
|
||||
DASHBOARD_ORDER_CONFIG_KEY,
|
||||
DASHBOARD_ORDER_STORAGE_KEY,
|
||||
normalizeDashboardOrderConfig,
|
||||
)
|
||||
if (order !== undefined) {
|
||||
orderConfig.value = order
|
||||
}
|
||||
// 本地 Grid 布局覆盖
|
||||
dashboardGridLayout.value = readDashboardGridLayout()
|
||||
// Grid 布局覆盖
|
||||
const gridLayoutProfile = dashboardLayoutProfile.value
|
||||
const gridLayout = await loadDashboardGridLayoutConfig(gridLayoutProfile)
|
||||
dashboardGridLayout.value = gridLayout ?? {}
|
||||
// 排序
|
||||
if (orderConfig.value) {
|
||||
sortDashboardConfigs()
|
||||
@@ -554,18 +710,16 @@ async function saveDashboardConfig(payload?: { enabled?: Record<string, boolean>
|
||||
}
|
||||
|
||||
// 启用配置
|
||||
const enableString = JSON.stringify(enableConfig.value)
|
||||
localStorage.setItem('MP_DASHBOARD', enableString)
|
||||
saveLocalDashboardConfig(DASHBOARD_ENABLE_STORAGE_KEY, enableConfig.value)
|
||||
|
||||
// 顺序配置,从dashboardConfigs中提取
|
||||
const orderObj = dashboardConfigs.value.map(item => ({ id: item.id, key: item.key }))
|
||||
const orderString = JSON.stringify(orderObj)
|
||||
localStorage.setItem('MP_DASHBOARD_ORDER', orderString)
|
||||
saveLocalDashboardConfig(DASHBOARD_ORDER_STORAGE_KEY, orderObj)
|
||||
|
||||
// 保存到服务端
|
||||
try {
|
||||
await api.post('/user/config/Dashboard', enableConfig.value)
|
||||
await api.post('/user/config/DashboardOrder', orderObj)
|
||||
await saveUserDashboardConfig(DASHBOARD_ENABLE_CONFIG_KEY, enableConfig.value)
|
||||
await saveUserDashboardConfig(DASHBOARD_ORDER_CONFIG_KEY, orderObj)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
@@ -583,9 +737,6 @@ function buildPluginDashboardId(plugin_id: string, key: string) {
|
||||
|
||||
// 调用API获取所有插件的仪表板元信息
|
||||
async function getPluginDashboardMeta() {
|
||||
// 只有超级用户才能获取
|
||||
if (!superUser) return
|
||||
|
||||
try {
|
||||
pluginDashboardMeta.value = (await api.get('/plugin/dashboard/meta')) ?? []
|
||||
if (!isNullOrEmptyObject(pluginDashboardMeta.value)) {
|
||||
@@ -628,7 +779,7 @@ function schedulePluginDashboardRefresh(item: DashboardItem) {
|
||||
}
|
||||
|
||||
function refreshEnabledPluginDashboards() {
|
||||
if (!superUser || isNullOrEmptyObject(pluginDashboardMeta.value)) return
|
||||
if (isNullOrEmptyObject(pluginDashboardMeta.value)) return
|
||||
|
||||
pluginDashboardMeta.value.forEach((pluginDashboard: { id: string; key: string }) => {
|
||||
const pluginDashboardId = buildPluginDashboardId(pluginDashboard.id, pluginDashboard.key)
|
||||
@@ -684,9 +835,9 @@ function initializeDashboardGrid() {
|
||||
column: DASHBOARD_GRID_COLUMNS,
|
||||
columnOpts: {
|
||||
breakpoints: [
|
||||
{ w: 640, c: 1, layout: 'list' },
|
||||
{ w: 960, c: 6, layout: 'moveScale' },
|
||||
{ w: 1280, c: DASHBOARD_GRID_COLUMNS, layout: 'moveScale' },
|
||||
{ w: DASHBOARD_GRID_MOBILE_BREAKPOINT, c: 1, layout: 'list' },
|
||||
{ w: DASHBOARD_GRID_TABLET_BREAKPOINT, c: 6, layout: 'moveScale' },
|
||||
{ w: DASHBOARD_GRID_DESKTOP_BREAKPOINT, c: DASHBOARD_GRID_COLUMNS, layout: 'moveScale' },
|
||||
],
|
||||
layout: 'moveScale',
|
||||
},
|
||||
@@ -901,7 +1052,8 @@ function notifyDashboardContentResize() {
|
||||
function persistDashboardGridLayout(manualHeightId: string | false = false) {
|
||||
if (!dashboardGrid.value || isSyncingDashboardGrid.value) return
|
||||
|
||||
const savedWidgets = dashboardGrid.value.save(false, false, undefined, DASHBOARD_GRID_COLUMNS)
|
||||
const gridColumns = getCurrentDashboardGridColumns()
|
||||
const savedWidgets = dashboardGrid.value.save(false, false, undefined, gridColumns)
|
||||
const widgets = Array.isArray(savedWidgets) ? savedWidgets : (savedWidgets.children ?? [])
|
||||
const nextLayout = { ...dashboardGridLayout.value }
|
||||
|
||||
@@ -909,10 +1061,10 @@ function persistDashboardGridLayout(manualHeightId: string | false = false) {
|
||||
if (!widget.id) return
|
||||
|
||||
const id = String(widget.id)
|
||||
const width = clampGridNumber(widget.w, 1, DASHBOARD_GRID_COLUMNS, getDefaultDashboardGridWidthById(id))
|
||||
const width = clampGridNumber(widget.w, 1, gridColumns, getDefaultDashboardGridWidthById(id, gridColumns))
|
||||
const previousLayout = dashboardGridLayout.value[id]
|
||||
const nextItemLayout: DashboardGridLayoutItem = {
|
||||
x: clampGridNumber(widget.x, 0, DASHBOARD_GRID_COLUMNS - width, 0),
|
||||
x: clampGridNumber(widget.x, 0, gridColumns - width, 0),
|
||||
y: clampGridNumber(widget.y, 0, 999, 0),
|
||||
w: width,
|
||||
}
|
||||
@@ -930,10 +1082,10 @@ function persistDashboardGridLayout(manualHeightId: string | false = false) {
|
||||
}
|
||||
|
||||
// 根据组件 ID 查找默认宽度,保存布局时用于兜底。
|
||||
function getDefaultDashboardGridWidthById(id: string) {
|
||||
function getDefaultDashboardGridWidthById(id: string, maxColumns = DASHBOARD_GRID_COLUMNS) {
|
||||
const item = dashboardConfigs.value.find(config => buildPluginDashboardId(config.id, config.key) === id)
|
||||
|
||||
return item ? getDefaultDashboardGridWidth(item) : DASHBOARD_GRID_COLUMNS
|
||||
return item ? Math.min(getDefaultDashboardGridWidth(item), maxColumns) : maxColumns
|
||||
}
|
||||
|
||||
// 压实 GridStack 布局并保存本地占位信息。
|
||||
@@ -959,6 +1111,25 @@ watch(
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => display.width.value,
|
||||
async () => {
|
||||
const nextProfile = resolveDashboardLayoutProfile()
|
||||
if (nextProfile === dashboardLayoutProfile.value) return
|
||||
|
||||
if (dashboardGrid.value && !isSyncingDashboardGrid.value && !isDashboardGridLayoutResetPending.value) {
|
||||
persistDashboardGridLayout(false)
|
||||
}
|
||||
|
||||
dashboardLayoutProfile.value = nextProfile
|
||||
dashboardGridLayout.value = (await loadDashboardGridLayoutConfig(nextProfile)) ?? {}
|
||||
dashboardGrid.value?.column(getDashboardGridColumnsForProfile(nextProfile), getDashboardGridColumnLayout(nextProfile))
|
||||
dashboardGrid.value?.removeAll(false, false)
|
||||
await syncDashboardGrid()
|
||||
notifyDashboardContentResize()
|
||||
},
|
||||
)
|
||||
|
||||
onBeforeMount(async () => {
|
||||
await loadDashboardConfig()
|
||||
initializeColors()
|
||||
@@ -1007,14 +1178,8 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LoadingBanner v-if="!isDashboardRevealed" class="mt-12" />
|
||||
|
||||
<!-- 仪表板 -->
|
||||
<div
|
||||
ref="dashboardGridRef"
|
||||
class="grid-stack dashboard-grid"
|
||||
:class="{ 'is-editing': isLayoutEditing, 'is-ready': isDashboardRevealed }"
|
||||
>
|
||||
<div ref="dashboardGridRef" class="grid-stack dashboard-grid" :class="{ 'is-editing': isLayoutEditing }">
|
||||
<div
|
||||
v-for="gridItem in dashboardGridItems"
|
||||
:key="gridItem.id"
|
||||
@@ -1048,7 +1213,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<Teleport to="body" v-if="!appMode && route.path === '/dashboard'">
|
||||
<div class="compact-fab-stack">
|
||||
<div v-if="canAdmin" class="compact-fab-stack">
|
||||
<VFab
|
||||
icon="mdi-tune"
|
||||
color="info"
|
||||
@@ -1081,21 +1246,13 @@ onBeforeUnmount(() => {
|
||||
/* stylelint-disable selector-pseudo-class-no-unknown */
|
||||
|
||||
.dashboard-grid {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(8px);
|
||||
pointer-events: auto;
|
||||
transition:
|
||||
opacity 0.45s cubic-bezier(0.25, 1, 0.5, 1),
|
||||
transform 0.45s cubic-bezier(0.25, 1, 0.5, 1);
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
.dashboard-grid.is-ready {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.dashboard-grid-item.is-manual-height :deep(.v-card) {
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user