fix(frontend): 统一原生表单控件样式
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user