mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-08 09:16:58 +08:00
Refine assistant layering and plugin source settings
This commit is contained in:
@@ -11,6 +11,7 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import AgentPetStage from './pet/AgentPetStage.vue'
|
import AgentPetStage from './pet/AgentPetStage.vue'
|
||||||
import type { AgentPetActionName, AgentPetIntent } from './pet/types'
|
import type { AgentPetActionName, AgentPetIntent } from './pet/types'
|
||||||
import { useAgentPetMachine } from './pet/useAgentPetMachine'
|
import { useAgentPetMachine } from './pet/useAgentPetMachine'
|
||||||
|
import { AGENT_ASSISTANT_LAYER_Z_INDEX } from '@/constants/agentAssistant'
|
||||||
|
|
||||||
interface AgentAssistantEntryBubble {
|
interface AgentAssistantEntryBubble {
|
||||||
id: string
|
id: string
|
||||||
@@ -163,6 +164,7 @@ const fabPositionStyle = computed(() => {
|
|||||||
...fabPointerStyle.value,
|
...fabPointerStyle.value,
|
||||||
'--agent-assistant-fab-x': `${position.x}px`,
|
'--agent-assistant-fab-x': `${position.x}px`,
|
||||||
'--agent-assistant-fab-y': `${position.y}px`,
|
'--agent-assistant-fab-y': `${position.y}px`,
|
||||||
|
zIndex: AGENT_ASSISTANT_LAYER_Z_INDEX.entry,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const fabBubblePlacement = ref<FabBubblePlacement>('top')
|
const fabBubblePlacement = ref<FabBubblePlacement>('top')
|
||||||
@@ -1570,9 +1572,6 @@ defineExpose({
|
|||||||
.agent-assistant-fab {
|
.agent-assistant-fab {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|
||||||
/* 保持机器人和提示气泡高于 Vuetify 弹窗(2400)及全局 Toast(2500)。 */
|
|
||||||
z-index: 2600;
|
|
||||||
|
|
||||||
--agent-assistant-robot-outline: color-mix(in srgb, rgb(var(--v-theme-primary)) 72%, #090510 28%);
|
--agent-assistant-robot-outline: color-mix(in srgb, rgb(var(--v-theme-primary)) 72%, #090510 28%);
|
||||||
--agent-assistant-robot-outline-soft: color-mix(in srgb, rgb(var(--v-theme-primary)) 84%, #090510 16%);
|
--agent-assistant-robot-outline-soft: color-mix(in srgb, rgb(var(--v-theme-primary)) 84%, #090510 16%);
|
||||||
--agent-assistant-robot-shell-start: color-mix(in srgb, rgb(var(--v-theme-primary)) 38%, white 62%);
|
--agent-assistant-robot-shell-start: color-mix(in srgb, rgb(var(--v-theme-primary)) 38%, white 62%);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useDisplay } from 'vuetify'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAuthStore, useUserStore } from '@/stores'
|
import { useAuthStore, useUserStore } from '@/stores'
|
||||||
import { getCurrentLocale } from '@/plugins/i18n'
|
import { getCurrentLocale } from '@/plugins/i18n'
|
||||||
|
import { AGENT_ASSISTANT_LAYER_Z_INDEX } from '@/constants/agentAssistant'
|
||||||
|
|
||||||
type AgentMessageRole = 'user' | 'assistant'
|
type AgentMessageRole = 'user' | 'assistant'
|
||||||
type AgentMessageStatus = 'idle' | 'streaming' | 'done' | 'error'
|
type AgentMessageStatus = 'idle' | 'streaming' | 'done' | 'error'
|
||||||
@@ -296,6 +297,7 @@ const isOpen = computed({
|
|||||||
})
|
})
|
||||||
const drawerStyle = computed(() => ({
|
const drawerStyle = computed(() => ({
|
||||||
'--agent-assistant-panel-width': drawerWidth.value,
|
'--agent-assistant-panel-width': drawerWidth.value,
|
||||||
|
zIndex: AGENT_ASSISTANT_LAYER_Z_INDEX.panel,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// 创建前端展示用的临时 ID。
|
// 创建前端展示用的临时 ID。
|
||||||
@@ -2130,11 +2132,11 @@ onScopeDispose(() => {
|
|||||||
<VMenu
|
<VMenu
|
||||||
v-model="historyMenuOpen"
|
v-model="historyMenuOpen"
|
||||||
:close-on-content-click="false"
|
:close-on-content-click="false"
|
||||||
content-class="agent-assistant-history-overlay"
|
|
||||||
location="bottom end"
|
location="bottom end"
|
||||||
offset="8"
|
offset="8"
|
||||||
max-width="360"
|
max-width="360"
|
||||||
:z-index="2603"
|
:style="{ zIndex: AGENT_ASSISTANT_LAYER_Z_INDEX.overlay }"
|
||||||
|
:z-index="AGENT_ASSISTANT_LAYER_Z_INDEX.overlay"
|
||||||
>
|
>
|
||||||
<template #activator="{ props }">
|
<template #activator="{ props }">
|
||||||
<IconBtn v-bind="props" :title="t('agentAssistant.history')" :aria-label="t('agentAssistant.history')">
|
<IconBtn v-bind="props" :title="t('agentAssistant.history')" :aria-label="t('agentAssistant.history')">
|
||||||
@@ -2488,21 +2490,12 @@ onScopeDispose(() => {
|
|||||||
</aside>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.agent-assistant-history-overlay {
|
|
||||||
z-index: 2603 !important;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
/* stylelint-disable selector-pseudo-class-no-unknown */
|
/* stylelint-disable selector-pseudo-class-no-unknown */
|
||||||
/* stylelint-disable no-descending-specificity */
|
/* stylelint-disable no-descending-specificity */
|
||||||
|
|
||||||
.agent-assistant-panel {
|
.agent-assistant-panel {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|
||||||
/* Agent 会话层保持高于入口(2600)和业务弹窗,同时低于自身弹出菜单。 */
|
|
||||||
z-index: 2601;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: rgb(var(--v-theme-surface));
|
background: rgb(var(--v-theme-surface));
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import AgentAssistantEntry from './AgentAssistantEntry.vue'
|
import AgentAssistantEntry from './AgentAssistantEntry.vue'
|
||||||
import AgentAssistantPanel from './AgentAssistantPanel.vue'
|
import AgentAssistantPanel from './AgentAssistantPanel.vue'
|
||||||
import { useAppActivityLifecycle } from '@/composables/useAppActivityLifecycle'
|
import { useAppActivityLifecycle } from '@/composables/useAppActivityLifecycle'
|
||||||
|
import { useTheme } from 'vuetify'
|
||||||
|
|
||||||
type AgentAssistantEntryRef = InstanceType<typeof AgentAssistantEntry>
|
type AgentAssistantEntryRef = InstanceType<typeof AgentAssistantEntry>
|
||||||
|
|
||||||
@@ -9,6 +10,7 @@ const panelOpen = ref(false)
|
|||||||
const thinking = ref(false)
|
const thinking = ref(false)
|
||||||
const entryRef = ref<AgentAssistantEntryRef | null>(null)
|
const entryRef = ref<AgentAssistantEntryRef | null>(null)
|
||||||
const { allowsDecorativeMotion } = useAppActivityLifecycle()
|
const { allowsDecorativeMotion } = useAppActivityLifecycle()
|
||||||
|
const { themeClasses } = useTheme()
|
||||||
|
|
||||||
// 打开 Agent 面板并清空入口预览气泡。
|
// 打开 Agent 面板并清空入口预览气泡。
|
||||||
function openPanel() {
|
function openPanel() {
|
||||||
@@ -25,6 +27,9 @@ function handleAssistantPreview(value: string) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- 脱离 .v-application 的层叠上下文,确保弹窗打开时入口、消息气泡和面板仍在最上层。 -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div class="agent-assistant-layer" :class="themeClasses">
|
||||||
<AgentAssistantEntry
|
<AgentAssistantEntry
|
||||||
ref="entryRef"
|
ref="entryRef"
|
||||||
:active="!panelOpen"
|
:active="!panelOpen"
|
||||||
@@ -38,4 +43,6 @@ function handleAssistantPreview(value: string) {
|
|||||||
@assistant-preview="handleAssistantPreview"
|
@assistant-preview="handleAssistantPreview"
|
||||||
@thinking-change="thinking = $event"
|
@thinking-change="thinking = $event"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import AgentAssistantWidget from '@/components/agent/AgentAssistantWidget.vue'
|
||||||
|
import { AGENT_ASSISTANT_LAYER_Z_INDEX } from '@/constants/agentAssistant'
|
||||||
|
|
||||||
|
vi.mock('vuetify', () => ({
|
||||||
|
useTheme: () => ({ themeClasses: ref('v-theme--test') }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/useAppActivityLifecycle', () => ({
|
||||||
|
useAppActivityLifecycle: () => ({ allowsDecorativeMotion: ref(true) }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('AgentAssistantWidget layering', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
document.body
|
||||||
|
.querySelectorAll('.agent-assistant-layer, .agent-assistant-test-host')
|
||||||
|
.forEach(element => element.remove())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('teleports the assistant outside the application stacking context while preserving the active theme', () => {
|
||||||
|
const host = document.createElement('div')
|
||||||
|
host.className = 'agent-assistant-test-host v-application'
|
||||||
|
document.body.append(host)
|
||||||
|
|
||||||
|
const wrapper = mount(AgentAssistantWidget, {
|
||||||
|
attachTo: host,
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
AgentAssistantEntry: { template: '<div data-agent-assistant-entry />' },
|
||||||
|
AgentAssistantPanel: { template: '<div data-agent-assistant-panel />' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const layer = document.body.querySelector(':scope > .agent-assistant-layer')
|
||||||
|
|
||||||
|
expect(layer).not.toBeNull()
|
||||||
|
expect(host.contains(layer)).toBe(false)
|
||||||
|
expect(layer).toHaveClass('v-theme--test')
|
||||||
|
expect(layer?.querySelector('[data-agent-assistant-entry]')).not.toBeNull()
|
||||||
|
expect(layer?.querySelector('[data-agent-assistant-panel]')).not.toBeNull()
|
||||||
|
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reserves the highest CSS stacking levels in assistant display order', () => {
|
||||||
|
expect(AGENT_ASSISTANT_LAYER_Z_INDEX.entry).toBe(2_147_483_645)
|
||||||
|
expect(AGENT_ASSISTANT_LAYER_Z_INDEX.panel).toBe(2_147_483_646)
|
||||||
|
expect(AGENT_ASSISTANT_LAYER_Z_INDEX.overlay).toBe(2_147_483_647)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -24,7 +24,7 @@ const repoText = ref('')
|
|||||||
const newRepoUrl = ref('')
|
const newRepoUrl = ref('')
|
||||||
const editingIndex = ref<number | null>(null)
|
const editingIndex = ref<number | null>(null)
|
||||||
const editingUrl = ref('')
|
const editingUrl = ref('')
|
||||||
const syncingWiki = ref(false)
|
const syncingSources = ref(false)
|
||||||
|
|
||||||
const emit = defineEmits(['save', 'close'])
|
const emit = defineEmits(['save', 'close'])
|
||||||
|
|
||||||
@@ -139,10 +139,10 @@ async function saveHandle() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 从 Wiki 同步公开插件仓库清单并写入配置。 */
|
/** 同步公开插件源清单并写入配置。 */
|
||||||
async function syncWikiRepos() {
|
async function syncPluginSources() {
|
||||||
try {
|
try {
|
||||||
syncingWiki.value = true
|
syncingSources.value = true
|
||||||
const result: { [key: string]: any } = await api.post('system/setting/PLUGIN_MARKET/sync-wiki', {})
|
const result: { [key: string]: any } = await api.post('system/setting/PLUGIN_MARKET/sync-wiki', {})
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@@ -164,7 +164,7 @@ async function syncWikiRepos() {
|
|||||||
console.log(error)
|
console.log(error)
|
||||||
$toast.error(t('dialog.pluginMarketSetting.syncFailed', { message: error instanceof Error ? error.message : '' }))
|
$toast.error(t('dialog.pluginMarketSetting.syncFailed', { message: error instanceof Error ? error.message : '' }))
|
||||||
} finally {
|
} finally {
|
||||||
syncingWiki.value = false
|
syncingSources.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -483,11 +483,11 @@ onMounted(() => {
|
|||||||
color="success"
|
color="success"
|
||||||
variant="tonal"
|
variant="tonal"
|
||||||
prepend-icon="mdi-cloud-sync-outline"
|
prepend-icon="mdi-cloud-sync-outline"
|
||||||
:loading="syncingWiki"
|
:loading="syncingSources"
|
||||||
:disabled="syncingWiki"
|
:disabled="syncingSources"
|
||||||
@click="syncWikiRepos"
|
@click="syncPluginSources"
|
||||||
>
|
>
|
||||||
{{ t('dialog.pluginMarketSetting.syncWiki') }}
|
{{ t('dialog.pluginMarketSetting.syncSources') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
<VSpacer />
|
<VSpacer />
|
||||||
<VBtn
|
<VBtn
|
||||||
@@ -506,6 +506,8 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
/* stylelint-disable selector-pseudo-class-no-unknown */
|
||||||
|
|
||||||
.plugin-market-dialog-card {
|
.plugin-market-dialog-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -570,9 +572,12 @@ onMounted(() => {
|
|||||||
.plugin-market-mode-switch {
|
.plugin-market-mode-switch {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
padding: 0.125rem;
|
padding: 0.125rem;
|
||||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
border: var(--app-grouped-list-border);
|
||||||
border-radius: 0.375rem;
|
border-radius: var(--app-control-radius);
|
||||||
background: rgba(var(--v-theme-surface), 0.72);
|
-webkit-backdrop-filter: var(--app-grouped-list-backdrop-filter);
|
||||||
|
backdrop-filter: var(--app-grouped-list-backdrop-filter);
|
||||||
|
background: var(--app-grouped-list-background);
|
||||||
|
box-shadow: var(--app-surface-shadow);
|
||||||
gap: 0.125rem;
|
gap: 0.125rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -582,7 +587,7 @@ onMounted(() => {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 0.375rem;
|
border-radius: var(--app-control-radius);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
block-size: 2.25rem;
|
block-size: 2.25rem;
|
||||||
color: rgba(var(--v-theme-on-surface), 0.68);
|
color: rgba(var(--v-theme-on-surface), 0.68);
|
||||||
@@ -594,7 +599,7 @@ onMounted(() => {
|
|||||||
color 0.16s ease;
|
color 0.16s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: rgba(var(--v-theme-primary), 0.07);
|
background: var(--app-grouped-list-hover-background);
|
||||||
color: rgb(var(--v-theme-on-surface));
|
color: rgb(var(--v-theme-on-surface));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -604,7 +609,7 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.is-active {
|
&.is-active {
|
||||||
background: rgba(var(--v-theme-primary), 0.12);
|
background: var(--app-grouped-list-active-background);
|
||||||
color: rgb(var(--v-theme-primary));
|
color: rgb(var(--v-theme-primary));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -624,13 +629,23 @@ onMounted(() => {
|
|||||||
|
|
||||||
.plugin-market-list-wrap {
|
.plugin-market-list-wrap {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background: rgba(var(--v-theme-surface), 0.72);
|
border: var(--app-grouped-list-border);
|
||||||
|
border-radius: var(--app-grouped-list-radius);
|
||||||
|
-webkit-backdrop-filter: var(--app-grouped-list-backdrop-filter);
|
||||||
|
backdrop-filter: var(--app-grouped-list-backdrop-filter);
|
||||||
|
background: var(--app-grouped-list-background);
|
||||||
|
box-shadow: var(--app-surface-shadow);
|
||||||
min-block-size: 0;
|
min-block-size: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plugin-market-repo-list {
|
.plugin-market-repo-list {
|
||||||
background: transparent;
|
border: 0;
|
||||||
|
border-radius: inherit !important;
|
||||||
|
-webkit-backdrop-filter: none !important;
|
||||||
|
backdrop-filter: none !important;
|
||||||
|
background: transparent !important;
|
||||||
|
box-shadow: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plugin-market-repo-item {
|
.plugin-market-repo-item {
|
||||||
@@ -681,7 +696,12 @@ onMounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background: rgba(var(--v-theme-surface), 0.72);
|
border: var(--app-grouped-list-border);
|
||||||
|
border-radius: var(--app-grouped-list-radius);
|
||||||
|
-webkit-backdrop-filter: var(--app-grouped-list-backdrop-filter);
|
||||||
|
backdrop-filter: var(--app-grouped-list-backdrop-filter);
|
||||||
|
background: var(--app-grouped-list-background);
|
||||||
|
box-shadow: var(--app-surface-shadow);
|
||||||
min-block-size: 0;
|
min-block-size: 0;
|
||||||
transition:
|
transition:
|
||||||
border-color 0.2s ease,
|
border-color 0.2s ease,
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { cwd } from 'node:process'
|
||||||
|
import { resolve } from 'node:path'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
const dialogSource = readFileSync(resolve(cwd(), 'src/components/dialog/PluginMarketSettingDialog.vue'), 'utf8')
|
||||||
|
|
||||||
|
function getStyleRule(selector: string) {
|
||||||
|
const ruleStart = dialogSource.indexOf(`${selector} {`)
|
||||||
|
const ruleEnd = dialogSource.indexOf('\n}', ruleStart)
|
||||||
|
|
||||||
|
expect(ruleStart).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(ruleEnd).toBeGreaterThan(ruleStart)
|
||||||
|
|
||||||
|
return dialogSource.slice(ruleStart, ruleEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PluginMarketSettingDialog theme surfaces', () => {
|
||||||
|
it('uses shared theme tokens for the view switch and editor containers', () => {
|
||||||
|
const modeSwitchRule = getStyleRule('.plugin-market-mode-switch')
|
||||||
|
const listWrapRule = getStyleRule('.plugin-market-list-wrap')
|
||||||
|
const textareaRule = getStyleRule('.plugin-market-textarea-field')
|
||||||
|
|
||||||
|
expect(modeSwitchRule).toContain('border: var(--app-grouped-list-border)')
|
||||||
|
expect(modeSwitchRule).toContain('backdrop-filter: var(--app-grouped-list-backdrop-filter)')
|
||||||
|
expect(modeSwitchRule).toContain('background: var(--app-grouped-list-background)')
|
||||||
|
expect(dialogSource).toContain('background: var(--app-grouped-list-hover-background)')
|
||||||
|
expect(dialogSource).toContain('background: var(--app-grouped-list-active-background)')
|
||||||
|
expect(listWrapRule).toContain('border-radius: var(--app-grouped-list-radius)')
|
||||||
|
expect(listWrapRule).toContain('background: var(--app-grouped-list-background)')
|
||||||
|
expect(textareaRule).toContain('background: var(--app-grouped-list-background)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses plugin-source wording instead of exposing the Wiki implementation detail', () => {
|
||||||
|
expect(dialogSource).toContain("t('dialog.pluginMarketSetting.syncSources')")
|
||||||
|
expect(dialogSource).not.toContain("t('dialog.pluginMarketSetting.syncWiki')")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// Agent 助手需要覆盖应用内所有普通浮层,因此从 CSS 32 位层级上限向下预留内部顺序。
|
||||||
|
const MAX_CSS_Z_INDEX = 2_147_483_647
|
||||||
|
|
||||||
|
export const AGENT_ASSISTANT_LAYER_Z_INDEX = {
|
||||||
|
entry: MAX_CSS_Z_INDEX - 2,
|
||||||
|
panel: MAX_CSS_Z_INDEX - 1,
|
||||||
|
overlay: MAX_CSS_Z_INDEX,
|
||||||
|
} as const
|
||||||
@@ -4,7 +4,12 @@ import { useDisplay } from 'vuetify'
|
|||||||
import { NavMenu } from '@/@layouts/types'
|
import { NavMenu } from '@/@layouts/types'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useUserStore } from '@/stores'
|
import { useUserStore } from '@/stores'
|
||||||
import { buildUserPermissionContext, filterItemsByPermission, filterMenusByPermission, hasItemPermission } from '@/utils/permission'
|
import {
|
||||||
|
buildUserPermissionContext,
|
||||||
|
filterItemsByPermission,
|
||||||
|
filterMenusByPermission,
|
||||||
|
hasItemPermission,
|
||||||
|
} from '@/utils/permission'
|
||||||
import { useLaunchLoading } from '@/composables/useLaunchLoading'
|
import { useLaunchLoading } from '@/composables/useLaunchLoading'
|
||||||
import { usePWA } from '@/composables/usePWA'
|
import { usePWA } from '@/composables/usePWA'
|
||||||
import type { DynamicButtonMenuItem } from '@/composables/useDynamicButton'
|
import type { DynamicButtonMenuItem } from '@/composables/useDynamicButton'
|
||||||
@@ -327,8 +332,7 @@ function handleDynamicMenuItemClick(item: DynamicButtonMenuItem) {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 底部导航挂载在 body 上,而主题定制器与 Agent 面板受 .v-application 的层叠上下文限制,
|
// 移动端两个设置面板都是全屏展示,打开时隐藏底部导航,避免不可见控件继续参与焦点和合成。
|
||||||
// 无法仅靠 z-index 压过它。移动端两者都是全屏面板,打开时直接隐藏底部导航。
|
|
||||||
html[data-theme-customizer-open='true'],
|
html[data-theme-customizer-open='true'],
|
||||||
html[data-agent-assistant-open='true'] {
|
html[data-agent-assistant-open='true'] {
|
||||||
.footer-nav-container {
|
.footer-nav-container {
|
||||||
|
|||||||
@@ -3061,9 +3061,9 @@ export default {
|
|||||||
invalidText: 'There are {count} invalid URLs in the text. Fix them before saving.',
|
invalidText: 'There are {count} invalid URLs in the text. Fix them before saving.',
|
||||||
invalidTextIgnored: '{count} invalid URLs ignored',
|
invalidTextIgnored: '{count} invalid URLs ignored',
|
||||||
duplicateTextIgnored: 'Duplicate URLs will be removed automatically when saving.',
|
duplicateTextIgnored: 'Duplicate URLs will be removed automatically when saving.',
|
||||||
syncWiki: 'Sync Wiki',
|
syncSources: 'Sync Plugin Sources',
|
||||||
syncSuccess: 'Plugin repositories synced from Wiki. {added} added, {total} total.',
|
syncSuccess: 'Plugin sources synced. {added} added, {total} total.',
|
||||||
syncFailed: 'Failed to sync Wiki: {message}!',
|
syncFailed: 'Failed to sync plugin sources: {message}!',
|
||||||
close: 'Close',
|
close: 'Close',
|
||||||
save: 'Save',
|
save: 'Save',
|
||||||
saveSuccess: 'Plugin repository saved successfully',
|
saveSuccess: 'Plugin repository saved successfully',
|
||||||
|
|||||||
@@ -3006,9 +3006,9 @@ export default {
|
|||||||
invalidText: '文本中有 {count} 个无效地址,请修正后保存。',
|
invalidText: '文本中有 {count} 个无效地址,请修正后保存。',
|
||||||
invalidTextIgnored: '已忽略 {count} 个无效地址',
|
invalidTextIgnored: '已忽略 {count} 个无效地址',
|
||||||
duplicateTextIgnored: '重复地址会在保存时自动去重。',
|
duplicateTextIgnored: '重复地址会在保存时自动去重。',
|
||||||
syncWiki: '同步 Wiki',
|
syncSources: '同步插件源',
|
||||||
syncSuccess: '已从 Wiki 同步插件仓库,新增 {added} 个,共 {total} 个',
|
syncSuccess: '已同步插件源,新增 {added} 个,共 {total} 个',
|
||||||
syncFailed: '同步 Wiki 失败:{message}!',
|
syncFailed: '同步插件源失败:{message}!',
|
||||||
close: '关闭',
|
close: '关闭',
|
||||||
save: '保存',
|
save: '保存',
|
||||||
saveSuccess: '插件仓库保存成功',
|
saveSuccess: '插件仓库保存成功',
|
||||||
|
|||||||
@@ -3005,9 +3005,9 @@ export default {
|
|||||||
invalidText: '文字中有 {count} 個無效地址,請修正後儲存。',
|
invalidText: '文字中有 {count} 個無效地址,請修正後儲存。',
|
||||||
invalidTextIgnored: '已忽略 {count} 個無效地址',
|
invalidTextIgnored: '已忽略 {count} 個無效地址',
|
||||||
duplicateTextIgnored: '重複地址會在儲存時自動去重。',
|
duplicateTextIgnored: '重複地址會在儲存時自動去重。',
|
||||||
syncWiki: '同步 Wiki',
|
syncSources: '同步插件源',
|
||||||
syncSuccess: '已從 Wiki 同步插件倉庫,新增 {added} 個,共 {total} 個',
|
syncSuccess: '已同步插件源,新增 {added} 個,共 {total} 個',
|
||||||
syncFailed: '同步 Wiki 失敗:{message}!',
|
syncFailed: '同步插件源失敗:{message}!',
|
||||||
close: '關閉',
|
close: '關閉',
|
||||||
save: '儲存',
|
save: '儲存',
|
||||||
saveSuccess: '插件倉庫儲存成功',
|
saveSuccess: '插件倉庫儲存成功',
|
||||||
|
|||||||
@@ -139,6 +139,23 @@ describe('glass overlay material styles', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('reuses the popup menu material for compact FAB buttons', () => {
|
||||||
|
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
|
||||||
|
const ruleStart = styles.indexOf('.compact-fab .v-btn {')
|
||||||
|
const ruleEnd = styles.indexOf('\n }', ruleStart)
|
||||||
|
const rule = styles.slice(ruleStart, ruleEnd)
|
||||||
|
|
||||||
|
expect(ruleStart).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(rule).toContain('border: 1px solid var(--glass-border-raised) !important')
|
||||||
|
expect(rule).toContain('backdrop-filter: var(--glass-overlay-backdrop-filter) !important')
|
||||||
|
expect(rule).toContain('background-color: var(--glass-overlay-surface) !important')
|
||||||
|
expect(rule).toContain('background-image: var(--glass-sheen) !important')
|
||||||
|
expect(rule).toContain('box-shadow: var(--glass-shadow-raised) !important')
|
||||||
|
expect(styles).toMatch(
|
||||||
|
/\.compact-fab \.v-btn:hover\s*\{\s*background-color:\s*var\(--glass-overlay-surface\)\s*!important;/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('uses the shared hover-card contract instead of a Dashboard-specific shadow rule', () => {
|
it('uses the shared hover-card contract instead of a Dashboard-specific shadow rule', () => {
|
||||||
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
|
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
|
||||||
|
|
||||||
|
|||||||
@@ -603,7 +603,6 @@ html[data-theme='glass'] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-button,
|
.nav-button,
|
||||||
.compact-fab .v-btn,
|
|
||||||
.global-action-buttons .v-btn {
|
.global-action-buttons .v-btn {
|
||||||
border: 1px solid var(--glass-border) !important;
|
border: 1px solid var(--glass-border) !important;
|
||||||
-webkit-backdrop-filter: var(--glass-raised-backdrop-filter);
|
-webkit-backdrop-filter: var(--glass-raised-backdrop-filter);
|
||||||
@@ -613,6 +612,16 @@ html[data-theme='glass'] {
|
|||||||
box-shadow: var(--glass-control-shadow) !important;
|
box-shadow: var(--glass-control-shadow) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 聚合 FAB 与弹出菜单使用同一磨砂材质,展开后保持稳定底色和背景采样。
|
||||||
|
.compact-fab .v-btn {
|
||||||
|
border: 1px solid var(--glass-border-raised) !important;
|
||||||
|
-webkit-backdrop-filter: var(--glass-overlay-backdrop-filter) !important;
|
||||||
|
backdrop-filter: var(--glass-overlay-backdrop-filter) !important;
|
||||||
|
background-color: var(--glass-overlay-surface) !important;
|
||||||
|
background-image: var(--glass-sheen) !important;
|
||||||
|
box-shadow: var(--glass-shadow-raised) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.v-overlay__scrim {
|
.v-overlay__scrim {
|
||||||
-webkit-backdrop-filter: none;
|
-webkit-backdrop-filter: none;
|
||||||
backdrop-filter: none;
|
backdrop-filter: none;
|
||||||
@@ -1320,6 +1329,11 @@ html[data-theme='glass'] {
|
|||||||
background-color: rgba(var(--v-theme-error), 42%) !important;
|
background-color: rgba(var(--v-theme-error), 42%) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FAB 悬浮时保持弹出菜单材质,仅通过描边和阴影反馈交互,避免背景进一步透明。
|
||||||
|
.compact-fab .v-btn:hover {
|
||||||
|
background-color: var(--glass-overlay-surface) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.v-card--link:hover {
|
.v-card--link:hover {
|
||||||
border-color: var(--glass-border-hover) !important;
|
border-color: var(--glass-border-hover) !important;
|
||||||
box-shadow: var(--glass-shadow-hover) !important;
|
box-shadow: var(--glass-shadow-hover) !important;
|
||||||
|
|||||||
Reference in New Issue
Block a user