fix(frontend): 保留密钥编辑并隔离社区主题预览

This commit is contained in:
2026-09-05 17:46:12 +08:00
parent 0f08cd051b
commit 6d0c1400ce
6 changed files with 175 additions and 16 deletions
@@ -9,7 +9,69 @@ vi.mock('@/services/pluginService', () => ({ getPluginSettings: vi.fn(), updateP
const schema = (value = ''): PluginSettingsSchema => ({ plugin_id: 'demo', schema_version: 1, fields: [{ key: 'name', label: 'Name', type: 'string', description: '', required: false, options: [] }], values: { name: value }, secrets: {} })
let wrapper: VueWrapper
beforeEach(() => { vi.resetAllMocks(); vi.mocked(service.getPluginSettings).mockResolvedValue(schema()) })
afterEach(() => wrapper?.unmount())
afterEach(() => { wrapper?.unmount(); vi.unstubAllGlobals() })
function secretSchema(configured = false): PluginSettingsSchema {
return { ...schema(), fields: [{ key: 'token', label: 'Token', type: 'secret', description: '', required: false, options: [] }], secrets: { token: { configured } } }
}
it('preserves new secret input during a pending save and allows saving it next', async () => {
vi.mocked(service.getPluginSettings).mockResolvedValue(secretSchema())
let finish!: (value: Awaited<ReturnType<typeof service.putPluginSecret>>) => void
vi.mocked(service.putPluginSecret).mockReturnValueOnce(new Promise(resolve => { finish = resolve }))
wrapper = mount(PluginSettingsPanel, { props: { pluginId: 'demo' } })
await flushPromises()
await wrapper.get('input[type="password"]').setValue('first-fixture-value')
await wrapper.get('.secret-row button').trigger('click')
await wrapper.get('input[type="password"]').setValue('second-fixture-value')
await wrapper.get('.secret-row button').trigger('click')
expect(service.putPluginSecret).toHaveBeenCalledTimes(1)
finish({ plugin_id: 'demo', key: 'token', configured: true })
await flushPromises()
expect((wrapper.get('input[type="password"]').element as HTMLInputElement).value).toBe('second-fixture-value')
vi.mocked(service.putPluginSecret).mockResolvedValueOnce({ plugin_id: 'demo', key: 'token', configured: true })
await wrapper.get('.secret-row button').trigger('click')
await flushPromises()
expect(service.putPluginSecret).toHaveBeenLastCalledWith('demo', 'token', 'second-fixture-value')
expect((wrapper.get('input[type="password"]').element as HTMLInputElement).value).toBe('')
})
it('retains a secret draft on failure and allows retry', async () => {
vi.mocked(service.getPluginSettings).mockResolvedValue(secretSchema())
vi.mocked(service.putPluginSecret).mockRejectedValueOnce(new Error('Save failed'))
wrapper = mount(PluginSettingsPanel, { props: { pluginId: 'demo' } })
await flushPromises()
await wrapper.get('input[type="password"]').setValue('retry-fixture-value')
await wrapper.get('.secret-row button').trigger('click')
await flushPromises()
expect((wrapper.get('input[type="password"]').element as HTMLInputElement).value).toBe('retry-fixture-value')
expect(wrapper.get('.secret-row button').attributes('disabled')).toBeUndefined()
expect(wrapper.text()).toContain('Save failed')
})
it.each(['save', 'delete'] as const)('ignores old secret %s responses after switching plugins', async action => {
vi.mocked(service.getPluginSettings).mockResolvedValue(secretSchema(true))
let finish!: () => void
vi.mocked(service.putPluginSecret).mockReturnValue(new Promise(resolve => { finish = () => resolve({ plugin_id: 'demo', key: 'token', configured: true }) }))
if (action === 'delete') {
vi.stubGlobal('confirm', vi.fn(() => true))
vi.mocked(service.deletePluginSecret).mockReturnValue(new Promise(resolve => { finish = () => resolve({ plugin_id: 'demo', key: 'token', configured: false }) }))
}
wrapper = mount(PluginSettingsPanel, { props: { pluginId: 'demo' } })
await flushPromises()
await wrapper.get('input[type="password"]').setValue('old-fixture-value')
await wrapper.get(action === 'save' ? '.secret-row button' : '.secret-row .danger').trigger('click')
vi.mocked(service.getPluginSettings).mockResolvedValue(secretSchema(false))
await wrapper.setProps({ pluginId: 'other' })
await flushPromises()
await wrapper.get('input[type="password"]').setValue('new-fixture-value')
finish()
await flushPromises()
expect((wrapper.get('input[type="password"]').element as HTMLInputElement).value).toBe('new-fixture-value')
expect(wrapper.find('.secret-status').classes()).toContain('not-configured')
expect(wrapper.emitted('saved')).toBeUndefined()
vi.restoreAllMocks()
})
it('retains edits made during a save and submits them on the next save', async () => {
let resolveSave!: (value: PluginSettingsSchema) => void
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import type { PluginSettingsSchema, PluginSettingField } from '@/contracts'
import {
getPluginSettings,
@@ -83,33 +83,45 @@ async function save() {
}
async function saveSecret(key: string) {
if (!secrets[key]) return
if (!schema.value || !secrets[key] || isSaving.value) return
const version = loadVersion
const pluginId = props.pluginId
const submittedSecret = secrets[key]
isSaving.value = true
saveError.value = ''
try {
const result = await putPluginSecret(props.pluginId, key, secrets[key])
const result = await putPluginSecret(pluginId, key, submittedSecret)
if (version !== loadVersion || pluginId !== props.pluginId) return
if (schema.value) {
schema.value.secrets[key] = { configured: result.configured }
}
secrets[key] = ''
if (secrets[key] === submittedSecret) secrets[key] = ''
emit('saved')
} catch (error) {
saveError.value = error instanceof Error ? error.message : '密钥保存失败'
if (version === loadVersion && pluginId === props.pluginId) saveError.value = error instanceof Error ? error.message : '密钥保存失败'
} finally {
isSaving.value = false
if (version === loadVersion && pluginId === props.pluginId) isSaving.value = false
}
}
async function clearSecret(key: string) {
if (!schema.value || isSaving.value) return
if (!confirm(`确认删除 " ${key} " 的配置?`)) return
const version = loadVersion
const pluginId = props.pluginId
isSaving.value = true
saveError.value = ''
try {
await deletePluginSecret(props.pluginId, key)
await deletePluginSecret(pluginId, key)
if (version !== loadVersion || pluginId !== props.pluginId) return
if (schema.value) {
schema.value.secrets[key] = { configured: false }
}
emit('saved')
} catch (error) {
saveError.value = error instanceof Error ? error.message : '删除失败'
if (version === loadVersion && pluginId === props.pluginId) saveError.value = error instanceof Error ? error.message : '删除失败'
} finally {
if (version === loadVersion && pluginId === props.pluginId) isSaving.value = false
}
}
@@ -127,6 +139,7 @@ function setFieldValue(key: string, value: unknown, field: PluginSettingField) {
}
onMounted(load)
onBeforeUnmount(() => { loadVersion++ })
watch(() => props.pluginId, load)
</script>
@@ -220,10 +233,10 @@ watch(() => props.pluginId, load)
placeholder="重新输入以更新"
class="input"
/>
<button class="button-secondary" :disabled="!secrets[field.key]" @click="saveSecret(field.key)">
<button class="button-secondary" :disabled="!secrets[field.key] || isSaving" @click="saveSecret(field.key)">
更新
</button>
<button class="link-btn danger" @click="clearSecret(field.key)">清除</button>
<button class="link-btn danger" :disabled="isSaving" @click="clearSecret(field.key)">清除</button>
</template>
<template v-else>
<input
@@ -234,7 +247,7 @@ watch(() => props.pluginId, load)
/>
<button
class="button-primary"
:disabled="!secrets[field.key]"
:disabled="!secrets[field.key] || isSaving"
@click="saveSecret(field.key)"
>保存</button>
</template>
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { computed } from 'vue'
import { t } from '@/i18n'
import { getCommunityThemePreviewCss, mockCommunityThemes } from '@/services/themePackageService'
import tokensCss from '@/styles/tokens.css?raw'
const props = defineProps<{ themeId: string }>()
const emit = defineEmits<{ (event: 'close'): void }>()
const theme = computed(() => mockCommunityThemes.find(item => item.theme_id === props.themeId))
const previewDocument = computed(() => {
// Only bundled community CSS enters this script-free, isolated document.
// Previewing never installs a theme or changes application styles/storage.
const doc = document.implementation.createHTMLDocument(theme.value?.name ?? '')
doc.documentElement.dataset.theme = props.themeId
const style = doc.createElement('style')
style.textContent = `${tokensCss}\n${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 { padding:20px; border:1px solid var(--color-border-default); border-radius:8px; background:var(--color-surface-primary); } p { color:var(--color-text-secondary); } button { padding:8px 16px; border:0; border-radius:6px; background:var(--color-accent-primary); color:white; }`
doc.head.append(style)
const article = doc.createElement('article')
const heading = doc.createElement('h1'); heading.textContent = theme.value?.name ?? props.themeId
const text = doc.createElement('p'); text.textContent = t('知识的价值不只在于保存,更在于被重新发现和使用。', 'Knowledge gains value when it can be rediscovered and used.')
const button = doc.createElement('button'); button.textContent = t('示例按钮', 'Example button')
article.append(heading, text, button); doc.body.append(article)
return '<!doctype html>' + doc.documentElement.outerHTML
})
</script>
<template>
<div class="modal-backdrop" @click.self="emit('close')" @keydown.esc="emit('close')">
<section class="modal theme-preview-dialog" role="dialog" aria-modal="true" :aria-label="t('社区主题预览', 'Community theme preview')">
<div class="preview-heading"><h2>{{ theme?.name }}</h2><button class="button-secondary" autofocus @click="emit('close')">{{ t('关闭预览', 'Close preview') }}</button></div>
<iframe :title="`${t('主题预览', 'Theme preview')}: ${theme?.name ?? themeId}`" sandbox="" :srcdoc="previewDocument" />
<p class="subtle">{{ t('仅预览,不会安装或更改当前主题。', 'Preview only. Your installed themes and current appearance remain unchanged.') }}</p>
</section>
</div>
</template>
<style scoped>
.theme-preview-dialog { width: min(720px, calc(100vw - 32px)); }
.preview-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
iframe { display: block; width: 100%; height: min(420px, 60vh); margin: 16px 0; border: 1px solid var(--color-border-default); border-radius: 8px; }
</style>
@@ -0,0 +1,37 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import ThemesView from './ThemesView.vue'
import { useThemeStore } from '@/stores/theme'
import { mockCommunityThemes, getCommunityThemePreviewCss } from '@/services/themePackageService'
let wrapper: VueWrapper
beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()) })
afterEach(() => { wrapper?.unmount(); vi.useRealTimers() })
it.each(mockCommunityThemes)('previews uninstalled $theme_id using its actual CSS without changing the active theme', async theme => {
const store = useThemeStore()
store.applyTheme('light')
wrapper = mount(ThemesView, { global: { stubs: { MarkdownContent: true } } })
await flushPromises()
await wrapper.findAll('.tab-btn')[1]!.trigger('click')
const card = wrapper.findAll('article.theme-card').find(item => item.text().includes(theme.name))!
await card.findAll('button').find(button => button.text() === '预览')!.trigger('click')
expect(wrapper.get('[role="dialog"]').text()).toContain(theme.name)
const frame = wrapper.get('iframe')
expect(frame.attributes('sandbox')).toBe('')
const preview = new DOMParser().parseFromString(frame.attributes('srcdoc')!, 'text/html')
expect(preview.documentElement.dataset.theme).toBe(theme.theme_id)
expect(preview.querySelector('style')!.textContent).toContain(getCommunityThemePreviewCss(theme.theme_id))
expect(store.currentThemeId).toBe('light')
expect(localStorage.getItem('theme')).toBe('light')
expect(store.isThemeInstalled(theme.theme_id)).toBe(false)
expect(document.head.querySelectorAll('style[id^="theme-style-"]')).toHaveLength(0)
vi.useFakeTimers()
await wrapper.get('[role="dialog"] button').trigger('click')
store.applyTheme('dark')
await vi.advanceTimersByTimeAsync(2000)
expect(wrapper.find('iframe').exists()).toBe(false)
expect(store.currentThemeId).toBe('dark')
})
+4 -4
View File
@@ -5,12 +5,14 @@ import { useThemeStore } from '@/stores/theme'
import { mockCommunityThemes } from '@/services/themePackageService'
import type { ThemePackageInspection } from '@/contracts'
import { t } from '@/i18n'
import CommunityThemePreview from './CommunityThemePreview.vue'
const themeStore = useThemeStore()
const activeTab = ref<'installed' | 'community'>('installed')
const showImportDialog = ref(false)
const previewThemeId = ref<string | null>(null)
const communityPreviewId = ref<string | null>(null)
const actionError = ref('')
const shikiPreview = `\`\`\`typescript
@@ -71,10 +73,7 @@ async function installFromCommunity(themeId: string) {
}
function previewCommunity(themeId: string) {
// 临时切换预览
const current = themeStore.currentThemeId
themeStore.applyTheme(themeId)
setTimeout(() => themeStore.applyTheme(current), 1500)
communityPreviewId.value = themeId
}
onMounted(() => {
@@ -84,6 +83,7 @@ onMounted(() => {
<template>
<section class="feature-page">
<CommunityThemePreview v-if="communityPreviewId" :theme-id="communityPreviewId" @close="communityPreviewId = null" />
<header class="feature-header">
<div>
<h1>{{ t('主题', 'Themes') }}</h1>