Refine assistant layering and plugin source settings

This commit is contained in:
jxxghp
2026-08-06 10:33:20 +08:00
parent 8d18f82e68
commit ac29818fd3
13 changed files with 211 additions and 58 deletions

View File

@@ -11,6 +11,7 @@ import { useI18n } from 'vue-i18n'
import AgentPetStage from './pet/AgentPetStage.vue'
import type { AgentPetActionName, AgentPetIntent } from './pet/types'
import { useAgentPetMachine } from './pet/useAgentPetMachine'
import { AGENT_ASSISTANT_LAYER_Z_INDEX } from '@/constants/agentAssistant'
interface AgentAssistantEntryBubble {
id: string
@@ -163,6 +164,7 @@ const fabPositionStyle = computed(() => {
...fabPointerStyle.value,
'--agent-assistant-fab-x': `${position.x}px`,
'--agent-assistant-fab-y': `${position.y}px`,
zIndex: AGENT_ASSISTANT_LAYER_Z_INDEX.entry,
}
})
const fabBubblePlacement = ref<FabBubblePlacement>('top')
@@ -1570,9 +1572,6 @@ defineExpose({
.agent-assistant-fab {
position: fixed;
/* 保持机器人和提示气泡高于 Vuetify 弹窗2400及全局 Toast2500。 */
z-index: 2600;
--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-shell-start: color-mix(in srgb, rgb(var(--v-theme-primary)) 38%, white 62%);

View File

@@ -5,6 +5,7 @@ import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
import { useAuthStore, useUserStore } from '@/stores'
import { getCurrentLocale } from '@/plugins/i18n'
import { AGENT_ASSISTANT_LAYER_Z_INDEX } from '@/constants/agentAssistant'
type AgentMessageRole = 'user' | 'assistant'
type AgentMessageStatus = 'idle' | 'streaming' | 'done' | 'error'
@@ -296,6 +297,7 @@ const isOpen = computed({
})
const drawerStyle = computed(() => ({
'--agent-assistant-panel-width': drawerWidth.value,
zIndex: AGENT_ASSISTANT_LAYER_Z_INDEX.panel,
}))
// 创建前端展示用的临时 ID。
@@ -2130,11 +2132,11 @@ onScopeDispose(() => {
<VMenu
v-model="historyMenuOpen"
:close-on-content-click="false"
content-class="agent-assistant-history-overlay"
location="bottom end"
offset="8"
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 }">
<IconBtn v-bind="props" :title="t('agentAssistant.history')" :aria-label="t('agentAssistant.history')">
@@ -2488,21 +2490,12 @@ onScopeDispose(() => {
</aside>
</template>
<style lang="scss">
.agent-assistant-history-overlay {
z-index: 2603 !important;
}
</style>
<style lang="scss" scoped>
/* stylelint-disable selector-pseudo-class-no-unknown */
/* stylelint-disable no-descending-specificity */
.agent-assistant-panel {
position: fixed;
/* Agent 会话层保持高于入口2600和业务弹窗同时低于自身弹出菜单。 */
z-index: 2601;
overflow: hidden;
background: rgb(var(--v-theme-surface));

View File

@@ -2,6 +2,7 @@
import AgentAssistantEntry from './AgentAssistantEntry.vue'
import AgentAssistantPanel from './AgentAssistantPanel.vue'
import { useAppActivityLifecycle } from '@/composables/useAppActivityLifecycle'
import { useTheme } from 'vuetify'
type AgentAssistantEntryRef = InstanceType<typeof AgentAssistantEntry>
@@ -9,6 +10,7 @@ const panelOpen = ref(false)
const thinking = ref(false)
const entryRef = ref<AgentAssistantEntryRef | null>(null)
const { allowsDecorativeMotion } = useAppActivityLifecycle()
const { themeClasses } = useTheme()
// 打开 Agent 面板并清空入口预览气泡。
function openPanel() {
@@ -25,17 +27,22 @@ function handleAssistantPreview(value: string) {
</script>
<template>
<AgentAssistantEntry
ref="entryRef"
:active="!panelOpen"
:motion-active="allowsDecorativeMotion"
:thinking="thinking"
@open="openPanel"
/>
<AgentAssistantPanel
v-model="panelOpen"
:motion-active="allowsDecorativeMotion"
@assistant-preview="handleAssistantPreview"
@thinking-change="thinking = $event"
/>
<!-- 脱离 .v-application 的层叠上下文确保弹窗打开时入口消息气泡和面板仍在最上层 -->
<Teleport to="body">
<div class="agent-assistant-layer" :class="themeClasses">
<AgentAssistantEntry
ref="entryRef"
:active="!panelOpen"
:motion-active="allowsDecorativeMotion"
:thinking="thinking"
@open="openPanel"
/>
<AgentAssistantPanel
v-model="panelOpen"
:motion-active="allowsDecorativeMotion"
@assistant-preview="handleAssistantPreview"
@thinking-change="thinking = $event"
/>
</div>
</Teleport>
</template>

View File

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

View File

@@ -24,7 +24,7 @@ const repoText = ref('')
const newRepoUrl = ref('')
const editingIndex = ref<number | null>(null)
const editingUrl = ref('')
const syncingWiki = ref(false)
const syncingSources = ref(false)
const emit = defineEmits(['save', 'close'])
@@ -139,10 +139,10 @@ async function saveHandle() {
}
}
/** 从 Wiki 同步公开插件仓库清单并写入配置。 */
async function syncWikiRepos() {
/** 同步公开插件清单并写入配置。 */
async function syncPluginSources() {
try {
syncingWiki.value = true
syncingSources.value = true
const result: { [key: string]: any } = await api.post('system/setting/PLUGIN_MARKET/sync-wiki', {})
if (result.success) {
@@ -164,7 +164,7 @@ async function syncWikiRepos() {
console.log(error)
$toast.error(t('dialog.pluginMarketSetting.syncFailed', { message: error instanceof Error ? error.message : '' }))
} finally {
syncingWiki.value = false
syncingSources.value = false
}
}
@@ -483,11 +483,11 @@ onMounted(() => {
color="success"
variant="tonal"
prepend-icon="mdi-cloud-sync-outline"
:loading="syncingWiki"
:disabled="syncingWiki"
@click="syncWikiRepos"
:loading="syncingSources"
:disabled="syncingSources"
@click="syncPluginSources"
>
{{ t('dialog.pluginMarketSetting.syncWiki') }}
{{ t('dialog.pluginMarketSetting.syncSources') }}
</VBtn>
<VSpacer />
<VBtn
@@ -506,6 +506,8 @@ onMounted(() => {
</template>
<style scoped lang="scss">
/* stylelint-disable selector-pseudo-class-no-unknown */
.plugin-market-dialog-card {
display: flex;
flex-direction: column;
@@ -570,9 +572,12 @@ onMounted(() => {
.plugin-market-mode-switch {
display: inline-flex;
padding: 0.125rem;
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
border-radius: 0.375rem;
background: rgba(var(--v-theme-surface), 0.72);
border: var(--app-grouped-list-border);
border-radius: var(--app-control-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);
gap: 0.125rem;
}
@@ -582,7 +587,7 @@ onMounted(() => {
justify-content: center;
padding: 0;
border: 0;
border-radius: 0.375rem;
border-radius: var(--app-control-radius);
background: transparent;
block-size: 2.25rem;
color: rgba(var(--v-theme-on-surface), 0.68);
@@ -594,7 +599,7 @@ onMounted(() => {
color 0.16s ease;
&:hover {
background: rgba(var(--v-theme-primary), 0.07);
background: var(--app-grouped-list-hover-background);
color: rgb(var(--v-theme-on-surface));
}
@@ -604,7 +609,7 @@ onMounted(() => {
}
&.is-active {
background: rgba(var(--v-theme-primary), 0.12);
background: var(--app-grouped-list-active-background);
color: rgb(var(--v-theme-primary));
}
}
@@ -624,13 +629,23 @@ onMounted(() => {
.plugin-market-list-wrap {
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;
overflow-y: auto;
}
.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 {
@@ -681,7 +696,12 @@ onMounted(() => {
display: flex;
overflow: hidden;
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;
transition:
border-color 0.2s ease,

View File

@@ -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')")
})
})

View File

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

View File

@@ -4,7 +4,12 @@ import { useDisplay } from 'vuetify'
import { NavMenu } from '@/@layouts/types'
import { useI18n } from 'vue-i18n'
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 { usePWA } from '@/composables/usePWA'
import type { DynamicButtonMenuItem } from '@/composables/useDynamicButton'
@@ -327,8 +332,7 @@ function handleDynamicMenuItemClick(item: DynamicButtonMenuItem) {
pointer-events: none;
}
// 底部导航挂载在 body 上,而主题定制器与 Agent 面板受 .v-application 的层叠上下文限制,
// 无法仅靠 z-index 压过它。移动端两者都是全屏面板,打开时直接隐藏底部导航。
// 移动端两个设置面板都是全屏展示,打开时隐藏底部导航,避免不可见控件继续参与焦点和合成。
html[data-theme-customizer-open='true'],
html[data-agent-assistant-open='true'] {
.footer-nav-container {

View File

@@ -3061,9 +3061,9 @@ export default {
invalidText: 'There are {count} invalid URLs in the text. Fix them before saving.',
invalidTextIgnored: '{count} invalid URLs ignored',
duplicateTextIgnored: 'Duplicate URLs will be removed automatically when saving.',
syncWiki: 'Sync Wiki',
syncSuccess: 'Plugin repositories synced from Wiki. {added} added, {total} total.',
syncFailed: 'Failed to sync Wiki: {message}!',
syncSources: 'Sync Plugin Sources',
syncSuccess: 'Plugin sources synced. {added} added, {total} total.',
syncFailed: 'Failed to sync plugin sources: {message}!',
close: 'Close',
save: 'Save',
saveSuccess: 'Plugin repository saved successfully',

View File

@@ -3006,9 +3006,9 @@ export default {
invalidText: '文本中有 {count} 个无效地址,请修正后保存。',
invalidTextIgnored: '已忽略 {count} 个无效地址',
duplicateTextIgnored: '重复地址会在保存时自动去重。',
syncWiki: '同步 Wiki',
syncSuccess: '已从 Wiki 同步插件仓库,新增 {added} 个,共 {total} 个',
syncFailed: '同步 Wiki 失败:{message}',
syncSources: '同步插件源',
syncSuccess: '已同步插件,新增 {added} 个,共 {total} 个',
syncFailed: '同步插件源失败:{message}',
close: '关闭',
save: '保存',
saveSuccess: '插件仓库保存成功',

View File

@@ -3005,9 +3005,9 @@ export default {
invalidText: '文字中有 {count} 個無效地址,請修正後儲存。',
invalidTextIgnored: '已忽略 {count} 個無效地址',
duplicateTextIgnored: '重複地址會在儲存時自動去重。',
syncWiki: '同步 Wiki',
syncSuccess: '已從 Wiki 同步插件倉庫,新增 {added} 個,共 {total} 個',
syncFailed: '同步 Wiki 失敗:{message}',
syncSources: '同步插件源',
syncSuccess: '已同步插件,新增 {added} 個,共 {total} 個',
syncFailed: '同步插件源失敗:{message}',
close: '關閉',
save: '儲存',
saveSuccess: '插件倉庫儲存成功',

View File

@@ -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', () => {
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')

View File

@@ -603,7 +603,6 @@ html[data-theme='glass'] {
}
.nav-button,
.compact-fab .v-btn,
.global-action-buttons .v-btn {
border: 1px solid var(--glass-border) !important;
-webkit-backdrop-filter: var(--glass-raised-backdrop-filter);
@@ -613,6 +612,16 @@ html[data-theme='glass'] {
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 {
-webkit-backdrop-filter: none;
backdrop-filter: none;
@@ -1320,6 +1329,11 @@ html[data-theme='glass'] {
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 {
border-color: var(--glass-border-hover) !important;
box-shadow: var(--glass-shadow-hover) !important;