fix(ui): unify dialogs and complete theme component coverage
This commit is contained in:
@@ -30,3 +30,17 @@ AI 对话 Enter 发送,Shift+Enter 换行,输入法确认和长按 Enter 不
|
||||
## 下拉与折叠控件补充
|
||||
|
||||
编辑器外观行统一为标签、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
|
||||
name: 纸间时光 · Paper Moments
|
||||
version: 1.6.1
|
||||
version: 1.6.2
|
||||
author: NotesAgent
|
||||
description: 奶油纸张、手帐虚线与粉蓝胶带,把每天的灵感好好收藏。
|
||||
min_app_version: 0.2.0
|
||||
@@ -318,3 +318,7 @@ license: MIT
|
||||
@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); }
|
||||
}
|
||||
|
||||
/* 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
@@ -79,8 +79,8 @@ button:disabled { cursor: not-allowed; opacity: .55; box-shadow: none; transform
|
||||
color: var(--color-text-primary);
|
||||
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); }
|
||||
.textarea { min-height: 100px; padding: var(--space-md); resize: vertical; }
|
||||
input.input, .select { height: 38px; padding: 0 var(--space-md); }
|
||||
.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: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); }
|
||||
@@ -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: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::-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); }
|
||||
@@ -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.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 h2 { margin-bottom: var(--space-lg); }
|
||||
.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::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: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; }
|
||||
|
||||
@@ -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/无脚本隔离。自动结构检查不代替浏览器截图、布局和对比度检查。
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user