fix(ui): unify dialogs and complete theme component coverage
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
// Vitest disables CSS by default, including CSS raw imports. Load the real files here.
|
||||
vi.mock('@/styles/features.css?raw', async () => ({ default: (await import('node:fs')).readFileSync(process.cwd() + '/src/styles/features.css', 'utf8') }))
|
||||
vi.mock('@/styles/tokens.css?raw', async () => ({ default: (await import('node:fs')).readFileSync(process.cwd() + '/src/styles/tokens.css', 'utf8') }))
|
||||
import CommunityThemePreview from './CommunityThemePreview.vue'
|
||||
import { mockCommunityThemes } from '@/services/themePackageService'
|
||||
const themes = [...['light','dark','sepia'].map(theme_id => ({theme_id,name:theme_id,builtin:true})), ...mockCommunityThemes.map(t => ({...t,builtin:false}))]
|
||||
it.each(themes)('previews shared component states safely for $theme_id', theme => {
|
||||
const w = mount(CommunityThemePreview, {props:{themeId:theme.theme_id,name:theme.name,...(theme.builtin ? {css:''} : {})},attachTo:document.body})
|
||||
try {
|
||||
const iframe = w.get('iframe')
|
||||
expect(iframe.attributes('sandbox')).toBe('')
|
||||
const doc = new DOMParser().parseFromString(iframe.attributes('srcdoc')!, 'text/html')
|
||||
expect(doc.documentElement.dataset.theme).toBe(theme.theme_id)
|
||||
expect(doc.querySelector('script')).toBeNull()
|
||||
expect(doc.querySelector('meta[http-equiv="Content-Security-Policy"]')?.getAttribute('content')).toContain("default-src 'none'")
|
||||
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')
|
||||
} finally { w.unmount() }
|
||||
})
|
||||
@@ -1,8 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import AppDialog from '@/components/common/AppDialog.vue'
|
||||
import { computed } from 'vue'
|
||||
import { t } from '@/i18n'
|
||||
import { getCommunityThemePreviewCss, mockCommunityThemes } from '@/services/themePackageService'
|
||||
import tokensCss from '@/styles/tokens.css?raw'
|
||||
import featuresCss from '@/styles/features.css?raw'
|
||||
import specimenHtml from './themeSpecimen.html?raw'
|
||||
|
||||
const props = defineProps<{ themeId: string; name?: string; css?: string }>()
|
||||
const emit = defineEmits<{ (event: 'close'): void }>()
|
||||
@@ -17,7 +20,8 @@ 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${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 { 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; }`
|
||||
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); } }`
|
||||
|
||||
doc.head.append(style)
|
||||
const article = doc.createElement('article')
|
||||
article.className = 'panel'
|
||||
@@ -26,21 +30,22 @@ const previewDocument = computed(() => {
|
||||
header.append(heading)
|
||||
const journal = doc.createElement('section'); journal.className = 'editor-preview'; journal.style.cssText = 'padding:24px;margin:28px 0;'
|
||||
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')
|
||||
const button = doc.createElement('button'); button.className = 'button-primary'; button.textContent = t('示例按钮', 'Example button')
|
||||
journal.append(text)
|
||||
article.append(header, journal, button); doc.body.append(article)
|
||||
const specimen = doc.createElement('template'); specimen.innerHTML = specimenHtml
|
||||
article.append(header, journal, button, specimen.content); 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')">
|
||||
<AppDialog :label="t('社区主题预览', 'Community theme preview')" @close="emit('close')">
|
||||
<section class="modal theme-preview-dialog">
|
||||
<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>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -4,7 +4,7 @@ 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'
|
||||
import { mockCommunityThemes, getCommunityThemePreviewCss, inspectThemePackage } from '@/services/themePackageService'
|
||||
import paperPackage from '@/assets/themes/paper-moments.theme?raw'
|
||||
|
||||
let wrapper: VueWrapper
|
||||
@@ -76,7 +76,7 @@ it.each(mockCommunityThemes)('previews uninstalled $theme_id using its actual CS
|
||||
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)
|
||||
expect(wrapper.get('dialog.app-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')
|
||||
@@ -87,9 +87,24 @@ it.each(mockCommunityThemes)('previews uninstalled $theme_id using its actual CS
|
||||
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')
|
||||
await wrapper.get('dialog.app-dialog button').trigger('click')
|
||||
store.applyTheme('dark')
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
expect(wrapper.find('iframe').exists()).toBe(false)
|
||||
expect(store.currentThemeId).toBe('dark')
|
||||
})
|
||||
|
||||
it('offers and applies the paper theme update without discarding the active theme', async () => {
|
||||
const store = useThemeStore()
|
||||
const old = await inspectThemePackage(paperPackage.replace('version: 1.6.2', 'version: 1.6.1'))
|
||||
await store.installThemeFromInspection(old.manifest, old.css)
|
||||
store.applyTheme('paper-moments')
|
||||
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('Paper Moments'))!
|
||||
await card.findAll('button').find(button => button.text() === '更新')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(store.allThemes.find(theme => theme.theme_id === 'paper-moments')?.version).toBe('1.6.2')
|
||||
expect(document.getElementById('theme-style-paper-moments')!.textContent).toContain('.surface-nested')
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import AppDialog from '@/components/common/AppDialog.vue'
|
||||
import { computed, onMounted, onBeforeUnmount, ref } from 'vue'
|
||||
import MarkdownContent from '@/components/common/MarkdownContent.vue'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
@@ -228,7 +229,7 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showImportDialog" class="modal-backdrop" @click.self="closeImport">
|
||||
<AppDialog v-if="showImportDialog" label="导入主题包" :dismissible="!importing" @close="closeImport">
|
||||
<div class="modal import-modal">
|
||||
<span class="badge info">主题导入</span>
|
||||
<h2>导入主题包</h2>
|
||||
@@ -281,7 +282,7 @@ onMounted(() => {
|
||||
>安装主题</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppDialog>
|
||||
</section>
|
||||
<CommunityThemePreview v-if="previewImport && themeStore.pendingInspection?.compatible" :theme-id="themeStore.pendingInspection.manifest.theme_id" :name="themeStore.pendingInspection.manifest.name" :css="themeStore.pendingInspection.css" @close="previewImport = false" />
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<div class="theme-specimen">
|
||||
<div class="form-grid">
|
||||
<label class="field">单行输入<input class="input" placeholder="输入内容" /></label>
|
||||
<label class="field">下拉选择<select class="select"><option>选项一</option><option>选项二</option></select></label>
|
||||
<label class="field">禁用输入<input class="input" value="不可编辑" disabled /></label>
|
||||
<label class="field">多行文本<textarea class="textarea" rows="3">第一行
|
||||
第二行
|
||||
第三行</textarea></label>
|
||||
</div>
|
||||
<div class="inline-actions"><button class="button-primary">主要按钮</button><button class="button-secondary">次要按钮</button><button class="button-danger">危险操作</button><button class="button-primary" disabled>禁用按钮</button><span class="badge success">成功标签</span><span class="badge warning">提醒</span></div>
|
||||
<p class="error-banner" role="alert">错误状态示例:请检查输入内容。</p>
|
||||
<details class="ui-disclosure"><summary>折叠内容</summary><p>展开后检查箭头、边框与文字颜色。</p></details>
|
||||
<details open class="ui-disclosure"><summary>展开内容</summary><p>这段文字用于查看当前主题的展开样式。</p></details>
|
||||
<section class="surface-nested"><h3>嵌套卡片</h3><p>嵌套内容使用轻量边框,避免重复装饰。</p></section>
|
||||
<section class="specimen-markdown"><h2>Markdown 样例</h2><p>正文、<strong>粗体</strong>、<em>斜体</em>、<del>删除线</del>和 <code>inline code</code>。</p><blockquote>引用内容</blockquote><pre><code>const note = "主题预览";
|
||||
console.log(note);</code></pre><table><thead><tr><th>名称</th><th>状态</th></tr></thead><tbody><tr><td>长内容示例</td><td>已完成</td></tr></tbody></table></section>
|
||||
<figure class="specimen-chart"><figcaption>数据配色示例</figcaption><div role="img" aria-label="本地用量 30,提供商用量 70"><span style="height:30%;background:var(--color-accent-primary)"></span><span style="height:70%;background:var(--color-accent-secondary)"></span></div></figure>
|
||||
<p class="subtle specimen-long">超长模型标识:provider/model-with-a-very-long-identifier-for-layout-validation-012345678901234567890123456789</p>
|
||||
</div>
|
||||
Reference in New Issue
Block a user