fix(ui): unify dialogs and complete theme component coverage

This commit is contained in:
2026-09-05 23:56:02 +08:00
parent 6107b7ff1b
commit af2556d29e
22 changed files with 254 additions and 35 deletions
+3 -2
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import AppDialog from '@/components/common/AppDialog.vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAgentStore } from '@/stores/agent'
@@ -138,9 +139,9 @@ async function handleOpenCitation(data: Record<string, unknown>) {
/>
</div>
<div v-if="agentStore.permissionRequest" class="modal-backdrop">
<AppDialog v-if="agentStore.permissionRequest" :label="t('权限确认', 'Permission confirmation')" :dismissible="false">
<div class="modal"><span class="badge warning">{{ t('权限确认', 'Permission Confirmation') }}</span><h2>{{ toolLabel(agentStore.permissionRequest.tool_name) }}</h2><p>{{ agentStore.permissionRequest.impact }}</p><p class="subtle">{{ t('所需权限:', 'Required permission: ') }}{{ permissionLabel(agentStore.permissionRequest.permission) }} ({{ agentStore.permissionRequest.permission }})</p><pre>{{ JSON.stringify(localizeDetails(agentStore.permissionRequest.parameters), null, 2) }}</pre><div class="inline-actions permission-actions"><button class="button-primary" @click="agentStore.respondPermission('allow', 'once')">{{ t('仅本次允许', 'Allow once') }}</button><button class="button-secondary" @click="agentStore.respondPermission('allow', 'session')">{{ t('本次会话允许', 'Allow for session') }}</button><button class="button-danger" @click="agentStore.respondPermission('deny')">{{ t('拒绝', 'Deny') }}</button></div></div>
</div>
</AppDialog>
</section>
</template>
+2 -2
View File
@@ -10,7 +10,7 @@ const showOriginal = computed(() => props.description.length > 0)
</script>
<template>
<article class="tool-choice" :class="{ selected }">
<article class="tool-choice surface-nested" :class="{ selected }">
<label class="tool-selection">
<input type="checkbox" :checked="selected" @change="emit('toggle', name)" />
<span class="tool-copy">
@@ -19,7 +19,7 @@ const showOriginal = computed(() => props.description.length > 0)
<small class="tool-summary">{{ summary }}</small>
</span>
</label>
<details v-if="showOriginal" class="tool-original">
<details v-if="showOriginal" class="tool-original ui-disclosure">
<summary>{{ t('查看服务原文与参数', 'View original service description and parameters') }}</summary>
<p>{{ description }}</p>
</details>
@@ -241,7 +241,7 @@ const flatTrace = computed(() => flatNodes(filteredTree.value))
</div>
</div>
<div v-if="isDetailOpen(`event-${event.sequence}`) && showDetails" class="event-detail">
<details open>
<details open class="ui-disclosure">
<summary>完整数据</summary>
<pre>{{ prettyData(event.data) }}</pre>
</details>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { lockDialogScroll } from '@/components/common/dialogScroll'
import { onMounted, onBeforeUnmount, reactive, ref } from 'vue'
import { useChatPreferences, validAvatar } from '@/stores/chatPreferences'
import { t } from '@/i18n'
@@ -14,6 +15,7 @@ const saving = ref(false)
const loading = ref(0)
const dialog = ref<HTMLDialogElement>()
const previousFocus = document.activeElement as HTMLElement | null
let restoreScroll: (() => void) | undefined
let active = true
const generations = { aiAvatar: 0, userAvatar: 0 }
async function loadGlobal() {
@@ -21,8 +23,8 @@ async function loadGlobal() {
try { const result = await apiClient.get<GlobalPersona>('/api/settings/persona'); if (active) { Object.assign(remote,result); ready.value = true } }
catch { if (active) error.value = t('无法加载全局人设,请重试。', 'Could not load global persona. Retry.') }
}
onMounted(() => { dialog.value?.showModal(); void loadGlobal() })
onBeforeUnmount(() => { active = false; dialog.value?.close(); previousFocus?.focus() })
onMounted(() => { if (dialog.value) restoreScroll = lockDialogScroll(dialog.value); dialog.value?.showModal(); void loadGlobal() })
onBeforeUnmount(() => { active = false; dialog.value?.close(); restoreScroll?.(); previousFocus?.focus() })
async function chooseAvatar(event: Event, field: 'aiAvatar' | 'userAvatar') {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
+4 -3
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import AppDialog from '@/components/common/AppDialog.vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { Connection, Delete, EditPen, Plus, Refresh, VideoPlay } from '@element-plus/icons-vue'
import AppIcon from '@/components/common/AppIcon.vue'
@@ -242,7 +243,7 @@ onMounted(load)
</article>
</div>
<div v-if="dialogOpen" class="modal-backdrop" @click.self="!busy && closeEditor()">
<AppDialog v-if="dialogOpen" :label="dialogTitle" :dismissible="!busy" @close="closeEditor">
<form class="modal-card" @submit.prevent="save">
<fieldset :disabled="!!busy" class="editor-fields">
<header><h2><AppIcon :icon="Plus" /> {{ dialogTitle }}</h2><button type="button" class="close" @click="closeEditor">×</button></header>
@@ -261,7 +262,7 @@ onMounted(load)
<footer><button type="button" class="button-secondary" @click="closeEditor">{{ t('取消', 'Cancel') }}</button><button class="button-primary" :disabled="busy === 'save'">{{ t('保存', 'Save') }}</button></footer>
</fieldset>
</form>
</div>
</AppDialog>
</section>
</template>
@@ -280,7 +281,7 @@ onMounted(load)
.notice-banner,.error-banner { margin-bottom: var(--space-lg); }.server-list { display: grid; gap: var(--space-lg); }.server-card { display: grid; gap: var(--space-md); }
.server-main,.server-title,.metadata,.card-actions,.inline-actions,.template-row,.modal-card header,.modal-card footer { display: flex; align-items: center; gap: var(--space-sm); }.server-main { justify-content: space-between; }.server-title { align-items: flex-start; }.server-title h2 { margin-bottom: 4px; }.server-title code { color: var(--color-text-secondary); overflow-wrap: anywhere; }.metadata { flex-wrap: wrap; color: var(--color-text-tertiary); font-size: var(--font-size-sm); }.metadata span + span::before { content: '·'; margin-right: var(--space-sm); }.compact { margin: 0; }
.card-actions { flex-wrap: wrap; justify-content: flex-end; border-top: 1px solid var(--color-border-subtle); padding-top: var(--space-md); }.empty { text-align: center; place-items: center; display: grid; gap: var(--space-md); padding: 64px; }.secrets { border: 1px solid var(--color-border-subtle); border-radius: var(--radius-md); padding: var(--space-md); display: grid; gap: var(--space-sm); }.secrets label { display: grid; grid-template-columns: minmax(0,.7fr) minmax(0,1fr); align-items: center; gap: var(--space-md); }.secrets small,.modal-card small { color: var(--color-text-tertiary); }.secret-input { display: flex; gap: var(--space-sm); }.secret-input input { flex: 1; }
.modal-backdrop { position: fixed; inset: 0; z-index: 1000; background: rgb(0 0 0 / .48); display: grid; place-items: center; padding: var(--space-xl); }.modal-card { width: min(800px,100%); max-height: calc(100vh - 48px); overflow: auto; background: var(--color-background-primary); border: 1px solid var(--color-border-default); border-radius: var(--radius-xl); box-shadow: var(--shadow-xl); padding: var(--space-xl); display: grid; gap: var(--space-lg); animation: modal-in var(--motion-normal) ease-out; }.modal-card header,.modal-card footer { justify-content: space-between; }.modal-card footer { justify-content: flex-end; }.modal-card label { display: grid; gap: var(--space-xs); font-weight: 600; }.modal-card input,.modal-card textarea { width: 100%; border: 1px solid var(--color-border-default); border-radius: var(--radius-md); padding: 10px 12px; color: var(--color-text-primary); background: var(--color-background-secondary); font: inherit; }.modal-card textarea { resize: vertical; font-family: var(--font-ui-mono); font-size: var(--font-size-sm); }.json-editor { line-height: 1.55; }.close { border: 0; background: transparent; color: var(--color-text-secondary); font-size: 28px; cursor: pointer; }
.modal-card { width: min(800px,100%); max-height: calc(100vh - 48px); overflow: auto; background: var(--color-background-primary); border: 1px solid var(--color-border-default); border-radius: var(--radius-xl); box-shadow: var(--shadow-xl); padding: var(--space-xl); display: grid; gap: var(--space-lg); animation: modal-in var(--motion-normal) ease-out; }.modal-card header,.modal-card footer { justify-content: space-between; }.modal-card footer { justify-content: flex-end; }.modal-card label { display: grid; gap: var(--space-xs); font-weight: 600; }.modal-card input,.modal-card textarea { width: 100%; border: 1px solid var(--color-border-default); border-radius: var(--radius-md); padding: 10px 12px; color: var(--color-text-primary); background: var(--color-background-secondary); font: inherit; }.modal-card textarea { resize: vertical; font-family: var(--font-ui-mono); font-size: var(--font-size-sm); }.json-editor { line-height: 1.55; }.close { border: 0; background: transparent; color: var(--color-text-secondary); font-size: 28px; cursor: pointer; }
.template-row { flex-wrap: wrap; }.template-row > span { margin-right: auto; font-weight: 600; }.template,.mode-tabs button { border: 1px solid var(--color-border-default); background: var(--color-background-secondary); color: var(--color-text-secondary); padding: 7px 10px; border-radius: var(--radius-md); cursor: pointer; }.template.active,.mode-tabs button.active { color: var(--color-accent-primary); border-color: var(--color-accent-primary); background: var(--color-accent-soft); }.mode-tabs { display: inline-flex; justify-self: start; gap: 2px; padding: 3px; border-radius: var(--radius-md); background: var(--color-background-secondary); }.two-columns { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-md); }
@keyframes modal-in { from { opacity: 0; transform: translateY(8px) scale(.99); } } @media (max-width:720px) { .two-columns,.secrets label { grid-template-columns:1fr; }.card-actions { flex-wrap:wrap; } }
</style>
+5 -4
View File
@@ -110,14 +110,14 @@ onUnmounted(() => { stopped = true; clearTimeout(timer) })
<template>
<section class="media-page">
<header><h1>{{ t('音视频转写', 'Media Transcription') }}</h1><p class="subtle">{{ t('上传音频或视频音轨,转写、校对后保存到知识库。最多 128 MiB;超过 25 MiB 请启用仅本地处理。音轨最长 1 小时。', 'Upload audio or a video soundtrack, transcribe and correct it, then save it to the knowledge base. Up to 128 MiB; enable local-only processing above 25 MiB. Audio duration is limited to one hour.') }}</p></header>
<header class="feature-header"><div><h1>{{ t('音视频转写', 'Media Transcription') }}</h1><p class="subtle">{{ t('上传音频或视频音轨,转写、校对后保存到知识库。最多 128 MiB;超过 25 MiB 请启用仅本地处理。音轨最长 1 小时。', 'Upload audio or a video soundtrack, transcribe and correct it, then save it to the knowledge base. Up to 128 MiB; enable local-only processing above 25 MiB. Audio duration is limited to one hour.') }}</p></div></header>
<div v-if="error" class="error-banner" role="alert">{{ error }}</div><p v-if="notice" role="status">{{ notice }}</p>
<form class="panel upload" @submit.prevent="submit">
<FilePicker :file="file" :label="t('选择附件', 'Choose attachment')" :empty-label="t('尚未选择文件', 'No file selected')" accept=".wav,.mp3,.flac,.ogg,.m4a,.mp4,.webm,.txt,.md" @select="file = $event" />
<div class="upload-options"><label><input v-model="localOnly" type="checkbox" />{{ t('仅本地处理', 'Process locally only') }}</label>
<label><input v-model="diarization" type="checkbox" />{{ t('识别不同说话人', 'Identify different speakers') }}</label></div>
<p class="subtle">{{ localOnly ? t('本次任务不调用远程模型 API,模型需预先下载。', 'This job will not call a remote model API; models must already be downloaded.') : t('若配置了转写 API,将上传所选附件;API 失败后回退到本地模型。', 'When a transcription API is configured, the selected file is uploaded; failures fall back to the local model.') }}</p>
<details class="ui-disclosure"><summary>{{ t('术语校对', 'Terminology corrections') }}</summary><p class="subtle">{{ t('在识别完成后替换文本,原始识别结果会保留。', 'Replace text after recognition while retaining the original result.') }}</p><textarea v-model="terminology" class="input" rows="3" :placeholder="terminologyPlaceholder" /></details>
<details class="ui-disclosure"><summary>{{ t('术语校对', 'Terminology corrections') }}</summary><p class="subtle">{{ t('在识别完成后替换文本,原始识别结果会保留。', 'Replace text after recognition while retaining the original result.') }}</p><textarea v-model="terminology" class="textarea" rows="3" :placeholder="terminologyPlaceholder" /></details>
<div class="inline-actions upload-actions"><button type="button" class="button-secondary" :disabled="busy" @click="submission.reset(); notice = t('下一次提交将作为新任务处理', 'The next submission will be processed as a new job')">{{ t('重新处理为新任务', 'Process as new job') }}</button><button class="button-primary" :disabled="busy || !file">{{ busy ? t('处理中…', 'Processing…') : t('上传并转写', 'Upload and transcribe') }}</button></div>
<details class="ui-disclosure"><summary>{{ t('声纹参考比对', 'Speaker reference comparison') }}</summary><p class="subtle">{{ t('将所选附件与参考音频比对。至少各含 1 秒语音;分数是相似度,不是身份认证概率。临时参考文件在比对后清理。', 'Compare the selected file with reference audio. Each must contain at least one second of speech. The score is similarity, not an identity probability. Temporary files are removed afterward.') }}</p>
<FilePicker :file="reference" :label="t('选择参考音频', 'Choose reference audio')" :empty-label="t('尚未选择参考音频', 'No reference audio selected')" accept=".wav,.mp3,.flac,.ogg,.m4a" @select="reference = $event" />
@@ -145,9 +145,9 @@ onUnmounted(() => { stopped = true; clearTimeout(timer) })
<p v-if="selected.segments.length" class="subtle">{{ t('时间戳对应音频分段边界可点击定位播放', 'Timestamps mark segment boundaries; click one to seek playback.') }}</p>
<div v-for="segment in selected.segments" :key="segment.segment_id" class="segment" :class="{ current: position >= segment.start_time && position < segment.end_time }">
<button class="button-secondary" @click="seek(segment.start_time)">{{ stamp(segment.start_time) }}</button><small>{{ selected.speaker_names[segment.speaker || ''] || segment.speaker }}</small>
<textarea v-model="segment.text" class="input" rows="2" @input="dirty = true; selected.text = selected.segments.map(s => s.text).join('\n')" />
<textarea v-model="segment.text" class="textarea" rows="2" @input="dirty = true; selected.text = selected.segments.map(s => s.text).join('\n')" />
</div>
<textarea v-if="!selected.segments.length" v-model="selected.text" class="input" rows="12" @input="dirty = true" />
<textarea v-if="!selected.segments.length" v-model="selected.text" class="textarea" rows="12" @input="dirty = true" />
<div class="inline-actions"><button class="button-primary" :disabled="busy || !dirty" @click="action(async () => { selected = await mediaService.save(selected!); dirty = false; notice = t('校对已保存', 'Corrections saved') })">{{ t('保存校对', 'Save corrections') }}</button>
<button class="button-secondary" @click="action(async () => { history = (await mediaService.revisions(selected!.job_id)).items })">{{ t('修订历史', 'Revision history') }}</button></div>
<details class="ui-disclosure"><summary>{{ t('原始识别文本', 'Original recognition text') }}</summary><pre>{{ selected.original_text }}</pre></details>
@@ -161,5 +161,6 @@ onUnmounted(() => { stopped = true; clearTimeout(timer) })
</template>
<style scoped>
.media-page > :is(.feature-header, .panel, .media-columns, .error-banner) { width: 100%; max-width: 1180px; margin-inline: auto; }
.media-page{padding:28px;overflow:auto;height:100%;display:flex;flex-direction:column;gap:20px}.upload{display:grid;gap:12px;padding:20px}.upload-options{display:flex;flex-wrap:wrap;gap:16px}.upload-actions{justify-content:flex-end}.media-columns{display:grid;grid-template-columns:260px minmax(0,1fr);gap:20px}.panel{padding:20px}.job-row{display:flex;flex-direction:column;gap:6px;width:100%;text-align:left;padding:12px;background:transparent;border:1px solid var(--color-border-default);border-radius:10px;margin-bottom:8px;cursor:pointer;color:inherit}.job-row small{overflow:hidden;text-overflow:ellipsis;max-width:100%}.selected,.current{background:var(--color-background-hover);outline:1px solid var(--color-accent-primary)}.transcript{display:flex;flex-direction:column;gap:16px}.transcript header,.segment{display:flex;gap:12px;align-items:center}.transcript>.button-danger{align-self:flex-start}.transcript>label{white-space:nowrap}.transcript>label select{width:160px}.segment textarea{flex:1}.speaker-names{display:flex;flex-wrap:wrap;gap:10px}audio{width:100%;border-radius:var(--radius-md);accent-color:var(--color-accent-primary)}pre{white-space:pre-wrap;word-break:break-word}label{display:flex;gap:8px;align-items:center}@media(max-width:850px){.media-columns{grid-template-columns:1fr}.segment{flex-wrap:wrap}}@media(max-width:560px){.upload-actions>*{flex:1}.upload-options{flex-direction:column}}
</style>
@@ -32,6 +32,6 @@ const filtered = computed(() => {
.preset-grid { display: flex; flex-wrap: wrap; gap: 8px; max-height: 220px; overflow-y: auto; padding: 3px; }
.preset-chip { display: inline-flex; align-items: center; gap: 7px; padding: 6px 10px; border: 1px solid var(--color-border-default); border-radius: 11px; background: var(--color-surface-primary); color: var(--color-text-primary); cursor: pointer; font: inherit; font-size: 13px; }
.preset-chip:hover { background: var(--color-background-hover); }
.preset-chip.selected { border-color: #377cf6; background: color-mix(in srgb, #377cf6 12%, var(--color-surface-primary)); color: #377cf6; box-shadow: 0 0 0 1px #377cf6; }
.preset-chip.selected { border-color: var(--color-accent-primary); background: color-mix(in srgb, var(--color-accent-primary) 12%, var(--color-surface-primary)); color: var(--color-accent-primary); box-shadow: 0 0 0 1px var(--color-accent-primary); }
.preset-chip:focus-visible { outline: 2px solid var(--color-border-focus); outline-offset: 2px; }
</style>
@@ -72,7 +72,7 @@ async function exportRules() {
<div class="rule-selectors"><label>{{ t('能力', 'Capability') }}<select v-model="rule.capability" class="select" @change="publish"><option value="chat">{{ t('聊天', 'Chat') }}</option><option value="embedding">Embedding</option><option value="transcription">{{ t('音频转写', 'Transcription') }}</option><option value="speaker_matching">{{ t('声纹比对', 'Speaker matching') }}</option></select></label>
<label>{{ t('模型', 'Model') }}<input v-model="rule.model" class="input" :placeholder="t('留空:全部模型', 'Blank: all models')" @input="publish" /></label>
<label>{{ t('请求模式', 'Request mode') }}<select v-model="rule.stream" class="select" @change="publish"><option :value="null">{{ t('全部', 'All') }}</option><option :value="true">{{ t('仅流式', 'Streaming only') }}</option><option :value="false">{{ t('仅非流式', 'Non-streaming only') }}</option></select></label></div>
<textarea v-model="rule.draft" class="input json-body" rows="6" :aria-label="t('自定义请求 JSON', 'Custom request JSON')" spellcheck="false" placeholder='{"stream_options":{"include_usage":true}}' @input="publish" />
<textarea v-model="rule.draft" class="textarea json-body" rows="6" :aria-label="t('自定义请求 JSON', 'Custom request JSON')" spellcheck="false" placeholder='{"stream_options":{"include_usage":true}}' @input="publish" />
<p v-if="rule.error" class="error-text" role="alert">{{ rule.error }}</p>
<div class="inline-actions"><button type="button" class="button-secondary" @click="format(index)">{{ t('格式化', 'Format') }}</button><button type="button" class="button-danger" @click="rules.splice(index,1); publish()">{{ t('删除规则', 'Delete rule') }}</button></div>
</div>
@@ -82,4 +82,4 @@ async function exportRules() {
<p class="subtle">{{ t('导入替换当前请求规则,保存提供商后生效。导出仅包含请求规则,不包含凭据引用和 API Key。', 'Importing replaces the current request rules and takes effect after saving the provider. Exports contain rules only, without credential references or API keys.') }}</p>
</details>
</template>
<style scoped>.request-json{display:grid;gap:12px}.rule{padding:12px;border:1px solid var(--color-border-default);border-radius:8px;margin:12px 0}.rule-selectors{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px}.rule-selectors label{display:grid;gap:5px}.json-body{font-family:monospace;width:100%}.transfer-actions{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-sm);justify-content:space-between}</style>
<style scoped>.request-json{display:grid;gap:12px}.rule{padding:12px;border:1px solid var(--color-border-default);border-radius:8px;margin:12px 0}.rule-selectors{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px}.rule-selectors label{display:grid;gap:5px}.json-body{font-family:var(--font-ui-mono);width:100%}.transfer-actions{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-sm);justify-content:space-between}</style>
+2 -1
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import AppDialog from '@/components/common/AppDialog.vue'
import { onMounted, reactive, ref } from 'vue'
import type { TaskItem, TaskStatus } from '@/contracts'
import { useTaskStore } from '@/stores/task'
@@ -46,7 +47,7 @@ async function remove(task: TaskItem) {
</article>
</div>
<div v-else class="empty-state"><div><strong>{{ taskStore.isLoading ? t('正在加载任务…', 'Loading tasks…') : t('没有符合条件的任务', 'No matching tasks') }}</strong><p>{{ t('创建一项任务,或调整左侧筛选条件。', 'Create a task or adjust the filters.') }}</p></div></div>
<div v-if="showForm" class="modal-backdrop" @click.self="showForm = false"><div class="modal"><h2>{{ editingId ? t('编辑任务', 'Edit task') : t('新建任务', 'New task') }}</h2><form @submit.prevent="saveTask"><div class="field"><label>{{ t('标题', 'Title') }}</label><input v-model="form.title" class="input" required /></div><div class="field"><label>{{ t('描述', 'Description') }}</label><textarea v-model="form.description" class="textarea" /></div><div class="field"><label>{{ t('截止时间', 'Due date') }}</label><input v-model="form.due_date" class="input" type="datetime-local" /></div><div class="field"><label>{{ t('关联 Note ID', 'Linked Note ID') }}</label><input v-model="form.note_id" class="input" /></div><div class="inline-actions"><button class="button-primary">{{ t('保存', 'Save') }}</button><button type="button" class="button-secondary" @click="showForm = false">{{ t('取消', 'Cancel') }}</button></div></form></div></div>
<AppDialog v-if="showForm" :label="t('任务表单', 'Task form')" @close="showForm = false"><div class="modal"><h2>{{ editingId ? t('编辑任务', 'Edit task') : t('新建任务', 'New task') }}</h2><form @submit.prevent="saveTask"><div class="field"><label>{{ t('标题', 'Title') }}</label><input v-model="form.title" class="input" required /></div><div class="field"><label>{{ t('描述', 'Description') }}</label><textarea v-model="form.description" class="textarea" /></div><div class="field"><label>{{ t('截止时间', 'Due date') }}</label><input v-model="form.due_date" class="input" type="datetime-local" /></div><div class="field"><label>{{ t('关联 Note ID', 'Linked Note ID') }}</label><input v-model="form.note_id" class="input" /></div><div class="inline-actions"><button class="button-primary">{{ t('保存', 'Save') }}</button><button type="button" class="button-secondary" @click="showForm = false">{{ t('取消', 'Cancel') }}</button></div></form></div></AppDialog>
</section>
</template>
@@ -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')
})
+3 -2
View File
@@ -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>