fix(admin): i18n account-test media uploads and shared file pickers

Replace native file-input labels with translated choose-file buttons, localize
upload errors and previews, and fix ImageUpload defaults for zh/en.
This commit is contained in:
IanShaw027
2026-08-08 09:45:12 +08:00
parent e01ce90d47
commit bdeb13021a
7 changed files with 144 additions and 68 deletions
@@ -103,16 +103,38 @@
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ imageUploadLabel }}
</label>
<input
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
class="block w-full text-sm text-gray-600 file:mr-3 file:rounded-lg file:border-0 file:bg-primary-50 file:px-3 file:py-1.5 file:text-sm file:font-medium file:text-primary-700 hover:file:bg-primary-100 dark:text-gray-300 dark:file:bg-primary-500/20 dark:file:text-primary-300"
:disabled="status === 'connecting'"
@change="onImageFileChange"
/>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.grok.imageUploadHint') }}</p>
<div class="flex items-center gap-3">
<button
type="button"
class="btn btn-secondary btn-sm shrink-0"
:disabled="status === 'connecting'"
@click="imageFileInput?.click()"
>
{{ t('admin.accounts.grok.chooseImageFile') }}
</button>
<span class="min-w-0 truncate text-xs text-gray-500 dark:text-gray-400">
{{
uploadImageName
? t('common.selectedFile', { name: uploadImageName })
: t('common.noFileSelected')
}}
</span>
<input
ref="imageFileInput"
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
class="hidden"
:disabled="status === 'connecting'"
@change="onImageFileChange"
/>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ imageUploadHint }}</p>
<div v-if="uploadImagePreview" class="overflow-hidden rounded-lg border border-gray-200 dark:border-dark-500">
<img :src="uploadImagePreview" alt="upload preview" class="max-h-40 w-full object-contain bg-gray-50 dark:bg-dark-700" />
<img
:src="uploadImagePreview"
:alt="t('admin.accounts.grok.uploadPreviewAlt')"
class="max-h-40 w-full object-contain bg-gray-50 dark:bg-dark-700"
/>
</div>
</div>
@@ -120,29 +142,32 @@
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.accounts.grok.audioUploadLabel') }}
</label>
<input
type="file"
accept="audio/*,.wav,.mp3,.m4a,.ogg,.webm"
class="block w-full text-sm text-gray-600 file:mr-3 file:rounded-lg file:border-0 file:bg-primary-50 file:px-3 file:py-1.5 file:text-sm file:font-medium file:text-primary-700 hover:file:bg-primary-100 dark:text-gray-300 dark:file:bg-primary-500/20 dark:file:text-primary-300"
:disabled="status === 'connecting'"
@change="onAudioFileChange"
/>
<div class="flex items-center gap-3">
<button
type="button"
class="btn btn-secondary btn-sm shrink-0"
:disabled="status === 'connecting'"
@click="audioFileInput?.click()"
>
{{ t('admin.accounts.grok.chooseAudioFile') }}
</button>
<span class="min-w-0 truncate text-xs text-gray-500 dark:text-gray-400">
{{
uploadAudioName
? t('common.selectedFile', { name: uploadAudioName })
: t('common.noFileSelected')
}}
</span>
<input
ref="audioFileInput"
type="file"
accept="audio/*,.wav,.mp3,.m4a,.ogg,.webm"
class="hidden"
:disabled="status === 'connecting'"
@change="onAudioFileChange"
/>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.grok.audioUploadHint') }}</p>
<p v-if="uploadAudioName" class="text-xs text-gray-600 dark:text-gray-300">{{ uploadAudioName }}</p>
</div>
<div v-if="supportsVideoUploadURL" class="space-y-1.5">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.accounts.grok.videoUploadUrlLabel') }}
</label>
<input
v-model="videoUploadURL"
type="url"
class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 outline-none focus:border-primary-500 focus:ring-1 focus:ring-primary-500 dark:border-dark-500 dark:bg-dark-700 dark:text-gray-100"
:placeholder="t('admin.accounts.grok.videoUploadUrlPlaceholder')"
:disabled="status === 'connecting'"
/>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.grok.videoUploadUrlHint') }}</p>
</div>
<!-- Terminal Output -->
@@ -210,7 +235,11 @@
class="group/img relative cursor-pointer overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm transition hover:border-primary-300 hover:shadow-md dark:border-dark-500 dark:bg-dark-700"
@click="previewImageUrl = image.url"
>
<img :src="image.url" :alt="`test-image-${index + 1}`" class="max-h-[360px] w-full object-contain" />
<img
:src="image.url"
:alt="t('admin.accounts.imagePreviewAlt', { index: index + 1 })"
class="max-h-[360px] w-full object-contain"
/>
<div class="absolute inset-0 flex items-center justify-center bg-black/0 transition-colors group-hover/img:bg-black/20">
<Icon name="eye" size="lg" class="text-white opacity-0 drop-shadow-lg transition-opacity group-hover/img:opacity-100" :stroke-width="2" />
</div>
@@ -267,7 +296,7 @@
</button>
<img
:src="previewImageUrl"
alt="preview"
:alt="t('admin.accounts.imageLightboxAlt')"
class="max-h-[90vh] max-w-[90vw] rounded-lg object-contain shadow-2xl"
/>
</div>
@@ -388,9 +417,11 @@ const testMode = ref<'default' | 'compact'>('default')
const grokTestMode = ref<'text' | 'image' | 'video' | 'search' | 'tts' | 'stt' | 'realtime'>('text')
const uploadImageDataURL = ref('')
const uploadImagePreview = ref('')
const uploadImageName = ref('')
const uploadAudioDataURL = ref('')
const uploadAudioName = ref('')
const videoUploadURL = ref('')
const imageFileInput = ref<HTMLInputElement | null>(null)
const audioFileInput = ref<HTMLInputElement | null>(null)
const isOpenAIAccount = computed(() => props.account?.platform === 'openai')
const isGrokAccount = computed(() => props.account?.platform === 'grok')
const openAITestModeOptions = computed(() => [
@@ -481,18 +512,23 @@ const supportsImageUpload = computed(
() => isGrokAccount.value && (grokTestMode.value === 'image' || grokTestMode.value === 'video')
)
const supportsAudioUpload = computed(() => isGrokAccount.value && grokTestMode.value === 'stt')
const supportsVideoUploadURL = computed(() => isGrokAccount.value && grokTestMode.value === 'video')
const imageUploadLabel = computed(() =>
grokTestMode.value === 'video'
? t('admin.accounts.grok.videoFirstFrameLabel')
: t('admin.accounts.grok.imageUploadLabel')
)
const imageUploadHint = computed(() =>
grokTestMode.value === 'video'
? t('admin.accounts.grok.videoFirstFrameHint')
: t('admin.accounts.grok.imageUploadHint')
)
const readFileAsDataURL = (file: File): Promise<string> =>
new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result || ''))
reader.onerror = () => reject(new Error('Failed to read file'))
reader.onerror = () => reject(new Error(t('admin.accounts.grok.fileReadFailed')))
reader.readAsDataURL(file)
})
@@ -502,6 +538,7 @@ const onImageFileChange = async (event: Event) => {
if (!file) {
uploadImageDataURL.value = ''
uploadImagePreview.value = ''
uploadImageName.value = ''
return
}
if (file.size > 6 * 1024 * 1024) {
@@ -514,9 +551,13 @@ const onImageFileChange = async (event: Event) => {
const dataURL = await readFileAsDataURL(file)
uploadImageDataURL.value = dataURL
uploadImagePreview.value = dataURL
uploadImageName.value = file.name
} catch {
uploadImageDataURL.value = ''
uploadImagePreview.value = ''
uploadImageName.value = ''
errorMessage.value = t('admin.accounts.grok.fileReadFailed')
status.value = 'error'
}
}
@@ -540,15 +581,19 @@ const onAudioFileChange = async (event: Event) => {
} catch {
uploadAudioDataURL.value = ''
uploadAudioName.value = ''
errorMessage.value = t('admin.accounts.grok.fileReadFailed')
status.value = 'error'
}
}
const clearMediaUploads = () => {
uploadImageDataURL.value = ''
uploadImagePreview.value = ''
uploadImageName.value = ''
uploadAudioDataURL.value = ''
uploadAudioName.value = ''
videoUploadURL.value = ''
if (imageFileInput.value) imageFileInput.value.value = ''
if (audioFileInput.value) audioFileInput.value.value = ''
}
const promptInputLabel = computed(() => {
@@ -805,7 +850,6 @@ const startTest = async () => {
mode?: string
image_data_url?: string
audio_data_url?: string
video_upload_url?: string
} = {
model_id: showModelSelect.value ? selectedModelId.value : '',
prompt: supportsPromptInput.value ? testPrompt.value.trim() : ''
@@ -831,9 +875,6 @@ const startTest = async () => {
if (uploadAudioDataURL.value && grokTestMode.value === 'stt') {
requestBody.audio_data_url = uploadAudioDataURL.value
}
if (videoUploadURL.value.trim() && grokTestMode.value === 'video') {
requestBody.video_upload_url = videoUploadURL.value.trim()
}
}
// Use the configured API base; EventSource does not support POST.
@@ -857,7 +898,7 @@ const startTest = async () => {
const reader = response.body?.getReader()
if (!reader) {
throw new Error('No response body')
throw new Error(t('admin.accounts.grok.noResponseBody'))
}
const decoder = new TextDecoder()
@@ -891,9 +932,9 @@ const startTest = async () => {
return
}
status.value = 'error'
const msg = error instanceof Error ? error.message : 'Unknown error'
const msg = error instanceof Error ? error.message : t('common.unknownError')
errorMessage.value = msg
addLine(`Error: ${msg}`, 'text-red-400')
addLine(t('admin.accounts.errorPrefix', { message: msg }), 'text-red-400')
}
}
@@ -991,13 +1032,13 @@ const handleEvent = (event: {
status.value = 'success'
} else {
status.value = 'error'
errorMessage.value = event.error || 'Test failed'
errorMessage.value = event.error || t('admin.accounts.testFailed')
}
break
case 'error':
status.value = 'error'
errorMessage.value = event.error || 'Unknown error'
errorMessage.value = event.error || t('common.unknownError')
if (streamingContent.value) {
addLine(streamingContent.value, 'text-green-300')
streamingContent.value = ''
@@ -156,7 +156,7 @@ const readFileAsText = async (sourceFile: File): Promise<string> => {
return await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result ?? ''))
reader.onerror = () => reject(reader.error || new Error('Failed to read file'))
reader.onerror = () => reject(reader.error || new Error(t('common.fileReadFailed')))
reader.readAsText(sourceFile)
})
}
+16 -7
View File
@@ -50,7 +50,7 @@
@change="handleUpload"
/>
<Icon name="upload" size="sm" class="mr-1.5" :stroke-width="2" />
{{ uploadLabel }}
{{ resolvedUploadLabel }}
</label>
<button
v-if="modelValue"
@@ -59,7 +59,7 @@
@click="$emit('update:modelValue', '')"
>
<Icon name="trash" size="sm" class="mr-1.5" :stroke-width="2" />
{{ removeLabel }}
{{ resolvedRemoveLabel }}
</button>
</div>
<p v-if="hint" class="text-xs text-gray-500 dark:text-gray-400">{{ hint }}</p>
@@ -70,9 +70,12 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Icon from '@/components/icons/Icon.vue'
import { sanitizeSvg } from '@/utils/sanitize'
const { t } = useI18n()
const props = withDefaults(defineProps<{
modelValue: string
mode?: 'image' | 'svg'
@@ -84,8 +87,8 @@ const props = withDefaults(defineProps<{
}>(), {
mode: 'image',
size: 'md',
uploadLabel: 'Upload',
removeLabel: 'Remove',
uploadLabel: '',
removeLabel: '',
hint: '',
maxSize: 300 * 1024,
})
@@ -96,6 +99,9 @@ const emit = defineEmits<{
const error = ref('')
const resolvedUploadLabel = computed(() => props.uploadLabel || t('common.upload'))
const resolvedRemoveLabel = computed(() => props.removeLabel || t('common.remove'))
const acceptTypes = computed(() => props.mode === 'svg' ? '.svg' : 'image/*')
const sanitizedValue = computed(() =>
@@ -114,7 +120,10 @@ function handleUpload(event: Event) {
if (!file) return
if (props.maxSize && file.size > props.maxSize) {
error.value = `File too large (${(file.size / 1024).toFixed(1)} KB), max ${(props.maxSize / 1024).toFixed(0)} KB`
error.value = t('common.fileTooLargeKb', {
size: (file.size / 1024).toFixed(1),
max: (props.maxSize / 1024).toFixed(0)
})
input.value = ''
return
}
@@ -128,7 +137,7 @@ function handleUpload(event: Event) {
reader.readAsText(file)
} else {
if (!file.type.startsWith('image/')) {
error.value = 'Please select an image file'
error.value = t('common.selectImageFile')
input.value = ''
return
}
@@ -139,7 +148,7 @@ function handleUpload(event: Event) {
}
reader.onerror = () => {
error.value = 'Failed to read file'
error.value = t('common.fileReadFailed')
}
input.value = ''
}
+13 -7
View File
@@ -645,14 +645,17 @@ export default {
videoFirstFrameLabel: 'First-frame / reference image (optional)',
imageUploadHint:
'PNG/JPEG recommended, both sides ≥ 8 px, under ~4 MB for edits. Uploading a source image switches to /images/edits (image-to-image). Leave empty for text-to-image /images/generations.',
videoFirstFrameHint:
'Optional first-frame / reference image for image-to-video. PNG/JPEG recommended, both sides ≥ 8 px.',
audioUploadLabel: 'Audio file (optional for STT)',
audioUploadHint:
'Upload a real audio clip to transcribe. Without a file, a silent WAV is used for connectivity only.',
videoUploadUrlLabel: 'Video output.upload_url (ZDR, optional)',
videoUploadUrlPlaceholder: 'https://your-public-host/v1/media/uploads/<token>',
videoUploadUrlHint:
'Required for full video generation on Zero Data Retention teams: a public HTTPS URL that accepts PUT of the finished MP4. Without it, the test only verifies connectivity.',
mediaTooLarge: 'File is too large (max ~6 MB for admin test uploads).'
mediaTooLarge: 'File is too large (max ~6 MB for admin test uploads).',
chooseImageFile: 'Choose image',
chooseAudioFile: 'Choose audio',
uploadPreviewAlt: 'Upload preview',
fileReadFailed: 'Failed to read the selected file',
noResponseBody: 'No response body from server'
},
anthropic: {
apiKeyPassthrough: 'Auto passthrough (auth only)',
@@ -1337,6 +1340,9 @@ export default {
reAuthorizedSuccess: 'Account re-authorized successfully',
// Test Modal
testAccountConnection: 'Test Account Connection',
errorPrefix: 'Error: {message}',
imagePreviewAlt: 'Test image {index}',
imageLightboxAlt: 'Image preview',
account: 'Account',
readyToTest: 'Ready to test. Click "Start Test" to begin...',
connectingToApi: 'Connecting to API...',
@@ -1361,13 +1367,13 @@ export default {
imagePromptPlaceholder: 'Example: Generate an orange cat astronaut sticker in pixel-art style on a solid background.',
imagePromptDefault: 'Generate a cute orange cat astronaut sticker on a clean pastel background.',
imageTestHint:
'Calls standalone /v1/images/generations (Grok uses response_format=b64_json for ZDR-safe previews) and shows the returned image below.',
'Calls standalone /v1/images/generations and shows the returned image below.',
imageTestMode: 'Mode: Image generation test',
videoPromptLabel: 'Video prompt',
videoPromptPlaceholder: 'Example: A red ball bouncing once on a white floor, short simple motion.',
videoPromptDefault: 'A red ball bouncing once on a white floor, short simple motion.',
videoTestHint:
'Calls standalone /v1/videos/generations. Zero Data Retention (ZDR) teams require a public output.upload_url so xAI can PUT the finished MP4 — this admin probe does not host that receiver, so ZDR accounts get connectivity-only success without producing a video file.',
'Calls standalone /v1/videos/generations, polls until done, then downloads the finished video for on-page preview.',
videoTestMode: 'Mode: Video generation test',
sendingVideoRequest: 'Sending video generation request...',
imagePreview: 'Generated images:',
+7
View File
@@ -81,6 +81,13 @@ export default {
view: 'View',
settings: 'Settings',
chooseFile: 'Choose File',
upload: 'Upload',
remove: 'Remove',
noFileSelected: 'No file selected',
selectedFile: 'Selected: {name}',
fileReadFailed: 'Failed to read file',
selectImageFile: 'Please select an image file',
fileTooLargeKb: 'File too large ({size} KB), max {max} KB',
copy: 'Copy',
notAvailable: 'N/A',
now: 'Now',
+13 -7
View File
@@ -715,13 +715,16 @@ export default {
videoFirstFrameLabel: '首帧 / 参考图(可选)',
imageUploadHint:
'建议 PNG/JPEG,宽高均 ≥ 8 像素,编辑建议小于约 4MB。上传源图会走 /images/edits(图生图);不上传则走 /images/generations 文生图。',
videoFirstFrameHint:
'可选首帧/参考图用于图生视频。建议 PNG/JPEG,宽高均 ≥ 8 像素。',
audioUploadLabel: '音频文件(STT 可选)',
audioUploadHint: '上传真实音频做转写;不上传则用静音 WAV 仅测连通。',
videoUploadUrlLabel: '视频 output.upload_url(ZDR,可选)',
videoUploadUrlPlaceholder: 'https://your-public-host/v1/media/uploads/<token>',
videoUploadUrlHint:
'零数据保留(ZDR)账号完整出片需要:公网 HTTPS 地址,接受成品 MP4 的 PUT。不填则只验证连通性,不会真正出视频。',
mediaTooLarge: '文件过大(管理端测试上传上限约 6MB)。'
mediaTooLarge: '文件过大(管理端测试上传上限约 6MB)。',
chooseImageFile: '选择图片',
chooseAudioFile: '选择音频',
uploadPreviewAlt: '上传预览',
fileReadFailed: '读取所选文件失败',
noResponseBody: '服务器未返回响应体'
},
anthropic: {
apiKeyPassthrough: '自动透传(仅替换认证)',
@@ -1389,6 +1392,9 @@ export default {
reAuthorizedSuccess: '账号重新授权成功',
// Test Modal
testAccountConnection: '测试账号连接',
errorPrefix: '错误:{message}',
imagePreviewAlt: '测试图片 {index}',
imageLightboxAlt: '图片预览',
account: '账号',
readyToTest: '准备测试。点击"开始测试"按钮开始...',
connectingToApi: '连接 API 中...',
@@ -1411,13 +1417,13 @@ export default {
imagePromptPlaceholder: '例如:生成一只戴宇航员头盔的橘猫,像素插画风格,纯色背景。',
imagePromptDefault: 'Generate a cute orange cat astronaut sticker on a clean pastel background.',
imageTestHint:
'调用独立 /v1/images/generations 生图(Grok 使用 response_format=b64_json 以兼容 ZDR),并在下方预览返回图片。',
'调用独立 /v1/images/generations 生图,并在下方预览返回图片。',
imageTestMode: '模式:生图测试',
videoPromptLabel: '视频提示词',
videoPromptPlaceholder: '例如:一只红球在白地板上弹跳一次,动作简短。',
videoPromptDefault: 'A red ball bouncing once on a white floor, short simple motion.',
videoTestHint:
'调用独立 /v1/videos/generations。零数据保留(ZDR)账号必须提供公网可 PUT 的 output.upload_url,xAI 才会把成品 MP4 写回去;管理端测试不托管该上传接收地址,因此 ZDR 账号只会得到「连通性成功」,不会实际生成视频文件。',
'调用独立 /v1/videos/generations,轮询至完成后下载成品视频并在页面上预览。',
videoTestMode: '模式:视频生成测试',
sendingVideoRequest: '正在发送视频生成测试请求...',
imagePreview: '生成结果:',
+7
View File
@@ -81,6 +81,13 @@ export default {
view: '查看',
settings: '设置',
chooseFile: '选择文件',
upload: '上传',
remove: '移除',
noFileSelected: '未选择文件',
selectedFile: '已选:{name}',
fileReadFailed: '读取文件失败',
selectImageFile: '请选择图片文件',
fileTooLargeKb: '文件过大({size} KB),上限 {max} KB',
copy: '复制',
notAvailable: '不可用',
now: '现在',