fix(frontend): unify extension installation and restore theme preview scrolling
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import ExtensionInstallDialog from './ExtensionInstallDialog.vue'
|
||||
|
||||
let wrapper: VueWrapper
|
||||
afterEach(() => { wrapper?.unmount() })
|
||||
|
||||
it.each(['Skill', 'Plugin'] as const)('installs %s from a trimmed directory and prevents duplicate submissions', async kind => {
|
||||
let complete!: () => void
|
||||
const install = vi.fn(() => new Promise<void>(resolve => { complete = resolve }))
|
||||
wrapper = mount(ExtensionInstallDialog, { props: { kind, install } })
|
||||
expect(wrapper.text()).toContain(`${kind.toLowerCase()}.yaml`)
|
||||
expect(wrapper.get('button[type="submit"]').attributes('disabled')).toBeDefined()
|
||||
await wrapper.get('input').setValue(' G:\\packages\\example ')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
expect(install).toHaveBeenCalledExactlyOnceWith('G:\\packages\\example')
|
||||
await wrapper.get('dialog').trigger('cancel')
|
||||
expect(wrapper.emitted('close')).toBeUndefined()
|
||||
expect(wrapper.get('input').attributes('disabled')).toBeDefined()
|
||||
complete()
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('installed')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps the path and displays validation errors for retry', async () => {
|
||||
const install = vi.fn().mockRejectedValueOnce(new Error('Manifest does not exist')).mockResolvedValueOnce(undefined)
|
||||
wrapper = mount(ExtensionInstallDialog, { props: { kind: 'Plugin', install } })
|
||||
await wrapper.get('input').setValue('G:\\packages\\example')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(wrapper.get('[role="alert"]').text()).toBe('Manifest does not exist')
|
||||
expect((wrapper.get('input').element as HTMLInputElement).value).toBe('G:\\packages\\example')
|
||||
expect(wrapper.emitted('installed')).toBeUndefined()
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
|
||||
expect(wrapper.emitted('installed')).toHaveLength(1)
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { FolderOpened } from '@element-plus/icons-vue'
|
||||
import AppDialog from './AppDialog.vue'
|
||||
import AppIcon from './AppIcon.vue'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const props = defineProps<{ kind: 'Skill' | 'Plugin'; install: (path: string) => Promise<unknown> }>()
|
||||
const emit = defineEmits<{ close: []; installed: [] }>()
|
||||
const path = ref('')
|
||||
const busy = ref(false)
|
||||
const error = ref('')
|
||||
const title = computed(() => t(`安装 ${props.kind}`, `Install ${props.kind}`))
|
||||
const manifest = computed(() => `${props.kind.toLowerCase()}.yaml`)
|
||||
|
||||
async function submit() {
|
||||
if (busy.value || !path.value.trim()) return
|
||||
error.value = ''
|
||||
busy.value = true
|
||||
try {
|
||||
await props.install(path.value.trim())
|
||||
emit('installed')
|
||||
} catch (reason) {
|
||||
error.value = reason instanceof Error ? reason.message : t('安装失败,请检查包目录后重试。', 'Installation failed. Check the package directory and retry.')
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog :label="title" :dismissible="!busy" @close="emit('close')">
|
||||
<form class="modal extension-install-modal" :aria-busy="busy" @submit.prevent="submit">
|
||||
<span class="badge info">{{ t('扩展安装', 'Extension installation') }}</span>
|
||||
<h2>{{ title }}</h2>
|
||||
<p class="muted">{{ t('从本地包目录安装,安装时会校验清单与依赖。', 'Install from a local package directory. The manifest and dependencies are checked during installation.') }}</p>
|
||||
<div class="package-source">
|
||||
<AppIcon :icon="FolderOpened" :size="30" />
|
||||
<strong>{{ t('本地包目录', 'Local package directory') }}</strong>
|
||||
<p class="muted">{{ t('选择包含以下清单的完整解压目录:', 'Use the extracted directory containing:') }} <code>{{ manifest }}</code></p>
|
||||
<label class="package-field">
|
||||
<span>{{ t('目录路径', 'Directory path') }}</span>
|
||||
<input v-model="path" class="input" autofocus required :disabled="busy" :placeholder="t('粘贴本地包目录的完整路径', 'Paste the full package directory path')" aria-describedby="extension-path-help" />
|
||||
</label>
|
||||
<p id="extension-path-help" class="subtle">{{ t('路径须位于 AI Core 所在电脑。ZIP 请先解压,再填写目录路径。', 'The directory must be on the AI Core computer. Extract ZIP packages before entering the directory path.') }}</p>
|
||||
</div>
|
||||
<div v-if="error" class="error-banner" role="alert">{{ error }}</div>
|
||||
<p v-if="busy" class="muted" role="status">{{ t('正在校验并安装,请稍候…', 'Validating and installing…') }}</p>
|
||||
<footer class="install-actions">
|
||||
<button type="button" class="button-secondary" :disabled="busy" @click="emit('close')">{{ t('取消', 'Cancel') }}</button>
|
||||
<button type="submit" class="button-primary" :disabled="busy || !path.trim()">{{ busy ? t('安装中…', 'Installing…') : title }}</button>
|
||||
</footer>
|
||||
</form>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.extension-install-modal { width: min(520px, 100%); }
|
||||
h2 { margin: var(--space-sm) 0 var(--space-md); }
|
||||
.package-source { display: grid; justify-items: center; gap: var(--space-md); margin: var(--space-lg) 0; padding: clamp(16px, 4vw, 28px); border: 2px dashed var(--color-border-default); border-radius: var(--radius-md); text-align: center; }
|
||||
.package-source > .app-icon { color: var(--color-accent-primary); }
|
||||
.package-field { display: grid; gap: var(--space-sm); width: 100%; min-width: 0; text-align: left; }
|
||||
.package-field input { min-width: 0; }
|
||||
.install-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--space-sm); margin-top: var(--space-lg); }
|
||||
.error-banner { overflow-wrap: anywhere; }
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { Connection } from '@element-plus/icons-vue'
|
||||
import AppIcon from '@/components/common/AppIcon.vue'
|
||||
import ExtensionInstallDialog from '@/components/common/ExtensionInstallDialog.vue'
|
||||
import PluginMcpPanel from './PluginMcpPanel.vue'
|
||||
import PluginCommandPanel from './PluginCommandPanel.vue'
|
||||
import PluginSettingsPanel from './PluginSettingsPanel.vue'
|
||||
@@ -12,6 +13,7 @@ import { t } from '@/i18n'
|
||||
|
||||
const pluginStore = usePluginStore()
|
||||
const actionError = ref('')
|
||||
const showInstall = ref(false)
|
||||
const activeTab = ref<'info' | 'settings' | 'commands'>('info')
|
||||
const pluginCommands = ref<PluginCommand[]>([])
|
||||
|
||||
@@ -29,12 +31,6 @@ watch(() => pluginStore.selectedPluginId, async (pluginId) => {
|
||||
}
|
||||
})
|
||||
|
||||
async function install() {
|
||||
const path = prompt(t('请输入 Plugin Package 路径', 'Enter the Plugin Package path'))?.trim()
|
||||
if (!path) return
|
||||
try { await pluginStore.installPlugin(path) }
|
||||
catch (error) { actionError.value = error instanceof Error ? error.message : t('安装失败', 'Installation failed') }
|
||||
}
|
||||
|
||||
async function toggle(id: string, enabled: boolean) {
|
||||
try {
|
||||
@@ -65,9 +61,10 @@ const hasCommandContribution = computed(() =>
|
||||
|
||||
<template>
|
||||
<section class="feature-page">
|
||||
<ExtensionInstallDialog v-if="showInstall" kind="Plugin" :install="pluginStore.installPlugin" @close="showInstall = false" @installed="showInstall = false; actionError = ''" />
|
||||
<header class="feature-header">
|
||||
<div><h1>{{ t('Plugin 与 MCP', 'Plugins and MCP') }}</h1><p>{{ t('管理插件生命周期、MCP Host、权限和受控 Contribution。', 'Manage plugin lifecycles, MCP hosts, permissions, and controlled contributions.') }}</p></div>
|
||||
<button class="button-primary" @click="install">{{ t('安装 Plugin', 'Install Plugin') }}</button>
|
||||
<button class="button-primary" @click="showInstall = true">{{ t('安装 Plugin', 'Install Plugin') }}</button>
|
||||
</header>
|
||||
|
||||
<div v-if="pluginStore.error || actionError" class="error-banner">
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { Lightning } from '@element-plus/icons-vue'
|
||||
import AppIcon from '@/components/common/AppIcon.vue'
|
||||
import ExtensionInstallDialog from '@/components/common/ExtensionInstallDialog.vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useSkillStore } from '@/stores/skill'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const skillStore = useSkillStore()
|
||||
const actionError = ref('')
|
||||
const showInstall = ref(false)
|
||||
onMounted(() => { void skillStore.loadSkills() })
|
||||
|
||||
async function install() {
|
||||
const path = prompt(t('请输入 Skill Package 路径', 'Enter the Skill package path'))?.trim()
|
||||
if (!path) return
|
||||
try { await skillStore.installSkill(path) } catch (error) { actionError.value = error instanceof Error ? error.message : t('安装失败', 'Installation failed') }
|
||||
}
|
||||
|
||||
async function toggle(skillId: string, enabled: boolean) {
|
||||
try { enabled ? await skillStore.disableSkill(skillId) : await skillStore.enableSkill(skillId) } catch (error) { actionError.value = error instanceof Error ? error.message : t('状态更新失败', 'Status update failed') }
|
||||
}
|
||||
@@ -25,7 +23,8 @@ async function uninstall(skillId: string, name: string) {
|
||||
|
||||
<template>
|
||||
<section class="feature-page">
|
||||
<header class="feature-header"><div><h1>{{ t('Skill 管理', 'Skill Management') }}</h1><p>{{ t('查看工作流使用的 Tool、权限、检索配置和模型要求。', 'Review the tools, permissions, retrieval settings, and model requirements used by workflows.') }}</p></div><button class="button-primary" @click="install">{{ t('安装 Skill', 'Install Skill') }}</button></header>
|
||||
<ExtensionInstallDialog v-if="showInstall" kind="Skill" :install="skillStore.installSkill" @close="showInstall = false" @installed="showInstall = false; actionError = ''" />
|
||||
<header class="feature-header"><div><h1>{{ t('Skill 管理', 'Skill Management') }}</h1><p>{{ t('查看工作流使用的 Tool、权限、检索配置和模型要求。', 'Review the tools, permissions, retrieval settings, and model requirements used by workflows.') }}</p></div><button class="button-primary" @click="showInstall = true">{{ t('安装 Skill', 'Install Skill') }}</button></header>
|
||||
<div v-if="skillStore.error || actionError" class="error-banner">{{ skillStore.error || actionError }}</div>
|
||||
<div v-if="skillStore.selectedSkill" class="panel detail-panel">
|
||||
<div class="detail-head"><div><span class="badge" :class="{ success: skillStore.selectedSkill.status === 'ready', error: skillStore.selectedSkill.status === 'error', warning: skillStore.selectedSkill.status.includes('missing') }">{{ skillStore.selectedSkill.status }}</span><h2>{{ skillStore.selectedSkill.icon }} {{ skillStore.selectedSkill.name }}</h2><p class="muted">v{{ skillStore.selectedSkill.version }} · {{ skillStore.selectedSkill.author || t('未知作者', 'Unknown author') }}</p></div><div class="inline-actions"><button class="button-secondary" @click="toggle(skillStore.selectedSkill.skill_id, skillStore.selectedSkill.enabled)">{{ skillStore.selectedSkill.enabled ? t('停用', 'Disable') : t('启用', 'Enable') }}</button><button class="button-danger" @click="uninstall(skillStore.selectedSkill.skill_id, skillStore.selectedSkill.name)">{{ t('卸载', 'Uninstall') }}</button></div></div>
|
||||
|
||||
@@ -19,5 +19,13 @@ it.each(themes)('previews shared component states safely for $theme_id', theme =
|
||||
for (const selector of ['input.input','input:disabled','textarea.textarea','select.select','.ui-disclosure[open]','.ui-disclosure:not([open])','.button-primary:disabled','.badge.success','.error-banner','.specimen-markdown code','.specimen-markdown table','.specimen-chart','.specimen-long']) expect(doc.querySelector(selector), selector).not.toBeNull()
|
||||
expect(doc.querySelector('style')!.textContent).toContain('.button-primary:hover')
|
||||
expect(doc.querySelector('style')!.textContent).not.toContain('color:white')
|
||||
const rules = Array.from(doc.styleSheets[0]!.cssRules) as CSSStyleRule[]
|
||||
const rootRule = rules.filter(rule => rule.selectorText === 'html').pop()!
|
||||
const bodyRule = rules.filter(rule => rule.selectorText === 'body').pop()!
|
||||
// The embedded document must override the app-shell overflow lock.
|
||||
expect(rootRule.style.getPropertyValue('overflow-y')).toBe('auto')
|
||||
expect(rootRule.style.getPropertyPriority('overflow-y')).toBe('important')
|
||||
expect(bodyRule.style.getPropertyValue('height')).toBe('auto')
|
||||
expect(bodyRule.style.getPropertyValue('overflow')).toBe('visible')
|
||||
} finally { w.unmount() }
|
||||
})
|
||||
|
||||
@@ -20,7 +20,7 @@ const previewDocument = computed(() => {
|
||||
policy.content = "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:; base-uri 'none'; form-action 'none'"
|
||||
doc.head.append(policy)
|
||||
const style = doc.createElement('style')
|
||||
style.textContent = `${tokensCss}\n${featuresCss}\n${props.css ?? getCommunityThemePreviewCss(props.themeId)}\nbody { margin:0; padding:24px; background:var(--color-background-primary); color:var(--color-text-primary); font:16px/1.6 system-ui; } article { min-width:0; } .theme-specimen { display:grid; gap:16px; margin-top:20px; } .surface-nested { padding:12px; border:1px solid var(--color-border-default); border-radius:var(--radius-md); } .specimen-markdown { overflow:auto; } .specimen-markdown code { background:var(--color-code-background, var(--color-background-secondary)); color:var(--color-code-text, var(--color-text-primary)); padding:3px 6px; border-radius:4px; } .specimen-markdown pre { padding:12px; background:var(--color-background-secondary); } .specimen-markdown blockquote { border-left:3px solid var(--color-accent-primary); padding-left:12px; } .specimen-markdown table { width:100%; border-collapse:collapse; } .specimen-markdown td,.specimen-markdown th { padding:8px; border:1px solid var(--color-border-default); } .specimen-markdown th { background:var(--color-markdown-table-header); } .specimen-chart > div { display:flex; align-items:flex-end; gap:12px; height:100px; border-bottom:1px solid var(--color-border-default); } .specimen-chart span { width:36px; } .specimen-long { overflow-wrap:anywhere; } @media(max-width:480px) { body { padding:12px; } .form-grid { grid-template-columns:minmax(0,1fr); } }`
|
||||
style.textContent = `${tokensCss}\n${featuresCss}\n${props.css ?? getCommunityThemePreviewCss(props.themeId)}\nhtml { height:100% !important; overflow-y:auto !important; overflow-x:hidden !important; overscroll-behavior:contain; } body { height:auto !important; min-height:100%; overflow:visible !important; margin:0; padding:24px; background:var(--color-background-primary); color:var(--color-text-primary); font:16px/1.6 system-ui; } article { min-width:0; } .theme-specimen { display:grid; gap:16px; margin-top:20px; } .surface-nested { padding:12px; border:1px solid var(--color-border-default); border-radius:var(--radius-md); } .specimen-markdown { overflow:auto; } .specimen-markdown code { background:var(--color-code-background, var(--color-background-secondary)); color:var(--color-code-text, var(--color-text-primary)); padding:3px 6px; border-radius:4px; } .specimen-markdown pre { padding:12px; background:var(--color-background-secondary); } .specimen-markdown blockquote { border-left:3px solid var(--color-accent-primary); padding-left:12px; } .specimen-markdown table { width:100%; border-collapse:collapse; } .specimen-markdown td,.specimen-markdown th { padding:8px; border:1px solid var(--color-border-default); } .specimen-markdown th { background:var(--color-markdown-table-header); } .specimen-chart > div { display:flex; align-items:flex-end; gap:12px; height:100px; border-bottom:1px solid var(--color-border-default); } .specimen-chart span { width:36px; } .specimen-long { overflow-wrap:anywhere; } @media(max-width:480px) { body { padding:12px; } .form-grid { grid-template-columns:minmax(0,1fr); } }`
|
||||
|
||||
doc.head.append(style)
|
||||
const article = doc.createElement('article')
|
||||
|
||||
@@ -322,7 +322,7 @@ onMounted(() => {
|
||||
.preview-paper span:nth-child(2) { background: #d8e7e8; }
|
||||
.preview-paper span:nth-child(3) { background: #f6e9b8; }
|
||||
.preview-paper div { border: 1px solid #b5a693; background: repeating-linear-gradient(#fffef8 0 14px, #dce4db 14px 15px); }
|
||||
.theme-actions a { text-decoration: none; }
|
||||
.theme-actions a { display: inline-flex; align-items: center; justify-content: center; text-align: center; text-decoration: none; }
|
||||
|
||||
.theme-info { display: flex; justify-content: space-between; gap: var(--space-md); align-items: flex-start; }
|
||||
.theme-info strong { display: block; margin-bottom: 2px; }
|
||||
|
||||
Reference in New Issue
Block a user