fix(frontend): 统一原生表单控件样式

This commit is contained in:
2026-09-05 09:57:33 +08:00
parent 311f953855
commit d15ceafbe0
5 changed files with 150 additions and 17 deletions
@@ -0,0 +1,36 @@
// @vitest-environment happy-dom
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import FilePicker from './FilePicker.vue'
describe('FilePicker', () => {
it('keeps the native file input accessible and reports the selected file', async () => {
const wrapper = mount(FilePicker, {
props: { file: null, label: '选择文件', emptyLabel: '尚未选择文件', accept: '.json' },
})
const input = wrapper.get('input[type="file"]')
const file = new File(['{}'], 'rules.json', { type: 'application/json' })
Object.defineProperty(input.element, 'files', { value: [file], configurable: true })
await input.trigger('change')
expect(wrapper.emitted('select')).toEqual([[file]])
expect(wrapper.get('label').attributes('for')).toBe(input.attributes('id'))
expect(wrapper.text()).toContain('尚未选择文件')
await wrapper.setProps({ file })
expect(wrapper.text()).toContain('rules.json')
})
it('emits null when the native selection is cleared', async () => {
const wrapper = mount(FilePicker, {
props: { file: null, label: '选择文件', emptyLabel: '尚未选择文件' },
})
const input = wrapper.get('input[type="file"]')
Object.defineProperty(input.element, 'files', { value: [], configurable: true })
await input.trigger('change')
expect(wrapper.emitted('select')).toEqual([[null]])
})
})
@@ -0,0 +1,58 @@
<script setup lang="ts">
import { useId } from 'vue'
import { Upload } from '@element-plus/icons-vue'
defineProps<{
file: File | null
label: string
emptyLabel: string
accept?: string
disabled?: boolean
}>()
const emit = defineEmits<{ select: [file: File | null] }>()
const inputId = useId()
function selectFile(event: Event) {
emit('select', (event.target as HTMLInputElement).files?.[0] ?? null)
}
function allowReselect(event: MouseEvent) {
;(event.currentTarget as HTMLInputElement).value = ''
}
</script>
<template>
<div class="file-picker" :class="{ disabled }">
<input
:id="inputId"
class="file-picker-input"
type="file"
:accept="accept"
:disabled="disabled"
@click="allowReselect"
@change="selectFile"
/>
<label class="file-picker-trigger" :for="inputId">
<Upload aria-hidden="true" />
<span>{{ label }}</span>
</label>
<span class="file-picker-name" :class="{ empty: !file }" :title="file?.name || emptyLabel">
{{ file?.name || emptyLabel }}
</span>
</div>
</template>
<style scoped>
.file-picker { display: flex; min-width: 0; align-items: center; gap: var(--space-sm); }
.file-picker-input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
.file-picker-trigger { display: inline-flex; min-height: 36px; flex: 0 0 auto; align-items: center; gap: var(--space-sm); padding: 0 var(--space-md); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-surface-primary); font-weight: 600; cursor: pointer; transition: border-color var(--motion-fast), background-color var(--motion-fast), color var(--motion-fast), box-shadow var(--motion-fast), transform var(--motion-fast); }
.file-picker-trigger svg { width: 16px; height: 16px; }
.file-picker-trigger:hover { border-color: var(--color-accent-secondary); background: var(--color-background-hover); color: var(--color-accent-primary); transform: translateY(-1px); }
.file-picker-input:focus-visible + .file-picker-trigger { outline: 2px solid var(--color-border-focus); outline-offset: 2px; }
.file-picker-name { min-width: 0; overflow: hidden; color: var(--color-text-secondary); text-overflow: ellipsis; white-space: nowrap; user-select: text; }
.file-picker-name.empty { color: var(--color-text-tertiary); }
.disabled { opacity: .55; }
.disabled .file-picker-trigger { cursor: not-allowed; transform: none; }
@media (max-width: 560px) { .file-picker { align-items: stretch; flex-direction: column; } .file-picker-trigger { justify-content: center; } }
</style>
+11 -10
View File
@@ -3,6 +3,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { mediaService, createMediaSubmission, type MediaJob } from '@/services/mediaService'
import { localeTag, t } from '@/i18n'
import FilePicker from '@/components/common/FilePicker.vue'
const route = useRoute()
const submission = createMediaSubmission()
@@ -108,14 +109,14 @@ onUnmounted(() => { stopped = true; clearTimeout(timer) })
<header><h1>{{ t('音视频转写', 'Media Transcription') }}</h1><p class="subtle">{{ t('上传音频或视频音轨,转写、校对后保存到知识库。单个文件最多 25 MiB。', 'Upload audio or a video soundtrack, transcribe and correct it, then save it to the knowledge base. Maximum file size: 25 MiB.') }}</p></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">
<label>{{ t('选择附件', 'Choose attachment') }}<input type="file" accept=".wav,.mp3,.flac,.ogg,.m4a,.mp4,.webm,.txt,.md" @change="file = ($event.target as HTMLInputElement).files?.[0] || null" /></label>
<label><input v-model="localOnly" type="checkbox" />{{ t('仅本地处理', 'Process locally only') }}</label>
<label><input v-model="diarization" type="checkbox" />{{ t('识别不同说话人', 'Identify different speakers') }}</label>
<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><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>
<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>
<details><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>
<input type="file" accept=".wav,.mp3,.flac,.ogg,.m4a" :aria-label="t('声纹参考音频', 'Speaker reference audio')" @change="reference = ($event.target as HTMLInputElement).files?.[0] || null" />
<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>
<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" />
<button type="button" class="button-secondary" :disabled="busy || !file || !reference" @click="compareSpeaker">{{ t('比对声纹', 'Compare speakers') }}</button><p v-if="matchResult">{{ matchResult }}</p></details>
</form>
<div class="media-columns">
@@ -145,8 +146,8 @@ onUnmounted(() => { stopped = true; clearTimeout(timer) })
<textarea v-if="!selected.segments.length" v-model="selected.text" class="input" 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><summary>{{ t('原始识别文本', 'Original recognition text') }}</summary><pre>{{ selected.original_text }}</pre></details>
<details v-for="revision in history" :key="revision.revision"><summary>{{ t('修订', 'Revision') }} {{ revision.revision }}</summary><pre>{{ revision.text }}</pre></details>
<details class="ui-disclosure"><summary>{{ t('原始识别文本', 'Original recognition text') }}</summary><pre>{{ selected.original_text }}</pre></details>
<details v-for="revision in history" :key="revision.revision" class="ui-disclosure"><summary>{{ t('修订', 'Revision') }} {{ revision.revision }}</summary><pre>{{ revision.text }}</pre></details>
<div class="inline-actions"><label><input v-model="updateExisting" type="checkbox" />{{ t('更新上次导出的笔记(已手动修改则拒绝)', 'Update the previously exported note (refuse if manually edited)') }}</label><input v-model="title" class="input" :aria-label="t('笔记标题', 'Note title')" /><button class="button-primary" :disabled="busy || dirty || !title.trim()" @click="action(async () => { const note = await mediaService.note(selected!.job_id, title, updateExisting); notice = `${t('已保存笔记:', 'Saved note: ')}${note.title}` })">{{ t('保存为笔记', 'Save as note') }}</button></div>
</template>
</article>
@@ -156,5 +157,5 @@ onUnmounted(() => { stopped = true; clearTimeout(timer) })
</template>
<style scoped>
.media-page{padding:28px;overflow:auto;height:100%;display:flex;flex-direction:column;gap:20px}.upload{display:grid;gap:12px;padding:20px}.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%}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-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>
@@ -3,6 +3,7 @@ import { ref, watch } from 'vue'
import { apiClient } from '@/services/apiClient'
import type { RequestOverride } from '@/contracts'
import { t } from '@/i18n'
import FilePicker from '@/components/common/FilePicker.vue'
const props = defineProps<{modelValue: RequestOverride[]}>()
const emit = defineEmits<{ 'update:modelValue': [value:RequestOverride[]]; valid:[value:boolean] }>()
const transferError = ref('')
@@ -38,10 +39,7 @@ watch(() => props.modelValue, value => {
}
}, {deep: true})
function reset() { rules.value = []; transferError.value = ''; publish() }
async function importRules(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
async function importRules(file: File | null) {
if (!file) return
const current = ++generation
transferError.value = ''
@@ -68,7 +66,7 @@ async function exportRules() {
</script>
<template>
<details class="request-json"><summary>{{ t('高级:自定义请求 JSON', 'Advanced: Custom request JSON') }}</summary>
<details class="request-json ui-disclosure"><summary>{{ t('高级:自定义请求 JSON', 'Advanced: Custom request JSON') }}</summary>
<p class="subtle">{{ t('提供商通用规则先应用,再应用模型规则。对象递归合并,数组整体替换,null 作为实际值;删除键后恢复继承。密钥继续使用独立 API Key 配置。', 'Provider-wide rules are applied before model rules. Objects merge recursively, arrays replace whole values, and null is kept as a value. Delete a key to inherit it again. API keys remain in the separate credential setting.') }}</p>
<div v-for="(rule,index) in rules" :key="index" class="rule">
<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>
@@ -79,9 +77,9 @@ async function exportRules() {
<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>
<button type="button" class="button-secondary" @click="add">{{ t('添加请求规则', 'Add request rule') }}</button>
<div class="inline-actions"><button type="button" class="button-secondary" @click="reset">{{ t('恢复默认请求', 'Restore default request') }}</button><button type="button" class="button-secondary" @click="exportRules">{{ t('导出请求配置', 'Export request settings') }}</button><label>{{ t('导入请求配置', 'Import request settings') }}<input type="file" accept=".json" @change="importRules" /></label></div>
<div class="transfer-actions"><div class="inline-actions"><button type="button" class="button-secondary" @click="reset">{{ t('恢复默认请求', 'Restore default request') }}</button><button type="button" class="button-secondary" @click="exportRules">{{ t('导出请求配置', 'Export request settings') }}</button></div><FilePicker :file="null" :label="t('导入请求配置', 'Import request settings')" :empty-label="t('选择 JSON 文件', 'Choose a JSON file')" accept=".json,application/json" @select="importRules" /></div>
<p v-if="transferError" class="error-text" role="alert">{{ transferError }}</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>
</template>
<style scoped>.request-json{display:grid;gap:12px}.rule{padding:12px;border:1px solid var(--border-color);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%}</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:monospace;width:100%}.transfer-actions{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-sm);justify-content:space-between}</style>
+40
View File
@@ -85,6 +85,44 @@ button:disabled { cursor: not-allowed; opacity: .55; box-shadow: none; transform
.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); }
input[type='checkbox'], input[type='radio'] {
appearance: none;
display: inline-grid;
place-content: center;
width: 18px;
height: 18px;
flex: 0 0 18px;
border: 1px solid var(--color-border-default);
background: var(--color-background-primary);
transition: border-color var(--motion-fast), background-color var(--motion-fast), box-shadow var(--motion-fast);
}
input[type='checkbox'] { border-radius: 5px; }
input[type='radio'] { border-radius: 50%; }
input[type='checkbox']::before, input[type='radio']::before { content: ''; width: 10px; height: 10px; transform: scale(0); transition: transform var(--motion-fast); }
input[type='checkbox']::before { clip-path: polygon(14% 44%, 0 59%, 39% 96%, 100% 20%, 84% 7%, 37% 68%); background: var(--color-text-inverse); }
input[type='radio']::before { border-radius: 50%; background: var(--color-text-inverse); }
input[type='checkbox']:hover, input[type='radio']:hover { border-color: var(--color-accent-primary); }
input[type='checkbox']:checked, input[type='radio']:checked { border-color: var(--color-accent-primary); background: var(--color-accent-primary); }
input[type='checkbox']:checked::before, input[type='radio']:checked::before { transform: scale(1); }
input[type='checkbox']:disabled, input[type='radio']:disabled { cursor: not-allowed; opacity: .55; }
input[type='range'] { appearance: none; height: 20px; background: transparent; cursor: pointer; }
input[type='range']::-webkit-slider-runnable-track { height: 5px; border-radius: var(--radius-full); background: var(--color-background-tertiary); }
input[type='range']::-webkit-slider-thumb { appearance: none; width: 16px; height: 16px; margin-top: -5.5px; border: 2px solid var(--color-surface-primary); border-radius: 50%; background: var(--color-accent-primary); box-shadow: 0 1px 4px color-mix(in srgb, var(--color-text-primary) 24%, transparent); }
progress { appearance: none; width: 100%; height: 8px; overflow: hidden; border: 0; border-radius: var(--radius-full); background: var(--color-background-tertiary); }
progress::-webkit-progress-bar { border-radius: var(--radius-full); background: var(--color-background-tertiary); }
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 > 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); }
.ui-disclosure[open] > summary::after { transform: rotate(225deg); }
.ui-disclosure[open] > summary { margin-bottom: var(--space-md); color: var(--color-text-primary); }
.ui-disclosure > :not(summary) + :not(summary) { margin-top: var(--space-sm); }
.badge {
display: inline-flex;
align-items: center;
@@ -143,6 +181,8 @@ button:disabled { cursor: not-allowed; opacity: .55; box-shadow: none; transform
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes progress-pulse { from { background-position: 100% 0; } to { background-position: -100% 0; } }
@media (max-width: 900px) {
.feature-page { padding: var(--space-lg); }
.split-view { grid-template-columns: 1fr; }