mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-06-25 09:33:51 +08:00
Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
530fe9d35b | ||
|
|
48ed396a19 | ||
|
|
b0356c217d | ||
|
|
55eed1ecb5 | ||
|
|
50ae739a4d | ||
|
|
d9cbcc2991 | ||
|
|
ad12701fe2 | ||
|
|
2b426a47c6 | ||
|
|
7f0f12ac41 | ||
|
|
6789d63ca1 | ||
|
|
3202251f55 | ||
|
|
8e99ad9cf9 | ||
|
|
83dde400e7 | ||
|
|
1b57f925ee | ||
|
|
16428066b9 | ||
|
|
e211a80cf4 | ||
|
|
ea0b5b62d9 | ||
|
|
62dc2c4f66 | ||
|
|
b2a2c7080e | ||
|
|
05c2e7855a | ||
|
|
8d9c622dc5 | ||
|
|
bf0b17c314 | ||
|
|
37f31f6554 | ||
|
|
3de409fb07 | ||
|
|
7e9c0fd206 | ||
|
|
fb4f5658a8 | ||
|
|
a9f4ec963b | ||
|
|
542e33d7b4 | ||
|
|
39c250ba09 | ||
|
|
924fcef403 | ||
|
|
e586342b19 | ||
|
|
c795de9b2d | ||
|
|
6fa1cf28f4 | ||
|
|
3f70aafdad | ||
|
|
f8ceee39b3 | ||
|
|
0a22f33e34 | ||
|
|
cf88ed9a58 | ||
|
|
49dfd794c1 | ||
|
|
68f2f010d1 | ||
|
|
9eed2fea87 |
123
.github/workflows/pr-agent.yml
vendored
Normal file
123
.github/workflows/pr-agent.yml
vendored
Normal file
@@ -0,0 +1,123 @@
|
||||
name: PR Agent
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
# PR-Agent 通过 base repo 上下文读取 PR diff 并发布 Review,不 checkout 或执行 PR 分支代码。
|
||||
# pull_request_target 允许 fork PR 使用仓库 secrets,因此 workflow 只运行固定 digest 的 PR-Agent 容器。
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- ready_for_review
|
||||
- review_requested
|
||||
- synchronize
|
||||
issue_comment:
|
||||
# 手动命令如 "/review"、"/describe"、"/improve" 和 "/ask ..." 只在 PR 评论中有意义。
|
||||
# issue_comment 同时覆盖普通 issue,因此 job 里还会再判断是否属于 PR。
|
||||
types:
|
||||
- created
|
||||
- edited
|
||||
|
||||
permissions:
|
||||
# 读取仓库内容和 PR diff。
|
||||
contents: read
|
||||
# 更新 PR 描述、发布 PR Review 或修改 PR 相关元数据。
|
||||
pull-requests: write
|
||||
# PR 评论在 GitHub API 中属于 issue comments,手动命令和总结评论需要该权限。
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
pr-agent:
|
||||
name: PR-Agent review and describe
|
||||
# PR 事件自动处理;评论命令仅允许指定身份在 PR 下触发,避免任意评论消耗模型配额。
|
||||
if: >-
|
||||
github.event.sender.type != 'Bot' &&
|
||||
(
|
||||
github.event_name == 'pull_request_target' ||
|
||||
(
|
||||
github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request != null &&
|
||||
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIME_CONTRIBUTOR"]'), github.event.comment.author_association) &&
|
||||
(
|
||||
github.event.comment.body == '/review' ||
|
||||
startsWith(github.event.comment.body, '/review ') ||
|
||||
github.event.comment.body == '/describe' ||
|
||||
startsWith(github.event.comment.body, '/describe ') ||
|
||||
github.event.comment.body == '/improve' ||
|
||||
startsWith(github.event.comment.body, '/improve ') ||
|
||||
github.event.comment.body == '/ask' ||
|
||||
startsWith(github.event.comment.body, '/ask ')
|
||||
)
|
||||
)
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Run PR-Agent
|
||||
id: pragent
|
||||
# 使用版本号加 digest 固定容器构建,避免 tag 被重推后改变运行内容。
|
||||
uses: docker://pragent/pr-agent:0.37.0-github_action@sha256:4ec7bac814050a1bc8c96ab2fab6b7b0f65df0049a5ec43f3fee1a0b551c28ca
|
||||
env:
|
||||
# PR-Agent 使用该 token 读取 PR 元数据并发布评论。
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
# 仓库设置中添加的 Secret:Settings -> Secrets and variables -> Actions。
|
||||
# 该 key 只传给 PR-Agent 运行时,不写入仓库。
|
||||
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
|
||||
|
||||
# 仓库设置中添加的 Secret。OpenAI 兼容服务通常需要填写以 "/v1" 结尾的 API 根地址。
|
||||
OPENAI.API_BASE: ${{ secrets.OPENAI_API_BASE }}
|
||||
|
||||
# 模型、输出语言和大 diff 处理策略。
|
||||
config.model: "gpt-5.5"
|
||||
config.fallback_models: '["gpt-5.4"]'
|
||||
config.reasoning_effort: "xhigh"
|
||||
config.ai_timeout: "900"
|
||||
config.response_language: "zh-CN"
|
||||
config.large_patch_policy: "clip"
|
||||
config.ignore_pr_title: '["^\\[Auto\\]", "^Auto"]'
|
||||
config.ignore_pr_labels: '["skip pr-agent"]'
|
||||
|
||||
# pull_request_target 事件默认自动执行 /review 和 /describe;/improve 保持手动触发。
|
||||
github_action_config.auto_review: "true"
|
||||
github_action_config.auto_describe: "true"
|
||||
github_action_config.auto_improve: "false"
|
||||
|
||||
# 允许触发自动工具的 PR 动作。包含 synchronize,便于新 commit 推送后刷新结果。
|
||||
github_action_config.pr_actions: '["opened", "reopened", "ready_for_review", "review_requested", "synchronize"]'
|
||||
|
||||
# 保留 action outputs,便于后续 workflow 编排或排查。
|
||||
github_action_config.enable_output: "true"
|
||||
|
||||
# /describe 行为控制;与自动触发配置放在同一层,避免使用默认图表和标签策略。
|
||||
pr_description.generate_ai_title: "false"
|
||||
pr_description.publish_labels: "false"
|
||||
pr_description.enable_pr_diagram: "false"
|
||||
pr_description.collapsible_file_list: "adaptive"
|
||||
pr_description.add_original_user_description: "true"
|
||||
|
||||
# /review 输出策略,聚焦维护者需要处理的风险和缺口。
|
||||
pr_reviewer.extra_instructions: |
|
||||
请用中文输出。
|
||||
优先指出 P0/P1 风险,避免纠结纯格式问题。
|
||||
重点检查安全、权限、状态一致性、异步/缓存、副作用和测试缺口。
|
||||
pr_reviewer.num_max_findings: "5"
|
||||
pr_reviewer.persistent_comment: "true"
|
||||
pr_reviewer.publish_output_no_suggestions: "true"
|
||||
pr_reviewer.require_tests_review: "true"
|
||||
pr_reviewer.require_security_review: "true"
|
||||
pr_reviewer.require_estimate_effort_to_review: "true"
|
||||
pr_reviewer.require_can_be_split_review: "true"
|
||||
pr_reviewer.require_todo_scan: "false"
|
||||
pr_reviewer.enable_review_labels_effort: "false"
|
||||
pr_reviewer.enable_review_labels_security: "true"
|
||||
|
||||
# /improve 和 /ask 的手动命令策略。
|
||||
pr_code_suggestions.focus_only_on_problems: "true"
|
||||
pr_code_suggestions.suggestions_score_threshold: "7"
|
||||
pr_code_suggestions.commitable_code_suggestions: "false"
|
||||
pr_questions.use_conversation_history: "true"
|
||||
|
||||
# 可选成本和噪音控制:
|
||||
# github_action_config.auto_improve: "true"
|
||||
# config.verbosity_level: "1"
|
||||
# pr_reviewer.num_max_findings: "3"
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -37,3 +37,4 @@ src/@iconify/*.js
|
||||
public/plugin_icon/**
|
||||
docs-lock/
|
||||
.trae/
|
||||
output/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "moviepilot",
|
||||
"version": "2.13.11",
|
||||
"version": "2.13.15",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": "dist/service.js",
|
||||
|
||||
@@ -35,6 +35,23 @@ http {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location = /service-worker.js {
|
||||
# Service Worker 必须保持稳定 URL 并每次重新验证,避免前端更新后继续注册旧版本。
|
||||
expires off;
|
||||
add_header Cache-Control "no-cache, must-revalidate";
|
||||
root html;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location = /manifest.webmanifest {
|
||||
# Web App Manifest 参与 PWA 安装与资源发现,不能跟普通静态资源一起长缓存。
|
||||
expires off;
|
||||
default_type application/manifest+json;
|
||||
add_header Cache-Control "no-cache, must-revalidate";
|
||||
root html;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||
# 静态资源
|
||||
expires 1y;
|
||||
@@ -44,8 +61,7 @@ http {
|
||||
|
||||
location /assets {
|
||||
# 静态资源
|
||||
expires 1y;
|
||||
add_header Cache-Control "public";
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
root html;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { checkAndEmitUnreadMessages } from '@/utils/badge'
|
||||
import { preloadImage } from './@core/utils/image'
|
||||
import { globalLoadingStateManager } from '@/utils/loadingStateManager'
|
||||
import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager'
|
||||
import PWAInstallPrompt from '@/components/PWAInstallPrompt.vue'
|
||||
import PWAInstallPrompt from '@/components/pwa/PWAInstallPrompt.vue'
|
||||
import SharedDialogHost from '@/components/dialog/SharedDialogHost.vue'
|
||||
import { applyStoredThemeCustomizerAppearance } from '@/composables/useThemeCustomizer'
|
||||
import { completeLaunchLoading } from '@/composables/useLaunchLoading'
|
||||
|
||||
2505
src/components/agent/AgentAssistantEntry.vue
Normal file
2505
src/components/agent/AgentAssistantEntry.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import mdLinkAttributes from 'markdown-it-link-attributes'
|
||||
import type { PerfectScrollbarExpose } from 'vue3-perfect-scrollbar'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore, useUserStore } from '@/stores'
|
||||
@@ -119,6 +118,21 @@ const display = useDisplay()
|
||||
const authStore = useAuthStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: boolean
|
||||
}>(),
|
||||
{
|
||||
modelValue: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'assistant-preview': [value: string]
|
||||
'thinking-change': [value: boolean]
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
|
||||
const STORAGE_KEY = 'moviepilot-agent-assistant-state'
|
||||
const HISTORY_STORAGE_KEY = 'moviepilot-agent-assistant-history'
|
||||
const HISTORY_PAGE_SIZE = 30
|
||||
@@ -126,8 +140,9 @@ const MAX_LOCAL_HISTORY_SESSIONS = 120
|
||||
const MAX_PERSISTED_MESSAGES = 30
|
||||
const HISTORY_TITLE_LENGTH = 36
|
||||
const HISTORY_ITEM_HEIGHT = 76
|
||||
const MESSAGE_SCROLL_FOLLOW_THRESHOLD = 96
|
||||
const STREAM_STATE_PERSIST_DELAY = 1000
|
||||
|
||||
const drawer = ref(false)
|
||||
const inputText = ref('')
|
||||
const messages = ref<AgentChatMessage[]>([])
|
||||
const historySessions = ref<AgentSessionHistoryItem[]>([])
|
||||
@@ -135,7 +150,7 @@ const sessionId = ref('')
|
||||
const sending = ref(false)
|
||||
const streamError = ref('')
|
||||
const historyMenuOpen = ref(false)
|
||||
const messageListRef = ref<PerfectScrollbarExpose | null>(null)
|
||||
const messageListRef = ref<HTMLElement | null>(null)
|
||||
const inputRef = ref<HTMLTextAreaElement | null>(null)
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
const pendingAttachments = ref<AgentPendingAttachment[]>([])
|
||||
@@ -151,6 +166,9 @@ let mediaRecorder: MediaRecorder | null = null
|
||||
let mediaRecorderStream: MediaStream | null = null
|
||||
let recordingTimer: number | null = null
|
||||
let recordingChunks: BlobPart[] = []
|
||||
let messageScrollFrame: number | null = null
|
||||
let pendingMessageScrollToBottom = false
|
||||
let streamPersistTimer: number | null = null
|
||||
|
||||
const md = new MarkdownIt({
|
||||
html: true,
|
||||
@@ -167,7 +185,8 @@ md.use(mdLinkAttributes, {
|
||||
})
|
||||
|
||||
const canSend = computed(
|
||||
() => (inputText.value.trim().length > 0 || pendingAttachments.value.length > 0) && !sending.value && !recording.value,
|
||||
() =>
|
||||
(inputText.value.trim().length > 0 || pendingAttachments.value.length > 0) && !sending.value && !recording.value,
|
||||
)
|
||||
const canRecord = computed(() => !sending.value && !recording.value)
|
||||
const recordingTimeText = computed(() => {
|
||||
@@ -182,6 +201,10 @@ const drawerWidth = computed(() => (display.mdAndDown.value ? '100vw' : '30rem')
|
||||
const hasMessages = computed(() => messages.value.length > 0)
|
||||
const hasHistorySessions = computed(() => historySessions.value.length > 0)
|
||||
const currentUserName = computed(() => userStore.getUserName || t('common.user'))
|
||||
const isOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => emit('update:modelValue', value),
|
||||
})
|
||||
const drawerStyle = computed(() => ({
|
||||
'--agent-assistant-panel-width': drawerWidth.value,
|
||||
}))
|
||||
@@ -254,7 +277,9 @@ function normalizeServerSession(item: AgentServerSession, withMessages = false):
|
||||
return {
|
||||
sessionId: sessionIdValue,
|
||||
clientSessionId: item.client_session_id,
|
||||
title: item.title?.trim() || (messages.length ? buildSessionHistoryTitle(messages) : t('agentAssistant.untitledSession')),
|
||||
title:
|
||||
item.title?.trim() ||
|
||||
(messages.length ? buildSessionHistoryTitle(messages) : t('agentAssistant.untitledSession')),
|
||||
preview: item.preview,
|
||||
channel: item.channel,
|
||||
source: item.source,
|
||||
@@ -320,7 +345,9 @@ async function loadServerHistorySessions() {
|
||||
try {
|
||||
const data = await fetchAgentApi(`message/agent/sessions?page=1&count=${HISTORY_PAGE_SIZE}`)
|
||||
const sessions = Array.isArray(data)
|
||||
? data.map(item => normalizeServerSession(item as AgentServerSession)).filter(Boolean) as AgentSessionHistoryItem[]
|
||||
? (data
|
||||
.map(item => normalizeServerSession(item as AgentServerSession))
|
||||
.filter(Boolean) as AgentSessionHistoryItem[])
|
||||
: []
|
||||
historySessions.value = dedupeHistorySessions(sessions)
|
||||
historyHasMore.value = sessions.length >= HISTORY_PAGE_SIZE
|
||||
@@ -344,14 +371,15 @@ async function loadMoreServerHistorySessions(options?: { done?: (status: Infinit
|
||||
const nextPage = historyPage.value + 1
|
||||
const data = await fetchAgentApi(`message/agent/sessions?page=${nextPage}&count=${HISTORY_PAGE_SIZE}`)
|
||||
const sessions = Array.isArray(data)
|
||||
? data.map(item => normalizeServerSession(item as AgentServerSession)).filter(Boolean) as AgentSessionHistoryItem[]
|
||||
? (data
|
||||
.map(item => normalizeServerSession(item as AgentServerSession))
|
||||
.filter(Boolean) as AgentSessionHistoryItem[])
|
||||
: []
|
||||
const existingIds = new Set(historySessions.value.map(item => item.sessionId))
|
||||
historySessions.value = dedupeHistorySessions([
|
||||
...historySessions.value,
|
||||
...sessions.filter(item => !existingIds.has(item.sessionId)),
|
||||
])
|
||||
.slice(0, MAX_LOCAL_HISTORY_SESSIONS)
|
||||
]).slice(0, MAX_LOCAL_HISTORY_SESSIONS)
|
||||
historyPage.value = nextPage
|
||||
historyHasMore.value = sessions.length >= HISTORY_PAGE_SIZE
|
||||
persistHistorySessions()
|
||||
@@ -377,8 +405,10 @@ async function loadServerHistorySession(targetSessionId: string) {
|
||||
const session = normalizeServerSession(data as AgentServerSession, true)
|
||||
if (!session) throw new Error(t('agentAssistant.historyLoadFailed'))
|
||||
|
||||
historySessions.value = dedupeHistorySessions([session, ...historySessions.value.filter(item => item.sessionId !== targetSessionId)])
|
||||
.slice(0, MAX_LOCAL_HISTORY_SESSIONS)
|
||||
historySessions.value = dedupeHistorySessions([
|
||||
session,
|
||||
...historySessions.value.filter(item => item.sessionId !== targetSessionId),
|
||||
]).slice(0, MAX_LOCAL_HISTORY_SESSIONS)
|
||||
persistHistorySessions()
|
||||
|
||||
return session
|
||||
@@ -483,8 +513,10 @@ function upsertCurrentSessionHistory() {
|
||||
messages: storedMessages,
|
||||
}
|
||||
|
||||
historySessions.value = dedupeHistorySessions([nextSession, ...historySessions.value.filter(item => item.sessionId !== sessionId.value)])
|
||||
.slice(0, MAX_LOCAL_HISTORY_SESSIONS)
|
||||
historySessions.value = dedupeHistorySessions([
|
||||
nextSession,
|
||||
...historySessions.value.filter(item => item.sessionId !== sessionId.value),
|
||||
]).slice(0, MAX_LOCAL_HISTORY_SESSIONS)
|
||||
|
||||
persistHistorySessions()
|
||||
}
|
||||
@@ -533,21 +565,46 @@ function resolveApiUrl(path: string) {
|
||||
return `${baseUrl.replace(/\/?$/, '/')}${path.replace(/^\//, '')}`
|
||||
}
|
||||
|
||||
// 获取 PerfectScrollbar 内部滚动元素,供自动滚动和滚动条刷新使用。
|
||||
// 消息主列表使用原生滚动,避免流式回复时 JS 滚动库频繁测量影响手感。
|
||||
function getMessageScrollerElement() {
|
||||
return messageListRef.value?.ps?.element || null
|
||||
return messageListRef.value
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
nextTick(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const scroller = getMessageScrollerElement()
|
||||
if (!scroller) return
|
||||
function isMessageScrollerNearBottom() {
|
||||
const scroller = getMessageScrollerElement()
|
||||
if (!scroller) return true
|
||||
|
||||
messageListRef.value?.ps?.update()
|
||||
scroller.scrollTop = scroller.scrollHeight
|
||||
messageListRef.value?.ps?.update()
|
||||
})
|
||||
return scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight <= MESSAGE_SCROLL_FOLLOW_THRESHOLD
|
||||
}
|
||||
|
||||
function scheduleMessageScrollerUpdate(options: { toBottom?: boolean } = {}) {
|
||||
const { toBottom = false } = options
|
||||
pendingMessageScrollToBottom ||= toBottom
|
||||
if (messageScrollFrame !== null) return
|
||||
|
||||
messageScrollFrame = window.requestAnimationFrame(() => {
|
||||
messageScrollFrame = null
|
||||
const scroller = getMessageScrollerElement()
|
||||
if (!scroller) return
|
||||
|
||||
if (pendingMessageScrollToBottom) scroller.scrollTop = scroller.scrollHeight
|
||||
pendingMessageScrollToBottom = false
|
||||
})
|
||||
}
|
||||
|
||||
function scrollToBottom(options: { smooth?: boolean } = {}) {
|
||||
const { smooth = false } = options
|
||||
nextTick(() => {
|
||||
const scroller = getMessageScrollerElement()
|
||||
if (!scroller) return
|
||||
|
||||
if (smooth) {
|
||||
scroller.scrollTo({ top: scroller.scrollHeight, behavior: 'smooth' })
|
||||
scheduleMessageScrollerUpdate()
|
||||
return
|
||||
}
|
||||
|
||||
scheduleMessageScrollerUpdate({ toBottom: true })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -558,13 +615,34 @@ function scrollToTop() {
|
||||
const scroller = getMessageScrollerElement()
|
||||
if (!scroller) return
|
||||
|
||||
messageListRef.value?.ps?.update()
|
||||
scroller.scrollTop = 0
|
||||
messageListRef.value?.ps?.update()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function clearStreamPersistTimer() {
|
||||
if (streamPersistTimer === null) return
|
||||
|
||||
window.clearTimeout(streamPersistTimer)
|
||||
streamPersistTimer = null
|
||||
}
|
||||
|
||||
function clearMessageScrollFrame() {
|
||||
if (messageScrollFrame === null) return
|
||||
|
||||
window.cancelAnimationFrame(messageScrollFrame)
|
||||
messageScrollFrame = null
|
||||
pendingMessageScrollToBottom = false
|
||||
}
|
||||
|
||||
function scheduleStreamPersist() {
|
||||
clearStreamPersistTimer()
|
||||
streamPersistTimer = window.setTimeout(() => {
|
||||
persistState()
|
||||
streamPersistTimer = null
|
||||
}, STREAM_STATE_PERSIST_DELAY)
|
||||
}
|
||||
|
||||
function syncInputHeight() {
|
||||
nextTick(() => {
|
||||
const input = inputRef.value
|
||||
@@ -616,9 +694,12 @@ function markToolsDone(message: AgentChatMessage) {
|
||||
}
|
||||
|
||||
function applyStreamEvent(event: AgentStreamEvent, assistantMessage: AgentChatMessage) {
|
||||
const shouldFollowBottom = isMessageScrollerNearBottom()
|
||||
|
||||
switch (event.type) {
|
||||
case 'delta':
|
||||
assistantMessage.content += event.content || ''
|
||||
emit('assistant-preview', assistantMessage.content)
|
||||
break
|
||||
case 'tool':
|
||||
markToolsDone(assistantMessage)
|
||||
@@ -651,6 +732,7 @@ function applyStreamEvent(event: AgentStreamEvent, assistantMessage: AgentChatMe
|
||||
assistantMessage.status = 'error'
|
||||
// 后端流式错误已经以 AI 消息展示,避免底部提示条重复且持续占位。
|
||||
assistantMessage.content ||= event.message || t('agentAssistant.error')
|
||||
emit('assistant-preview', assistantMessage.content)
|
||||
markToolsDone(assistantMessage)
|
||||
break
|
||||
case 'start':
|
||||
@@ -660,9 +742,10 @@ function applyStreamEvent(event: AgentStreamEvent, assistantMessage: AgentChatMe
|
||||
break
|
||||
}
|
||||
|
||||
refreshMessageList()
|
||||
persistState()
|
||||
scrollToBottom()
|
||||
scheduleStreamPersist()
|
||||
nextTick(() => {
|
||||
scheduleMessageScrollerUpdate({ toBottom: shouldFollowBottom })
|
||||
})
|
||||
}
|
||||
|
||||
function parseSseBlock(block: string) {
|
||||
@@ -863,6 +946,7 @@ async function streamAgentMessage(
|
||||
const assistantMessage = addMessage('assistant', '', 'streaming')
|
||||
|
||||
abortController = new AbortController()
|
||||
let shouldFollowBottomAfterStream = true
|
||||
|
||||
try {
|
||||
const response = await fetch(resolveApiUrl('message/agent/stream'), {
|
||||
@@ -888,6 +972,7 @@ async function streamAgentMessage(
|
||||
}
|
||||
|
||||
await readAgentStream(response, assistantMessage)
|
||||
shouldFollowBottomAfterStream = isMessageScrollerNearBottom()
|
||||
if (assistantMessage.status === 'streaming') {
|
||||
assistantMessage.status = 'done'
|
||||
markToolsDone(assistantMessage)
|
||||
@@ -907,6 +992,7 @@ async function streamAgentMessage(
|
||||
refreshMessageList()
|
||||
} finally {
|
||||
abortController = null
|
||||
clearStreamPersistTimer()
|
||||
persistState()
|
||||
try {
|
||||
await saveCurrentSessionToServer()
|
||||
@@ -914,7 +1000,7 @@ async function streamAgentMessage(
|
||||
} catch (error) {
|
||||
// 服务端历史保存失败时保留本地兜底历史,不影响当前会话继续交互。
|
||||
}
|
||||
scrollToBottom()
|
||||
if (shouldFollowBottomAfterStream) scrollToBottom()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1181,13 +1267,8 @@ function formatHistoryTime(timestamp: number) {
|
||||
}).format(new Date(timestamp))
|
||||
}
|
||||
|
||||
function openDrawer() {
|
||||
drawer.value = true
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
function closeDrawer() {
|
||||
drawer.value = false
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
function syncAgentAssistantOpenState(isOpen: boolean) {
|
||||
@@ -1215,7 +1296,7 @@ function clearAgentAssistantOpenState() {
|
||||
}
|
||||
|
||||
function handleGlobalKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && drawer.value) closeDrawer()
|
||||
if (event.key === 'Escape' && isOpen.value) closeDrawer()
|
||||
}
|
||||
|
||||
function handleInputKeydown(event: KeyboardEvent) {
|
||||
@@ -1224,11 +1305,17 @@ function handleInputKeydown(event: KeyboardEvent) {
|
||||
sendMessage()
|
||||
}
|
||||
|
||||
watch(drawer, syncAgentAssistantOpenState, { immediate: true })
|
||||
watch(isOpen, syncAgentAssistantOpenState, { immediate: true })
|
||||
watch(drawerWidth, () => {
|
||||
if (drawer.value) syncAgentAssistantOpenState(true)
|
||||
if (isOpen.value) syncAgentAssistantOpenState(true)
|
||||
})
|
||||
|
||||
watch(isOpen, open => {
|
||||
if (open) scrollToBottom()
|
||||
})
|
||||
|
||||
watch(sending, value => emit('thinking-change', value), { immediate: true })
|
||||
|
||||
onMounted(() => {
|
||||
restoreHistorySessions()
|
||||
restoreState()
|
||||
@@ -1240,6 +1327,8 @@ onMounted(() => {
|
||||
onScopeDispose(clearAgentAssistantOpenState)
|
||||
onScopeDispose(clearPendingAttachments)
|
||||
onScopeDispose(cancelVoiceRecording)
|
||||
onScopeDispose(clearMessageScrollFrame)
|
||||
onScopeDispose(clearStreamPersistTimer)
|
||||
onScopeDispose(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
@@ -1248,19 +1337,8 @@ onScopeDispose(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
v-if="!drawer"
|
||||
class="agent-assistant-fab"
|
||||
type="button"
|
||||
:aria-label="t('agentAssistant.title')"
|
||||
:title="t('agentAssistant.title')"
|
||||
@click="openDrawer"
|
||||
>
|
||||
<VIcon class="agent-assistant-fab__icon" icon="lucide:bot" size="21" />
|
||||
</button>
|
||||
|
||||
<aside
|
||||
v-show="drawer"
|
||||
v-show="isOpen"
|
||||
class="agent-assistant-panel"
|
||||
:style="drawerStyle"
|
||||
role="dialog"
|
||||
@@ -1270,7 +1348,16 @@ onScopeDispose(() => {
|
||||
<header class="agent-assistant-header">
|
||||
<div class="agent-assistant-title">
|
||||
<div class="agent-assistant-title__mark">
|
||||
<VIcon icon="lucide:bot" size="22" />
|
||||
<span class="agent-assistant-mini-bot" aria-hidden="true">
|
||||
<span class="agent-assistant-mini-bot__antenna" />
|
||||
<span class="agent-assistant-mini-bot__head">
|
||||
<span class="agent-assistant-mini-bot__face">
|
||||
<span class="agent-assistant-mini-bot__eye agent-assistant-mini-bot__eye--left" />
|
||||
<span class="agent-assistant-mini-bot__eye agent-assistant-mini-bot__eye--right" />
|
||||
</span>
|
||||
</span>
|
||||
<span class="agent-assistant-mini-bot__body" />
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-subtitle-1 font-weight-semibold">{{ t('agentAssistant.title') }}</div>
|
||||
@@ -1317,11 +1404,7 @@ onScopeDispose(() => {
|
||||
class="agent-assistant-history-infinite"
|
||||
@load="handleHistoryInfiniteLoad"
|
||||
>
|
||||
<VVirtualScroll
|
||||
renderless
|
||||
:items="historySessions"
|
||||
:item-height="HISTORY_ITEM_HEIGHT"
|
||||
>
|
||||
<VVirtualScroll renderless :items="historySessions" :item-height="HISTORY_ITEM_HEIGHT">
|
||||
<template #default="{ item: historySession, itemRef }">
|
||||
<button
|
||||
:ref="itemRef"
|
||||
@@ -1377,153 +1460,157 @@ onScopeDispose(() => {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<PerfectScrollbar
|
||||
<main
|
||||
ref="messageListRef"
|
||||
tag="main"
|
||||
class="agent-assistant-messages"
|
||||
:class="{ 'agent-assistant-messages--has-content': hasMessages }"
|
||||
:options="{ wheelPropagation: false }"
|
||||
>
|
||||
<div v-if="!hasMessages" class="agent-assistant-empty">
|
||||
<div class="agent-assistant-empty__mark">
|
||||
<VIcon icon="lucide:sparkles" size="28" />
|
||||
</div>
|
||||
<div class="agent-assistant-empty__title">{{ t('agentAssistant.emptyTitle') }}</div>
|
||||
<div class="agent-assistant-empty__subtitle">{{ t('agentAssistant.emptySubtitle') }}</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="message in messages"
|
||||
:key="message.id"
|
||||
class="agent-assistant-message"
|
||||
:class="`agent-assistant-message--${message.role}`"
|
||||
>
|
||||
<div class="agent-assistant-message__meta">
|
||||
<VIcon :icon="message.role === 'user' ? 'mdi-account-circle-outline' : 'lucide:bot'" size="16" />
|
||||
<span>{{ message.role === 'user' ? currentUserName : t('agentAssistant.assistant') }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="message.tools.length" class="agent-assistant-tools">
|
||||
<div v-for="tool in message.tools" :key="tool.id" class="agent-assistant-tool">
|
||||
<VIcon
|
||||
:icon="
|
||||
tool.status === 'running' && message.status === 'streaming'
|
||||
? 'line-md:loading-twotone-loop'
|
||||
: 'mdi-check-circle-outline'
|
||||
"
|
||||
size="16"
|
||||
/>
|
||||
<span>{{ tool.message }}</span>
|
||||
<div class="agent-assistant-messages__content">
|
||||
<div v-if="!hasMessages" class="agent-assistant-empty">
|
||||
<div class="agent-assistant-empty__mark">
|
||||
<VIcon icon="lucide:sparkles" size="28" />
|
||||
</div>
|
||||
<div class="agent-assistant-empty__title">{{ t('agentAssistant.emptyTitle') }}</div>
|
||||
<div class="agent-assistant-empty__subtitle">{{ t('agentAssistant.emptySubtitle') }}</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="message.content"
|
||||
class="agent-assistant-message__bubble markdown-body"
|
||||
v-html="renderMarkdown(message.content)"
|
||||
/>
|
||||
v-for="message in messages"
|
||||
:key="message.id"
|
||||
class="agent-assistant-message"
|
||||
:class="`agent-assistant-message--${message.role}`"
|
||||
>
|
||||
<div class="agent-assistant-message__meta">
|
||||
<VIcon :icon="message.role === 'user' ? 'mdi-account-circle-outline' : 'lucide:bot'" size="16" />
|
||||
<span>{{ message.role === 'user' ? currentUserName : t('agentAssistant.assistant') }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="message.choices.length" class="agent-assistant-choices">
|
||||
<div v-for="choice in message.choices" :key="choice.id" class="agent-assistant-choice">
|
||||
<div v-if="choice.title" class="agent-assistant-choice__title">{{ choice.title }}</div>
|
||||
<div class="agent-assistant-choice__prompt">{{ choice.prompt }}</div>
|
||||
<div v-if="choice.status === 'selected'" class="agent-assistant-choice__selected">
|
||||
<VIcon icon="mdi-check-circle-outline" size="16" />
|
||||
<span>{{ t('agentAssistant.choiceSelected', { option: choice.selected_label }) }}</span>
|
||||
</div>
|
||||
<div v-else-if="choice.status === 'expired'" class="agent-assistant-choice__selected is-expired">
|
||||
<VIcon icon="mdi-alert-circle-outline" size="16" />
|
||||
<span>{{ t('agentAssistant.choiceExpired') }}</span>
|
||||
</div>
|
||||
<div class="agent-assistant-choice__buttons">
|
||||
<VBtn
|
||||
v-for="button in choice.buttons"
|
||||
:key="button.callback_data"
|
||||
size="small"
|
||||
rounded="lg"
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
:disabled="sending || choice.status !== 'pending'"
|
||||
@click="handleChoiceClick(choice, button)"
|
||||
>
|
||||
{{ button.label }}
|
||||
</VBtn>
|
||||
<div v-if="message.tools.length" class="agent-assistant-tools">
|
||||
<div v-for="tool in message.tools" :key="tool.id" class="agent-assistant-tool">
|
||||
<VIcon
|
||||
:icon="
|
||||
tool.status === 'running' && message.status === 'streaming'
|
||||
? 'line-md:loading-twotone-loop'
|
||||
: 'mdi-check-circle-outline'
|
||||
"
|
||||
size="16"
|
||||
/>
|
||||
<span>{{ tool.message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="message.attachments.length" class="agent-assistant-attachments">
|
||||
<div
|
||||
v-for="attachment in message.attachments"
|
||||
:key="`${message.id}-${attachment.url}`"
|
||||
class="agent-assistant-attachment"
|
||||
:class="`agent-assistant-attachment--${attachment.kind}`"
|
||||
>
|
||||
<img
|
||||
v-if="attachment.kind === 'image'"
|
||||
class="agent-assistant-attachment__image"
|
||||
:src="resolveAttachmentUrl(attachment.url)"
|
||||
:alt="getAttachmentName(attachment)"
|
||||
loading="lazy"
|
||||
/>
|
||||
v-if="message.content"
|
||||
class="agent-assistant-message__bubble markdown-body"
|
||||
v-html="renderMarkdown(message.content)"
|
||||
/>
|
||||
|
||||
<template v-else-if="attachment.kind === 'audio'">
|
||||
<div class="agent-assistant-attachment__meta">
|
||||
<VIcon :icon="getAttachmentIcon(attachment)" size="18" />
|
||||
<span>{{ getAttachmentName(attachment) }}</span>
|
||||
<div v-if="message.choices.length" class="agent-assistant-choices">
|
||||
<div v-for="choice in message.choices" :key="choice.id" class="agent-assistant-choice">
|
||||
<div v-if="choice.title" class="agent-assistant-choice__title">{{ choice.title }}</div>
|
||||
<div class="agent-assistant-choice__prompt">{{ choice.prompt }}</div>
|
||||
<div v-if="choice.status === 'selected'" class="agent-assistant-choice__selected">
|
||||
<VIcon icon="mdi-check-circle-outline" size="16" />
|
||||
<span>{{ t('agentAssistant.choiceSelected', { option: choice.selected_label }) }}</span>
|
||||
</div>
|
||||
<audio class="agent-assistant-attachment__audio" controls :src="resolveAttachmentUrl(attachment.url)" />
|
||||
<VBtn
|
||||
class="agent-assistant-surface-btn"
|
||||
:href="getAttachmentDownloadUrl(attachment)"
|
||||
:download="getAttachmentName(attachment)"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
prepend-icon="mdi-download"
|
||||
>
|
||||
{{ t('agentAssistant.download') }}
|
||||
</VBtn>
|
||||
</template>
|
||||
<div v-else-if="choice.status === 'expired'" class="agent-assistant-choice__selected is-expired">
|
||||
<VIcon icon="mdi-alert-circle-outline" size="16" />
|
||||
<span>{{ t('agentAssistant.choiceExpired') }}</span>
|
||||
</div>
|
||||
<div class="agent-assistant-choice__buttons">
|
||||
<VBtn
|
||||
v-for="button in choice.buttons"
|
||||
:key="button.callback_data"
|
||||
size="small"
|
||||
rounded="lg"
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
:disabled="sending || choice.status !== 'pending'"
|
||||
@click="handleChoiceClick(choice, button)"
|
||||
>
|
||||
{{ button.label }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="agent-assistant-attachment__file">
|
||||
<VIcon :icon="getAttachmentIcon(attachment)" size="22" />
|
||||
<div class="agent-assistant-attachment__file-text">
|
||||
<div v-if="message.attachments.length" class="agent-assistant-attachments">
|
||||
<div
|
||||
v-for="attachment in message.attachments"
|
||||
:key="`${message.id}-${attachment.url}`"
|
||||
class="agent-assistant-attachment"
|
||||
:class="`agent-assistant-attachment--${attachment.kind}`"
|
||||
>
|
||||
<img
|
||||
v-if="attachment.kind === 'image'"
|
||||
class="agent-assistant-attachment__image"
|
||||
:src="resolveAttachmentUrl(attachment.url)"
|
||||
:alt="getAttachmentName(attachment)"
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
<template v-else-if="attachment.kind === 'audio'">
|
||||
<div class="agent-assistant-attachment__meta">
|
||||
<VIcon :icon="getAttachmentIcon(attachment)" size="18" />
|
||||
<span>{{ getAttachmentName(attachment) }}</span>
|
||||
<small>{{ attachment.mime_type || formatAttachmentSize(attachment.size) }}</small>
|
||||
</div>
|
||||
<audio
|
||||
class="agent-assistant-attachment__audio"
|
||||
controls
|
||||
:src="resolveAttachmentUrl(attachment.url)"
|
||||
/>
|
||||
<VBtn
|
||||
class="agent-assistant-surface-btn"
|
||||
:href="getAttachmentDownloadUrl(attachment)"
|
||||
:download="getAttachmentName(attachment)"
|
||||
icon
|
||||
variant="text"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
:aria-label="t('agentAssistant.download')"
|
||||
prepend-icon="mdi-download"
|
||||
>
|
||||
<VIcon icon="mdi-download" />
|
||||
{{ t('agentAssistant.download') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="agent-assistant-attachment__file">
|
||||
<VIcon :icon="getAttachmentIcon(attachment)" size="22" />
|
||||
<div class="agent-assistant-attachment__file-text">
|
||||
<span>{{ getAttachmentName(attachment) }}</span>
|
||||
<small>{{ attachment.mime_type || formatAttachmentSize(attachment.size) }}</small>
|
||||
</div>
|
||||
<VBtn
|
||||
class="agent-assistant-surface-btn"
|
||||
:href="getAttachmentDownloadUrl(attachment)"
|
||||
:download="getAttachmentName(attachment)"
|
||||
icon
|
||||
variant="text"
|
||||
color="primary"
|
||||
:aria-label="t('agentAssistant.download')"
|
||||
>
|
||||
<VIcon icon="mdi-download" />
|
||||
</VBtn>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
!message.content &&
|
||||
!message.attachments.length &&
|
||||
!message.choices.length &&
|
||||
message.status === 'streaming'
|
||||
"
|
||||
class="agent-assistant-typing"
|
||||
>
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
!message.content &&
|
||||
!message.attachments.length &&
|
||||
!message.choices.length &&
|
||||
message.status === 'streaming'
|
||||
"
|
||||
class="agent-assistant-typing"
|
||||
>
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
</div>
|
||||
</PerfectScrollbar>
|
||||
</main>
|
||||
|
||||
<footer class="agent-assistant-composer">
|
||||
<VAlert v-if="streamError" type="error" variant="tonal" density="compact" class="mb-3">
|
||||
@@ -1591,10 +1678,14 @@ onScopeDispose(() => {
|
||||
:class="{ 'is-recording': recording }"
|
||||
:disabled="!recording && !canRecord"
|
||||
:title="
|
||||
recording ? t('agentAssistant.stopRecording', { time: recordingTimeText }) : t('agentAssistant.recordVoice')
|
||||
recording
|
||||
? t('agentAssistant.stopRecording', { time: recordingTimeText })
|
||||
: t('agentAssistant.recordVoice')
|
||||
"
|
||||
:aria-label="
|
||||
recording ? t('agentAssistant.stopRecording', { time: recordingTimeText }) : t('agentAssistant.recordVoice')
|
||||
recording
|
||||
? t('agentAssistant.stopRecording', { time: recordingTimeText })
|
||||
: t('agentAssistant.recordVoice')
|
||||
"
|
||||
@click="toggleVoiceRecording"
|
||||
>
|
||||
@@ -1625,44 +1716,6 @@ onScopeDispose(() => {
|
||||
/* stylelint-disable selector-pseudo-class-no-unknown */
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
|
||||
.agent-assistant-fab {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
display: grid;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
border-radius: 999px 0 0 999px;
|
||||
backdrop-filter: blur(10px);
|
||||
background: rgba(var(--v-theme-surface), 0.86);
|
||||
block-size: 2.5rem;
|
||||
border-inline-end: 0;
|
||||
box-shadow: var(--app-surface-shadow);
|
||||
color: rgb(var(--v-theme-primary));
|
||||
inline-size: 2.8rem;
|
||||
/* 入口避开屏幕正中,放到视觉上更轻的下三分之一位置。 */
|
||||
inset-block-start: clamp(8rem, 66.666vh, calc(100vh - 8rem));
|
||||
inset-inline-end: 0;
|
||||
place-items: center;
|
||||
transform: translate(1rem, -50%);
|
||||
transition:
|
||||
inset-inline-end 0.2s ease,
|
||||
transform 0.18s ease,
|
||||
box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.agent-assistant-fab:hover {
|
||||
box-shadow: var(--app-surface-hover-shadow);
|
||||
transform: translate(0, -50%);
|
||||
}
|
||||
|
||||
.agent-assistant-fab__icon {
|
||||
transform: rotate(-90deg);
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
|
||||
.agent-assistant-fab:hover .agent-assistant-fab__icon {
|
||||
transform: rotate(0);
|
||||
}
|
||||
|
||||
.agent-assistant-panel {
|
||||
position: fixed;
|
||||
z-index: 2101;
|
||||
@@ -1692,7 +1745,7 @@ onScopeDispose(() => {
|
||||
position: relative;
|
||||
display: grid;
|
||||
block-size: 100%;
|
||||
grid-template-rows: auto 1fr;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
min-block-size: 0;
|
||||
|
||||
--agent-assistant-assistant-bg: rgba(var(--v-theme-surface), 0.92);
|
||||
@@ -1723,12 +1776,123 @@ onScopeDispose(() => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 12px;
|
||||
--agent-assistant-mini-robot-outline: #5b00c5;
|
||||
--agent-assistant-mini-robot-outline-soft: #7432df;
|
||||
--agent-assistant-mini-robot-shell-start: #d3bbff;
|
||||
--agent-assistant-mini-robot-shell-mid: #a576ff;
|
||||
--agent-assistant-mini-robot-shell-end: #8d51f9;
|
||||
--agent-assistant-mini-robot-face-start: #24124e;
|
||||
--agent-assistant-mini-robot-face-end: #100525;
|
||||
--agent-assistant-mini-robot-eye: #f1dcff;
|
||||
background: rgba(var(--v-theme-primary), 0.12);
|
||||
block-size: 2.5rem;
|
||||
color: rgb(var(--v-theme-primary));
|
||||
inline-size: 2.5rem;
|
||||
}
|
||||
|
||||
.agent-assistant-mini-bot,
|
||||
.agent-assistant-mini-bot span {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.agent-assistant-mini-bot {
|
||||
position: relative;
|
||||
display: block;
|
||||
block-size: 1.85rem;
|
||||
inline-size: 1.85rem;
|
||||
}
|
||||
|
||||
.agent-assistant-mini-bot__antenna {
|
||||
position: absolute;
|
||||
display: block;
|
||||
border-radius: 999px;
|
||||
background: var(--agent-assistant-mini-robot-outline);
|
||||
block-size: 0.42rem;
|
||||
inline-size: 0.12rem;
|
||||
inset-block-start: 0;
|
||||
inset-inline-start: 1.18rem;
|
||||
transform: rotate(20deg);
|
||||
transform-origin: bottom center;
|
||||
}
|
||||
|
||||
.agent-assistant-mini-bot__antenna::after {
|
||||
position: absolute;
|
||||
border: 1.5px solid var(--agent-assistant-mini-robot-outline);
|
||||
border-radius: 999px;
|
||||
background: var(--agent-assistant-mini-robot-shell-start);
|
||||
block-size: 0.28rem;
|
||||
content: '';
|
||||
inline-size: 0.28rem;
|
||||
inset-block-start: -0.24rem;
|
||||
inset-inline-start: -0.09rem;
|
||||
}
|
||||
|
||||
.agent-assistant-mini-bot__head {
|
||||
position: absolute;
|
||||
display: block;
|
||||
border: 1.5px solid var(--agent-assistant-mini-robot-outline);
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
var(--agent-assistant-mini-robot-shell-start) 0%,
|
||||
var(--agent-assistant-mini-robot-shell-end) 100%
|
||||
);
|
||||
block-size: 1.04rem;
|
||||
box-shadow:
|
||||
inset 0 -0.12rem 0 rgba(54, 0, 126, 0.22),
|
||||
inset 0.08rem 0.08rem 0 rgba(255, 255, 255, 0.22);
|
||||
inline-size: 1.45rem;
|
||||
inset-block-start: 0.42rem;
|
||||
inset-inline-start: 0.2rem;
|
||||
}
|
||||
|
||||
.agent-assistant-mini-bot__face {
|
||||
position: absolute;
|
||||
display: block;
|
||||
border: 1.5px solid var(--agent-assistant-mini-robot-outline-soft);
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(180deg, var(--agent-assistant-mini-robot-face-start) 0%, var(--agent-assistant-mini-robot-face-end) 100%);
|
||||
block-size: 0.62rem;
|
||||
inline-size: 1rem;
|
||||
inset-block-start: 0.18rem;
|
||||
inset-inline-start: 0.16rem;
|
||||
}
|
||||
|
||||
.agent-assistant-mini-bot__eye {
|
||||
position: absolute;
|
||||
display: block;
|
||||
animation: agent-fab-blink 4.8s ease-in-out infinite;
|
||||
border-radius: 0 0 999px 999px;
|
||||
border-block-end: 0.1rem solid var(--agent-assistant-mini-robot-eye);
|
||||
block-size: 0.24rem;
|
||||
inline-size: 0.22rem;
|
||||
inset-block-start: 0.16rem;
|
||||
}
|
||||
|
||||
.agent-assistant-mini-bot__eye--left {
|
||||
inset-inline-start: 0.22rem;
|
||||
}
|
||||
|
||||
.agent-assistant-mini-bot__eye--right {
|
||||
inset-inline-end: 0.22rem;
|
||||
}
|
||||
|
||||
.agent-assistant-mini-bot__body {
|
||||
position: absolute;
|
||||
display: block;
|
||||
border: 1.5px solid var(--agent-assistant-mini-robot-outline);
|
||||
border-radius: 0.4rem;
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
var(--agent-assistant-mini-robot-shell-mid) 0%,
|
||||
var(--agent-assistant-mini-robot-shell-end) 82%
|
||||
);
|
||||
block-size: 0.54rem;
|
||||
inline-size: 0.98rem;
|
||||
inset-block-start: 1.3rem;
|
||||
inset-inline-start: 0.44rem;
|
||||
}
|
||||
|
||||
.agent-assistant-status {
|
||||
color: rgba(var(--v-theme-on-surface), 0.62);
|
||||
font-size: 0.78rem;
|
||||
@@ -1756,8 +1920,8 @@ onScopeDispose(() => {
|
||||
.agent-assistant-history-list {
|
||||
block-size: min(26rem, calc(100vh - 7rem));
|
||||
max-block-size: min(26rem, calc(100vh - 7rem));
|
||||
overscroll-behavior: contain;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding-block: 0.35rem;
|
||||
}
|
||||
|
||||
@@ -1779,6 +1943,7 @@ onScopeDispose(() => {
|
||||
}
|
||||
|
||||
.agent-assistant-history-infinite {
|
||||
gap: 0.25rem;
|
||||
min-block-size: 100%;
|
||||
}
|
||||
|
||||
@@ -1807,9 +1972,9 @@ onScopeDispose(() => {
|
||||
column-gap: 0.4rem;
|
||||
cursor: pointer;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
min-block-size: 4.75rem;
|
||||
inline-size: calc(100% - 0.7rem);
|
||||
margin-inline: 0.35rem;
|
||||
min-block-size: 4.75rem;
|
||||
padding-block: 0.55rem;
|
||||
padding-inline: 0.65rem 0.25rem;
|
||||
text-align: start;
|
||||
@@ -1865,24 +2030,27 @@ onScopeDispose(() => {
|
||||
|
||||
.agent-assistant-messages {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
block-size: 100%;
|
||||
min-block-size: 0;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior: contain;
|
||||
padding-block: 1rem;
|
||||
padding-inline: 1rem;
|
||||
scroll-behavior: auto;
|
||||
scrollbar-gutter: stable;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
:deep(.ps__rail-x),
|
||||
:deep(.ps__rail-y) {
|
||||
display: none !important;
|
||||
}
|
||||
.agent-assistant-messages__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-block-size: 100%;
|
||||
}
|
||||
|
||||
/* 只有消息态预留输入框空间,避免 iOS 空态被 padding 撑出不可滚动的滚动条。 */
|
||||
.agent-assistant-messages--has-content {
|
||||
padding-block-end: calc(env(safe-area-inset-bottom, 0px) + 6rem);
|
||||
padding-block-end: calc(env(safe-area-inset-bottom, 0px) + 8.75rem);
|
||||
}
|
||||
|
||||
.agent-assistant-empty {
|
||||
@@ -2431,6 +2599,22 @@ onScopeDispose(() => {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@keyframes agent-fab-blink {
|
||||
0%,
|
||||
4%,
|
||||
8%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
scale: 1 1;
|
||||
}
|
||||
|
||||
6% {
|
||||
opacity: 0.45;
|
||||
scale: 1 0.15;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 960px) {
|
||||
.agent-assistant-panel {
|
||||
inline-size: 100vw !important;
|
||||
@@ -2457,4 +2641,14 @@ onScopeDispose(() => {
|
||||
inset-inline: 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.agent-assistant-mini-bot__eye,
|
||||
.agent-assistant-typing span {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
26
src/components/agent/AgentAssistantWidget.vue
Normal file
26
src/components/agent/AgentAssistantWidget.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import AgentAssistantEntry from './AgentAssistantEntry.vue'
|
||||
import AgentAssistantPanel from './AgentAssistantPanel.vue'
|
||||
|
||||
type AgentAssistantEntryRef = InstanceType<typeof AgentAssistantEntry>
|
||||
|
||||
const panelOpen = ref(false)
|
||||
const thinking = ref(false)
|
||||
const entryRef = ref<AgentAssistantEntryRef | null>(null)
|
||||
|
||||
function openPanel() {
|
||||
panelOpen.value = true
|
||||
entryRef.value?.clearBubbles()
|
||||
}
|
||||
|
||||
function handleAssistantPreview(value: string) {
|
||||
if (panelOpen.value) return
|
||||
|
||||
entryRef.value?.showAssistantReplyPreview(value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AgentAssistantEntry ref="entryRef" :active="!panelOpen" :thinking="thinking" @open="openPanel" />
|
||||
<AgentAssistantPanel v-model="panelOpen" @assistant-preview="handleAssistantPreview" @thinking-change="thinking = $event" />
|
||||
</template>
|
||||
@@ -46,17 +46,18 @@ const getImgUrl = computed(() => {
|
||||
<template>
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:height="props.height"
|
||||
:width="props.width"
|
||||
class="ring-gray-500"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
'ring-1': imageLoaded,
|
||||
}"
|
||||
@click="goPlay"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="backdrop-card-hover-area">
|
||||
<VCard
|
||||
:height="props.height"
|
||||
:width="props.width"
|
||||
class="app-hover-lift-card ring-gray-500"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
'ring-1': imageLoaded,
|
||||
}"
|
||||
@click="goPlay"
|
||||
>
|
||||
<template #image>
|
||||
<VImg :src="getImgUrl" aspect-ratio="2/3" cover @load="imageLoadHandler" @error="imageErrorHandler">
|
||||
<template #placeholder>
|
||||
@@ -86,7 +87,14 @@ const getImgUrl = computed(() => {
|
||||
color="success"
|
||||
/>
|
||||
</div>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.backdrop-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -73,16 +73,16 @@ async function deleteDownload() {
|
||||
<template>
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-if="cardState"
|
||||
v-bind="hover.props"
|
||||
:key="props.info?.hash"
|
||||
class="downloading-card app-surface flex flex-col h-full overflow-hidden"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
}"
|
||||
min-height="150"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-if="cardState" v-bind="hover.props" class="downloading-card-hover-area h-full">
|
||||
<VCard
|
||||
:key="props.info?.hash"
|
||||
class="downloading-card app-hover-lift-card app-surface flex flex-col h-full overflow-hidden"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
}"
|
||||
min-height="150"
|
||||
>
|
||||
<template #image>
|
||||
<VImg
|
||||
:src="props.info?.media.image"
|
||||
@@ -130,7 +130,8 @@ async function deleteDownload() {
|
||||
<VBtn color="error" icon="mdi-trash-can-outline" @click="deleteDownload" />
|
||||
</VCardActions>
|
||||
</div>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</template>
|
||||
@@ -138,6 +139,10 @@ async function deleteDownload() {
|
||||
<style lang="scss" scoped>
|
||||
/* stylelint-disable selector-pseudo-class-no-unknown */
|
||||
|
||||
.downloading-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.downloading-card-image {
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
@@ -156,15 +156,17 @@ onMounted(async () => {
|
||||
<template>
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:height="props.height"
|
||||
:width="props.width"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
}"
|
||||
@click="goPlay"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="library-card-hover-area">
|
||||
<VCard
|
||||
:height="props.height"
|
||||
:width="props.width"
|
||||
class="app-hover-lift-card"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
}"
|
||||
@click="goPlay"
|
||||
>
|
||||
<template #image>
|
||||
<canvas ref="canvasRef" width="640" height="360" class="w-full h-full hidden" />
|
||||
<VImg :src="imgUrl" aspect-ratio="2/3" cover @load="imageLoadHandler" @error="imageErrorHandler">
|
||||
@@ -184,7 +186,14 @@ onMounted(async () => {
|
||||
</template>
|
||||
</VImg>
|
||||
</template>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.library-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -493,14 +493,14 @@ onBeforeUnmount(() => {
|
||||
<template>
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<div ref="mediaCardRef">
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div ref="mediaCardRef" v-bind="hover.props" class="media-card-hover-area">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:height="props.height"
|
||||
:width="props.width"
|
||||
class="outline-none ring-gray-500 media-card"
|
||||
class="app-hover-lift-card outline-none ring-gray-500 media-card"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
'ring-1': isImageLoaded,
|
||||
}"
|
||||
@click.stop="goMediaDetail(hover.isHovering ?? false)"
|
||||
@@ -591,6 +591,10 @@ onBeforeUnmount(() => {
|
||||
</VHover>
|
||||
</template>
|
||||
<style scoped>
|
||||
.media-card-hover-area {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.media-card-title {
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.25rem;
|
||||
|
||||
@@ -75,15 +75,17 @@ function goPersonDetail() {
|
||||
<template>
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:height="personProps.height"
|
||||
:width="personProps.width"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
}"
|
||||
@click.stop="goPersonDetail"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="person-card-hover-area">
|
||||
<VCard
|
||||
:height="personProps.height"
|
||||
:width="personProps.width"
|
||||
class="app-hover-lift-card"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
}"
|
||||
@click.stop="goPersonDetail"
|
||||
>
|
||||
<div class="person-card relative cursor-pointer ring-gray-700">
|
||||
<div style="padding-block-end: 150%">
|
||||
<div class="absolute inset-0 flex h-full w-full flex-col items-center p-2">
|
||||
@@ -107,12 +109,17 @@ function goPersonDetail() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.person-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.person-card {
|
||||
background-image: linear-gradient(
|
||||
45deg,
|
||||
|
||||
@@ -230,16 +230,17 @@ onUnmounted(() => {
|
||||
<div>
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
@click="showPluginDetail"
|
||||
class="flex flex-col h-full"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
}"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="plugin-app-card-hover-area h-full">
|
||||
<VCard
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
@click="showPluginDetail"
|
||||
class="app-hover-lift-card flex flex-col h-full"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="flex-grow"
|
||||
:style="`background: linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.5) 100%), linear-gradient(${backgroundColor} 0%, ${backgroundColor} 100%)`"
|
||||
@@ -325,13 +326,18 @@ onUnmounted(() => {
|
||||
</IconBtn>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.plugin-app-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.plugin-app-card__tags-section {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -567,19 +567,19 @@ watch(
|
||||
<!-- 插件卡片 -->
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-if="isVisible"
|
||||
v-bind="hover.props"
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
@click="handleCardClick"
|
||||
class="flex flex-col h-full"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering && !props.sortable,
|
||||
'cursor-move': props.sortable,
|
||||
}"
|
||||
:ripple="!props.sortable"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-if="isVisible" v-bind="hover.props" class="plugin-card-hover-area h-full">
|
||||
<VCard
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
@click="handleCardClick"
|
||||
class="app-hover-lift-card flex flex-col h-full"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering && !props.sortable,
|
||||
'cursor-move': props.sortable,
|
||||
}"
|
||||
:ripple="!props.sortable"
|
||||
>
|
||||
<div
|
||||
class="flex-grow"
|
||||
:style="`background: linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.5) 100%), linear-gradient(${backgroundColor} 0%, ${backgroundColor} 100%)`"
|
||||
@@ -669,7 +669,8 @@ watch(
|
||||
<div v-if="props.plugin?.has_update" class="me-n3 absolute top-0 right-5">
|
||||
<VIcon icon="mdi-new-box" class="text-white" />
|
||||
</div>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
|
||||
@@ -677,6 +678,10 @@ watch(
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.plugin-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.card-cover-blurred::before {
|
||||
position: absolute;
|
||||
/* stylelint-disable-next-line property-no-vendor-prefix */
|
||||
|
||||
@@ -211,20 +211,21 @@ const dropdownItems = ref([
|
||||
<!-- 文件夹卡片 -->
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:ripple="false"
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
min-height="8.5rem"
|
||||
@click="handleCardClick"
|
||||
class="plugin-folder-card h-full"
|
||||
:class="{
|
||||
'plugin-folder-card--mobile': display.mobile,
|
||||
'plugin-folder-card--hover': hover.isHovering && !props.sortable,
|
||||
'plugin-folder-card--sortable': props.sortable,
|
||||
}"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="plugin-folder-card-hover-area h-full">
|
||||
<VCard
|
||||
:ripple="false"
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
min-height="8.5rem"
|
||||
@click="handleCardClick"
|
||||
class="plugin-folder-card app-hover-lift-card h-full"
|
||||
:class="{
|
||||
'plugin-folder-card--mobile': display.mobile,
|
||||
'plugin-folder-card--hover': hover.isHovering && !props.sortable,
|
||||
'plugin-folder-card--sortable': props.sortable,
|
||||
}"
|
||||
>
|
||||
<template v-if="backgroundImage" #image>
|
||||
<VImg :src="backgroundImage" cover position="top"> </VImg>
|
||||
</template>
|
||||
@@ -288,25 +289,29 @@ const dropdownItems = ref([
|
||||
</VMenu>
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.plugin-folder-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.plugin-folder-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&--sortable {
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
&--hover {
|
||||
transform: translateY(-4px);
|
||||
transform: translate3d(0, -0.25rem, 0);
|
||||
}
|
||||
|
||||
&__bg {
|
||||
|
||||
@@ -47,16 +47,17 @@ async function goPlay(isHovering: boolean | null = false) {
|
||||
<template>
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:height="props.height"
|
||||
:width="props.width"
|
||||
class="outline-none ring-gray-500"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
'ring-1': isImageLoaded,
|
||||
}"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="poster-card-hover-area">
|
||||
<VCard
|
||||
:height="props.height"
|
||||
:width="props.width"
|
||||
class="app-hover-lift-card outline-none ring-gray-500"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
'ring-1': isImageLoaded,
|
||||
}"
|
||||
>
|
||||
<VImg
|
||||
aspect-ratio="2/3"
|
||||
:src="getImgUrl"
|
||||
@@ -93,7 +94,14 @@ async function goPlay(isHovering: boolean | null = false) {
|
||||
{{ props.media?.title }}
|
||||
</h1>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.poster-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -239,25 +239,27 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VCard
|
||||
class="site-card relative h-full flex flex-col overflow-hidden group transition-all duration-300"
|
||||
:class="[
|
||||
cardProps.site?.is_active ? '' : 'opacity-70',
|
||||
{
|
||||
'border-error': statColor === 'error',
|
||||
'border-warning': statColor === 'warning',
|
||||
'border-success': statColor === 'success',
|
||||
'cursor-pointer hover:-translate-y-1': !cardProps.sortable,
|
||||
'cursor-move': cardProps.sortable,
|
||||
'site-card--sortable': cardProps.sortable,
|
||||
},
|
||||
]"
|
||||
:ripple="false"
|
||||
variant="flat"
|
||||
elevation="0"
|
||||
:hover="!cardProps.sortable"
|
||||
@click="handleCardClick"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div class="site-card-hover-area h-full">
|
||||
<VCard
|
||||
class="site-card app-hover-lift-card relative h-full flex flex-col overflow-hidden group"
|
||||
:class="[
|
||||
cardProps.site?.is_active ? '' : 'opacity-70',
|
||||
{
|
||||
'border-error': statColor === 'error',
|
||||
'border-warning': statColor === 'warning',
|
||||
'border-success': statColor === 'success',
|
||||
'cursor-pointer site-card--hoverable': !cardProps.sortable,
|
||||
'cursor-move': cardProps.sortable,
|
||||
'site-card--sortable': cardProps.sortable,
|
||||
},
|
||||
]"
|
||||
:ripple="false"
|
||||
variant="flat"
|
||||
elevation="0"
|
||||
:hover="!cardProps.sortable"
|
||||
@click="handleCardClick"
|
||||
>
|
||||
<!-- 装饰性状态指示器 -->
|
||||
<div v-if="cardProps.site?.is_active" class="site-status-indicator" :class="statColor"></div>
|
||||
|
||||
@@ -419,11 +421,20 @@ onMounted(() => {
|
||||
</VMenu>
|
||||
</VBtn>
|
||||
</VSheet>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.site-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.site-card-hover-area:hover .site-card--hoverable {
|
||||
transform: translate3d(0, -0.25rem, 0);
|
||||
}
|
||||
|
||||
.site-status-indicator {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
@@ -455,7 +466,7 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
/* 站点卡片悬停时状态指示器变化 */
|
||||
.site-card:not(.site-card--sortable):hover .site-status-indicator {
|
||||
.site-card-hover-area:hover .site-card:not(.site-card--sortable) .site-status-indicator {
|
||||
block-size: 2px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
@@ -644,7 +655,7 @@ onMounted(() => {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.site-card:hover .site-card-actions {
|
||||
.site-card-hover-area:hover .site-card-actions {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
visibility: visible;
|
||||
|
||||
@@ -404,26 +404,27 @@ function handleCardClick() {
|
||||
<div>
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<div
|
||||
class="subscribe-card-shell w-full h-full relative"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering && !props.sortable,
|
||||
'outline-dotted outline-pink-500 outline-2': props.batchMode && props.selected,
|
||||
}"
|
||||
>
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:key="props.media?.id"
|
||||
class="flex flex-col h-full overflow-hidden"
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="subscribe-card-hover-area w-full h-full">
|
||||
<div
|
||||
class="subscribe-card-shell app-hover-lift-card w-full h-full relative"
|
||||
:class="{
|
||||
'subscribe-card-paused': subscribeState === 'S',
|
||||
'subscribe-card-pending-tint': subscribeState === 'P',
|
||||
'cursor-move': props.sortable,
|
||||
'app-hover-lift-card--hovering': hover.isHovering && !props.sortable,
|
||||
'outline-dotted outline-pink-500 outline-2': props.batchMode && props.selected,
|
||||
}"
|
||||
min-height="150"
|
||||
@click="handleCardClick"
|
||||
:ripple="!props.batchMode && !props.sortable"
|
||||
>
|
||||
<VCard
|
||||
:key="props.media?.id"
|
||||
class="flex flex-col h-full overflow-hidden"
|
||||
:class="{
|
||||
'subscribe-card-paused': subscribeState === 'S',
|
||||
'subscribe-card-pending-tint': subscribeState === 'P',
|
||||
'cursor-move': props.sortable,
|
||||
}"
|
||||
min-height="150"
|
||||
@click="handleCardClick"
|
||||
:ripple="!props.batchMode && !props.sortable"
|
||||
>
|
||||
<div
|
||||
v-if="bestVersionBadge && imageLoaded"
|
||||
class="best-version-badge"
|
||||
@@ -568,13 +569,18 @@ function handleCardClick() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</div>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
.subscribe-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.subscribe-card-background {
|
||||
background-image: linear-gradient(180deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%);
|
||||
}
|
||||
|
||||
@@ -93,16 +93,17 @@ function doDelete() {
|
||||
<div class="h-full">
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<div
|
||||
class="w-full h-full overflow-hidden"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
}"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="subscribe-share-card-hover-area w-full h-full">
|
||||
<div
|
||||
class="app-hover-lift-card w-full h-full overflow-hidden"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
}"
|
||||
>
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:key="props.media?.id"
|
||||
class="flex flex-col h-full"
|
||||
class="app-hover-lift-card flex flex-col h-full"
|
||||
min-height="150"
|
||||
@click="showForkSubscribe"
|
||||
>
|
||||
@@ -155,13 +156,18 @@ function doDelete() {
|
||||
{{ dateText }}
|
||||
</VCardText>
|
||||
</div>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</div>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
.subscribe-share-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.subscribe-card-background {
|
||||
background-image: linear-gradient(180deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%);
|
||||
}
|
||||
|
||||
@@ -100,12 +100,13 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full">
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div class="subtitle-card-hover-area h-full">
|
||||
<VCard
|
||||
:width="props.width || '100%'"
|
||||
:variant="isDownloaded ? 'outlined' : 'flat'"
|
||||
@click="handleAddDownload"
|
||||
class="h-full cursor-pointer transition-transform hover:-translate-y-1 duration-300 d-flex flex-column overflow-hidden subtitle-card"
|
||||
class="app-hover-lift-card h-full cursor-pointer d-flex flex-column overflow-hidden subtitle-card"
|
||||
:class="{ 'border-success border-2 opacity-85': isDownloaded }"
|
||||
hover
|
||||
>
|
||||
@@ -203,11 +204,19 @@ watch(
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.subtitle-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.subtitle-card-hover-area:hover .subtitle-card {
|
||||
transform: translate3d(0, -0.25rem, 0);
|
||||
}
|
||||
|
||||
.subtitle-card {
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.subtitle-card:hover {
|
||||
.subtitle-card-hover-area:hover .subtitle-card {
|
||||
border-color: rgba(var(--v-theme-primary), 0.3);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -99,10 +99,11 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-100">
|
||||
<!-- Hover 命中区域保持静止,避免列表项上浮后底边反复触发 mouseleave。 -->
|
||||
<div class="subtitle-item-hover-area w-100">
|
||||
<VListItem
|
||||
:value="subtitle?.enclosure"
|
||||
class="pa-3 mb-2 rounded subtitle-item transition-all duration-300 hover:-translate-y-1 overflow-hidden"
|
||||
class="app-hover-lift-card pa-3 mb-2 rounded subtitle-item overflow-hidden"
|
||||
:class="{ 'border-start border-success border-3 opacity-85': isDownloaded }"
|
||||
@click="handleAddDownload"
|
||||
>
|
||||
@@ -206,11 +207,19 @@ watch(
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.subtitle-item-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.subtitle-item-hover-area:hover .subtitle-item {
|
||||
transform: translate3d(0, -0.25rem, 0);
|
||||
}
|
||||
|
||||
.subtitle-item {
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.subtitle-item:hover {
|
||||
.subtitle-item-hover-area:hover .subtitle-item {
|
||||
border-color: rgba(var(--v-theme-primary), 0.3);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -146,12 +146,13 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full">
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div class="torrent-card-hover-area h-full">
|
||||
<VCard
|
||||
:width="props.width || '100%'"
|
||||
:variant="isDownloaded ? 'outlined' : 'flat'"
|
||||
@click="handleAddDownload(props.torrent)"
|
||||
class="h-full cursor-pointer transition-transform hover:-translate-y-1 duration-300 d-flex flex-column overflow-hidden torrent-card"
|
||||
class="app-hover-lift-card h-full cursor-pointer d-flex flex-column overflow-hidden torrent-card"
|
||||
:class="{ 'border-success border-2 opacity-85': isDownloaded }"
|
||||
hover
|
||||
>
|
||||
@@ -316,12 +317,20 @@ watch(
|
||||
inset-inline-end: 0;
|
||||
}
|
||||
|
||||
.torrent-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.torrent-card-hover-area:hover .torrent-card {
|
||||
transform: translate3d(0, -0.25rem, 0);
|
||||
}
|
||||
|
||||
/* 卡片悬停效果 */
|
||||
.torrent-card {
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.torrent-card:hover {
|
||||
.torrent-card-hover-area:hover .torrent-card {
|
||||
border-color: rgba(var(--v-theme-primary), 0.3);
|
||||
}
|
||||
|
||||
|
||||
@@ -115,10 +115,11 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-100">
|
||||
<!-- Hover 命中区域保持静止,避免列表项上浮后底边反复触发 mouseleave。 -->
|
||||
<div class="torrent-item-hover-area w-100">
|
||||
<VListItem
|
||||
:value="props.torrent?.torrent_info?.enclosure"
|
||||
class="pa-3 mb-2 rounded torrent-item transition-all duration-300 hover:-translate-y-1 overflow-hidden"
|
||||
class="app-hover-lift-card pa-3 mb-2 rounded torrent-item overflow-hidden"
|
||||
:class="{ 'border-start border-success border-3 opacity-85': isDownloaded }"
|
||||
@click="handleAddDownload"
|
||||
>
|
||||
@@ -262,11 +263,19 @@ watch(
|
||||
inset-inline-end: 0;
|
||||
}
|
||||
|
||||
.torrent-item-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.torrent-item-hover-area:hover .torrent-item {
|
||||
transform: translate3d(0, -0.25rem, 0);
|
||||
}
|
||||
|
||||
.torrent-item {
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.torrent-item:hover {
|
||||
.torrent-item-hover-area:hover .torrent-item {
|
||||
border-color: rgba(var(--v-theme-primary), 0.3);
|
||||
}
|
||||
|
||||
|
||||
@@ -127,14 +127,16 @@ onMounted(() => {
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<VCard
|
||||
:class="[
|
||||
'transition-transform duration-300 hover:-translate-y-1',
|
||||
!props.user.is_active ? 'opacity-85 bg-surface-lighten-1' : '',
|
||||
]"
|
||||
class="user-card flex flex-column h-full"
|
||||
@click="editUser"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div class="user-card-hover-area h-full">
|
||||
<VCard
|
||||
:class="[
|
||||
'app-hover-lift-card',
|
||||
!props.user.is_active ? 'opacity-85 bg-surface-lighten-1' : '',
|
||||
]"
|
||||
class="user-card flex flex-column h-full"
|
||||
@click="editUser"
|
||||
>
|
||||
<div class="user-card__body flex-grow flex-grow-1">
|
||||
<!-- 用户头像和基本信息 -->
|
||||
<VCardItem :class="[user.is_superuser ? 'admin-header' : '']">
|
||||
@@ -302,10 +304,19 @@ onMounted(() => {
|
||||
</div>
|
||||
</VCardText>
|
||||
</div>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.user-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.user-card-hover-area:hover .user-card {
|
||||
transform: translate3d(0, -0.25rem, 0);
|
||||
}
|
||||
|
||||
.user-card {
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
@@ -95,17 +95,18 @@ function doDelete() {
|
||||
<div class="h-full">
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:key="props.workflow?.id"
|
||||
class="workflow-share-card flex flex-col h-full cursor-pointer overflow-hidden"
|
||||
:class="{
|
||||
'workflow-share-card--hovering': hover.isHovering,
|
||||
}"
|
||||
min-height="150"
|
||||
:style="{ background: gradientStyle }"
|
||||
@click="showForkWorkflow"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="workflow-share-card-hover-area h-full">
|
||||
<VCard
|
||||
:key="props.workflow?.id"
|
||||
class="workflow-share-card app-hover-lift-card flex flex-col h-full cursor-pointer overflow-hidden"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
}"
|
||||
min-height="150"
|
||||
:style="{ background: gradientStyle }"
|
||||
@click="showForkWorkflow"
|
||||
>
|
||||
<div class="h-full flex flex-col">
|
||||
<VCardText class="flex items-center pa-3 pb-1 grow">
|
||||
<div class="flex flex-col justify-center w-full">
|
||||
@@ -134,20 +135,16 @@ function doDelete() {
|
||||
{{ dateText }}
|
||||
</VCardText>
|
||||
</div>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// 阴影需要落在实际卡片上,不能被额外的 overflow 容器裁掉。
|
||||
.workflow-share-card {
|
||||
transition: transform 0.3s ease, box-shadow 0.2s ease;
|
||||
transform: translateZ(0);
|
||||
.workflow-share-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.workflow-share-card--hovering {
|
||||
transform: translate3d(0, -0.25rem, 0);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -220,14 +220,15 @@ const resolveProgress = (item: Workflow) => {
|
||||
<template>
|
||||
<div class="h-full">
|
||||
<VHover v-slot="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
class="mx-auto h-full"
|
||||
@click="handleFlow(workflow)"
|
||||
:ripple="false"
|
||||
:loading="loading"
|
||||
:class="{ 'transition transform-cpu duration-300 -translate-y-1': hover.isHovering }"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="workflow-task-card-hover-area h-full">
|
||||
<VCard
|
||||
class="app-hover-lift-card mx-auto h-full"
|
||||
@click="handleFlow(workflow)"
|
||||
:ripple="false"
|
||||
:loading="loading"
|
||||
:class="{ 'app-hover-lift-card--hovering': hover.isHovering }"
|
||||
>
|
||||
<VCardItem
|
||||
class="px-2 py-2"
|
||||
:style="{
|
||||
@@ -367,7 +368,14 @@ const resolveProgress = (item: Workflow) => {
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</VHover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.workflow-task-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -228,21 +228,19 @@ onUnmounted(() => {
|
||||
:text="props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion')"
|
||||
/>
|
||||
<div class="plugin-market-detail-actions">
|
||||
<VBtn
|
||||
variant="tonal"
|
||||
@click="showUpdateHistory"
|
||||
prepend-icon="mdi-update"
|
||||
>
|
||||
{{ t('plugin.versionHistory') }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
color="primary"
|
||||
@click="installPlugin()"
|
||||
prepend-icon="mdi-download"
|
||||
:disabled="props.plugin?.system_version_compatible === false"
|
||||
>
|
||||
{{ t('plugin.installToLocal') }}
|
||||
</VBtn>
|
||||
<div class="plugin-market-detail-actions__buttons">
|
||||
<VBtn
|
||||
color="primary"
|
||||
@click="installPlugin()"
|
||||
prepend-icon="mdi-download"
|
||||
:disabled="props.plugin?.system_version_compatible === false"
|
||||
>
|
||||
{{ t('plugin.installToLocal') }}
|
||||
</VBtn>
|
||||
<VBtn variant="tonal" @click="showUpdateHistory" prepend-icon="mdi-update">
|
||||
{{ t('plugin.versionHistory') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
<div class="plugin-market-detail-actions__downloads" v-if="props.count">
|
||||
<VIcon icon="mdi-fire" />
|
||||
{{ t('plugin.totalDownloads', { count: formatDownloadCount(props.count) }) }}
|
||||
@@ -261,9 +259,17 @@ onUnmounted(() => {
|
||||
.plugin-market-detail-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.plugin-market-detail-actions__buttons {
|
||||
/* 窄屏换行时用统一 gap 控制按钮间距,避免第二个按钮带左边距导致视觉偏移。 */
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.plugin-market-detail-actions__downloads {
|
||||
@@ -273,11 +279,15 @@ onUnmounted(() => {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
@media (width >= 960px) {
|
||||
.plugin-market-detail-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.plugin-market-detail-actions__buttons {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.plugin-market-detail-actions__downloads {
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import { useBackground } from '@/composables/useBackground'
|
||||
import MediaIdSelector from '../misc/MediaIdSelector.vue'
|
||||
import ProgressDialog from './ProgressDialog.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { nextTick } from 'vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
|
||||
@@ -150,13 +149,7 @@ const normalizedItems = computed(() => dedupeFileItems(props.items))
|
||||
|
||||
// 分页
|
||||
const previewPage = ref(1)
|
||||
const previewPageSize = ref(10)
|
||||
|
||||
// 预览列表主体元素
|
||||
const previewFileBodyRef = ref<HTMLElement>()
|
||||
|
||||
// 预览列表尺寸观察器
|
||||
let previewFileBodyResizeObserver: ResizeObserver | undefined
|
||||
const previewPageSize = ref(20)
|
||||
|
||||
// 所有存储
|
||||
const storages = ref<StorageConf[]>([])
|
||||
@@ -419,9 +412,39 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
// 过滤后的预览数据
|
||||
// 过滤并排序后的预览数据
|
||||
const filteredPreviewItems = computed(() => {
|
||||
return previewData.value?.items ?? []
|
||||
const items = [...(previewData.value?.items ?? [])]
|
||||
|
||||
return items.sort((a, b) => {
|
||||
// 1. 获取季号(如果有的话优先按季号排)
|
||||
const seasonA = getPreviewSeasonNumber(a)
|
||||
const seasonB = getPreviewSeasonNumber(b)
|
||||
if (seasonA !== seasonB) {
|
||||
if (seasonA === undefined) return 1
|
||||
if (seasonB === undefined) return -1
|
||||
return seasonA - seasonB
|
||||
}
|
||||
|
||||
// 2. 获取集数
|
||||
const epA = toPreviewNumber(a.episode)
|
||||
const epB = toPreviewNumber(b.episode)
|
||||
|
||||
// 如果都有集数,按集数排序
|
||||
if (epA !== undefined && epB !== undefined) {
|
||||
if (epA !== epB) return epA - epB
|
||||
// 集数相同(可能是同集的视频、字幕等),退化到按文件名排序,保证相关文件挨在一起
|
||||
}
|
||||
|
||||
// 3. 有集数的排前面,没集数的(通常是其他文件)排后面
|
||||
if (epA !== undefined && epB === undefined) return -1
|
||||
if (epA === undefined && epB !== undefined) return 1
|
||||
|
||||
// 4. 如果都没集数,或者集数完全相同,则按照目标路径(或源路径)的字母顺序排
|
||||
const nameA = a.target || a.source || ''
|
||||
const nameB = b.target || b.source || ''
|
||||
return nameA.localeCompare(nameB, undefined, { numeric: true })
|
||||
})
|
||||
})
|
||||
|
||||
// 分页后的预览数据(含文件名解析)
|
||||
@@ -1110,7 +1133,6 @@ async function previewTransfer() {
|
||||
|
||||
previewData.value = mergedPreviewData
|
||||
previewLoaded.value = true
|
||||
nextTick(() => updatePreviewPageSize())
|
||||
|
||||
if (previewHasFailures(mergedPreviewData)) {
|
||||
$toast.warning(getPreviewResultSummaryMessage(mergedPreviewData))
|
||||
@@ -1137,45 +1159,6 @@ async function togglePreview() {
|
||||
await previewTransfer()
|
||||
}
|
||||
|
||||
// 根据可用高度自动计算每页条数,保持统一行高
|
||||
function updatePreviewPageSize() {
|
||||
const bodyHeight = previewFileBodyRef.value?.clientHeight ?? 0
|
||||
if (bodyHeight <= 0) return
|
||||
|
||||
const firstRow = previewFileBodyRef.value?.querySelector('.preview-file-row')
|
||||
const rowHeight = firstRow?.getBoundingClientRect().height ?? 46
|
||||
const pageSize = Math.max(1, Math.floor(bodyHeight / rowHeight))
|
||||
previewPageSize.value = pageSize
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filteredPreviewItems.value.length / pageSize))
|
||||
if (previewPage.value > totalPages) {
|
||||
previewPage.value = totalPages
|
||||
}
|
||||
}
|
||||
|
||||
// 启动预览列表高度监听
|
||||
function setupPreviewFileBodyObserver() {
|
||||
previewFileBodyResizeObserver?.disconnect()
|
||||
|
||||
if (!previewFileBodyRef.value || typeof ResizeObserver === 'undefined') return
|
||||
|
||||
previewFileBodyResizeObserver = new ResizeObserver(() => {
|
||||
updatePreviewPageSize()
|
||||
})
|
||||
previewFileBodyResizeObserver.observe(previewFileBodyRef.value)
|
||||
}
|
||||
|
||||
watch([() => previewLoaded.value, () => previewVisible.value], ([loaded, visible]) => {
|
||||
if (loaded && visible) {
|
||||
nextTick(() => {
|
||||
setupPreviewFileBodyObserver()
|
||||
updatePreviewPageSize()
|
||||
})
|
||||
} else {
|
||||
previewFileBodyResizeObserver?.disconnect()
|
||||
}
|
||||
})
|
||||
|
||||
// 整理文件
|
||||
async function handleTransfer(item: FileItem, background: boolean = false) {
|
||||
try {
|
||||
@@ -1303,7 +1286,6 @@ onMounted(async () => {
|
||||
onUnmounted(() => {
|
||||
stopLoadingProgress()
|
||||
if (episodeGroupQueryTimer) clearTimeout(episodeGroupQueryTimer)
|
||||
previewFileBodyResizeObserver?.disconnect()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1671,7 +1653,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
<div class="reorganize-preview-list">
|
||||
<div v-if="pagedPreviewRows.length" ref="previewFileBodyRef" class="preview-file-body">
|
||||
<div v-if="pagedPreviewRows.length" class="preview-file-body">
|
||||
<div
|
||||
v-for="(item, index) in pagedPreviewRows"
|
||||
:key="`${item.source}-${item.target}-${index}`"
|
||||
@@ -1894,6 +1876,8 @@ onUnmounted(() => {
|
||||
.preview-overview-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-radius: 0.5rem;
|
||||
gap: 0.375rem;
|
||||
min-inline-size: 0;
|
||||
padding-block: 0.875rem;
|
||||
@@ -1919,6 +1903,8 @@ onUnmounted(() => {
|
||||
.preview-custom-words {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-radius: 0.5rem;
|
||||
gap: 0.75rem;
|
||||
padding-block: 0.875rem;
|
||||
padding-inline: 1rem;
|
||||
@@ -1970,8 +1956,12 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.preview-custom-words__chip {
|
||||
block-size: auto !important;
|
||||
max-inline-size: 100%;
|
||||
min-block-size: 1.5rem;
|
||||
padding-block: 0.25rem;
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.reorganize-preview-pane__scroll {
|
||||
@@ -2011,9 +2001,9 @@ onUnmounted(() => {
|
||||
flex: 0 0 auto;
|
||||
flex-direction: column;
|
||||
margin-block-end: 1.5rem;
|
||||
margin-inline: 1.5rem;
|
||||
min-block-size: 0;
|
||||
min-inline-size: 0;
|
||||
padding-inline: 1.5rem;
|
||||
}
|
||||
|
||||
.preview-file-body {
|
||||
@@ -2024,13 +2014,13 @@ onUnmounted(() => {
|
||||
gap: 0.75rem;
|
||||
min-block-size: 0;
|
||||
min-inline-size: 0;
|
||||
padding-block: 1rem;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
.preview-file-row {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-radius: 0.5rem;
|
||||
gap: 0.875rem;
|
||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||||
min-block-size: 5.25rem;
|
||||
@@ -2039,10 +2029,6 @@ onUnmounted(() => {
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
.preview-file-row + .preview-file-row {
|
||||
border-block-start: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
}
|
||||
|
||||
.preview-file-row--failed {
|
||||
background: rgba(var(--v-theme-error), 0.04);
|
||||
}
|
||||
@@ -2168,7 +2154,7 @@ onUnmounted(() => {
|
||||
|
||||
.reorganize-preview-list {
|
||||
margin-block-end: 1rem;
|
||||
margin-inline: 1rem;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import api from '@/api'
|
||||
import { MediaInfo, MediaSeason, NotExistMediaInfo } from '@/api/types'
|
||||
import { PropType } from 'vue'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import NoDataFound from '@/components/states/NoDataFound.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ import useDragAndDrop from '@core/utils/workflow'
|
||||
import { Workflow } from '@/api/types'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import api from '@/api'
|
||||
import WorkflowSidebar from '@/layouts/components/WorkflowSidebar.vue'
|
||||
import DropzoneBackground from '@/layouts/components/DropzoneBackground.vue'
|
||||
import WorkflowSidebar from '@/components/workflow/WorkflowSidebar.vue'
|
||||
import DropzoneBackground from '@/components/workflow/DropzoneBackground.vue'
|
||||
import ImportCodeDialog from '@/components/dialog/ImportCodeDialog.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import FileList from './filebrowser/FileList.vue'
|
||||
import FileToolbar from './filebrowser/FileToolbar.vue'
|
||||
import FileNavigator from './filebrowser/FileNavigator.vue'
|
||||
import FileList from './FileList.vue'
|
||||
import FileToolbar from './FileToolbar.vue'
|
||||
import FileNavigator from './FileNavigator.vue'
|
||||
import type { EndPoints, FileItem, StorageConf } from '@/api/types'
|
||||
import { storageIconDict } from '@/api/constants'
|
||||
import type { AxiosInstance } from 'axios'
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
themeCustomizerPrimaryColors,
|
||||
themeCustomizerShadowLevels,
|
||||
useThemeCustomizer,
|
||||
type ThemeCustomizerLayout,
|
||||
type ThemeCustomizerRadius,
|
||||
@@ -86,62 +87,35 @@ const skinOptions = computed<Array<{ title: string; value: ThemeCustomizerSkin }
|
||||
{ title: t('theme.customizer.skinBordered'), value: 'bordered' },
|
||||
])
|
||||
|
||||
const shadowOptions = computed<
|
||||
Array<{
|
||||
title: string
|
||||
value: ThemeCustomizerShadow
|
||||
}>
|
||||
>(() => [
|
||||
{
|
||||
title: t('theme.customizer.shadowNone'),
|
||||
value: 'none',
|
||||
},
|
||||
{
|
||||
title: t('theme.customizer.shadowLow'),
|
||||
value: 'low',
|
||||
},
|
||||
{
|
||||
title: t('theme.customizer.shadowMedium'),
|
||||
value: 'medium',
|
||||
},
|
||||
{
|
||||
title: t('theme.customizer.shadowHigh'),
|
||||
value: 'high',
|
||||
},
|
||||
])
|
||||
// 当前阴影滑杆数值,界面使用 number,主题设置继续存储 Vuetify elevation 字符串档位。
|
||||
const shadowSliderValue = computed(() => Number(settings.value.shadow))
|
||||
|
||||
const radiusOptions = computed<
|
||||
Array<{
|
||||
previewRadius: string
|
||||
title: string
|
||||
value: ThemeCustomizerRadius
|
||||
}>
|
||||
>(() => [
|
||||
{
|
||||
previewRadius: '4px',
|
||||
title: t('theme.customizer.radiusNone'),
|
||||
value: 'none',
|
||||
},
|
||||
{
|
||||
title: t('theme.customizer.radiusSmall'),
|
||||
value: 'small',
|
||||
},
|
||||
{
|
||||
previewRadius: '8px',
|
||||
title: t('theme.customizer.radiusDefault'),
|
||||
value: 'default',
|
||||
},
|
||||
{
|
||||
previewRadius: '12px',
|
||||
title: t('theme.customizer.radiusLarge'),
|
||||
value: 'large',
|
||||
},
|
||||
{
|
||||
previewRadius: '16px',
|
||||
title: t('theme.customizer.radiusExtra'),
|
||||
value: 'extra',
|
||||
},
|
||||
{
|
||||
previewRadius: '24px',
|
||||
title: t('theme.customizer.radiusHuge'),
|
||||
value: 'huge',
|
||||
},
|
||||
])
|
||||
|
||||
const layoutOptions = computed<Array<{ icon: string; title: string; value: ThemeCustomizerLayout }>>(() => [
|
||||
@@ -156,7 +130,7 @@ const hasAppModeCustomization = computed(() => {
|
||||
return (
|
||||
settings.value.primaryColor !== defaultPrimaryColor ||
|
||||
settings.value.radius !== 'default' ||
|
||||
settings.value.shadow !== 'none' ||
|
||||
settings.value.shadow !== '0' ||
|
||||
settings.value.skin !== 'default' ||
|
||||
settings.value.theme !== 'auto'
|
||||
)
|
||||
@@ -189,6 +163,19 @@ function handleLayoutChange(layout: ThemeCustomizerLayout) {
|
||||
setLayout(layout)
|
||||
}
|
||||
|
||||
// 将 Vuetify 滑杆的数字步进写回字符串型 elevation 档位。
|
||||
function handleShadowSliderChange(value: unknown) {
|
||||
const rawValue = Array.isArray(value) ? value[0] : value
|
||||
const numericValue = Number(rawValue)
|
||||
|
||||
if (!Number.isFinite(numericValue)) return
|
||||
|
||||
const clampedValue = Math.min(24, Math.max(0, Math.round(numericValue)))
|
||||
const shadow = String(clampedValue) as ThemeCustomizerShadow
|
||||
|
||||
if (themeCustomizerShadowLevels.includes(shadow)) setShadow(shadow)
|
||||
}
|
||||
|
||||
async function handleResetSettings() {
|
||||
if (!appMode.value) {
|
||||
await resetSettings()
|
||||
@@ -199,7 +186,7 @@ async function handleResetSettings() {
|
||||
// App 模式共享定制器,但保留桌面导航相关偏好,只重置 App 侧可调整的外观设置。
|
||||
await setPrimaryColor(defaultPrimaryColor)
|
||||
await setRadius('default')
|
||||
await setShadow('none')
|
||||
await setShadow('0')
|
||||
await setSkin('default')
|
||||
await setTheme('auto')
|
||||
}
|
||||
@@ -320,7 +307,7 @@ async function handleResetSettings() {
|
||||
>
|
||||
<span
|
||||
class="theme-customizer-radius-scene"
|
||||
:style="{ '--theme-customizer-radius-preview': radius.previewRadius }"
|
||||
:class="`theme-customizer-radius-scene--${radius.value}`"
|
||||
>
|
||||
<span class="theme-customizer-radius-scene__card">
|
||||
<span class="theme-customizer-radius-scene__badge" />
|
||||
@@ -335,29 +322,41 @@ async function handleResetSettings() {
|
||||
<VDivider class="mt-7" />
|
||||
|
||||
<h3 class="theme-customizer-section-title">{{ t('theme.customizer.shadow') }}</h3>
|
||||
<div class="theme-customizer-preview-grid theme-customizer-preview-grid--shadow">
|
||||
<div
|
||||
v-for="shadow in shadowOptions"
|
||||
:key="shadow.value"
|
||||
class="theme-customizer-preview-option"
|
||||
:class="{ 'is-active': settings.shadow === shadow.value }"
|
||||
@click="setShadow(shadow.value)"
|
||||
>
|
||||
<span class="theme-customizer-shadow-scene" :class="`theme-customizer-shadow-scene--${shadow.value}`">
|
||||
<span class="theme-customizer-shadow-scene__panel">
|
||||
<span class="theme-customizer-shadow-scene__panel-line" />
|
||||
<span
|
||||
class="theme-customizer-shadow-scene__panel-line theme-customizer-shadow-scene__panel-line--short"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span class="theme-customizer-shadow-scene__card">
|
||||
<span class="theme-customizer-shadow-scene__badge" />
|
||||
<span class="theme-customizer-shadow-scene__line theme-customizer-shadow-scene__line--short" />
|
||||
<span class="theme-customizer-shadow-scene__line" />
|
||||
</span>
|
||||
<div class="theme-customizer-shadow-slider">
|
||||
<div class="theme-customizer-shadow-slider__header">
|
||||
<span>{{ t('theme.customizer.shadowLevel', { level: settings.shadow }) }}</span>
|
||||
<span>0 - 24</span>
|
||||
</div>
|
||||
<div class="theme-customizer-shadow-slider__control">
|
||||
<span
|
||||
class="theme-customizer-shadow-slider__sample"
|
||||
:style="{ boxShadow: `var(--app-elevation-${settings.shadow})` }"
|
||||
>
|
||||
<span class="theme-customizer-shadow-slider__sample-accent" />
|
||||
<span class="theme-customizer-shadow-slider__sample-line" />
|
||||
<span class="theme-customizer-shadow-slider__sample-line theme-customizer-shadow-slider__sample-line--short" />
|
||||
</span>
|
||||
<span>{{ shadow.title }}</span>
|
||||
<VSlider
|
||||
:model-value="shadowSliderValue"
|
||||
:aria-label="t('theme.customizer.shadow')"
|
||||
:max="24"
|
||||
:min="0"
|
||||
:step="1"
|
||||
color="primary"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
show-ticks="always"
|
||||
thumb-label
|
||||
tick-size="2"
|
||||
@update:model-value="handleShadowSliderChange"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="theme-customizer-shadow-slider__scale"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span>0</span>
|
||||
<span>24</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -626,10 +625,6 @@ async function handleResetSettings() {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.theme-customizer-preview-grid--shadow {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.theme-customizer-preview-grid--radius {
|
||||
grid-template-columns: repeat(auto-fit, minmax(92px, 1fr));
|
||||
}
|
||||
@@ -646,8 +641,7 @@ async function handleResetSettings() {
|
||||
box-shadow: none !important;
|
||||
|
||||
.theme-customizer-mini-layout,
|
||||
.theme-customizer-radius-scene,
|
||||
.theme-customizer-shadow-scene {
|
||||
.theme-customizer-radius-scene {
|
||||
border-width: 2px;
|
||||
border-color: rgb(var(--v-theme-primary));
|
||||
background: rgba(var(--v-theme-primary), 0.04);
|
||||
@@ -747,6 +741,24 @@ async function handleResetSettings() {
|
||||
block-size: 90px;
|
||||
inline-size: 100%;
|
||||
min-inline-size: 0;
|
||||
|
||||
--theme-customizer-preview-radius: var(--app-vuetify-rounded);
|
||||
}
|
||||
|
||||
.theme-customizer-radius-scene--none {
|
||||
--theme-customizer-preview-radius: var(--app-vuetify-rounded-0);
|
||||
}
|
||||
|
||||
.theme-customizer-radius-scene--small {
|
||||
--theme-customizer-preview-radius: var(--app-vuetify-rounded-sm);
|
||||
}
|
||||
|
||||
.theme-customizer-radius-scene--large {
|
||||
--theme-customizer-preview-radius: var(--app-vuetify-rounded-lg);
|
||||
}
|
||||
|
||||
.theme-customizer-radius-scene--extra {
|
||||
--theme-customizer-preview-radius: var(--app-vuetify-rounded-xl);
|
||||
}
|
||||
|
||||
.theme-customizer-radius-scene__card {
|
||||
@@ -754,7 +766,7 @@ async function handleResetSettings() {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
border-radius: var(--theme-customizer-radius-preview);
|
||||
border-radius: var(--theme-customizer-preview-radius);
|
||||
background: rgb(var(--v-theme-surface));
|
||||
gap: 8px;
|
||||
inset: 16px;
|
||||
@@ -765,17 +777,18 @@ async function handleResetSettings() {
|
||||
.theme-customizer-radius-scene__badge,
|
||||
.theme-customizer-radius-scene__line {
|
||||
display: block;
|
||||
border-radius: 999px;
|
||||
background: rgba(var(--v-theme-on-surface), 0.1);
|
||||
}
|
||||
|
||||
.theme-customizer-radius-scene__badge {
|
||||
border-radius: var(--theme-customizer-preview-radius);
|
||||
block-size: 8px;
|
||||
inline-size: 42%;
|
||||
min-inline-size: 28px;
|
||||
}
|
||||
|
||||
.theme-customizer-radius-scene__line {
|
||||
border-radius: var(--theme-customizer-preview-radius);
|
||||
block-size: 7px;
|
||||
}
|
||||
|
||||
@@ -783,112 +796,89 @@ async function handleResetSettings() {
|
||||
inline-size: 66%;
|
||||
}
|
||||
|
||||
.theme-customizer-shadow-scene {
|
||||
position: relative;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
.theme-customizer-shadow-slider {
|
||||
padding: 16px 18px 12px;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||
border-radius: 10px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(var(--v-theme-on-surface), 0.02), rgba(var(--v-theme-on-surface), 0.06)),
|
||||
rgb(var(--v-theme-surface));
|
||||
block-size: 110px;
|
||||
inline-size: 100%;
|
||||
min-inline-size: 0;
|
||||
border-radius: var(--app-vuetify-rounded-lg);
|
||||
background: rgba(var(--v-theme-surface), 0.72);
|
||||
}
|
||||
|
||||
.theme-customizer-shadow-scene__panel,
|
||||
.theme-customizer-shadow-scene__card {
|
||||
position: absolute;
|
||||
.theme-customizer-shadow-slider__header,
|
||||
.theme-customizer-shadow-slider__scale {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.theme-customizer-shadow-slider__header {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.3;
|
||||
margin-block-end: 14px;
|
||||
|
||||
> span:first-child {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.theme-customizer-shadow-slider__control {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
grid-template-columns: 56px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.theme-customizer-shadow-slider__sample {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
border-radius: var(--app-vuetify-rounded);
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
background: rgb(var(--v-theme-surface));
|
||||
box-shadow: none;
|
||||
transition: box-shadow 0.18s ease;
|
||||
block-size: 42px;
|
||||
gap: 5px;
|
||||
inline-size: 42px;
|
||||
padding-block: 8px;
|
||||
padding-inline: 9px;
|
||||
}
|
||||
|
||||
.theme-customizer-shadow-scene__panel {
|
||||
padding: 12px;
|
||||
gap: 8px;
|
||||
inset-block-start: 16px;
|
||||
inset-inline: 14px;
|
||||
min-block-size: 54px;
|
||||
}
|
||||
|
||||
.theme-customizer-shadow-scene__card {
|
||||
gap: 8px;
|
||||
inset-block-end: 12px;
|
||||
inset-inline: 20px 16px;
|
||||
min-block-size: 46px;
|
||||
padding-block: 10px;
|
||||
padding-inline: 12px;
|
||||
}
|
||||
|
||||
.theme-customizer-shadow-scene__panel-line,
|
||||
.theme-customizer-shadow-scene__line,
|
||||
.theme-customizer-shadow-scene__badge {
|
||||
.theme-customizer-shadow-slider__sample-accent,
|
||||
.theme-customizer-shadow-slider__sample-line {
|
||||
display: block;
|
||||
border-radius: 999px;
|
||||
background: rgba(var(--v-theme-on-surface), 0.1);
|
||||
}
|
||||
|
||||
.theme-customizer-shadow-scene__badge {
|
||||
block-size: 6px;
|
||||
inline-size: 34%;
|
||||
min-inline-size: 28px;
|
||||
.theme-customizer-shadow-slider__sample-accent {
|
||||
background: rgba(var(--v-theme-primary), 0.48);
|
||||
block-size: 5px;
|
||||
inline-size: 44%;
|
||||
}
|
||||
|
||||
.theme-customizer-shadow-scene__panel-line,
|
||||
.theme-customizer-shadow-scene__line {
|
||||
block-size: 7px;
|
||||
.theme-customizer-shadow-slider__sample-line {
|
||||
background: rgba(var(--v-theme-on-surface), 0.12);
|
||||
block-size: 4px;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.theme-customizer-shadow-scene__panel-line--short,
|
||||
.theme-customizer-shadow-scene__line--short {
|
||||
inline-size: 62%;
|
||||
.theme-customizer-shadow-slider__sample-line--short {
|
||||
inline-size: 68%;
|
||||
}
|
||||
|
||||
.theme-customizer-shadow-scene--low {
|
||||
.theme-customizer-shadow-scene__panel {
|
||||
box-shadow:
|
||||
0 8px 18px rgba(var(--v-theme-on-surface), 0.08),
|
||||
0 2px 6px rgba(var(--v-theme-on-surface), 0.05);
|
||||
}
|
||||
|
||||
.theme-customizer-shadow-scene__card {
|
||||
box-shadow:
|
||||
0 10px 22px rgba(var(--v-theme-on-surface), 0.1),
|
||||
0 4px 10px rgba(var(--v-theme-on-surface), 0.06);
|
||||
}
|
||||
.theme-customizer-shadow-slider__scale {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
margin-block-start: 2px;
|
||||
margin-inline-start: 72px;
|
||||
}
|
||||
|
||||
.theme-customizer-shadow-scene--medium {
|
||||
.theme-customizer-shadow-scene__panel {
|
||||
box-shadow:
|
||||
0 12px 28px rgba(var(--v-theme-on-surface), 0.12),
|
||||
0 4px 12px rgba(var(--v-theme-on-surface), 0.08);
|
||||
}
|
||||
|
||||
.theme-customizer-shadow-scene__card {
|
||||
box-shadow:
|
||||
0 16px 34px rgba(var(--v-theme-on-surface), 0.14),
|
||||
0 6px 16px rgba(var(--v-theme-on-surface), 0.09);
|
||||
}
|
||||
.theme-customizer-shadow-slider :deep(.v-slider.v-input) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.theme-customizer-shadow-scene--high {
|
||||
.theme-customizer-shadow-scene__panel {
|
||||
box-shadow:
|
||||
0 16px 38px rgba(var(--v-theme-on-surface), 0.16),
|
||||
0 6px 18px rgba(var(--v-theme-on-surface), 0.1);
|
||||
}
|
||||
|
||||
.theme-customizer-shadow-scene__card {
|
||||
box-shadow:
|
||||
0 22px 48px rgba(var(--v-theme-on-surface), 0.18),
|
||||
0 8px 22px rgba(var(--v-theme-on-surface), 0.12);
|
||||
}
|
||||
.theme-customizer-shadow-slider :deep(.v-slider-track__tick) {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
@@ -24,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'
|
||||
|
||||
@@ -44,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
|
||||
|
||||
@@ -73,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,
|
||||
@@ -247,7 +289,7 @@ export function isDefaultThemeCustomizerSettings(settings: ThemeCustomizerSettin
|
||||
primaryColor: defaultPrimaryColor,
|
||||
radius: 'default',
|
||||
semiDarkMenu: false,
|
||||
shadow: 'none',
|
||||
shadow: '0',
|
||||
skin: 'default',
|
||||
theme: 'auto',
|
||||
})
|
||||
@@ -324,7 +366,7 @@ export function useThemeCustomizer() {
|
||||
primaryColor: defaultPrimaryColor,
|
||||
radius: 'default',
|
||||
semiDarkMenu: false,
|
||||
shadow: 'none',
|
||||
shadow: '0',
|
||||
skin: 'default',
|
||||
theme: 'auto',
|
||||
})
|
||||
|
||||
@@ -1,498 +0,0 @@
|
||||
<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 { useBackground } from '@/composables/useBackground'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { useDelayedSSE } = useBackground()
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
// 固定通知项高度,配合 VVirtualScroll 避免历史通知过多时一次性渲染全部 DOM。
|
||||
const NOTIFICATION_ITEM_HEIGHT = 104
|
||||
const MEDIA_NOTIFICATION_TYPES = ['资源下载', '整理入库', '订阅', '媒体服务器', '手动处理']
|
||||
|
||||
const appsMenu = ref(false)
|
||||
const hasNewMessage = ref(false)
|
||||
const notificationList = ref<SystemNotification[]>([])
|
||||
const page = ref(1)
|
||||
const loading = ref(false)
|
||||
const hasMore = ref(true)
|
||||
const notificationKeys = new Set<string>()
|
||||
|
||||
const hasUnreadNotifications = computed(() => notificationList.value.some(item => item.read === false))
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
function getNotificationContentKey(item: SystemNotification) {
|
||||
return [
|
||||
getNotificationKind(item),
|
||||
getNotificationTimeBucket(item),
|
||||
normalizeText(item.title),
|
||||
normalizeText(item.text),
|
||||
item.image ?? '',
|
||||
item.link ?? '',
|
||||
normalizeNote(item.note),
|
||||
].join('::')
|
||||
}
|
||||
|
||||
function getNotificationKeys(item: SystemNotification) {
|
||||
return [item.id ? `id:${item.id}` : '', `content:${getNotificationContentKey(item)}`].filter(Boolean)
|
||||
}
|
||||
|
||||
function getNotificationKey(item: SystemNotification) {
|
||||
return item.id ? `id:${item.id}` : `content:${getNotificationContentKey(item)}`
|
||||
}
|
||||
|
||||
function parseNotificationTime(value: string) {
|
||||
if (!value) return 0
|
||||
return new Date(value.includes('T') ? value : value.replaceAll(/-/g, '/')).getTime() || 0
|
||||
}
|
||||
|
||||
function sortNotifications() {
|
||||
notificationList.value = [...notificationList.value].sort(
|
||||
(a, b) => parseNotificationTime(getNotificationTime(b)) - parseNotificationTime(getNotificationTime(a)),
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
mergeNotifications(items, { read: true })
|
||||
page.value += 1
|
||||
hasMore.value = items.length >= PAGE_SIZE
|
||||
done(hasMore.value ? 'ok' : 'empty')
|
||||
} catch (error) {
|
||||
console.error('加载通知失败:', error)
|
||||
done('error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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 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) || MEDIA_NOTIFICATION_TYPES.includes(item.mtype || '')
|
||||
}
|
||||
|
||||
function openNotification(item: SystemNotification) {
|
||||
item.read = true
|
||||
hasNewMessage.value = hasUnreadNotifications.value
|
||||
if (!hasUnreadNotifications.value) void clearUnreadMessages()
|
||||
if (item.link) window.open(item.link, '_blank')
|
||||
}
|
||||
|
||||
useDelayedSSE(
|
||||
`${import.meta.env.VITE_API_BASE_URL}system/message?role=notification`,
|
||||
handleMessage,
|
||||
'user-notification',
|
||||
3000,
|
||||
{
|
||||
backgroundCloseDelay: 5000,
|
||||
reconnectDelay: 3000,
|
||||
maxReconnectAttempts: 3,
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VMenu
|
||||
v-model="appsMenu"
|
||||
width="420"
|
||||
max-width="calc(100vw - 24px)"
|
||||
transition="scale-transition"
|
||||
close-on-content-click
|
||||
class="notification-menu"
|
||||
scrim
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<VBadge v-if="hasNewMessage" dot color="error" :offset-x="5" :offset-y="5" v-bind="props">
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-bell-outline" />
|
||||
</IconBtn>
|
||||
</VBadge>
|
||||
<IconBtn v-else v-bind="props">
|
||||
<VIcon icon="mdi-bell-outline" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
|
||||
<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.stop="markAllAsRead">
|
||||
<VIcon icon="mdi-email-check-outline" size="20" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
</template>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
|
||||
<div class="notification-list-container">
|
||||
<VInfiniteScroll
|
||||
mode="intersect"
|
||||
side="end"
|
||||
:items="notificationList"
|
||||
class="notification-list-scroll"
|
||||
@load="loadNotifications"
|
||||
>
|
||||
<template #loading>
|
||||
<div class="py-3 text-center text-caption text-medium-emphasis">
|
||||
{{ t('message.loadMore') }}
|
||||
</div>
|
||||
</template>
|
||||
<template #empty>
|
||||
<div v-if="notificationList.length > 0" class="py-3 text-center text-caption text-medium-emphasis">
|
||||
{{ t('message.noMoreData') }}
|
||||
</div>
|
||||
<div v-else class="notification-empty">
|
||||
<VIcon icon="mdi-bell-sleep-outline" size="40" class="mb-3" />
|
||||
<div>{{ t('notification.empty') }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<VVirtualScroll
|
||||
v-if="notificationList.length > 0"
|
||||
renderless
|
||||
:items="notificationList"
|
||||
:item-height="NOTIFICATION_ITEM_HEIGHT"
|
||||
>
|
||||
<template #default="{ item, itemRef }">
|
||||
<div :ref="itemRef" :key="getNotificationKey(item)" class="notification-virtual-item">
|
||||
<button
|
||||
type="button"
|
||||
class="notification-row"
|
||||
:class="{
|
||||
'notification-row--unread': item.read === false,
|
||||
'notification-row--media': isMediaNotification(item),
|
||||
}"
|
||||
@click="openNotification(item)"
|
||||
>
|
||||
<div v-if="isMediaNotification(item)" class="notification-media">
|
||||
<VImg v-if="item.image" :src="item.image" cover class="notification-media__image">
|
||||
<template #placeholder>
|
||||
<VSkeletonLoader class="h-100 w-100" />
|
||||
</template>
|
||||
</VImg>
|
||||
<div v-else class="notification-media__fallback">
|
||||
<VIcon :icon="getNotificationIcon(item)" size="24" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="notification-icon" :class="`text-${getNotificationColor(item)}`">
|
||||
<VIcon :icon="getNotificationIcon(item)" size="22" />
|
||||
</div>
|
||||
|
||||
<div class="notification-content">
|
||||
<div class="notification-title-row">
|
||||
<span class="notification-title">{{ item.title }}</span>
|
||||
<span v-if="item.read === false" class="notification-unread-dot" />
|
||||
</div>
|
||||
<div v-if="item.text" class="notification-text">
|
||||
{{ item.text }}
|
||||
</div>
|
||||
<div class="notification-meta">
|
||||
<span v-if="item.mtype" class="notification-type">{{ item.mtype }}</span>
|
||||
<span>{{ formatDateDifference(getNotificationTime(item)) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</VVirtualScroll>
|
||||
</VInfiniteScroll>
|
||||
</div>
|
||||
</VCard>
|
||||
</VMenu>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.notification-panel {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.notification-list-container {
|
||||
overflow: hidden;
|
||||
max-block-size: min(560px, 62vh);
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.notification-list-scroll {
|
||||
max-block-size: min(560px, 62vh);
|
||||
min-block-size: 160px;
|
||||
}
|
||||
|
||||
.notification-virtual-item {
|
||||
block-size: 110px;
|
||||
padding-block: 4px;
|
||||
padding-inline: 8px;
|
||||
}
|
||||
|
||||
.notification-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 10px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
block-size: 100%;
|
||||
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,
|
||||
.notification-media__fallback {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.notification-media__fallback,
|
||||
.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: center;
|
||||
gap: 8px;
|
||||
min-block-size: 20px;
|
||||
}
|
||||
|
||||
.notification-title {
|
||||
overflow: hidden;
|
||||
font-size: 0.925rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.notification-unread-dot {
|
||||
flex: 0 0 7px;
|
||||
border-radius: 999px;
|
||||
background: rgb(var(--v-theme-error));
|
||||
block-size: 7px;
|
||||
inline-size: 7px;
|
||||
}
|
||||
|
||||
.notification-text {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.8125rem;
|
||||
-webkit-line-clamp: 2;
|
||||
line-height: 1.45;
|
||||
margin-block-start: 4px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import DefaultLayout from './components/DefaultLayout.vue'
|
||||
import DefaultLayout from './default/components/DefaultLayout.vue'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
import VerticalNavSectionTitle from '@/@layouts/components/VerticalNavSectionTitle.vue'
|
||||
import VerticalNavLayout from '@layouts/components/VerticalNavLayout.vue'
|
||||
import VerticalNavLink from '@layouts/components/VerticalNavLink.vue'
|
||||
import Footer from '@/layouts/components/Footer.vue'
|
||||
import UserNofification from '@/layouts/components/UserNotification.vue'
|
||||
import SearchBar from '@/layouts/components/SearchBar.vue'
|
||||
import ShortcutBar from '@/layouts/components/ShortcutBar.vue'
|
||||
import UserProfile from '@/layouts/components/UserProfile.vue'
|
||||
import QuickAccess from '@/layouts/components/QuickAccess.vue'
|
||||
import HeaderTab from '@/layouts/components/HeaderTab.vue'
|
||||
import AgentAssistantWidget from '@/components/AgentAssistantWidget.vue'
|
||||
import ThemeCustomizer from '@/components/ThemeCustomizer.vue'
|
||||
import Footer from './Footer.vue'
|
||||
import UserNofification from './UserNotification.vue'
|
||||
import SearchBar from './SearchBar.vue'
|
||||
import ShortcutBar from './ShortcutBar.vue'
|
||||
import UserProfile from './UserProfile.vue'
|
||||
import QuickAccess from './QuickAccess.vue'
|
||||
import HeaderTab from './HeaderTab.vue'
|
||||
import AgentAssistantWidget from '@/components/agent/AgentAssistantWidget.vue'
|
||||
import ThemeCustomizer from '@/components/theme/ThemeCustomizer.vue'
|
||||
import { useGlobalSettingsStore, usePluginSidebarNavStore, useUserStore } from '@/stores'
|
||||
import { getNavMenus } from '@/router/i18n-menu'
|
||||
import { filterPluginSidebarNavEntries } from '@/utils/pluginSidebarNav'
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
} from '@/utils/permission'
|
||||
import { usePullDownGesture } from '@/composables/usePullDownGesture'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import OfflinePage from '@/layouts/components/OfflinePage.vue'
|
||||
import OfflinePage from './OfflinePage.vue'
|
||||
import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus'
|
||||
import {
|
||||
readThemeCustomizerSettings,
|
||||
@@ -55,7 +55,9 @@ const globalSettingsStore = useGlobalSettingsStore()
|
||||
// 获取用户权限信息
|
||||
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
|
||||
const canAdmin = computed(() => hasPermission(userPermissions.value, 'admin'))
|
||||
const showAgentAssistant = computed(() => globalSettingsStore.get('AI_AGENT_ENABLE') === true)
|
||||
const showAgentAssistant = computed(
|
||||
() => globalSettingsStore.get('AI_AGENT_ENABLE') === true && globalSettingsStore.get('AI_AGENT_HIDE_ENTRY') !== true,
|
||||
)
|
||||
|
||||
// 开始菜单项
|
||||
const startMenus = ref<NavMenu[]>([])
|
||||
@@ -223,7 +223,7 @@ function handleDynamicMenuItemClick(item: DynamicButtonMenuItem) {
|
||||
<VCardText class="footer-card-content">
|
||||
<!-- 添加指示器 -->
|
||||
<div ref="indicator" class="nav-indicator"></div>
|
||||
<VBtnToggle class="footer-btn-group" :mandatory="true" v-model="currentMenu">
|
||||
<VBtnToggle class="footer-btn-group" :mandatory="true" variant="plain" v-model="currentMenu">
|
||||
<!-- 遍历底部菜单项 -->
|
||||
<VBtn
|
||||
v-for="menu in footerMenus"
|
||||
@@ -343,6 +343,9 @@ function handleDynamicMenuItemClick(item: DynamicButtonMenuItem) {
|
||||
transition: all 0.5s cubic-bezier(0.25, 1, 0.5, 1);
|
||||
will-change: transform, max-inline-size, opacity;
|
||||
|
||||
--app-control-radius: var(--app-vuetify-rounded-pill);
|
||||
--app-surface-radius: var(--app-vuetify-rounded-pill);
|
||||
|
||||
// 透明主题下的特殊样式
|
||||
.v-theme--transparent & {
|
||||
backdrop-filter: blur(var(--transparent-blur-heavy, 16px));
|
||||
@@ -361,13 +364,19 @@ function handleDynamicMenuItemClick(item: DynamicButtonMenuItem) {
|
||||
padding-inline: 6px;
|
||||
}
|
||||
|
||||
.footer-btn-group {
|
||||
.footer-nav-card .footer-btn-group.v-btn-group {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
border: none;
|
||||
border-radius: 9999px !important;
|
||||
background-color: transparent;
|
||||
box-shadow: none !important;
|
||||
inline-size: 100%;
|
||||
|
||||
&:hover {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.footer-nav-btn {
|
||||
@@ -377,12 +386,15 @@ function handleDynamicMenuItemClick(item: DynamicButtonMenuItem) {
|
||||
flex-grow: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9999px !important;
|
||||
background-color: transparent;
|
||||
block-size: 48px;
|
||||
box-shadow: none !important;
|
||||
|
||||
&:hover,
|
||||
&.v-btn--active {
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.btn-content {
|
||||
884
src/layouts/default/components/UserNotification.vue
Normal file
884
src/layouts/default/components/UserNotification.vue
Normal file
@@ -0,0 +1,884 @@
|
||||
<script setup lang="ts">
|
||||
import type { SystemNotification } from '@/api/types'
|
||||
import api from '@/api'
|
||||
import { appUnreadMessageCount, clearUnreadMessages } from '@/utils/badge'
|
||||
import { emitAgentAssistantNotificationBubble } from '@/utils/agentAssistantBubble'
|
||||
import { formatDateDifference } from '@core/utils/formatters'
|
||||
import { useBackground } from '@/composables/useBackground'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
|
||||
type NotificationDisplayItem =
|
||||
| { kind: 'section'; key: string; title: string; count: number }
|
||||
| { kind: 'notification'; key: string; notification: SystemNotification }
|
||||
type NotificationClearScope = 'all' | 'system' | 'media'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { useDelayedSSE } = useBackground()
|
||||
const $toast = useToast()
|
||||
const createConfirm = useConfirm()
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
// 虚拟滚动的默认通知项高度,展开后的实际高度由 VVirtualScroll 的 itemRef 动态测量。
|
||||
const NOTIFICATION_ITEM_HEIGHT = 136
|
||||
|
||||
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 expandedNotificationKeys = ref(new Set<string>())
|
||||
|
||||
const hasUnreadNotifications = computed(() => notificationList.value.some(item => item.read === false))
|
||||
const hasBadgeUnreadMessages = computed(() => appUnreadMessageCount.value > 0)
|
||||
const canMarkAllAsRead = computed(() => hasUnreadNotifications.value || hasBadgeUnreadMessages.value)
|
||||
const hasUnreadMessageIndicator = computed(() => hasNewMessage.value || canMarkAllAsRead.value)
|
||||
const notificationDisplayList = computed(() => buildNotificationDisplayList(notificationList.value))
|
||||
const notificationClearCounts = computed(() => getNotificationClearCounts())
|
||||
const notificationClearOptions = computed(() => [
|
||||
{
|
||||
scope: 'system' as const,
|
||||
title: t('notification.clearSystemMessages'),
|
||||
icon: 'mdi-alert-circle-outline',
|
||||
color: 'error',
|
||||
count: notificationClearCounts.value.system,
|
||||
},
|
||||
{
|
||||
scope: 'media' as const,
|
||||
title: t('notification.clearMediaMessages'),
|
||||
icon: 'mdi-image-outline',
|
||||
color: 'primary',
|
||||
count: notificationClearCounts.value.media,
|
||||
},
|
||||
{
|
||||
scope: 'all' as const,
|
||||
title: t('notification.clearAllMessages'),
|
||||
icon: 'mdi-trash-can-outline',
|
||||
color: 'secondary',
|
||||
count: notificationClearCounts.value.all,
|
||||
},
|
||||
])
|
||||
|
||||
/** 将通知备注统一转换成稳定字符串,用于生成去重 key。 */
|
||||
function normalizeNote(note: SystemNotification['note']) {
|
||||
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 sortNotifications() {
|
||||
notificationList.value = [...notificationList.value].sort(
|
||||
(a, b) => parseNotificationTime(getNotificationTime(b)) - parseNotificationTime(getNotificationTime(a)),
|
||||
)
|
||||
}
|
||||
|
||||
/** 压缩当前通知列表,移除同一内容或同一 ID 的重复项。 */
|
||||
function compactNotifications(items: SystemNotification[]) {
|
||||
const contentKeys = new Set<string>()
|
||||
const idKeys = new Set<string>()
|
||||
const compactedItems: SystemNotification[] = []
|
||||
|
||||
items.forEach(item => {
|
||||
const contentKey = getNotificationContentKey(item)
|
||||
const idKey = item.id ? `id:${item.id}` : ''
|
||||
|
||||
if (contentKeys.has(contentKey) || (idKey && idKeys.has(idKey))) return
|
||||
|
||||
contentKeys.add(contentKey)
|
||||
if (idKey) idKeys.add(idKey)
|
||||
compactedItems.push(item)
|
||||
})
|
||||
|
||||
return compactedItems
|
||||
}
|
||||
|
||||
/** 规范化通知展示字段,并补齐默认标题、类型和已读状态。 */
|
||||
function normalizeNotification(item: SystemNotification, read = true): SystemNotification {
|
||||
return {
|
||||
...item,
|
||||
read,
|
||||
title: item.title || item.source || item.mtype || t('notification.center'),
|
||||
type: item.type || (item.action === 1 ? 'notification' : item.type),
|
||||
}
|
||||
}
|
||||
|
||||
/** 合并新通知到当前列表,并维护去重集合、排序和已读状态。 */
|
||||
function mergeNotifications(items: SystemNotification[], options: { prepend?: boolean; read?: boolean } = {}) {
|
||||
const normalizedItems = items.map(item => normalizeNotification(item, options.read ?? true))
|
||||
const acceptedItems: SystemNotification[] = []
|
||||
|
||||
normalizedItems.forEach(item => {
|
||||
const keys = getNotificationKeys(item)
|
||||
if (keys.some(key => notificationKeys.has(key))) return
|
||||
|
||||
keys.forEach(key => notificationKeys.add(key))
|
||||
acceptedItems.push(item)
|
||||
})
|
||||
|
||||
if (acceptedItems.length === 0) return false
|
||||
|
||||
notificationList.value = options.prepend
|
||||
? [...acceptedItems, ...notificationList.value]
|
||||
: [...notificationList.value, ...acceptedItems]
|
||||
notificationList.value = compactNotifications(notificationList.value)
|
||||
sortNotifications()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/** 重置通知分页状态,用于清理后重新进入空列表状态。 */
|
||||
function resetNotifications() {
|
||||
notificationList.value = []
|
||||
notificationKeys.clear()
|
||||
expandedNotificationKeys.value = new Set()
|
||||
page.value = 1
|
||||
hasMore.value = true
|
||||
hasNewMessage.value = false
|
||||
}
|
||||
|
||||
/** 重新根据当前列表生成去重 key,避免分类清理后遗留已移除消息的去重状态。 */
|
||||
function rebuildNotificationKeys() {
|
||||
notificationKeys.clear()
|
||||
notificationList.value.forEach(item => {
|
||||
getNotificationKeys(item).forEach(key => notificationKeys.add(key))
|
||||
})
|
||||
}
|
||||
|
||||
/** 清理已移除通知的展开状态,避免虚拟列表复用时保留无效 key。 */
|
||||
function rebuildExpandedNotificationKeys() {
|
||||
const currentKeys = new Set(notificationList.value.map(getNotificationExpansionKey))
|
||||
expandedNotificationKeys.value = new Set(
|
||||
[...expandedNotificationKeys.value].filter(key => currentKeys.has(key)),
|
||||
)
|
||||
}
|
||||
|
||||
/** 列表内容变化后同步未读红点和应用角标状态。 */
|
||||
function syncUnreadStateAfterListChange() {
|
||||
// 只用当前列表更新通知中心红点,应用 badge 数量由 badge 工具维护。
|
||||
hasNewMessage.value = canMarkAllAsRead.value
|
||||
}
|
||||
|
||||
/** 统计当前已加载通知中各清理范围的数量,用于菜单展示和禁用空操作。 */
|
||||
function getNotificationClearCounts() {
|
||||
const counts: Record<NotificationClearScope, number> = {
|
||||
all: notificationList.value.length,
|
||||
system: 0,
|
||||
media: 0,
|
||||
}
|
||||
|
||||
notificationList.value.forEach(item => {
|
||||
counts[getNotificationClearScope(item)] += 1
|
||||
})
|
||||
|
||||
return counts
|
||||
}
|
||||
|
||||
/** 移除指定范围的通知,并让分页从第一页重新校验,方便继续加载剩余分类历史。 */
|
||||
function removeNotificationsByScope(scope: NotificationClearScope) {
|
||||
if (scope === 'all') {
|
||||
resetNotifications()
|
||||
hasMore.value = false
|
||||
return
|
||||
}
|
||||
|
||||
notificationList.value = notificationList.value.filter(item => getNotificationClearScope(item) !== scope)
|
||||
page.value = 1
|
||||
hasMore.value = true
|
||||
rebuildNotificationKeys()
|
||||
rebuildExpandedNotificationKeys()
|
||||
syncUnreadStateAfterListChange()
|
||||
}
|
||||
|
||||
/** 获取不同清理范围的确认文案。 */
|
||||
function getClearConfirmText(scope: NotificationClearScope) {
|
||||
if (scope === 'system') return t('notification.clearSystemConfirm')
|
||||
if (scope === 'media') return t('notification.clearMediaConfirm')
|
||||
return t('notification.clearAllConfirm')
|
||||
}
|
||||
|
||||
/** 获取不同清理范围的成功文案。 */
|
||||
function getClearSuccessText(scope: NotificationClearScope) {
|
||||
if (scope === 'system') return t('notification.clearSystemSuccess')
|
||||
if (scope === 'media') return t('notification.clearMediaSuccess')
|
||||
return t('notification.clearAllSuccess')
|
||||
}
|
||||
|
||||
/** 调用后端记录清理范围,后续分页查询会直接返回过滤后的通知。 */
|
||||
async function tryDeleteNotificationHistory(scope: NotificationClearScope) {
|
||||
const result: { [key: string]: any } = await api.delete('message/notification', {
|
||||
params: { scope },
|
||||
})
|
||||
return result?.success !== false
|
||||
}
|
||||
|
||||
/** 确认并清空通知中心历史,同时同步清理未读角标。 */
|
||||
async function clearNotifications(scope: NotificationClearScope) {
|
||||
if (clearing.value || notificationClearCounts.value[scope] === 0) return
|
||||
|
||||
const confirmed = await createConfirm({
|
||||
type: 'warn',
|
||||
title: t('notification.clear'),
|
||||
content: getClearConfirmText(scope),
|
||||
confirmText: t('notification.clear'),
|
||||
})
|
||||
if (!confirmed) return
|
||||
|
||||
clearing.value = true
|
||||
try {
|
||||
const cleared = await tryDeleteNotificationHistory(scope)
|
||||
if (!cleared) {
|
||||
$toast.error(t('notification.clearFailed'))
|
||||
return
|
||||
}
|
||||
|
||||
removeNotificationsByScope(scope)
|
||||
if (scope === 'all') {
|
||||
await clearUnreadMessages()
|
||||
appsMenu.value = false
|
||||
}
|
||||
$toast.success(getClearSuccessText(scope))
|
||||
} catch (error: any) {
|
||||
$toast.error(error?.response?.data?.message || error?.message || t('notification.clearFailed'))
|
||||
} finally {
|
||||
clearing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 按页请求历史通知,并合并到当前虚拟列表。 */
|
||||
async function fetchNotificationPage() {
|
||||
const items = (await api.get('message/notification', {
|
||||
params: {
|
||||
page: page.value,
|
||||
count: PAGE_SIZE,
|
||||
},
|
||||
})) as SystemNotification[]
|
||||
|
||||
if (items.length === 0) {
|
||||
hasMore.value = false
|
||||
return items
|
||||
}
|
||||
|
||||
mergeNotifications(items, { read: true })
|
||||
page.value += 1
|
||||
hasMore.value = items.length >= PAGE_SIZE
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
/** 刷新通知中心首屏数据,确保点开红点时能立即看到后端已有的新消息。 */
|
||||
async function refreshNotificationsOnOpen() {
|
||||
if (loading.value) return
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
page.value = 1
|
||||
hasMore.value = true
|
||||
notificationKeys.clear()
|
||||
notificationList.value = compactNotifications(notificationList.value)
|
||||
rebuildNotificationKeys()
|
||||
await fetchNotificationPage()
|
||||
} catch (error) {
|
||||
console.error('刷新通知失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 按页加载历史通知,并合并到当前虚拟列表。 */
|
||||
async function loadNotifications({ done }: { done: (status: 'ok' | 'empty' | 'error') => void }) {
|
||||
if (loading.value) {
|
||||
done('ok')
|
||||
return
|
||||
}
|
||||
|
||||
if (!hasMore.value) {
|
||||
done('empty')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
|
||||
const items = await fetchNotificationPage()
|
||||
if (items.length === 0) {
|
||||
done('empty')
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
emitAgentAssistantNotificationBubble(notification)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('解析通知失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/** 将通知列表标记为已读,并同步清理应用角标、未读红点和通知弹窗。 */
|
||||
function markAllAsRead() {
|
||||
if (!canMarkAllAsRead.value) return
|
||||
|
||||
hasNewMessage.value = false
|
||||
notificationList.value.forEach(item => {
|
||||
item.read = true
|
||||
})
|
||||
appsMenu.value = false
|
||||
void clearUnreadMessages()
|
||||
}
|
||||
|
||||
/** 根据通知分类和业务类型选择列表图标。 */
|
||||
function getNotificationIcon(item: SystemNotification) {
|
||||
if (getNotificationKind(item) === 'plugin') return 'mdi-puzzle-outline'
|
||||
if (item.mtype === '资源下载') return 'mdi-download'
|
||||
if (item.mtype === '整理入库') return 'mdi-folder-check-outline'
|
||||
if (item.mtype === '订阅') return 'mdi-rss'
|
||||
if (item.mtype === '智能体') return 'lucide:bot'
|
||||
return getNotificationKind(item) === 'system' ? 'mdi-alert-circle-outline' : 'mdi-bell-outline'
|
||||
}
|
||||
|
||||
/** 根据通知分类和业务类型选择图标颜色。 */
|
||||
function getNotificationColor(item: SystemNotification) {
|
||||
if (getNotificationKind(item) === 'system') return 'error'
|
||||
if (getNotificationKind(item) === 'plugin') return 'warning'
|
||||
if (item.mtype === '资源下载') return 'info'
|
||||
if (item.mtype === '整理入库') return 'success'
|
||||
if (item.mtype === '订阅') return 'primary'
|
||||
return 'secondary'
|
||||
}
|
||||
|
||||
/** 判断通知是否有真实媒体图,决定是否使用媒体缩略图样式。 */
|
||||
function isMediaNotification(item: SystemNotification) {
|
||||
return Boolean(item.image)
|
||||
}
|
||||
|
||||
/** 获取通知清理范围,目前通知中心展示上以是否包含图片区分媒体和系统消息。 */
|
||||
function getNotificationClearScope(item: SystemNotification): Exclude<NotificationClearScope, 'all'> {
|
||||
return isMediaNotification(item) ? 'media' : 'system'
|
||||
}
|
||||
|
||||
/** 按系统类消息和媒体消息生成带分组标题的虚拟列表数据。 */
|
||||
function buildNotificationDisplayList(items: SystemNotification[]) {
|
||||
const systemItems = items.filter(item => !isMediaNotification(item))
|
||||
const mediaItems = items.filter(isMediaNotification)
|
||||
const sections = [
|
||||
{ key: 'system', title: t('notification.systemMessages'), items: systemItems },
|
||||
{ key: 'media', title: t('notification.mediaMessages'), items: mediaItems },
|
||||
]
|
||||
const displayItems: NotificationDisplayItem[] = []
|
||||
|
||||
sections.forEach(section => {
|
||||
if (section.items.length === 0) return
|
||||
|
||||
displayItems.push({
|
||||
kind: 'section',
|
||||
key: `section:${section.key}`,
|
||||
title: section.title,
|
||||
count: section.items.length,
|
||||
})
|
||||
section.items.forEach(item => {
|
||||
displayItems.push({
|
||||
kind: 'notification',
|
||||
key: `notification:${getNotificationKey(item)}`,
|
||||
notification: item,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return displayItems
|
||||
}
|
||||
|
||||
/** 判断通知正文是否已经展开。 */
|
||||
function isNotificationExpanded(item: SystemNotification) {
|
||||
return expandedNotificationKeys.value.has(getNotificationExpansionKey(item))
|
||||
}
|
||||
|
||||
/** 标记单条通知为已读,仅同步当前通知中心列表的未读状态。 */
|
||||
function markNotificationAsRead(item: SystemNotification) {
|
||||
item.read = true
|
||||
hasNewMessage.value = hasUnreadNotifications.value
|
||||
}
|
||||
|
||||
/** 切换通知正文展开状态。 */
|
||||
function toggleNotificationExpanded(item: SystemNotification) {
|
||||
markNotificationAsRead(item)
|
||||
if (!item.text) return
|
||||
|
||||
const key = getNotificationExpansionKey(item)
|
||||
const expandedKeys = new Set(expandedNotificationKeys.value)
|
||||
if (expandedKeys.has(key)) expandedKeys.delete(key)
|
||||
else expandedKeys.add(key)
|
||||
expandedNotificationKeys.value = expandedKeys
|
||||
}
|
||||
|
||||
useDelayedSSE(
|
||||
`${import.meta.env.VITE_API_BASE_URL}system/message?role=notification`,
|
||||
handleMessage,
|
||||
'user-notification',
|
||||
3000,
|
||||
{
|
||||
backgroundCloseDelay: 5000,
|
||||
reconnectDelay: 3000,
|
||||
maxReconnectAttempts: 3,
|
||||
},
|
||||
)
|
||||
|
||||
/** 监听通知中心展开状态,展开时主动刷新首屏通知。 */
|
||||
function handleNotificationMenuVisibleChange(open: boolean) {
|
||||
if (open) void refreshNotificationsOnOpen()
|
||||
}
|
||||
|
||||
watch(appsMenu, handleNotificationMenuVisibleChange)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VMenu
|
||||
v-model="appsMenu"
|
||||
width="420"
|
||||
max-width="calc(100vw - 24px)"
|
||||
transition="scale-transition"
|
||||
:close-on-content-click="false"
|
||||
class="notification-menu"
|
||||
scrim
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<VBadge v-if="hasUnreadMessageIndicator" dot color="error" :offset-x="5" :offset-y="5" v-bind="props">
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-bell-outline" size="22" />
|
||||
</IconBtn>
|
||||
</VBadge>
|
||||
<IconBtn v-else v-bind="props">
|
||||
<VIcon icon="mdi-bell-outline" size="22" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
|
||||
<VCard class="notification-panel">
|
||||
<VCardItem class="py-3">
|
||||
<VCardTitle>{{ t('notification.center') }}</VCardTitle>
|
||||
<template #append>
|
||||
<div class="notification-actions">
|
||||
<VTooltip :text="t('notification.clear')">
|
||||
<template #activator="{ props }">
|
||||
<VMenu location="bottom end" :close-on-content-click="true">
|
||||
<template #activator="{ props: menuProps }">
|
||||
<IconBtn
|
||||
v-bind="{ ...props, ...menuProps }"
|
||||
:disabled="notificationList.length === 0 || clearing"
|
||||
@click.stop
|
||||
>
|
||||
<VProgressCircular v-if="clearing" indeterminate size="18" width="2" />
|
||||
<VIcon v-else icon="mdi-trash-can-outline" size="20" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
<VList density="compact" min-width="180">
|
||||
<VListItem
|
||||
v-for="option in notificationClearOptions"
|
||||
:key="option.scope"
|
||||
:disabled="option.count === 0 || clearing"
|
||||
@click="clearNotifications(option.scope)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="option.icon" :color="option.color" size="20" />
|
||||
</template>
|
||||
<VListItemTitle>{{ option.title }}</VListItemTitle>
|
||||
<template #append>
|
||||
<span class="notification-clear-count">{{ option.count }}</span>
|
||||
</template>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VTooltip :text="t('notification.markRead')">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn v-bind="props" :disabled="!canMarkAllAsRead" @click.stop="markAllAsRead">
|
||||
<VIcon icon="mdi-email-check-outline" size="20" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
</div>
|
||||
</template>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
|
||||
<div class="notification-list-container">
|
||||
<VInfiniteScroll
|
||||
mode="intersect"
|
||||
side="end"
|
||||
:items="notificationList"
|
||||
class="notification-list-scroll"
|
||||
@load="loadNotifications"
|
||||
>
|
||||
<template #loading>
|
||||
<div class="py-3 text-center text-caption text-medium-emphasis">
|
||||
{{ t('message.loadMore') }}
|
||||
</div>
|
||||
</template>
|
||||
<template #empty>
|
||||
<div v-if="notificationList.length === 0" class="notification-empty">
|
||||
<div class="notification-empty__icon">
|
||||
<VIcon icon="mdi-bell-sleep-outline" size="22" />
|
||||
</div>
|
||||
<div>{{ t('notification.empty') }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<VVirtualScroll
|
||||
v-if="notificationList.length > 0"
|
||||
renderless
|
||||
:items="notificationDisplayList"
|
||||
:item-height="NOTIFICATION_ITEM_HEIGHT"
|
||||
>
|
||||
<template #default="{ item, itemRef }">
|
||||
<div
|
||||
:ref="itemRef"
|
||||
:key="item.key"
|
||||
class="notification-virtual-item"
|
||||
:class="{ 'notification-virtual-item--section': item.kind === 'section' }"
|
||||
>
|
||||
<div v-if="item.kind === 'section'" class="notification-section-heading">
|
||||
<span class="notification-section-heading__title">{{ item.title }}</span>
|
||||
<span class="notification-section-heading__count">{{ item.count }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="notification-row"
|
||||
:class="{
|
||||
'notification-row--unread': item.notification.read === false,
|
||||
'notification-row--media': isMediaNotification(item.notification),
|
||||
}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-expanded="item.notification.text ? isNotificationExpanded(item.notification) : undefined"
|
||||
@click="toggleNotificationExpanded(item.notification)"
|
||||
@keydown.enter.prevent="toggleNotificationExpanded(item.notification)"
|
||||
@keydown.space.prevent="toggleNotificationExpanded(item.notification)"
|
||||
>
|
||||
<div v-if="item.notification.image" class="notification-media">
|
||||
<VImg
|
||||
v-if="item.notification.image"
|
||||
:src="item.notification.image"
|
||||
cover
|
||||
class="notification-media__image"
|
||||
>
|
||||
<template #placeholder>
|
||||
<VSkeletonLoader class="h-100 w-100" />
|
||||
</template>
|
||||
</VImg>
|
||||
</div>
|
||||
<div v-else class="notification-icon" :class="`text-${getNotificationColor(item.notification)}`">
|
||||
<VIcon :icon="getNotificationIcon(item.notification)" size="22" />
|
||||
</div>
|
||||
|
||||
<div class="notification-content">
|
||||
<div class="notification-title-row">
|
||||
<span class="notification-title">{{ item.notification.title }}</span>
|
||||
<span v-if="item.notification.read === false" class="notification-unread-dot" />
|
||||
</div>
|
||||
<div
|
||||
v-if="item.notification.text"
|
||||
class="notification-text"
|
||||
:class="{ 'notification-text--expanded': isNotificationExpanded(item.notification) }"
|
||||
>
|
||||
{{ item.notification.text }}
|
||||
</div>
|
||||
<div class="notification-meta">
|
||||
<span v-if="item.notification.mtype" class="notification-type">{{ item.notification.mtype }}</span>
|
||||
<span>{{ formatDateDifference(getNotificationTime(item.notification)) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</VVirtualScroll>
|
||||
</VInfiniteScroll>
|
||||
</div>
|
||||
</VCard>
|
||||
</VMenu>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.notification-panel {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.notification-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.notification-clear-count {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-disabled-opacity));
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
margin-inline-start: 16px;
|
||||
}
|
||||
|
||||
.notification-list-container {
|
||||
overflow: hidden;
|
||||
max-block-size: min(560px, 62vh);
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.notification-list-scroll {
|
||||
max-block-size: min(560px, 62vh);
|
||||
min-block-size: 160px;
|
||||
}
|
||||
|
||||
.notification-virtual-item {
|
||||
padding-block: 4px;
|
||||
padding-inline: 8px;
|
||||
}
|
||||
|
||||
.notification-virtual-item--section {
|
||||
padding-block: 10px 2px;
|
||||
}
|
||||
|
||||
.notification-section-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: rgba(var(--v-theme-on-surface), 0.42);
|
||||
gap: 8px;
|
||||
letter-spacing: 0;
|
||||
padding-inline: 10px;
|
||||
}
|
||||
|
||||
.notification-section-heading__title {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.notification-section-heading__count {
|
||||
color: rgba(var(--v-theme-on-surface), 0.34);
|
||||
font-size: 0.625rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.notification-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 10px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
gap: 12px;
|
||||
inline-size: 100%;
|
||||
text-align: start;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
|
||||
.notification-row:hover {
|
||||
background: rgba(var(--v-theme-primary), 0.08);
|
||||
}
|
||||
|
||||
.notification-row--unread {
|
||||
background: rgba(var(--v-theme-error), 0.07);
|
||||
}
|
||||
|
||||
.notification-row--media {
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.notification-media {
|
||||
overflow: hidden;
|
||||
flex: 0 0 56px;
|
||||
border-radius: 6px;
|
||||
background: rgba(var(--v-theme-on-surface), 0.06);
|
||||
block-size: 84px;
|
||||
}
|
||||
|
||||
.notification-media__image {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.notification-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.notification-icon {
|
||||
flex: 0 0 40px;
|
||||
border-radius: 8px;
|
||||
background: rgba(var(--v-theme-on-surface), 0.06);
|
||||
block-size: 40px;
|
||||
}
|
||||
|
||||
.notification-content {
|
||||
flex: 1;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.notification-title-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
min-block-size: 24px;
|
||||
}
|
||||
|
||||
.notification-title {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
flex: 1 1 auto;
|
||||
-webkit-box-orient: vertical;
|
||||
font-size: 0.925rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
-webkit-line-clamp: 2;
|
||||
text-overflow: ellipsis;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.notification-unread-dot {
|
||||
flex: 0 0 7px;
|
||||
border-radius: 999px;
|
||||
background: rgb(var(--v-theme-error));
|
||||
block-size: 7px;
|
||||
inline-size: 7px;
|
||||
margin-block-start: 0.45rem;
|
||||
}
|
||||
|
||||
.notification-text {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
block-size: auto;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
cursor: pointer;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.45;
|
||||
margin-block-start: 4px;
|
||||
max-block-size: calc(0.8125rem * 1.45 * 3);
|
||||
text-align: start;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.notification-text--expanded {
|
||||
max-block-size: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.notification-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-disabled-opacity));
|
||||
font-size: 0.75rem;
|
||||
gap: 6px;
|
||||
line-height: 1.2;
|
||||
margin-block-start: 6px;
|
||||
}
|
||||
|
||||
.notification-type {
|
||||
border-radius: 999px;
|
||||
background: rgba(var(--v-theme-primary), 0.1);
|
||||
color: rgb(var(--v-theme-primary));
|
||||
padding-block: 2px;
|
||||
padding-inline: 6px;
|
||||
}
|
||||
|
||||
.notification-empty {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
padding-block: 32px;
|
||||
padding-inline: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.notification-empty__icon {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border-radius: 8px;
|
||||
background: rgba(var(--v-theme-on-surface), 0.06);
|
||||
block-size: 40px;
|
||||
inline-size: 40px;
|
||||
margin-block-end: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -170,16 +170,13 @@ export default {
|
||||
skinDefault: 'Default',
|
||||
skinBordered: 'Bordered',
|
||||
radius: 'Corners',
|
||||
radiusNone: 'Square',
|
||||
radiusSmall: 'Small',
|
||||
radiusDefault: 'Default',
|
||||
radiusLarge: 'Large',
|
||||
radiusExtra: 'Larger',
|
||||
radiusHuge: 'Extra Large',
|
||||
shadow: 'Shadows',
|
||||
shadowNone: 'Flat',
|
||||
shadowLow: 'Soft',
|
||||
shadowMedium: 'Balanced',
|
||||
shadowHigh: 'Bold',
|
||||
shadowLevel: 'Level {level}',
|
||||
semiDarkMenu: 'Semi Dark Menu',
|
||||
layout: 'Layout',
|
||||
layoutVertical: 'Vertical',
|
||||
@@ -282,6 +279,7 @@ export default {
|
||||
login: {
|
||||
wallpapers: 'Wallpapers',
|
||||
tagline: 'Your smart media library',
|
||||
welcomeBack: 'Welcome Back',
|
||||
copyright: '© {year} MoviePilot',
|
||||
username: 'Username',
|
||||
password: 'Password',
|
||||
@@ -462,7 +460,22 @@ export default {
|
||||
notification: {
|
||||
center: 'Notification Center',
|
||||
markRead: 'Mark as Read',
|
||||
clear: 'Clear Notifications',
|
||||
clearConfirm: 'Clear all notification history from Notification Center?',
|
||||
clearAllMessages: 'Clear All Messages',
|
||||
clearSystemMessages: 'Clear System Messages',
|
||||
clearMediaMessages: 'Clear Media Messages',
|
||||
clearAllConfirm: 'Clear all notification history from Notification Center?',
|
||||
clearSystemConfirm: 'Clear system message history from Notification Center?',
|
||||
clearMediaConfirm: 'Clear media message history from Notification Center?',
|
||||
clearSuccess: 'Notifications cleared',
|
||||
clearAllSuccess: 'All notifications cleared',
|
||||
clearSystemSuccess: 'System messages cleared',
|
||||
clearMediaSuccess: 'Media messages cleared',
|
||||
clearFailed: 'Failed to clear notifications',
|
||||
empty: 'No Notifications',
|
||||
systemMessages: 'System Messages',
|
||||
mediaMessages: 'Media Messages',
|
||||
channel: 'Notification Channel',
|
||||
name: 'Name',
|
||||
nameHint: 'Name of notification channel',
|
||||
@@ -1616,6 +1629,9 @@ export default {
|
||||
'Set the check interval for scheduled wake. Select "Disabled" to disable scheduled tasks.',
|
||||
aiAgentVerbose: 'Verbose Mode',
|
||||
aiAgentVerboseHint: 'When enabled, tool call process will be displayed in AI agent responses',
|
||||
aiAgentHideEntry: 'Hide Global Entry',
|
||||
aiAgentHideEntryHint:
|
||||
'Only hide the floating AI assistant entry in the bottom-right corner. Message channels and background assistant features are not affected.',
|
||||
aiAgentJobIntervalDisabled: 'Disabled',
|
||||
aiAgentJobInterval1h: '1 Hour',
|
||||
aiAgentJobInterval3h: '3 Hours',
|
||||
|
||||
@@ -170,16 +170,13 @@ export default {
|
||||
skinDefault: '默认',
|
||||
skinBordered: '边框',
|
||||
radius: '圆角',
|
||||
radiusNone: '无圆角',
|
||||
radiusSmall: '小圆角',
|
||||
radiusDefault: '默认',
|
||||
radiusLarge: '大圆角',
|
||||
radiusExtra: '更大圆角',
|
||||
radiusHuge: '超大圆角',
|
||||
shadow: '阴影',
|
||||
shadowNone: '无阴影',
|
||||
shadowLow: '柔和',
|
||||
shadowMedium: '标准',
|
||||
shadowHigh: '强烈',
|
||||
shadowLevel: '层级 {level}',
|
||||
semiDarkMenu: '半暗菜单',
|
||||
layout: '布局',
|
||||
layoutVertical: '垂直',
|
||||
@@ -281,6 +278,7 @@ export default {
|
||||
login: {
|
||||
wallpapers: '壁纸',
|
||||
tagline: '你的智能影视媒体库',
|
||||
welcomeBack: '欢迎回来',
|
||||
copyright: '© {year} MoviePilot',
|
||||
username: '用户名',
|
||||
password: '密码',
|
||||
@@ -460,7 +458,22 @@ export default {
|
||||
notification: {
|
||||
center: '通知中心',
|
||||
markRead: '设为已读',
|
||||
clear: '清理通知',
|
||||
clearConfirm: '是否确认清理通知中心内的全部历史消息?',
|
||||
clearAllMessages: '清理全部消息',
|
||||
clearSystemMessages: '清理系统消息',
|
||||
clearMediaMessages: '清理媒体消息',
|
||||
clearAllConfirm: '是否确认清理通知中心内的全部历史消息?',
|
||||
clearSystemConfirm: '是否确认清理通知中心内的系统类历史消息?',
|
||||
clearMediaConfirm: '是否确认清理通知中心内的媒体历史消息?',
|
||||
clearSuccess: '通知已清理',
|
||||
clearAllSuccess: '全部通知已清理',
|
||||
clearSystemSuccess: '系统消息已清理',
|
||||
clearMediaSuccess: '媒体消息已清理',
|
||||
clearFailed: '通知清理失败',
|
||||
empty: '暂无通知',
|
||||
systemMessages: '系统类消息',
|
||||
mediaMessages: '媒体消息',
|
||||
channel: '通知渠道',
|
||||
name: '名称',
|
||||
nameHint: '通知渠道名称',
|
||||
@@ -1602,6 +1615,8 @@ export default {
|
||||
aiAgentJobIntervalHint: '设置定时唤醒的检查间隔,选择"不启用"则不执行定时任务',
|
||||
aiAgentVerbose: '啰嗦模式',
|
||||
aiAgentVerboseHint: '开启后会在智能体回复时显示工具调用过程',
|
||||
aiAgentHideEntry: '隐藏全局入口',
|
||||
aiAgentHideEntryHint: '仅隐藏页面右下角的智能助手浮动入口,不影响消息渠道和后台智能助手功能',
|
||||
aiAgentJobIntervalDisabled: '不启用',
|
||||
aiAgentJobInterval1h: '1小时',
|
||||
aiAgentJobInterval3h: '3小时',
|
||||
|
||||
@@ -170,16 +170,13 @@ export default {
|
||||
skinDefault: '默認',
|
||||
skinBordered: '邊框',
|
||||
radius: '圓角',
|
||||
radiusNone: '無圓角',
|
||||
radiusSmall: '小圓角',
|
||||
radiusDefault: '默認',
|
||||
radiusLarge: '大圓角',
|
||||
radiusExtra: '更大圓角',
|
||||
radiusHuge: '超大圓角',
|
||||
shadow: '陰影',
|
||||
shadowNone: '無陰影',
|
||||
shadowLow: '柔和',
|
||||
shadowMedium: '標準',
|
||||
shadowHigh: '強烈',
|
||||
shadowLevel: '層級 {level}',
|
||||
semiDarkMenu: '半暗菜單',
|
||||
layout: '佈局',
|
||||
layoutVertical: '垂直',
|
||||
@@ -281,6 +278,7 @@ export default {
|
||||
login: {
|
||||
wallpapers: '壁紙',
|
||||
tagline: '你的智能影視媒體庫',
|
||||
welcomeBack: '歡迎回來',
|
||||
copyright: '© {year} MoviePilot',
|
||||
username: '用戶名',
|
||||
password: '密碼',
|
||||
@@ -460,7 +458,22 @@ export default {
|
||||
notification: {
|
||||
center: '通知中心',
|
||||
markRead: '設為已讀',
|
||||
clear: '清理通知',
|
||||
clearConfirm: '是否確認清理通知中心內的全部歷史消息?',
|
||||
clearAllMessages: '清理全部消息',
|
||||
clearSystemMessages: '清理系統消息',
|
||||
clearMediaMessages: '清理媒體消息',
|
||||
clearAllConfirm: '是否確認清理通知中心內的全部歷史消息?',
|
||||
clearSystemConfirm: '是否確認清理通知中心內的系統類歷史消息?',
|
||||
clearMediaConfirm: '是否確認清理通知中心內的媒體歷史消息?',
|
||||
clearSuccess: '通知已清理',
|
||||
clearAllSuccess: '全部通知已清理',
|
||||
clearSystemSuccess: '系統消息已清理',
|
||||
clearMediaSuccess: '媒體消息已清理',
|
||||
clearFailed: '通知清理失敗',
|
||||
empty: '暫無通知',
|
||||
systemMessages: '系統類消息',
|
||||
mediaMessages: '媒體消息',
|
||||
channel: '通知渠道',
|
||||
name: '名稱',
|
||||
nameHint: '通知渠道名稱',
|
||||
@@ -1603,6 +1616,8 @@ export default {
|
||||
aiAgentJobIntervalHint: '設置定時喚醒的檢查間隔,選擇「不啟用」則不執行定時任務',
|
||||
aiAgentVerbose: '囉嗦模式',
|
||||
aiAgentVerboseHint: '開啟後會在智能體回覆時顯示工具調用過程',
|
||||
aiAgentHideEntry: '隱藏全域入口',
|
||||
aiAgentHideEntryHint: '僅隱藏頁面右下角的智能助手浮動入口,不影響消息渠道和後台智能助手功能',
|
||||
aiAgentJobIntervalDisabled: '不啟用',
|
||||
aiAgentJobInterval1h: '1小時',
|
||||
aiAgentJobInterval3h: '3小時',
|
||||
|
||||
65
src/main.ts
65
src/main.ts
@@ -14,9 +14,14 @@ import App from '@/App.vue'
|
||||
import { PerfectScrollbarPlugin } from 'vue3-perfect-scrollbar'
|
||||
|
||||
// 4. 其他插件和功能模块
|
||||
import Toast from 'vue-toastification'
|
||||
import Toast, { TYPE, type PluginOptions } from 'vue-toastification'
|
||||
import ConfirmDialog from '@/composables/useConfirm'
|
||||
import { configureApexChartsTheme } from '@/utils/apexCharts'
|
||||
import {
|
||||
canUseAgentAssistantBubble,
|
||||
emitAgentAssistantToastBubble,
|
||||
type AgentAssistantBubbleVariant,
|
||||
} from '@/utils/agentAssistantBubble'
|
||||
|
||||
// 5. 注册自定义组件
|
||||
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
|
||||
@@ -29,6 +34,8 @@ import '@/styles/main.scss'
|
||||
// 7. 状态恢复插件
|
||||
import stateRestorePlugin from '@/plugins/stateRestore'
|
||||
|
||||
type ToastFilterPayload = Parameters<NonNullable<PluginOptions['filterBeforeCreate']>>[0]
|
||||
|
||||
function runWhenBrowserIdle(callback: () => void, timeout = 1500) {
|
||||
const requestIdle = globalThis.requestIdleCallback
|
||||
if (requestIdle) {
|
||||
@@ -53,6 +60,61 @@ function loadRemoteComponentsAfterLogin() {
|
||||
})
|
||||
}
|
||||
|
||||
function shouldUseAgentAssistantToastBubble() {
|
||||
const settings = pinia.state.value.globalSettings
|
||||
if (!settings?.initialized) return false
|
||||
|
||||
return (
|
||||
settings.data?.AI_AGENT_ENABLE === true &&
|
||||
settings.data?.AI_AGENT_HIDE_ENTRY !== true &&
|
||||
canUseAgentAssistantBubble()
|
||||
)
|
||||
}
|
||||
|
||||
function getAgentAssistantToastVariant(type?: ToastFilterPayload['type']): AgentAssistantBubbleVariant {
|
||||
const variants: Record<string, AgentAssistantBubbleVariant> = {
|
||||
[TYPE.DEFAULT]: 'default',
|
||||
[TYPE.ERROR]: 'error',
|
||||
[TYPE.INFO]: 'info',
|
||||
[TYPE.SUCCESS]: 'success',
|
||||
[TYPE.WARNING]: 'warning',
|
||||
}
|
||||
|
||||
return variants[type || TYPE.DEFAULT] || 'default'
|
||||
}
|
||||
|
||||
function getToastBubbleDuration(type?: ToastFilterPayload['type'], timeout?: ToastFilterPayload['timeout']) {
|
||||
if (typeof timeout === 'number') return timeout
|
||||
if (timeout === false) return undefined
|
||||
|
||||
return type === TYPE.ERROR || type === TYPE.WARNING ? 7000 : 4500
|
||||
}
|
||||
|
||||
function getToastTextContent(content: ToastFilterPayload['content']) {
|
||||
if (typeof content === 'string') return content
|
||||
|
||||
// 组件型 toast 可能包含操作按钮或复杂布局,无法可靠转成气泡文本时继续使用原生 toast。
|
||||
return ''
|
||||
}
|
||||
|
||||
function routeToastToAgentAssistantBubble(toast: ToastFilterPayload) {
|
||||
const text = getToastTextContent(toast.content)
|
||||
if (!text || !shouldUseAgentAssistantToastBubble()) return toast
|
||||
|
||||
const variant = getAgentAssistantToastVariant(toast.type)
|
||||
|
||||
emitAgentAssistantToastBubble({
|
||||
id: `toast-${String(toast.id)}`,
|
||||
kind: 'toast',
|
||||
variant,
|
||||
text,
|
||||
duration: getToastBubbleDuration(toast.type, toast.timeout),
|
||||
keepOpen: toast.timeout === false,
|
||||
})
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
let remoteComponentsInitialized = false
|
||||
|
||||
const AsyncAceEditor = defineAsyncComponent(async () => {
|
||||
@@ -111,6 +173,7 @@ app
|
||||
.use(Toast, {
|
||||
position: 'bottom-right',
|
||||
hideProgressBar: true,
|
||||
filterBeforeCreate: routeToastToAgentAssistantBubble,
|
||||
})
|
||||
.use(ConfirmDialog)
|
||||
.use(i18n)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import NoDataFound from '@/components/states/NoDataFound.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// 国际化
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import api from '@/api'
|
||||
import { DownloaderConf } from '@/api/types'
|
||||
import DownloadingListView from '@/views/reorganize/DownloadingListView.vue'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import NoDataFound from '@/components/states/NoDataFound.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
|
||||
import { useKeepAliveRefresh } from '@/composables/useKeepAliveRefresh'
|
||||
|
||||
@@ -719,6 +719,13 @@ onUnmounted(() => {
|
||||
<template>
|
||||
<!-- 登录页面容器 -->
|
||||
<div class="login-root">
|
||||
<!-- 装饰性背景光晕 -->
|
||||
<div class="login-bg-decor" aria-hidden="true">
|
||||
<div class="login-orb login-orb--1" />
|
||||
<div class="login-orb login-orb--2" />
|
||||
<div class="login-orb login-orb--3" />
|
||||
</div>
|
||||
|
||||
<!-- 顶部漂浮语言切换 -->
|
||||
<VMenu v-model="langMenu" :close-on-content-click="false">
|
||||
<template #activator="{ props }">
|
||||
@@ -728,7 +735,7 @@ onUnmounted(() => {
|
||||
<span class="ms-1">{{ SUPPORTED_LOCALES[currentLocale].title }}</span>
|
||||
</VBtn>
|
||||
</template>
|
||||
<VCard min-width="180">
|
||||
<VCard min-width="180" class="lang-menu-card">
|
||||
<VList>
|
||||
<VListItem
|
||||
v-for="locale in locales"
|
||||
@@ -749,15 +756,18 @@ onUnmounted(() => {
|
||||
<!-- 登录表单 -->
|
||||
<div v-if="!mfaDialog" class="auth-wrapper d-flex align-center justify-center">
|
||||
<VCard
|
||||
class="auth-card login-card pa-7 pa-sm-8 w-full h-full login-card--enter"
|
||||
class="auth-card login-card pa-7 pa-sm-9 w-full h-full login-card--enter"
|
||||
:class="{ 'glass-effect': !isTransparentTheme }"
|
||||
max-width="23rem"
|
||||
max-width="24rem"
|
||||
flat
|
||||
>
|
||||
<!-- 卡片头部:Logo + 标题 + 标语 -->
|
||||
<!-- 卡片头部:Logo + 标题 + 欢迎语 -->
|
||||
<div class="login-head">
|
||||
<VImg :src="logo" width="68" height="68" class="login-logo" />
|
||||
<div class="login-logo-wrapper">
|
||||
<VImg :src="logo" width="72" height="72" class="login-logo" />
|
||||
</div>
|
||||
<h1 class="login-title">MoviePilot</h1>
|
||||
<p class="login-subtitle">{{ t('login.welcomeBack') || 'Welcome Back' }}</p>
|
||||
</div>
|
||||
|
||||
<VCardText class="login-body">
|
||||
@@ -777,6 +787,8 @@ onUnmounted(() => {
|
||||
hide-details
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
prepend-inner-icon="mdi-account-outline"
|
||||
class="login-input"
|
||||
@input="scheduleLoginAutofillSync"
|
||||
@change="scheduleLoginAutofillSync"
|
||||
/>
|
||||
@@ -790,11 +802,13 @@ onUnmounted(() => {
|
||||
name="password"
|
||||
id="password"
|
||||
autocomplete="current-password"
|
||||
prepend-inner-icon="mdi-lock-outline"
|
||||
:append-inner-icon="isPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
|
||||
:rules="[requiredValidator]"
|
||||
hide-details
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
class="login-input"
|
||||
@click:append-inner="isPasswordVisible = !isPasswordVisible"
|
||||
@input="scheduleLoginAutofillSync"
|
||||
@change="scheduleLoginAutofillSync"
|
||||
@@ -809,6 +823,7 @@ onUnmounted(() => {
|
||||
required
|
||||
hide-details
|
||||
density="compact"
|
||||
class="login-checkbox"
|
||||
/>
|
||||
</div>
|
||||
</VCol>
|
||||
@@ -842,14 +857,15 @@ onUnmounted(() => {
|
||||
block
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
class="mt-3"
|
||||
class="mt-3 plugin-auth-btn"
|
||||
:prepend-icon="provider.icon || 'mdi-login-variant'"
|
||||
:loading="pluginAuthLoading && selectedAuthProvider?.id === provider.id"
|
||||
rounded="lg"
|
||||
@click="openPluginAuth(provider)"
|
||||
>
|
||||
{{ provider.name }}
|
||||
</VBtn>
|
||||
<VAlert v-if="errorMessage" type="error" variant="tonal" class="mt-3">
|
||||
<VAlert v-if="errorMessage" type="error" variant="tonal" class="mt-4 login-alert">
|
||||
{{ errorMessage }}
|
||||
</VAlert>
|
||||
</VCol>
|
||||
@@ -865,7 +881,7 @@ onUnmounted(() => {
|
||||
</VCard>
|
||||
</div>
|
||||
<VDialog v-model="pluginAuthDialog" max-width="520" persistent>
|
||||
<VCard>
|
||||
<VCard class="plugin-auth-card">
|
||||
<VCardItem>
|
||||
<VCardTitle>{{ selectedAuthProvider?.name }}</VCardTitle>
|
||||
<template #append>
|
||||
@@ -894,12 +910,15 @@ onUnmounted(() => {
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* stylelint-disable selector-pseudo-class-no-unknown */
|
||||
|
||||
@use '@core/scss/pages/page-auth';
|
||||
|
||||
/* ===================== 布局根容器 ===================== */
|
||||
.login-root {
|
||||
position: relative;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -908,16 +927,99 @@ onUnmounted(() => {
|
||||
min-block-size: 100dvh;
|
||||
}
|
||||
|
||||
/* ===================== 装饰性背景光晕 ===================== */
|
||||
.login-bg-decor {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.login-orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.login-orb--1 {
|
||||
animation: orb-float-1 12s ease-in-out infinite alternate;
|
||||
background: rgba(var(--v-theme-primary), 0.35);
|
||||
block-size: 360px;
|
||||
filter: blur(60px);
|
||||
inline-size: 360px;
|
||||
inset-block-start: -15%;
|
||||
inset-inline-end: -12%;
|
||||
}
|
||||
|
||||
.login-orb--2 {
|
||||
animation: orb-float-2 15s ease-in-out infinite alternate;
|
||||
background: rgba(var(--v-theme-primary), 0.25);
|
||||
block-size: 300px;
|
||||
filter: blur(55px);
|
||||
inline-size: 300px;
|
||||
inset-block-end: -10%;
|
||||
inset-inline-start: -15%;
|
||||
}
|
||||
|
||||
.login-orb--3 {
|
||||
animation: orb-float-3 10s ease-in-out infinite alternate;
|
||||
background: rgba(var(--v-theme-primary), 0.15);
|
||||
block-size: 220px;
|
||||
filter: blur(50px);
|
||||
inline-size: 220px;
|
||||
inset-block-start: 50%;
|
||||
inset-inline-end: 15%;
|
||||
}
|
||||
|
||||
@keyframes orb-float-1 {
|
||||
0% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(-30px, 40px) scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes orb-float-2 {
|
||||
0% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(25px, -30px) scale(1.08);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes orb-float-3 {
|
||||
0% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(-20px, 20px) scale(0.92);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===================== 浮动语言切换 ===================== */
|
||||
.lang-switch-btn {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
border: 1px solid rgba(var(--v-border-color), calc(var(--v-border-opacity) * 0.6));
|
||||
border-radius: 999px;
|
||||
backdrop-filter: blur(10px);
|
||||
background: rgba(var(--v-theme-surface), 0.55);
|
||||
backdrop-filter: blur(12px) saturate(140%);
|
||||
background: rgba(var(--v-theme-surface), 0.6);
|
||||
inset-block-start: calc(env(safe-area-inset-top, 0px) + 16px);
|
||||
inset-inline-end: calc(env(safe-area-inset-right, 0px) + 16px);
|
||||
transition:
|
||||
background 200ms ease,
|
||||
border-color 200ms ease;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(var(--v-theme-primary), 0.3);
|
||||
background: rgba(var(--v-theme-surface), 0.85);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===================== 表单容器 ===================== */
|
||||
@@ -935,17 +1037,19 @@ onUnmounted(() => {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
border: none !important;
|
||||
border-radius: var(--app-surface-radius, 16px) !important;
|
||||
box-shadow: var(
|
||||
--app-overlay-shadow,
|
||||
0 18px 42px rgba(var(--app-shadow-rgb, 0, 0, 0), 0.14),
|
||||
0 6px 18px rgba(var(--app-shadow-rgb, 0, 0, 0), 0.08)
|
||||
) !important;
|
||||
border-radius: var(--app-surface-radius, 20px) !important;
|
||||
box-shadow:
|
||||
0 20px 50px rgba(var(--app-shadow-rgb, 0, 0, 0), 0.12),
|
||||
0 8px 20px rgba(var(--app-shadow-rgb, 0, 0, 0), 0.06),
|
||||
0 0 0 1px rgba(var(--v-theme-primary), 0.04) !important;
|
||||
transition: box-shadow 300ms ease;
|
||||
|
||||
/* 顶部高光线,营造立体感 */
|
||||
&::before {
|
||||
position: absolute;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 40%), transparent);
|
||||
z-index: 1;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, transparent 10%, rgba(255, 255, 255, 35%) 50%, transparent 90%);
|
||||
block-size: 1px;
|
||||
content: '';
|
||||
inset-block-start: 0;
|
||||
@@ -956,8 +1060,8 @@ onUnmounted(() => {
|
||||
|
||||
/* 非透明主题:磨砂玻璃卡片 */
|
||||
.glass-effect {
|
||||
backdrop-filter: blur(24px) saturate(160%) !important;
|
||||
background: rgba(var(--v-theme-surface), 0.72) !important;
|
||||
backdrop-filter: blur(28px) saturate(170%) !important;
|
||||
background: rgba(var(--v-theme-surface), 0.75) !important;
|
||||
}
|
||||
|
||||
/* 深色主题上叠一条更亮的描边,区分背景 */
|
||||
@@ -976,34 +1080,80 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-block-end: 6px;
|
||||
gap: 4px;
|
||||
margin-block-end: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-logo-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-block-end: 8px;
|
||||
|
||||
/* Logo 背后的柔光环 */
|
||||
&::before {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
border-radius: 50%;
|
||||
animation: logo-pulse 4s ease-in-out infinite;
|
||||
background: radial-gradient(circle, rgba(var(--v-theme-primary), 0.2) 0%, transparent 70%);
|
||||
block-size: 120px;
|
||||
content: '';
|
||||
inline-size: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
filter: drop-shadow(0 6px 16px rgba(var(--v-theme-primary), 0.35));
|
||||
margin-block-end: 4px;
|
||||
animation: logo-float 6s ease-in-out infinite;
|
||||
filter: drop-shadow(0 8px 20px rgba(var(--v-theme-primary), 0.3));
|
||||
}
|
||||
|
||||
@keyframes logo-float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes logo-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.6;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
.login-title {
|
||||
margin: 0;
|
||||
background: linear-gradient(120deg, rgb(var(--v-theme-on-surface)), rgba(var(--v-theme-primary), 1));
|
||||
background: linear-gradient(135deg, rgb(var(--v-theme-on-surface)) 30%, rgba(var(--v-theme-primary), 1) 100%);
|
||||
background-clip: text;
|
||||
font-size: 1.8rem;
|
||||
font-size: 1.85rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.025em;
|
||||
letter-spacing: 0.03em;
|
||||
line-height: 1.2;
|
||||
-webkit-text-fill-color: transparent;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.login-tagline {
|
||||
margin: 0;
|
||||
.login-subtitle {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.01em;
|
||||
margin-block: 4px 0;
|
||||
margin-inline: 0;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* ===================== 卡片主体 ===================== */
|
||||
@@ -1011,29 +1161,110 @@ onUnmounted(() => {
|
||||
padding-block: 8px !important;
|
||||
}
|
||||
|
||||
/* 输入框聚焦时增加主色光晕 */
|
||||
:deep(.login-body .v-field.v-field--focused) {
|
||||
box-shadow: 0 0 0 2px rgba(var(--v-theme-primary), 0.18);
|
||||
/* 输入框增强样式 */
|
||||
:deep(.login-input .v-field) {
|
||||
border-radius: 12px;
|
||||
transition:
|
||||
box-shadow 200ms ease,
|
||||
border-color 200ms ease;
|
||||
}
|
||||
|
||||
/* 登录按钮:主色 + 悬浮抬升 */
|
||||
.login-submit {
|
||||
box-shadow: 0 8px 20px rgba(var(--v-theme-primary), 0.35);
|
||||
letter-spacing: 0.02em;
|
||||
transition:
|
||||
transform var(--mp-motion-duration-overlay, 160ms) var(--mp-motion-ease-standard, ease),
|
||||
box-shadow var(--mp-motion-duration-overlay, 160ms) var(--mp-motion-ease-standard, ease);
|
||||
/* 输入框聚焦时增加主色光晕 */
|
||||
:deep(.login-body .v-field.v-field--focused) {
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(var(--v-theme-primary), 0.12),
|
||||
0 4px 12px rgba(var(--v-theme-primary), 0.08);
|
||||
}
|
||||
|
||||
/* 输入框悬停 */
|
||||
:deep(.login-input .v-field:hover:not(.v-field--focused)) {
|
||||
border-color: rgba(var(--v-theme-primary), 0.4);
|
||||
}
|
||||
|
||||
/* Remember me 复选框样式优化 */
|
||||
.login-checkbox {
|
||||
opacity: 0.85;
|
||||
transition: opacity 150ms ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 12px 26px rgba(var(--v-theme-primary), 0.42);
|
||||
transform: translateY(-1px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* 登录按钮:渐变 + 悬浮抬升 + 光泽 */
|
||||
.login-submit {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8px 24px rgba(var(--v-theme-primary), 0.35);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
transition:
|
||||
transform 200ms cubic-bezier(0.2, 0.8, 0.2, 1),
|
||||
box-shadow 200ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 12px 32px rgba(var(--v-theme-primary), 0.45);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
box-shadow: 0 4px 12px rgba(var(--v-theme-primary), 0.3);
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 登录按钮内部光泽扫描层 */
|
||||
.login-submit :deep(.v-btn__content)::after {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
background: linear-gradient(
|
||||
105deg,
|
||||
transparent 35%,
|
||||
rgba(255, 255, 255, 30%) 43%,
|
||||
rgba(255, 255, 255, 40%) 50%,
|
||||
rgba(255, 255, 255, 30%) 57%,
|
||||
transparent 65%
|
||||
);
|
||||
content: '';
|
||||
inset-block: -50%;
|
||||
inset-inline: -50%;
|
||||
pointer-events: none;
|
||||
transform: translateX(-120%);
|
||||
transition: transform 700ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.login-submit:hover :deep(.v-btn__content)::after {
|
||||
transform: translateX(120%);
|
||||
}
|
||||
|
||||
/* Passkey 按钮 */
|
||||
.passkey-btn {
|
||||
border-radius: 12px;
|
||||
font-weight: 500;
|
||||
transition:
|
||||
background 200ms ease,
|
||||
border-color 200ms ease,
|
||||
transform 150ms ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
/* 插件认证按钮 */
|
||||
.plugin-auth-btn {
|
||||
border-radius: 12px;
|
||||
font-weight: 500;
|
||||
transition:
|
||||
background 200ms ease,
|
||||
border-color 200ms ease,
|
||||
transform 150ms ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
/* or 分隔线 */
|
||||
.or-divider {
|
||||
position: relative;
|
||||
@@ -1044,21 +1275,26 @@ onUnmounted(() => {
|
||||
&::before,
|
||||
&::after {
|
||||
flex: 1;
|
||||
border-block-end: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-block-end: 1px solid rgba(var(--v-border-color), calc(var(--v-border-opacity) * 0.7));
|
||||
content: '';
|
||||
}
|
||||
|
||||
.or-divider-text {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
padding-inline: 14px;
|
||||
letter-spacing: 0.1em;
|
||||
padding-inline: 16px;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
/* 错误提示 */
|
||||
.login-alert {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* 浅色主题下 passkey 按钮保持绿色辨识度 */
|
||||
:deep(.v-theme--light) .passkey-btn.v-btn--variant-outlined {
|
||||
color: rgb(86, 170, 0) !important;
|
||||
@@ -1070,43 +1306,103 @@ onUnmounted(() => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(var(--v-theme-on-surface), calc(var(--v-disabled-opacity) * 1.4));
|
||||
font-size: 0.72rem;
|
||||
font-size: 0.7rem;
|
||||
gap: 8px;
|
||||
letter-spacing: 0.02em;
|
||||
margin-block-start: 8px;
|
||||
letter-spacing: 0.03em;
|
||||
margin-block-start: 14px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.login-version {
|
||||
border-inline-start: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-inline-start: 1px solid rgba(var(--v-border-color), calc(var(--v-border-opacity) * 0.6));
|
||||
padding-inline: 6px;
|
||||
}
|
||||
|
||||
/* ===================== 入场动画 ===================== */
|
||||
.login-card--enter {
|
||||
animation: login-enter 520ms var(--mp-motion-ease-standard, cubic-bezier(0.2, 0.8, 0.2, 1)) both;
|
||||
animation: login-enter 600ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
@keyframes login-enter {
|
||||
0% {
|
||||
filter: blur(4px);
|
||||
opacity: 0;
|
||||
transform: translateY(14px) scale(0.985);
|
||||
transform: translateY(20px) scale(0.97);
|
||||
}
|
||||
|
||||
100% {
|
||||
filter: blur(0);
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Logo 入场 */
|
||||
.login-logo-wrapper {
|
||||
animation: logo-enter 700ms cubic-bezier(0.16, 1, 0.3, 1) 100ms both;
|
||||
}
|
||||
|
||||
@keyframes logo-enter {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.8) translateY(10px);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 标题入场 */
|
||||
.login-title {
|
||||
animation: text-enter 600ms cubic-bezier(0.16, 1, 0.3, 1) 200ms both;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
animation: text-enter 600ms cubic-bezier(0.16, 1, 0.3, 1) 300ms both;
|
||||
}
|
||||
|
||||
@keyframes text-enter {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===================== 无障碍:尊重减少动态偏好 ===================== */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.login-card--enter {
|
||||
.login-card--enter,
|
||||
.login-logo-wrapper,
|
||||
.login-title,
|
||||
.login-subtitle {
|
||||
animation-duration: 1ms !important;
|
||||
}
|
||||
|
||||
.login-submit {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.login-submit :deep(.v-btn__content)::after {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
.login-orb {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
.login-logo-wrapper::before {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===================== 小屏适配 ===================== */
|
||||
@@ -1121,6 +1417,21 @@ onUnmounted(() => {
|
||||
|
||||
.login-card {
|
||||
padding: 1.5rem !important;
|
||||
border-radius: 16px !important;
|
||||
}
|
||||
|
||||
.login-orb--1 {
|
||||
block-size: 220px;
|
||||
inline-size: 220px;
|
||||
}
|
||||
|
||||
.login-orb--2 {
|
||||
block-size: 180px;
|
||||
inline-size: 180px;
|
||||
}
|
||||
|
||||
.login-orb--3 {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { debounce } from 'lodash-es'
|
||||
import type { LocationQuery } from 'vue-router'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import NoDataFound from '@/components/states/NoDataFound.vue'
|
||||
import api from '@/api'
|
||||
import type { Context, SubtitleInfo } from '@/api/types'
|
||||
import TorrentCard from '@/components/cards/TorrentCard.vue'
|
||||
|
||||
@@ -283,6 +283,17 @@ async function setStoredUnreadCount(count: number): Promise<void> {
|
||||
await set(UNREAD_COUNT_KEY, count)
|
||||
}
|
||||
|
||||
// 通知已打开的页面同步未读计数,保证前台通知中心能感知 PWA badge 的变化。
|
||||
async function broadcastUnreadCount(count: number) {
|
||||
const clients = await self.clients.matchAll({ includeUncontrolled: true, type: 'window' })
|
||||
clients.forEach(client => {
|
||||
client.postMessage({
|
||||
type: 'UNREAD_COUNT_UPDATE',
|
||||
count,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function updateBadge(count: number) {
|
||||
if ('setAppBadge' in self.navigator) {
|
||||
try {
|
||||
@@ -309,6 +320,7 @@ async function clearBadge() {
|
||||
|
||||
try {
|
||||
await setStoredUnreadCount(0)
|
||||
await broadcastUnreadCount(0)
|
||||
} catch (error) {
|
||||
console.error('Failed to clear unread count:', error)
|
||||
}
|
||||
@@ -422,7 +434,11 @@ self.addEventListener('push', function (event) {
|
||||
const currentCount = await getStoredUnreadCount()
|
||||
const newCount = currentCount + 1
|
||||
await setStoredUnreadCount(newCount)
|
||||
await Promise.all([self.registration.showNotification(payload.title, content), updateBadge(newCount)])
|
||||
await Promise.all([
|
||||
self.registration.showNotification(payload.title, content),
|
||||
updateBadge(newCount),
|
||||
broadcastUnreadCount(newCount),
|
||||
])
|
||||
})(),
|
||||
)
|
||||
} catch (e) {
|
||||
@@ -454,6 +470,7 @@ self.addEventListener('message', function (event) {
|
||||
const count = event.data.count || 0
|
||||
setStoredUnreadCount(count)
|
||||
.then(() => updateBadge(count))
|
||||
.then(() => broadcastUnreadCount(count))
|
||||
.then(() => {
|
||||
event.ports[0]?.postMessage({ success: true })
|
||||
})
|
||||
|
||||
@@ -1,6 +1,30 @@
|
||||
/* stylelint-disable custom-property-pattern */
|
||||
/* stylelint-disable no-duplicate-selectors */
|
||||
/* stylelint-disable scss/at-rule-no-unknown */
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
@use 'sass:map';
|
||||
@use 'vuetify/settings' as vuetify-settings;
|
||||
|
||||
// 返回 Vuetify 指定 elevation 的完整三层 box-shadow,供主题定制器映射全局阴影档位。
|
||||
@function app-vuetify-elevation($level) {
|
||||
@return map.get(vuetify-settings.$shadow-key-umbra, $level),
|
||||
map.get(vuetify-settings.$shadow-key-penumbra, $level),
|
||||
map.get(vuetify-settings.$shadow-key-ambient, $level);
|
||||
}
|
||||
|
||||
// 将相对阴影层级限制在 Vuetify elevation 的 0 到 24 档范围内。
|
||||
@function app-clamp-elevation($level) {
|
||||
@if $level < 0 {
|
||||
@return 0;
|
||||
}
|
||||
|
||||
@if $level > 24 {
|
||||
@return 24;
|
||||
}
|
||||
|
||||
@return $level;
|
||||
}
|
||||
|
||||
// 公共样式 - 所有主题都需要
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@@ -38,24 +62,36 @@ html.quick-access-scroll-locked body {
|
||||
}
|
||||
}
|
||||
|
||||
// 全局卡片外观 token:圆角和阴影在主题定制器中即时切换。
|
||||
// 全局外观 token:圆角和阴影复用 Vuetify 的 rounded / elevation 分级。
|
||||
html {
|
||||
--app-theme-surface-radius: 8px;
|
||||
--app-vuetify-rounded-0: #{map.get(vuetify-settings.$rounded, 0)};
|
||||
--app-vuetify-rounded-sm: #{map.get(vuetify-settings.$rounded, 'sm')};
|
||||
--app-vuetify-rounded: #{map.get(vuetify-settings.$rounded, null)};
|
||||
--app-vuetify-rounded-lg: #{map.get(vuetify-settings.$rounded, 'lg')};
|
||||
--app-vuetify-rounded-xl: #{map.get(vuetify-settings.$rounded, 'xl')};
|
||||
--app-vuetify-rounded-pill: #{map.get(vuetify-settings.$rounded, 'pill')};
|
||||
|
||||
@for $level from 0 through 24 {
|
||||
--app-elevation-#{$level}: #{app-vuetify-elevation($level)};
|
||||
}
|
||||
|
||||
--app-theme-surface-radius: var(--app-vuetify-rounded);
|
||||
--app-surface-radius: var(--app-theme-surface-radius);
|
||||
--app-field-radius: var(--app-theme-surface-radius);
|
||||
--app-field-radius: var(--app-vuetify-rounded);
|
||||
--app-control-radius: var(--app-vuetify-rounded);
|
||||
--app-overlay-radius: var(--app-vuetify-rounded);
|
||||
--app-surface-border-opacity: 0.06;
|
||||
--app-surface-border: 1px solid rgba(var(--v-theme-on-surface), var(--app-surface-border-opacity));
|
||||
--app-shadow-rgb: 15, 23, 42;
|
||||
--app-card-rest-shadow: none;
|
||||
--app-card-hover-shadow: none;
|
||||
--app-fab-shadow: none;
|
||||
--app-fab-shadow-strong: none;
|
||||
--app-fab-shadow-hover: none;
|
||||
--app-fab-shadow-strong-hover: none;
|
||||
--app-fab-shadow-active: none;
|
||||
--app-overlay-shadow: none;
|
||||
--app-surface-shadow: none;
|
||||
--app-surface-hover-shadow: none;
|
||||
--app-card-rest-shadow: var(--app-elevation-0);
|
||||
--app-card-hover-shadow: var(--app-elevation-0);
|
||||
--app-fab-shadow: var(--app-elevation-0);
|
||||
--app-fab-shadow-strong: var(--app-elevation-0);
|
||||
--app-fab-shadow-hover: var(--app-elevation-0);
|
||||
--app-fab-shadow-strong-hover: var(--app-elevation-0);
|
||||
--app-fab-shadow-active: var(--app-elevation-0);
|
||||
--app-overlay-shadow: var(--app-elevation-0);
|
||||
--app-surface-shadow: var(--app-elevation-0);
|
||||
--app-surface-hover-shadow: var(--app-elevation-0);
|
||||
--mp-motion-duration-page: 180ms;
|
||||
--mp-motion-duration-overlay: 160ms;
|
||||
--mp-motion-ease-standard: cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
@@ -66,65 +102,47 @@ html[data-theme-skin='bordered'] {
|
||||
--app-surface-border-opacity: 0.1;
|
||||
}
|
||||
|
||||
html[data-theme-radius='none'] {
|
||||
--app-theme-surface-radius: var(--app-vuetify-rounded-0);
|
||||
--app-field-radius: var(--app-vuetify-rounded-0);
|
||||
--app-control-radius: var(--app-vuetify-rounded-0);
|
||||
--app-overlay-radius: var(--app-vuetify-rounded-0);
|
||||
}
|
||||
|
||||
html[data-theme-radius='small'] {
|
||||
--app-theme-surface-radius: 4px;
|
||||
--app-theme-surface-radius: var(--app-vuetify-rounded-sm);
|
||||
--app-field-radius: var(--app-vuetify-rounded-sm);
|
||||
--app-control-radius: var(--app-vuetify-rounded-sm);
|
||||
--app-overlay-radius: var(--app-vuetify-rounded-sm);
|
||||
}
|
||||
|
||||
html[data-theme-radius='large'] {
|
||||
--app-theme-surface-radius: 12px;
|
||||
--app-theme-surface-radius: var(--app-vuetify-rounded-lg);
|
||||
--app-field-radius: var(--app-vuetify-rounded-lg);
|
||||
--app-control-radius: var(--app-vuetify-rounded-lg);
|
||||
--app-overlay-radius: var(--app-vuetify-rounded-lg);
|
||||
}
|
||||
|
||||
html[data-theme-radius='extra'] {
|
||||
--app-theme-surface-radius: 16px;
|
||||
--app-theme-surface-radius: var(--app-vuetify-rounded-xl);
|
||||
--app-field-radius: var(--app-vuetify-rounded-xl);
|
||||
--app-control-radius: var(--app-vuetify-rounded-xl);
|
||||
--app-overlay-radius: var(--app-vuetify-rounded-xl);
|
||||
}
|
||||
|
||||
html[data-theme-radius='huge'] {
|
||||
--app-theme-surface-radius: 24px;
|
||||
}
|
||||
|
||||
html[data-theme='dark'],
|
||||
html[data-theme='purple'],
|
||||
html[data-theme='transparent'] {
|
||||
--app-shadow-rgb: 0, 0, 0;
|
||||
}
|
||||
|
||||
html[data-theme-shadow='low'] {
|
||||
--app-card-rest-shadow: 0 10px 24px rgba(var(--app-shadow-rgb), 0.06), 0 2px 8px rgba(var(--app-shadow-rgb), 0.04);
|
||||
--app-card-hover-shadow: 0 14px 30px rgba(var(--app-shadow-rgb), 0.08), 0 4px 12px rgba(var(--app-shadow-rgb), 0.05);
|
||||
--app-fab-shadow: 0 16px 34px rgba(var(--app-shadow-rgb), 0.16), 0 6px 16px rgba(var(--app-shadow-rgb), 0.1);
|
||||
--app-fab-shadow-strong: 0 20px 40px rgba(var(--app-shadow-rgb), 0.2), 0 8px 18px rgba(var(--app-shadow-rgb), 0.12);
|
||||
--app-fab-shadow-hover: 0 22px 42px rgba(var(--app-shadow-rgb), 0.22), 0 8px 18px rgba(var(--app-shadow-rgb), 0.12);
|
||||
--app-fab-shadow-strong-hover: 0 26px 46px rgba(var(--app-shadow-rgb), 0.24), 0 10px 22px rgba(var(--app-shadow-rgb), 0.14);
|
||||
--app-fab-shadow-active: 0 10px 22px rgba(var(--app-shadow-rgb), 0.16), 0 3px 8px rgba(var(--app-shadow-rgb), 0.1);
|
||||
--app-overlay-shadow: 0 18px 42px rgba(var(--app-shadow-rgb), 0.14), 0 6px 18px rgba(var(--app-shadow-rgb), 0.08);
|
||||
--app-surface-shadow: 0 10px 24px rgba(var(--app-shadow-rgb), 0.07), 0 2px 8px rgba(var(--app-shadow-rgb), 0.05);
|
||||
--app-surface-hover-shadow: 0 14px 30px rgba(var(--app-shadow-rgb), 0.09), 0 4px 12px rgba(var(--app-shadow-rgb), 0.06);
|
||||
}
|
||||
|
||||
html[data-theme-shadow='medium'] {
|
||||
--app-card-rest-shadow: 0 14px 32px rgba(var(--app-shadow-rgb), 0.09), 0 4px 12px rgba(var(--app-shadow-rgb), 0.06);
|
||||
--app-card-hover-shadow: 0 18px 40px rgba(var(--app-shadow-rgb), 0.11), 0 6px 16px rgba(var(--app-shadow-rgb), 0.07);
|
||||
--app-fab-shadow: 0 18px 40px rgba(var(--app-shadow-rgb), 0.2), 0 7px 18px rgba(var(--app-shadow-rgb), 0.12);
|
||||
--app-fab-shadow-strong: 0 24px 48px rgba(var(--app-shadow-rgb), 0.24), 0 10px 24px rgba(var(--app-shadow-rgb), 0.14);
|
||||
--app-fab-shadow-hover: 0 24px 46px rgba(var(--app-shadow-rgb), 0.24), 0 10px 22px rgba(var(--app-shadow-rgb), 0.14);
|
||||
--app-fab-shadow-strong-hover: 0 30px 54px rgba(var(--app-shadow-rgb), 0.28), 0 12px 28px rgba(var(--app-shadow-rgb), 0.16);
|
||||
--app-fab-shadow-active: 0 12px 26px rgba(var(--app-shadow-rgb), 0.18), 0 4px 10px rgba(var(--app-shadow-rgb), 0.12);
|
||||
--app-overlay-shadow: 0 24px 56px rgba(var(--app-shadow-rgb), 0.18), 0 10px 24px rgba(var(--app-shadow-rgb), 0.1);
|
||||
--app-surface-shadow: 0 14px 32px rgba(var(--app-shadow-rgb), 0.1), 0 4px 12px rgba(var(--app-shadow-rgb), 0.07);
|
||||
--app-surface-hover-shadow: 0 18px 40px rgba(var(--app-shadow-rgb), 0.12), 0 6px 16px rgba(var(--app-shadow-rgb), 0.08);
|
||||
}
|
||||
|
||||
html[data-theme-shadow='high'] {
|
||||
--app-card-rest-shadow: 0 18px 40px rgba(var(--app-shadow-rgb), 0.12), 0 6px 18px rgba(var(--app-shadow-rgb), 0.08);
|
||||
--app-card-hover-shadow: 0 22px 50px rgba(var(--app-shadow-rgb), 0.15), 0 8px 22px rgba(var(--app-shadow-rgb), 0.1);
|
||||
--app-fab-shadow: 0 22px 48px rgba(var(--app-shadow-rgb), 0.24), 0 10px 24px rgba(var(--app-shadow-rgb), 0.14);
|
||||
--app-fab-shadow-strong: 0 28px 58px rgba(var(--app-shadow-rgb), 0.3), 0 12px 30px rgba(var(--app-shadow-rgb), 0.18);
|
||||
--app-fab-shadow-hover: 0 28px 56px rgba(var(--app-shadow-rgb), 0.28), 0 12px 28px rgba(var(--app-shadow-rgb), 0.17);
|
||||
--app-fab-shadow-strong-hover: 0 34px 64px rgba(var(--app-shadow-rgb), 0.34), 0 14px 32px rgba(var(--app-shadow-rgb), 0.2);
|
||||
--app-fab-shadow-active: 0 14px 30px rgba(var(--app-shadow-rgb), 0.22), 0 5px 12px rgba(var(--app-shadow-rgb), 0.14);
|
||||
--app-overlay-shadow: 0 30px 70px rgba(var(--app-shadow-rgb), 0.22), 0 14px 30px rgba(var(--app-shadow-rgb), 0.12);
|
||||
--app-surface-shadow: 0 18px 40px rgba(var(--app-shadow-rgb), 0.13), 0 6px 18px rgba(var(--app-shadow-rgb), 0.09);
|
||||
--app-surface-hover-shadow: 0 22px 50px rgba(var(--app-shadow-rgb), 0.16), 0 8px 22px rgba(var(--app-shadow-rgb), 0.11);
|
||||
@for $level from 1 through 24 {
|
||||
html[data-theme-shadow='#{$level}'] {
|
||||
--app-card-rest-shadow: var(--app-elevation-#{app-clamp-elevation($level - 1)});
|
||||
--app-card-hover-shadow: var(--app-elevation-#{app-clamp-elevation($level + 1)});
|
||||
--app-fab-shadow: var(--app-elevation-#{$level});
|
||||
--app-fab-shadow-active: var(--app-elevation-#{app-clamp-elevation($level - 2)});
|
||||
--app-fab-shadow-hover: var(--app-elevation-#{app-clamp-elevation($level + 3)});
|
||||
--app-fab-shadow-strong: var(--app-elevation-#{app-clamp-elevation($level + 2)});
|
||||
--app-fab-shadow-strong-hover: var(--app-elevation-#{app-clamp-elevation($level + 4)});
|
||||
--app-overlay-shadow: var(--app-elevation-#{app-clamp-elevation($level + 6)});
|
||||
--app-surface-shadow: var(--app-elevation-#{$level});
|
||||
--app-surface-hover-shadow: var(--app-elevation-#{app-clamp-elevation($level + 2)});
|
||||
}
|
||||
}
|
||||
|
||||
// 进度条样式
|
||||
@@ -166,6 +184,16 @@ html[data-theme-shadow='high'] {
|
||||
box-shadow: var(--app-surface-shadow) !important;
|
||||
}
|
||||
|
||||
// 统一卡片上浮反馈;hover 命中区域应放在静止外层,避免上浮后底边反复触发 mouseleave。
|
||||
.app-hover-lift-card {
|
||||
transition: transform 0.3s ease, box-shadow 0.2s ease;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.app-hover-lift-card--hovering {
|
||||
transform: translate3d(0, -0.25rem, 0);
|
||||
}
|
||||
|
||||
// 全局页面与 overlay 动效:短距离、轻缩放,保持快速但不生硬。
|
||||
.mp-page-route {
|
||||
inline-size: 100%;
|
||||
@@ -421,12 +449,12 @@ html[data-theme-shadow='high'] {
|
||||
}
|
||||
|
||||
.v-btn:not(.v-btn--rounded, .v-btn--flat, .v-btn--icon, [class^='rounded-'], [class*=' rounded-']) {
|
||||
border-radius: var(--app-surface-radius);
|
||||
border-radius: var(--app-control-radius);
|
||||
transition: border-radius 0.2s ease;
|
||||
}
|
||||
|
||||
.v-btn-group:not(.v-btn-group--variant-plain, .v-btn-group--variant-text) {
|
||||
border-radius: var(--app-surface-radius);
|
||||
border-radius: var(--app-control-radius);
|
||||
box-shadow: var(--app-surface-shadow) !important;
|
||||
transition: box-shadow 0.2s ease, border-radius 0.2s ease;
|
||||
}
|
||||
@@ -703,7 +731,7 @@ html[data-theme="transparent"] .app-card-colorful,
|
||||
}
|
||||
|
||||
.Vue-Toastification__toast {
|
||||
border-radius: var(--app-surface-radius);
|
||||
border-radius: var(--app-overlay-radius);
|
||||
box-shadow: var(--app-overlay-shadow);
|
||||
}
|
||||
|
||||
@@ -1175,6 +1203,7 @@ html[data-theme="transparent"] .app-card-colorful,
|
||||
.v-bottom-sheet > .v-bottom-sheet__content.v-overlay__content > .v-card,
|
||||
.v-menu > .v-overlay__content > .v-card,
|
||||
.v-menu > .v-overlay__content > .v-list {
|
||||
border-radius: var(--app-overlay-radius) !important;
|
||||
box-shadow: var(--app-overlay-shadow) !important;
|
||||
}
|
||||
|
||||
@@ -1191,8 +1220,8 @@ html[data-theme="transparent"] .app-card-colorful,
|
||||
overflow: hidden;
|
||||
border-end-end-radius: 0 !important;
|
||||
border-end-start-radius: 0 !important;
|
||||
border-start-end-radius: var(--app-theme-surface-radius) !important;
|
||||
border-start-start-radius: var(--app-theme-surface-radius) !important;
|
||||
border-start-end-radius: var(--app-overlay-radius) !important;
|
||||
border-start-start-radius: var(--app-overlay-radius) !important;
|
||||
}
|
||||
|
||||
.v-dialog--fullscreen > .v-overlay__content > .v-card,
|
||||
@@ -1201,8 +1230,8 @@ html[data-theme="transparent"] .app-card-colorful,
|
||||
.v-dialog--fullscreen > .v-overlay__content > form > .v-sheet {
|
||||
border-end-end-radius: 0 !important;
|
||||
border-end-start-radius: 0 !important;
|
||||
border-start-end-radius: var(--app-theme-surface-radius) !important;
|
||||
border-start-start-radius: var(--app-theme-surface-radius) !important;
|
||||
border-start-end-radius: var(--app-overlay-radius) !important;
|
||||
border-start-start-radius: var(--app-overlay-radius) !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -157,6 +157,10 @@ html[data-theme="transparent"] {
|
||||
}
|
||||
|
||||
.agent-assistant-fab {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__bubble {
|
||||
backdrop-filter: blur(var(--transparent-blur));
|
||||
background-color: rgba(var(--v-theme-surface), var(--transparent-opacity-heavy));
|
||||
}
|
||||
|
||||
101
src/utils/agentAssistantBubble.ts
Normal file
101
src/utils/agentAssistantBubble.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { SystemNotification } from '@/api/types'
|
||||
|
||||
const AGENT_ASSISTANT_BUBBLE_EVENT = 'agentAssistantBubble'
|
||||
let agentAssistantBubbleListenerCount = 0
|
||||
let agentAssistantBubbleEntryActive = false
|
||||
|
||||
export type AgentAssistantBubbleKind = 'assistant' | 'custom' | 'notification' | 'toast'
|
||||
export type AgentAssistantBubbleVariant = 'default' | 'info' | 'success' | 'warning' | 'error'
|
||||
|
||||
export interface AgentAssistantBubblePayload {
|
||||
id: string
|
||||
kind?: AgentAssistantBubbleKind
|
||||
variant?: AgentAssistantBubbleVariant
|
||||
title?: string
|
||||
text?: string
|
||||
duration?: number
|
||||
keepOpen?: boolean
|
||||
type?: string
|
||||
mtype?: string
|
||||
source?: string
|
||||
date?: string
|
||||
reg_time?: string
|
||||
}
|
||||
|
||||
export interface AgentAssistantNotificationBubblePayload extends AgentAssistantBubblePayload {
|
||||
kind?: 'notification'
|
||||
}
|
||||
|
||||
export interface AgentAssistantToastBubblePayload extends AgentAssistantBubblePayload {
|
||||
kind: 'toast'
|
||||
variant: AgentAssistantBubbleVariant
|
||||
}
|
||||
|
||||
interface AgentAssistantBubbleEvent extends CustomEvent<AgentAssistantBubblePayload> {}
|
||||
|
||||
function createNotificationBubbleId(notification: SystemNotification) {
|
||||
if (notification.id) return `notification-${notification.id}`
|
||||
|
||||
return `notification-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
}
|
||||
|
||||
function emitAgentAssistantBubble(payload: AgentAssistantBubblePayload) {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<AgentAssistantBubblePayload>(AGENT_ASSISTANT_BUBBLE_EVENT, {
|
||||
detail: payload,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// 通知中心、toast 和智能助手入口没有父子关系,通过全局事件传递实时气泡数据。
|
||||
export function emitAgentAssistantNotificationBubble(notification: SystemNotification) {
|
||||
emitAgentAssistantBubble({
|
||||
id: createNotificationBubbleId(notification),
|
||||
kind: 'notification',
|
||||
title: notification.title,
|
||||
text: notification.text,
|
||||
type: notification.type,
|
||||
mtype: notification.mtype,
|
||||
source: notification.source,
|
||||
date: notification.date,
|
||||
reg_time: notification.reg_time,
|
||||
})
|
||||
}
|
||||
|
||||
export function emitAgentAssistantToastBubble(payload: AgentAssistantToastBubblePayload) {
|
||||
emitAgentAssistantBubble(payload)
|
||||
}
|
||||
|
||||
export function setAgentAssistantBubbleEntryActive(active: boolean) {
|
||||
agentAssistantBubbleEntryActive = active
|
||||
}
|
||||
|
||||
export function canUseAgentAssistantBubble() {
|
||||
return agentAssistantBubbleEntryActive && agentAssistantBubbleListenerCount > 0
|
||||
}
|
||||
|
||||
export function onAgentAssistantBubble(callback: (payload: AgentAssistantBubblePayload) => void) {
|
||||
if (typeof window === 'undefined') return () => {}
|
||||
|
||||
const handler = (event: Event) => {
|
||||
callback((event as AgentAssistantBubbleEvent).detail)
|
||||
}
|
||||
|
||||
window.addEventListener(AGENT_ASSISTANT_BUBBLE_EVENT, handler)
|
||||
agentAssistantBubbleListenerCount += 1
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(AGENT_ASSISTANT_BUBBLE_EVENT, handler)
|
||||
agentAssistantBubbleListenerCount = Math.max(0, agentAssistantBubbleListenerCount - 1)
|
||||
}
|
||||
}
|
||||
|
||||
export function onAgentAssistantNotificationBubble(
|
||||
callback: (payload: AgentAssistantNotificationBubblePayload) => void,
|
||||
) {
|
||||
return onAgentAssistantBubble(payload => {
|
||||
if ((payload.kind || 'notification') === 'notification') callback(payload as AgentAssistantNotificationBubblePayload)
|
||||
})
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { readonly, ref } from 'vue'
|
||||
|
||||
/**
|
||||
* PWA 徽章管理工具
|
||||
*/
|
||||
@@ -7,9 +9,27 @@ interface UnreadMessageEvent extends CustomEvent {
|
||||
detail: { count: number }
|
||||
}
|
||||
|
||||
const unreadMessageCount = ref(0)
|
||||
|
||||
// 暴露只读未读计数,供通知中心等组件直接判断应用角标状态。
|
||||
export const appUnreadMessageCount = readonly(unreadMessageCount)
|
||||
|
||||
function normalizeUnreadMessageCount(count: unknown) {
|
||||
const normalizedCount = Number(count)
|
||||
if (!Number.isFinite(normalizedCount) || normalizedCount <= 0) return 0
|
||||
|
||||
return Math.floor(normalizedCount)
|
||||
}
|
||||
|
||||
function setUnreadMessageCount(count: unknown) {
|
||||
unreadMessageCount.value = normalizeUnreadMessageCount(count)
|
||||
return unreadMessageCount.value
|
||||
}
|
||||
|
||||
// 发送全局未读消息事件
|
||||
export function emitUnreadMessageEvent(count: number) {
|
||||
const event = new CustomEvent('unreadMessage', { detail: { count } }) as UnreadMessageEvent
|
||||
const normalizedCount = setUnreadMessageCount(count)
|
||||
const event = new CustomEvent('unreadMessage', { detail: { count: normalizedCount } }) as UnreadMessageEvent
|
||||
window.dispatchEvent(event)
|
||||
}
|
||||
|
||||
@@ -88,9 +108,8 @@ export async function checkUnreadOnStartup(): Promise<number> {
|
||||
export async function checkAndEmitUnreadMessages() {
|
||||
try {
|
||||
const count = await checkUnreadOnStartup()
|
||||
if (count > 0) {
|
||||
emitUnreadMessageEvent(count)
|
||||
}
|
||||
// 启动时同步 0 值,避免组件复用上一轮角标状态。
|
||||
emitUnreadMessageEvent(count)
|
||||
} catch (error) {
|
||||
// 静默处理错误
|
||||
}
|
||||
@@ -139,11 +158,13 @@ export async function clearAppBadge(): Promise<boolean> {
|
||||
|
||||
// 更新桌面图标徽章数量
|
||||
export async function updateAppBadge(count: number): Promise<boolean> {
|
||||
const normalizedCount = normalizeUnreadMessageCount(count)
|
||||
|
||||
try {
|
||||
// 如果浏览器支持原生Badge API,直接调用
|
||||
if ('setAppBadge' in navigator) {
|
||||
if (count > 0) {
|
||||
await navigator.setAppBadge(count)
|
||||
if (normalizedCount > 0) {
|
||||
await navigator.setAppBadge(normalizedCount)
|
||||
} else {
|
||||
await navigator.clearAppBadge()
|
||||
}
|
||||
@@ -155,13 +176,18 @@ export async function updateAppBadge(count: number): Promise<boolean> {
|
||||
|
||||
return new Promise(resolve => {
|
||||
messageChannel.port1.onmessage = event => {
|
||||
resolve(event.data.success)
|
||||
const success = Boolean(event.data.success)
|
||||
if (success) emitUnreadMessageEvent(normalizedCount)
|
||||
resolve(success)
|
||||
}
|
||||
|
||||
navigator.serviceWorker.controller?.postMessage({ type: 'UPDATE_BADGE', count }, [messageChannel.port2])
|
||||
navigator.serviceWorker.controller?.postMessage({ type: 'UPDATE_BADGE', count: normalizedCount }, [
|
||||
messageChannel.port2,
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
emitUnreadMessageEvent(normalizedCount)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to update app badge:', error)
|
||||
@@ -195,3 +221,11 @@ export async function getUnreadCount(): Promise<number> {
|
||||
export function supportsBadgeAPI(): boolean {
|
||||
return 'setAppBadge' in navigator && 'clearAppBadge' in navigator
|
||||
}
|
||||
|
||||
if (typeof navigator !== 'undefined' && 'serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.addEventListener('message', event => {
|
||||
if (event.data?.type === 'UNREAD_COUNT_UPDATE') {
|
||||
emitUnreadMessageEvent(event.data.count || 0)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import api from '@/api'
|
||||
import type { MediaInfo } from '@/api/types'
|
||||
import MediaCard from '@/components/cards/MediaCard.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import NoDataFound from '@/components/states/NoDataFound.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -4,7 +4,7 @@ import PersonCardSlideView from './PersonCardSlideView.vue'
|
||||
import MediaCardSlideView from './MediaCardSlideView.vue'
|
||||
import api from '@/api'
|
||||
import type { MediaInfo, NotExistMediaInfo, Site, Subscribe, TmdbEpisode } from '@/api/types'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import NoDataFound from '@/components/states/NoDataFound.vue'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import { formatSeason } from '@/@core/utils/formatters'
|
||||
import { formatSeasonLabel } from '@/@core/utils/season'
|
||||
|
||||
@@ -3,7 +3,7 @@ import api from '@/api'
|
||||
import type { Person } from '@/api/types'
|
||||
import PersonCard from '@/components/cards/PersonCard.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import NoDataFound from '@/components/states/NoDataFound.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -3,7 +3,7 @@ import MediaCardListView from './MediaCardListView.vue'
|
||||
import api from '@/api'
|
||||
import personIcon from '@images/misc/person.png'
|
||||
import type { Person } from '@/api/types'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import NoDataFound from '@/components/states/NoDataFound.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { useToast } from 'vue-toastification'
|
||||
import api from '@/api'
|
||||
import type { Plugin } from '@/api/types'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import NoDataFound from '@/components/states/NoDataFound.vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import { getPluginTabs } from '@/router/i18n-menu'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import api from '@/api'
|
||||
import type { DownloadingInfo } from '@/api/types'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import NoDataFound from '@/components/states/NoDataFound.vue'
|
||||
import DownloadingCard from '@/components/cards/DownloadingCard.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import { useUserStore } from '@/stores'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import api from '@/api'
|
||||
import { FileItem, StorageConf, TransferDirectoryConf } from '@/api/types'
|
||||
import FileBrowser from '@/components/FileBrowser.vue'
|
||||
import FileBrowser from '@/components/filebrowser/FileBrowser.vue'
|
||||
|
||||
const endpoints = {
|
||||
list: {
|
||||
|
||||
@@ -49,6 +49,7 @@ const SystemSettings = ref<any>({
|
||||
CUSTOMIZE_WALLPAPER_API_URL: null,
|
||||
AI_AGENT_ENABLE: false,
|
||||
AI_AGENT_GLOBAL: false,
|
||||
AI_AGENT_HIDE_ENTRY: false,
|
||||
AI_AGENT_VERBOSE: false,
|
||||
AI_AGENT_JOB_INTERVAL: 24,
|
||||
LLM_PROVIDER: 'deepseek',
|
||||
@@ -61,7 +62,7 @@ const SystemSettings = ref<any>({
|
||||
LLM_BASE_URL: 'https://api.deepseek.com',
|
||||
LLM_USE_PROXY: true,
|
||||
LLM_BASE_URL_PRESET: null,
|
||||
LLM_MAX_CONTEXT_TOKENS: 64,
|
||||
LLM_MAX_CONTEXT_TOKENS: 128,
|
||||
LLM_USER_AGENT: null,
|
||||
AUDIO_INPUT_PROVIDER: 'openai',
|
||||
AUDIO_INPUT_API_KEY: null,
|
||||
@@ -1160,7 +1161,7 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
|
||||
<VExpandTransition>
|
||||
<VCardText v-show="!aiAgentSettingsCollapsed" class="pt-2">
|
||||
<VRow>
|
||||
<VCol cols="12" md="4">
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="SystemSettings.Basic.AI_AGENT_ENABLE"
|
||||
:label="t('setting.system.aiAgentEnable')"
|
||||
@@ -1168,7 +1169,7 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="4">
|
||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="SystemSettings.Basic.AI_AGENT_GLOBAL"
|
||||
:label="t('setting.system.aiAgentGlobal')"
|
||||
@@ -1176,7 +1177,7 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="4">
|
||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="SystemSettings.Basic.AI_AGENT_VERBOSE"
|
||||
:label="t('setting.system.aiAgentVerbose')"
|
||||
@@ -1184,6 +1185,14 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="SystemSettings.Basic.AI_AGENT_HIDE_ENTRY"
|
||||
:label="t('setting.system.aiAgentHideEntry')"
|
||||
:hint="t('setting.system.aiAgentHideEntryHint')"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
|
||||
<VAutocomplete
|
||||
v-model="SystemSettings.Basic.LLM_PROVIDER"
|
||||
|
||||
@@ -157,11 +157,12 @@ onMounted(() => {
|
||||
<VCardText>
|
||||
<p class="text-body-2 text-medium-emphasis mb-4">{{ t('setupWizard.preferences.quickPresetsDesc') }}</p>
|
||||
<VRow>
|
||||
<VCol v-for="(preset, key) in presetConfigs" :key="key" cols="12" sm="6" md="3">
|
||||
<!-- Hover 命中区域保持静止,避免预设卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<VCol v-for="(preset, key) in presetConfigs" :key="key" class="preset-card-hover-area" cols="12" sm="6" md="3">
|
||||
<VCard
|
||||
:color="selectedPreset === key ? preset.color : 'default'"
|
||||
:variant="selectedPreset === key ? 'tonal' : 'outlined'"
|
||||
class="cursor-pointer preset-card"
|
||||
class="app-hover-lift-card cursor-pointer preset-card"
|
||||
@click="selectPreset(key)"
|
||||
>
|
||||
<VCardText class="text-center pa-4">
|
||||
@@ -218,11 +219,10 @@ onMounted(() => {
|
||||
<style scoped>
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.preset-card:hover {
|
||||
transform: translateY(-4px);
|
||||
.preset-card-hover-area:hover .preset-card {
|
||||
transform: translate3d(0, -0.25rem, 0);
|
||||
}
|
||||
|
||||
.preset-card:active {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import api from '@/api'
|
||||
import type { Site, SiteUserData } from '@/api/types'
|
||||
import SiteCard from '@/components/cards/SiteCard.vue'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import NoDataFound from '@/components/states/NoDataFound.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import { useDynamicButton, type DynamicButtonMenuItem } from '@/composables/useDynamicButton'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -203,7 +203,7 @@ function getLibraryEpisodeCount(subscribe: Subscribe) {
|
||||
const libraryEpisode =
|
||||
typeof subscribe.lack_episode === 'number'
|
||||
? totalEpisode - subscribe.lack_episode
|
||||
: subscribe.completed_episode ?? 0
|
||||
: (subscribe.completed_episode ?? 0)
|
||||
|
||||
return clampEpisodeCount(libraryEpisode, totalEpisode)
|
||||
}
|
||||
@@ -218,9 +218,7 @@ function getLackEpisodeCount(subscribe: Subscribe) {
|
||||
function normalizeEpisodeNumbers(value: unknown) {
|
||||
if (!Array.isArray(value)) return []
|
||||
|
||||
return value
|
||||
.map(number => Number(number))
|
||||
.filter(number => Number.isFinite(number) && number > 0)
|
||||
return value.map(number => Number(number)).filter(number => Number.isFinite(number) && number > 0)
|
||||
}
|
||||
|
||||
function isEnabledFlag(value: unknown) {
|
||||
@@ -398,10 +396,7 @@ onActivated(() => {
|
||||
<template>
|
||||
<FullCalendar ref="calendarRef" :options="calendarOptions">
|
||||
<template #eventContent="arg">
|
||||
<div
|
||||
v-if="arg.event.extendedProps.isDayGroup"
|
||||
class="calendar-day-events"
|
||||
>
|
||||
<div v-if="arg.event.extendedProps.isDayGroup" class="calendar-day-events">
|
||||
<div
|
||||
v-for="calendarEvent in arg.event.extendedProps.visibleEvents"
|
||||
:key="`${calendarEvent.title}-${calendarEvent.subtitle}-${calendarEvent.calendarSortIndex}`"
|
||||
@@ -424,10 +419,7 @@ onActivated(() => {
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
<span
|
||||
v-if="calendarEvent.libraryState === 'complete'"
|
||||
class="calendar-library-check"
|
||||
>
|
||||
<span v-if="calendarEvent.libraryState === 'complete'" class="calendar-library-check">
|
||||
<VIcon icon="mdi-check" size="12" />
|
||||
</span>
|
||||
</div>
|
||||
@@ -679,18 +671,18 @@ onActivated(() => {
|
||||
.v-application .fc .fc-event,
|
||||
.v-application .fc .fc-h-event,
|
||||
.v-application .fc .fc-daygrid-event {
|
||||
padding: 0 !important;
|
||||
border-color: transparent;
|
||||
background: transparent !important;
|
||||
box-shadow: none;
|
||||
margin-block-end: 0.3rem;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.v-application .fc .fc-event-main {
|
||||
padding: 0 !important;
|
||||
color: inherit;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.v-application .fc tbody[role='rowgroup'] > tr > td[role='presentation'] {
|
||||
@@ -789,12 +781,12 @@ onActivated(() => {
|
||||
|
||||
.calendar-event-card {
|
||||
display: flex;
|
||||
gap: 0.55rem;
|
||||
overflow: hidden;
|
||||
align-items: flex-start;
|
||||
padding: 0.4rem;
|
||||
border-radius: 8px;
|
||||
background: rgba(var(--v-theme-surface), 0.72);
|
||||
overflow: hidden;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.calendar-day-events {
|
||||
@@ -806,10 +798,9 @@ onActivated(() => {
|
||||
|
||||
.calendar-expand-card {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-block-size: 2.1rem;
|
||||
padding: 0;
|
||||
border: 1px dashed rgba(var(--v-theme-primary), 0.44);
|
||||
border-radius: 8px;
|
||||
background: rgba(var(--v-theme-primary), 0.08);
|
||||
@@ -817,8 +808,9 @@ onActivated(() => {
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
gap: 0.35rem;
|
||||
inline-size: 100%;
|
||||
padding: 0;
|
||||
min-block-size: 2.1rem;
|
||||
}
|
||||
|
||||
.calendar-expand-card:hover {
|
||||
@@ -843,24 +835,24 @@ onActivated(() => {
|
||||
|
||||
.calendar-library-check {
|
||||
position: absolute;
|
||||
top: 0.18rem;
|
||||
right: 0.18rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid rgb(var(--v-theme-surface));
|
||||
border: 2px solid rgba(var(--v-theme-surface), 0.5);
|
||||
border-radius: 50%;
|
||||
background: rgb(var(--v-theme-success));
|
||||
block-size: 1.15rem;
|
||||
color: rgb(var(--v-theme-on-success));
|
||||
inline-size: 1.15rem;
|
||||
inset-block-start: 0.18rem;
|
||||
inset-inline-end: 0.18rem;
|
||||
}
|
||||
|
||||
.calendar-library-check--mobile {
|
||||
top: 0.12rem;
|
||||
right: 0.12rem;
|
||||
block-size: 1rem;
|
||||
inline-size: 1rem;
|
||||
inset-block-start: 0.12rem;
|
||||
inset-inline-end: 0.12rem;
|
||||
}
|
||||
|
||||
.calendar-event-content {
|
||||
@@ -874,23 +866,23 @@ onActivated(() => {
|
||||
.calendar-event-title {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
line-height: 1.28;
|
||||
max-block-size: calc(0.88rem * 1.28 * 2);
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.calendar-event-episode {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
column-gap: 0.2rem;
|
||||
font-size: 0.72rem;
|
||||
@@ -903,8 +895,8 @@ onActivated(() => {
|
||||
.calendar-event-episode,
|
||||
.calendar-event-time {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
column-gap: 0.2rem;
|
||||
line-height: 1.25;
|
||||
@@ -915,8 +907,8 @@ onActivated(() => {
|
||||
.calendar-event-library-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.18rem 0.3rem;
|
||||
align-items: center;
|
||||
gap: 0.18rem 0.3rem;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
@@ -946,8 +938,8 @@ onActivated(() => {
|
||||
.calendar-event-status,
|
||||
.calendar-event-progress,
|
||||
.calendar-event-time {
|
||||
max-inline-size: 100%;
|
||||
overflow: hidden;
|
||||
max-inline-size: 100%;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -960,15 +952,14 @@ onActivated(() => {
|
||||
|
||||
.calendar-mobile-episode {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
background: rgba(0, 0, 0, 0.58);
|
||||
background: rgba(0, 0, 0, 58%);
|
||||
color: #fff;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
inset-block-end: 0;
|
||||
inset-inline: 0;
|
||||
line-height: 1.25;
|
||||
padding-block: 0.1rem;
|
||||
padding-inline: 0.2rem;
|
||||
@@ -991,10 +982,10 @@ onActivated(() => {
|
||||
|
||||
.calendar-expand-card {
|
||||
flex-direction: column;
|
||||
gap: 0.12rem;
|
||||
min-block-size: 0;
|
||||
block-size: clamp(60px, 8.7vw, 96px);
|
||||
gap: 0.12rem;
|
||||
inline-size: clamp(40px, 5.8vw, 64px);
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.calendar-expand-count {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import draggable from 'vuedraggable'
|
||||
import api from '@/api'
|
||||
import type { Subscribe } from '@/api/types'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import NoDataFound from '@/components/states/NoDataFound.vue'
|
||||
import SubscribeCard from '@/components/cards/SubscribeCard.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import { useUserStore } from '@/stores'
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import api from '@/api'
|
||||
import type { MediaInfo } from '@/api/types'
|
||||
import MediaCard from '@/components/cards/MediaCard.vue'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import NoDataFound from '@/components/states/NoDataFound.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import api from '@/api'
|
||||
import type { SubscribeShare } from '@/api/types'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import NoDataFound from '@/components/states/NoDataFound.vue'
|
||||
import SubscribeShareCard from '@/components/cards/SubscribeShareCard.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import api from '@/api'
|
||||
import type { User } from '@/api/types'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import NoDataFound from '@/components/states/NoDataFound.vue'
|
||||
import UserCard from '@/components/cards/UserCard.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import { useDynamicButton } from '@/composables/useDynamicButton'
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import api from '@/api'
|
||||
import { Workflow } from '@/api/types'
|
||||
import WorkflowTaskCard from '@/components/cards/WorkflowTaskCard.vue'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import NoDataFound from '@/components/states/NoDataFound.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useKeepAliveRefresh } from '@/composables/useKeepAliveRefresh'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import api from '@/api'
|
||||
import type { WorkflowShare } from '@/api/types'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import NoDataFound from '@/components/states/NoDataFound.vue'
|
||||
import WorkflowShareCard from '@/components/cards/WorkflowShareCard.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
Reference in New Issue
Block a user