diff --git a/docs/development/frontend-phase2-pr23-followup.md b/docs/development/frontend-phase2-pr23-followup.md index f21f130..14bfcfe 100644 --- a/docs/development/frontend-phase2-pr23-followup.md +++ b/docs/development/frontend-phase2-pr23-followup.md @@ -20,3 +20,9 @@ - 浏览器确认安装未启用主题无样式影响、多主题切换无残留、回到内置主题清除样式;保存期间继续输入后可再次保存最新值;浅色和深色下 Mermaid 与 Shiki 均生成正常内容。 未执行生产插件后端的端到端验收;本次不包含后端实现修改。 + +## 再次审阅后的修复 + +- 密钥保存使用提交快照,只清空未变化的输入;保存失败保留草稿。密钥保存、删除与普通设置保存互斥,切换插件或卸载组件后忽略旧响应。 +- 未安装社区主题的预览改为独立、禁用脚本的 iframe,使用该社区主题的实际 CSS。打开和关闭预览不安装主题、不修改当前主题及持久化设置,也不保留延时回滚任务。 +- 最新验证:40 个测试文件、233 项测试通过,类型检查和构建通过;浏览器确认深色社区主题在预览窗口中生效,外层仍为浅色主题,关闭后预览被移除。 diff --git a/frontend/src/features/plugins/PluginSettingsPanel.spec.ts b/frontend/src/features/plugins/PluginSettingsPanel.spec.ts index c8fa78b..30bcf17 100644 --- a/frontend/src/features/plugins/PluginSettingsPanel.spec.ts +++ b/frontend/src/features/plugins/PluginSettingsPanel.spec.ts @@ -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>) => 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 diff --git a/frontend/src/features/plugins/PluginSettingsPanel.vue b/frontend/src/features/plugins/PluginSettingsPanel.vue index b51281f..7f17227 100644 --- a/frontend/src/features/plugins/PluginSettingsPanel.vue +++ b/frontend/src/features/plugins/PluginSettingsPanel.vue @@ -1,5 +1,5 @@ @@ -220,10 +233,10 @@ watch(() => props.pluginId, load) placeholder="重新输入以更新" class="input" /> - - + diff --git a/frontend/src/features/themes/CommunityThemePreview.vue b/frontend/src/features/themes/CommunityThemePreview.vue new file mode 100644 index 0000000..b1e5ecb --- /dev/null +++ b/frontend/src/features/themes/CommunityThemePreview.vue @@ -0,0 +1,41 @@ + + +