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
@@ -30,3 +30,17 @@ AI 对话 Enter 发送,Shift+Enter 换行,输入法确认和长按 Enter 不
## 下拉与折叠控件补充 ## 下拉与折叠控件补充
编辑器外观行统一为标签、38px 控件、说明三层,避免只有代码主题字段带说明时将其他控件拉偏。补齐所有原生 details 的 `ui-disclosure` 样式,统一折叠箭头、展开背景和边框。下拉框增加共享箭头、选项配色、焦点及禁用态;支持 `appearance: base-select` 的浏览器使用可主题化选项面板,其他浏览器保留原生选择行为并应用可支持的颜色。原生系统弹出层的完整装饰不能仅靠 CSS 在所有浏览器中保证。 编辑器外观行统一为标签、38px 控件、说明三层,避免只有代码主题字段带说明时将其他控件拉偏。补齐所有原生 details 的 `ui-disclosure` 样式,统一折叠箭头、展开背景和边框。下拉框增加共享箭头、选项配色、焦点及禁用态;支持 `appearance: base-select` 的浏览器使用可主题化选项面板,其他浏览器保留原生选择行为并应用可支持的颜色。原生系统弹出层的完整装饰不能仅靠 CSS 在所有浏览器中保证。
## 6107b7f 后续遗漏修复
- 任务、MCP、主题导入、社区预览、Agent 权限确认接入 AppDialog:原生顶层遮罩、内部滚动、祖先滚动锁及焦点归还。滚动锁使用引用计数,嵌套弹窗关闭不会提前解锁背景。权限确认禁止 Escape/点击遮罩隐式关闭;MCP 忙碌时禁止隐式关闭。人设弹窗补用同一滚动锁。
- 修复音视频三处、请求 JSON 一处 textarea 的单行高度覆盖;使用 textarea 样式、最小高度和纵向拉伸。单行高度规则仅匹配 input.input 与 select。
- Agent 工具原文和 Trace 完整数据接入 ui-disclosure,并合并重复容器规则。Provider 预设选中态使用主题强调色。
- 音视频页面标题和内容宽度对齐共享布局。纸间时光补充工具选择卡片的轻量纸张边框,版本 1.6.2;旧版更新入口及应用后 CSS 刷新已有自动测试。其余社区主题复用公共控件修复,未无意义提升包版本。
- 安装前预览加载真实 tokens.css 与 features.css,并增加多行/单行/下拉/禁用/展开折叠/错误标签/Markdown/图表配色及长标识样例;保留无脚本 sandbox 与 CSP,不改宿主主题。
验证:六主题在 1280、600、360 像素浏览器窗口下检查;样例 iframe 内容宽度分别为 660、508、268px,均无水平溢出,多行框均为 100px。实际任务页弹窗矩形覆盖 1280×720 全视口,焦点进入表单,Escape 关闭后回到新建按钮;实际术语校对框为 100px 并支持纵向拉伸。
可重复的隔离浏览器入口与操作说明位于 `frontend/tests/visual/README.md`,支持六主题、长内容弹窗和真实编辑器输入。测试不访问用户笔记或调用模型。组件样例矩阵并不等同于所有业务数据、浏览器和完整色彩对比度验收;不声称完成未执行的全页面截图或性能优化。
最终验证:56 个测试文件、323 项前端测试通过;生产构建通过。复扫结果:未接入共享样式的 details 为 0,误用 input 类的 textarea 为 0。
@@ -1,6 +1,6 @@
theme_id: paper-moments theme_id: paper-moments
name: 纸间时光 · Paper Moments name: 纸间时光 · Paper Moments
version: 1.6.1 version: 1.6.2
author: NotesAgent author: NotesAgent
description: 奶油纸张、手帐虚线与粉蓝胶带,把每天的灵感好好收藏。 description: 奶油纸张、手帐虚线与粉蓝胶带,把每天的灵感好好收藏。
min_app_version: 0.2.0 min_app_version: 0.2.0
@@ -318,3 +318,7 @@ license: MIT
@supports (appearance: base-select) { @supports (appearance: base-select) {
[data-theme="paper-moments"] ::picker(select) { border: 1px solid #b5a693; outline: 1px dashed #d5c8b5; outline-offset: -4px; background: #fffdf5; box-shadow: var(--shadow-md); } [data-theme="paper-moments"] ::picker(select) { border: 1px solid #b5a693; outline: 1px dashed #d5c8b5; outline-offset: -4px; background: #fffdf5; box-shadow: var(--shadow-md); }
} }
/* Nested choices retain a quiet paper border without repeating tape/shadows. */
[data-theme="paper-moments"] .surface-nested { border: 1px dashed #c5b9a7; background: #fffdf5; border-radius: 6px; }
[data-theme="paper-moments"] .surface-nested.selected { border-color: var(--color-accent-primary); background: var(--color-accent-soft); }
@@ -0,0 +1,36 @@
// @vitest-environment happy-dom
import { afterEach, expect, it } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import AppDialog from './AppDialog.vue'
const mounted: VueWrapper[] = []
afterEach(() => { mounted.splice(0).reverse().forEach(w => w.unmount()); document.body.innerHTML = ''; document.body.style.cssText = ''; document.documentElement.style.cssText = '' })
it('locks all scroll ancestors and restores focus and inline styles', async () => {
const opener = document.createElement('button'); document.body.append(opener); opener.focus()
const host = document.createElement('div'); host.style.setProperty('overflow', 'auto', 'important'); document.body.append(host)
const w = mount(AppDialog, { props:{label:'测试'}, slots:{default:'<section class="modal"><input autofocus /></section>'}, attachTo:host }); mounted.push(w)
expect(w.get('dialog').element.open).toBe(true)
expect(host.style.overflow).toBe('hidden')
expect(document.body.style.overflow).toBe('hidden')
await w.get('dialog').trigger('keydown', {key:'Escape'})
expect(w.emitted('close')).toHaveLength(1)
w.unmount(); mounted.pop()
expect(host.style.overflow).toBe('auto')
expect(host.style.getPropertyPriority('overflow')).toBe('important')
expect(document.body.style.overflow).toBe('')
expect(document.activeElement).toBe(opener)
})
it('retains scroll locks until the last nested dialog closes', () => {
const first = mount(AppDialog, {props:{label:'父弹窗'}, attachTo:document.body}); mounted.push(first)
const second = mount(AppDialog, {props:{label:'子弹窗'}, attachTo:document.body}); mounted.push(second)
first.unmount(); mounted.splice(0,1)
expect(document.body.style.overflow).toBe('hidden')
second.unmount(); mounted.pop()
expect(document.body.style.overflow).toBe('')
})
it('does not dismiss permission or busy dialogs through Escape or backdrop', async () => {
const w = mount(AppDialog, {props:{label:'权限确认',dismissible:false},attachTo:document.body}); mounted.push(w)
await w.get('dialog').trigger('keydown',{key:'Escape'})
await w.get('dialog').trigger('cancel')
await w.get('dialog').trigger('click')
expect(w.emitted('close')).toBeUndefined()
})
@@ -0,0 +1,39 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { lockDialogScroll } from './dialogScroll'
const props = withDefaults(defineProps<{ label: string; dismissible?: boolean }>(), { dismissible: true })
const emit = defineEmits<{ close: [] }>()
const dialog = ref<HTMLDialogElement>()
let restoreScroll: (() => void) | undefined
let previousFocus: HTMLElement | null = null
function dismiss() { if (props.dismissible) emit('close') }
function keydown(event: KeyboardEvent) {
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); dismiss() }
}
onMounted(() => {
previousFocus = document.activeElement as HTMLElement | null
if (!dialog.value) return
restoreScroll = lockDialogScroll(dialog.value)
dialog.value.showModal()
const first = dialog.value.querySelector<HTMLElement>('[autofocus], input:not(:disabled):not([type="hidden"]), textarea:not(:disabled), select:not(:disabled), button:not(:disabled)')
;(first ?? dialog.value).focus()
})
onBeforeUnmount(() => {
dialog.value?.close()
restoreScroll?.()
if (previousFocus?.isConnected) previousFocus.focus()
})
</script>
<template>
<dialog ref="dialog" class="app-dialog" :aria-label="label" tabindex="-1" @cancel.prevent="dismiss" @keydown="keydown" @click.self="dismiss">
<slot />
</dialog>
</template>
<style scoped>
.app-dialog { position: fixed; inset: 0; width: 100%; height: 100%; max-width: none; max-height: none; margin: 0; border: 0; padding: clamp(12px, 3vw, 24px); background: transparent; color: var(--color-text-primary); overflow: hidden; overscroll-behavior: contain; }
.app-dialog[open] { display: grid; place-items: center; }
.app-dialog::backdrop { background: var(--color-background-overlay); }
.app-dialog :deep(> .modal), .app-dialog :deep(> .modal-card) { min-width: 0; max-width: 100%; max-height: 100%; overflow: auto; overscroll-behavior: contain; }
</style>
@@ -0,0 +1,26 @@
// Reference counts keep the underlying page locked when dialogs are nested.
const locks = new WeakMap<HTMLElement, { count: number; value: string; priority: string }>()
export function lockDialogScroll(dialog: HTMLElement): () => void {
const elements: HTMLElement[] = []
for (let element = dialog.parentElement; element; element = element.parentElement) {
const lock = locks.get(element)
if (lock) lock.count++
else {
locks.set(element, { count: 1, value: element.style.getPropertyValue('overflow'), priority: element.style.getPropertyPriority('overflow') })
element.style.setProperty('overflow', 'hidden', 'important')
}
elements.push(element)
}
let released = false
return () => {
if (released) return
released = true
for (const element of elements) {
const lock = locks.get(element)!
if (--lock.count) continue
if (lock.value) element.style.setProperty('overflow', lock.value, lock.priority)
else element.style.removeProperty('overflow')
locks.delete(element)
}
}
}
+3 -2
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import AppDialog from '@/components/common/AppDialog.vue'
import { computed, onMounted, reactive, ref, watch } from 'vue' import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useAgentStore } from '@/stores/agent' import { useAgentStore } from '@/stores/agent'
@@ -138,9 +139,9 @@ async function handleOpenCitation(data: Record<string, unknown>) {
/> />
</div> </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 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> </section>
</template> </template>
+2 -2
View File
@@ -10,7 +10,7 @@ const showOriginal = computed(() => props.description.length > 0)
</script> </script>
<template> <template>
<article class="tool-choice" :class="{ selected }"> <article class="tool-choice surface-nested" :class="{ selected }">
<label class="tool-selection"> <label class="tool-selection">
<input type="checkbox" :checked="selected" @change="emit('toggle', name)" /> <input type="checkbox" :checked="selected" @change="emit('toggle', name)" />
<span class="tool-copy"> <span class="tool-copy">
@@ -19,7 +19,7 @@ const showOriginal = computed(() => props.description.length > 0)
<small class="tool-summary">{{ summary }}</small> <small class="tool-summary">{{ summary }}</small>
</span> </span>
</label> </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> <summary>{{ t('查看服务原文与参数', 'View original service description and parameters') }}</summary>
<p>{{ description }}</p> <p>{{ description }}</p>
</details> </details>
@@ -241,7 +241,7 @@ const flatTrace = computed(() => flatNodes(filteredTree.value))
</div> </div>
</div> </div>
<div v-if="isDetailOpen(`event-${event.sequence}`) && showDetails" class="event-detail"> <div v-if="isDetailOpen(`event-${event.sequence}`) && showDetails" class="event-detail">
<details open> <details open class="ui-disclosure">
<summary>完整数据</summary> <summary>完整数据</summary>
<pre>{{ prettyData(event.data) }}</pre> <pre>{{ prettyData(event.data) }}</pre>
</details> </details>
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { lockDialogScroll } from '@/components/common/dialogScroll'
import { onMounted, onBeforeUnmount, reactive, ref } from 'vue' import { onMounted, onBeforeUnmount, reactive, ref } from 'vue'
import { useChatPreferences, validAvatar } from '@/stores/chatPreferences' import { useChatPreferences, validAvatar } from '@/stores/chatPreferences'
import { t } from '@/i18n' import { t } from '@/i18n'
@@ -14,6 +15,7 @@ const saving = ref(false)
const loading = ref(0) const loading = ref(0)
const dialog = ref<HTMLDialogElement>() const dialog = ref<HTMLDialogElement>()
const previousFocus = document.activeElement as HTMLElement | null const previousFocus = document.activeElement as HTMLElement | null
let restoreScroll: (() => void) | undefined
let active = true let active = true
const generations = { aiAvatar: 0, userAvatar: 0 } const generations = { aiAvatar: 0, userAvatar: 0 }
async function loadGlobal() { 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 } } 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.') } catch { if (active) error.value = t('无法加载全局人设,请重试。', 'Could not load global persona. Retry.') }
} }
onMounted(() => { dialog.value?.showModal(); void loadGlobal() }) onMounted(() => { if (dialog.value) restoreScroll = lockDialogScroll(dialog.value); dialog.value?.showModal(); void loadGlobal() })
onBeforeUnmount(() => { active = false; dialog.value?.close(); previousFocus?.focus() }) onBeforeUnmount(() => { active = false; dialog.value?.close(); restoreScroll?.(); previousFocus?.focus() })
async function chooseAvatar(event: Event, field: 'aiAvatar' | 'userAvatar') { async function chooseAvatar(event: Event, field: 'aiAvatar' | 'userAvatar') {
const input = event.target as HTMLInputElement const input = event.target as HTMLInputElement
const file = input.files?.[0] const file = input.files?.[0]
+4 -3
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import AppDialog from '@/components/common/AppDialog.vue'
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { Connection, Delete, EditPen, Plus, Refresh, VideoPlay } from '@element-plus/icons-vue' import { Connection, Delete, EditPen, Plus, Refresh, VideoPlay } from '@element-plus/icons-vue'
import AppIcon from '@/components/common/AppIcon.vue' import AppIcon from '@/components/common/AppIcon.vue'
@@ -242,7 +243,7 @@ onMounted(load)
</article> </article>
</div> </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"> <form class="modal-card" @submit.prevent="save">
<fieldset :disabled="!!busy" class="editor-fields"> <fieldset :disabled="!!busy" class="editor-fields">
<header><h2><AppIcon :icon="Plus" /> {{ dialogTitle }}</h2><button type="button" class="close" @click="closeEditor">×</button></header> <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> <footer><button type="button" class="button-secondary" @click="closeEditor">{{ t('取消', 'Cancel') }}</button><button class="button-primary" :disabled="busy === 'save'">{{ t('保存', 'Save') }}</button></footer>
</fieldset> </fieldset>
</form> </form>
</div> </AppDialog>
</section> </section>
</template> </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); } .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; } .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; } .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); } .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; } } @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> </style>
+5 -4
View File
@@ -110,14 +110,14 @@ onUnmounted(() => { stopped = true; clearTimeout(timer) })
<template> <template>
<section class="media-page"> <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> <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"> <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" /> <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> <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> <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> <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> <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> <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" /> <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> <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 }"> <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> <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> </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> <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> <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> <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> </template>
<style scoped> <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}} .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> </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-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 { 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: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; } .preset-chip:focus-visible { outline: 2px solid var(--color-border-focus); outline-offset: 2px; }
</style> </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> <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('模型', '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> <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> <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 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> </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> <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> </details>
</template> </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"> <script setup lang="ts">
import AppDialog from '@/components/common/AppDialog.vue'
import { onMounted, reactive, ref } from 'vue' import { onMounted, reactive, ref } from 'vue'
import type { TaskItem, TaskStatus } from '@/contracts' import type { TaskItem, TaskStatus } from '@/contracts'
import { useTaskStore } from '@/stores/task' import { useTaskStore } from '@/stores/task'
@@ -46,7 +47,7 @@ async function remove(task: TaskItem) {
</article> </article>
</div> </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-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> </section>
</template> </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"> <script setup lang="ts">
import AppDialog from '@/components/common/AppDialog.vue'
import { computed } from 'vue' import { computed } from 'vue'
import { t } from '@/i18n' import { t } from '@/i18n'
import { getCommunityThemePreviewCss, mockCommunityThemes } from '@/services/themePackageService' import { getCommunityThemePreviewCss, mockCommunityThemes } from '@/services/themePackageService'
import tokensCss from '@/styles/tokens.css?raw' 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 props = defineProps<{ themeId: string; name?: string; css?: string }>()
const emit = defineEmits<{ (event: 'close'): void }>() 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'" 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) doc.head.append(policy)
const style = doc.createElement('style') 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) doc.head.append(style)
const article = doc.createElement('article') const article = doc.createElement('article')
article.className = 'panel' article.className = 'panel'
@@ -26,21 +30,22 @@ const previewDocument = computed(() => {
header.append(heading) header.append(heading)
const journal = doc.createElement('section'); journal.className = 'editor-preview'; journal.style.cssText = 'padding:24px;margin:28px 0;' 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 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) 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 return '<!doctype html>' + doc.documentElement.outerHTML
}) })
</script> </script>
<template> <template>
<div class="modal-backdrop" @click.self="emit('close')" @keydown.esc="emit('close')"> <AppDialog :label="t('社区主题预览', 'Community theme preview')" @close="emit('close')">
<section class="modal theme-preview-dialog" role="dialog" aria-modal="true" :aria-label="t('社区主题预览', 'Community theme preview')"> <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> <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" /> <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> <p class="subtle">{{ t('仅预览,不会安装或更改当前主题。', 'Preview only. Your installed themes and current appearance remain unchanged.') }}</p>
</section> </section>
</div> </AppDialog>
</template> </template>
<style scoped> <style scoped>
@@ -4,7 +4,7 @@ import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia' import { createPinia, setActivePinia } from 'pinia'
import ThemesView from './ThemesView.vue' import ThemesView from './ThemesView.vue'
import { useThemeStore } from '@/stores/theme' 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' import paperPackage from '@/assets/themes/paper-moments.theme?raw'
let wrapper: VueWrapper 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') await wrapper.findAll('.tab-btn')[1]!.trigger('click')
const card = wrapper.findAll('article.theme-card').find(item => item.text().includes(theme.name))! const card = wrapper.findAll('article.theme-card').find(item => item.text().includes(theme.name))!
await card.findAll('button').find(button => button.text() === '预览')!.trigger('click') 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') const frame = wrapper.get('iframe')
expect(frame.attributes('sandbox')).toBe('') expect(frame.attributes('sandbox')).toBe('')
const preview = new DOMParser().parseFromString(frame.attributes('srcdoc')!, 'text/html') 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(store.isThemeInstalled(theme.theme_id)).toBe(false)
expect(document.head.querySelectorAll('style[id^="theme-style-"]')).toHaveLength(0) expect(document.head.querySelectorAll('style[id^="theme-style-"]')).toHaveLength(0)
vi.useFakeTimers() vi.useFakeTimers()
await wrapper.get('[role="dialog"] button').trigger('click') await wrapper.get('dialog.app-dialog button').trigger('click')
store.applyTheme('dark') store.applyTheme('dark')
await vi.advanceTimersByTimeAsync(2000) await vi.advanceTimersByTimeAsync(2000)
expect(wrapper.find('iframe').exists()).toBe(false) expect(wrapper.find('iframe').exists()).toBe(false)
expect(store.currentThemeId).toBe('dark') 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"> <script setup lang="ts">
import AppDialog from '@/components/common/AppDialog.vue'
import { computed, onMounted, onBeforeUnmount, ref } from 'vue' import { computed, onMounted, onBeforeUnmount, ref } from 'vue'
import MarkdownContent from '@/components/common/MarkdownContent.vue' import MarkdownContent from '@/components/common/MarkdownContent.vue'
import { useThemeStore } from '@/stores/theme' import { useThemeStore } from '@/stores/theme'
@@ -228,7 +229,7 @@ onMounted(() => {
</div> </div>
</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"> <div class="modal import-modal">
<span class="badge info">主题导入</span> <span class="badge info">主题导入</span>
<h2>导入主题包</h2> <h2>导入主题包</h2>
@@ -281,7 +282,7 @@ onMounted(() => {
>安装主题</button> >安装主题</button>
</div> </div>
</div> </div>
</div> </AppDialog>
</section> </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" /> <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> </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>
+4 -5
View File
@@ -79,8 +79,8 @@ button:disabled { cursor: not-allowed; opacity: .55; box-shadow: none; transform
color: var(--color-text-primary); color: var(--color-text-primary);
transition: border-color var(--motion-fast), box-shadow var(--motion-fast), background-color var(--motion-fast); transition: border-color var(--motion-fast), box-shadow var(--motion-fast), background-color var(--motion-fast);
} }
.input, .select { height: 38px; padding: 0 var(--space-md); } input.input, .select { height: 38px; padding: 0 var(--space-md); }
.textarea { min-height: 100px; padding: var(--space-md); resize: vertical; } .textarea { min-height: 100px; height: auto; padding: var(--space-md); resize: vertical; line-height: var(--line-height-normal, 1.5); }
.input:hover, .select:hover, .textarea:hover { border-color: var(--color-text-tertiary); } .input:hover, .select:hover, .textarea:hover { border-color: var(--color-text-tertiary); }
.input:focus, .select:focus, .textarea:focus { border-color: var(--color-border-focus); box-shadow: 0 0 0 3px var(--color-accent-soft); background: var(--color-surface-primary); } .input:focus, .select:focus, .textarea:focus { border-color: var(--color-border-focus); box-shadow: 0 0 0 3px var(--color-accent-soft); background: var(--color-surface-primary); }
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: var(--space-md); } .form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: var(--space-md); }
@@ -115,7 +115,7 @@ progress::-webkit-progress-bar { border-radius: var(--radius-full); background:
progress::-webkit-progress-value { border-radius: var(--radius-full); background: linear-gradient(90deg, var(--color-accent-primary), var(--color-accent-secondary)); } progress::-webkit-progress-value { border-radius: var(--radius-full); background: linear-gradient(90deg, var(--color-accent-primary), var(--color-accent-secondary)); }
progress:not([value]) { background: linear-gradient(90deg, var(--color-background-tertiary) 25%, var(--color-accent-secondary) 50%, var(--color-background-tertiary) 75%); background-size: 200% 100%; animation: progress-pulse 1.2s linear infinite; } progress:not([value]) { background: linear-gradient(90deg, var(--color-background-tertiary) 25%, var(--color-accent-secondary) 50%, var(--color-background-tertiary) 75%); background-size: 200% 100%; animation: progress-pulse 1.2s linear infinite; }
.ui-disclosure { padding: var(--space-md); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-background-secondary); } .ui-disclosure { padding: var(--space-md); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-surface-primary); overflow-wrap: anywhere; }
.ui-disclosure > summary { display: flex; align-items: center; justify-content: space-between; gap: var(--space-sm); list-style: none; color: var(--color-text-secondary); font-weight: 600; cursor: pointer; } .ui-disclosure > summary { display: flex; align-items: center; justify-content: space-between; gap: var(--space-sm); list-style: none; color: var(--color-text-secondary); font-weight: 600; cursor: pointer; }
.ui-disclosure > summary::-webkit-details-marker { display: none; } .ui-disclosure > summary::-webkit-details-marker { display: none; }
.ui-disclosure > summary::after { content: ''; width: 8px; height: 8px; flex: 0 0 auto; border-right: 2px solid currentColor; border-bottom: 2px solid currentColor; transform: rotate(45deg); transition: transform var(--motion-fast); } .ui-disclosure > summary::after { content: ''; width: 8px; height: 8px; flex: 0 0 auto; border-right: 2px solid currentColor; border-bottom: 2px solid currentColor; transform: rotate(45deg); transition: transform var(--motion-fast); }
@@ -154,7 +154,7 @@ progress:not([value]) { background: linear-gradient(90deg, var(--color-backgroun
.sidebar-list-item:hover { background: var(--color-background-hover); transform: translateX(2px); } .sidebar-list-item:hover { background: var(--color-background-hover); transform: translateX(2px); }
.sidebar-list-item.active { border-color: color-mix(in srgb, var(--color-accent-primary) 18%, transparent); background: var(--color-accent-soft); color: var(--color-accent-primary); } .sidebar-list-item.active { border-color: color-mix(in srgb, var(--color-accent-primary) 18%, transparent); background: var(--color-accent-soft); color: var(--color-accent-primary); }
.modal-backdrop { position: fixed; inset: 0; z-index: var(--z-modal); display: grid; place-items: center; padding: var(--space-xl); background: var(--color-background-overlay); animation: backdrop-in var(--motion-fast) both; }
.modal { width: min(560px, 100%); max-height: 85vh; overflow: auto; padding: var(--space-2xl); border: 1px solid var(--color-border-default); border-radius: var(--radius-xl); background: var(--color-surface-elevated); box-shadow: var(--shadow-xl); animation: modal-in var(--motion-normal) both; } .modal { width: min(560px, 100%); max-height: 85vh; overflow: auto; padding: var(--space-2xl); border: 1px solid var(--color-border-default); border-radius: var(--radius-xl); background: var(--color-surface-elevated); box-shadow: var(--shadow-xl); animation: modal-in var(--motion-normal) both; }
.modal h2 { margin-bottom: var(--space-lg); } .modal h2 { margin-bottom: var(--space-lg); }
.modal form { display: grid; gap: var(--space-md); } .modal form { display: grid; gap: var(--space-md); }
@@ -219,7 +219,6 @@ progress:not([value]) { background: linear-gradient(90deg, var(--color-backgroun
select option:checked { color: var(--color-accent-primary); background: var(--color-accent-soft); } select option:checked { color: var(--color-accent-primary); background: var(--color-accent-soft); }
select option::checkmark { color: var(--color-accent-primary); } select option::checkmark { color: var(--color-accent-primary); }
} }
.ui-disclosure { border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-surface-primary); overflow-wrap: anywhere; }
.ui-disclosure > summary { min-height: 38px; box-sizing: border-box; line-height: 1.5; border-radius: var(--radius-sm); padding: 6px 8px; } .ui-disclosure > summary { min-height: 38px; box-sizing: border-box; line-height: 1.5; border-radius: var(--radius-sm); padding: 6px 8px; }
.ui-disclosure > summary:hover { background: var(--color-background-hover); color: var(--color-accent-primary); } .ui-disclosure > summary:hover { background: var(--color-background-hover); color: var(--color-accent-primary); }
.ui-disclosure[open] > summary { border-bottom: 1px solid var(--color-border-subtle); border-radius: var(--radius-sm) var(--radius-sm) 0 0; } .ui-disclosure[open] > summary { border-bottom: 1px solid var(--color-border-subtle); border-radius: var(--radius-sm) var(--radius-sm) 0 0; }
+9
View File
@@ -0,0 +1,9 @@
# 浏览器回归入口
先在 frontend 运行 `pnpm dev`。这些页面直接挂载真实组件,使用空的编辑器状态,不加载或保存用户笔记,也不调用模型 API;不在生产构建的入口中。
- `/tests/visual/index.html?theme=light`:将 theme 依次替换为 dark、sepia、paper-moments、ocean-blue、midnight-purple。检查输入、禁用、焦点、悬停、展开/折叠、Markdown、图表及长标识;在宽屏和窄窗口重复。
- `/tests/visual/index.html?case=dialog`:检查满视口遮罩、背景无法滚动、内部长内容可滚动、Tab 焦点限定、Escape 关闭与再次打开。
- `/tests/visual/index.html?case=editor`:逐字输入行内代码;先输入两个反引号、向左移再填字;连续普通/软换行;输入法提交;选区替换;撤销/重做。编辑器单元测试另覆盖 Markdown 序列化往返。
运行自动回归:`pnpm test`。AppDialog 测试覆盖滚动锁引用计数、恢复焦点、禁止隐式关闭;主题预览矩阵覆盖六主题的实际共享 CSS、控件状态和 CSP/无脚本隔离。自动结构检查不代替浏览器截图、布局和对比度检查。
+23
View File
@@ -0,0 +1,23 @@
<!doctype html>
<html><head><meta charset="utf-8"><title>隔离视觉回归</title></head><body><div id="app"></div>
<script type="module">
import { createApp, h, ref } from 'vue';
import { createPinia } from 'pinia';
import Preview from '/src/features/themes/CommunityThemePreview.vue';
import Editor from '/src/features/editor/VisualMarkdownEditor.vue';
import AppDialog from '/src/components/common/AppDialog.vue';
import '/src/styles/tokens.css';
import '/src/styles/features.css';
const params = new URLSearchParams(location.search);
const theme = params.get('theme') || 'light';
const builtin = ['light','dark','sepia'].includes(theme);
const mode = params.get('case') || 'theme';
createApp({setup(){
const open = ref(true);
return () => mode === 'editor' ? h(Editor,{initialContent:''}) : mode === 'dialog' ? h('main', {class:'feature-page',style:'height:100vh;'},[
h('div',{style:'height:120vh;'},'背景长页面'),
h('button',{class:'button-primary',onClick:()=>open.value=true},'打开测试弹窗'),
open.value && h(AppDialog,{label:'隔离弹窗',onClose:()=>open.value=false},{default:()=>h('section',{class:'modal'},[h('h2','弹窗滚动与焦点'),h('input',{class:'input',autofocus:true}),h('div',{style:'height:110vh;'},'内部长内容'),h('button',{class:'button-secondary',onClick:()=>open.value=false},'关闭')])})
]) : h('main',{},[h('button',{onClick:()=>open.value=true},'打开主题样例'),open.value&&h(Preview,{themeId:theme,name:theme,...(builtin?{css:''}:{}),onClose:()=>open.value=false})]);
}}).use(createPinia()).mount('#app');
</script></body></html>