feat(oauth): 优化凭据导入,支持多文件选择与更多 JSON 格式

前端:简化导入界面状态管理,支持多文件拖拽/选择并自动合并内容,
移除冗余的 importFileName/manualPasteText 状态。
后端:_parse_tokens_input 新增支持 JSON 对象数组和单个 JSON 对象格式解析。
This commit is contained in:
fawney19
2026-02-28 22:06:21 +08:00
parent 85a126f48a
commit fbcb54a8a5
2 changed files with 125 additions and 105 deletions

View File

@@ -401,22 +401,18 @@
<!-- ===== 导入授权 ===== --> <!-- ===== 导入授权 ===== -->
<div <div
class="flex flex-col gap-3 transition-opacity duration-150" class="flex flex-col gap-3 justify-center transition-opacity duration-150"
:class="mode === 'import' ? 'opacity-100' : 'opacity-0 pointer-events-none'" :class="mode === 'import' ? 'opacity-100' : 'opacity-0 pointer-events-none'"
> >
<input <input
ref="fileInputRef" ref="fileInputRef"
type="file" type="file"
accept=".json" accept=".json,.txt"
multiple
class="hidden" class="hidden"
@change="handleFileSelect" @change="handleFileSelect"
> >
<!-- 主区域拖拽 粘贴输入框同一位置切换 -->
<div
v-if="!importText"
class="mt-3"
>
<!-- 拖拽模式 --> <!-- 拖拽模式 -->
<div <div
v-if="!showManualInput" v-if="!showManualInput"
@@ -429,16 +425,16 @@
@dragleave.prevent="isDragging = false" @dragleave.prevent="isDragging = false"
@drop.prevent="handleFileDrop" @drop.prevent="handleFileDrop"
> >
<div class="flex flex-col items-center justify-center py-10 gap-2"> <div class="flex flex-col items-center justify-center py-12 gap-2">
<div class="w-8 h-8 rounded-full bg-muted/60 flex items-center justify-center"> <div class="w-9 h-9 rounded-full bg-muted/60 flex items-center justify-center">
<Upload class="w-4 h-4 text-muted-foreground" /> <Upload class="w-4 h-4 text-muted-foreground" />
</div> </div>
<div class="text-center"> <div class="text-center">
<p class="text-xs font-medium"> <p class="text-xs font-medium">
拖入授权文件或点击选择 拖入授权文件或点击选择
</p> </p>
<p class="text-[10px] text-muted-foreground mt-0.5"> <p class="text-[11px] text-muted-foreground mt-0.5">
支持 .json 格式 支持 .json / .txt可多选
</p> </p>
</div> </div>
</div> </div>
@@ -447,57 +443,30 @@
<!-- 粘贴模式 --> <!-- 粘贴模式 -->
<Textarea <Textarea
v-else v-else
v-model="manualPasteText" v-model="importText"
:disabled="importing" :disabled="importing"
placeholder="粘贴 Refresh Token 或 JSON 内容" placeholder="粘贴 Refresh Token 或 JSON 内容"
class="min-h-[168px] text-xs font-mono break-all !rounded-xl" class="min-h-[200px] text-xs font-mono break-all !rounded-xl"
spellcheck="false" spellcheck="false"
/> />
</div>
<!-- 底部切换链接占满剩余空间居中 --> <!-- 底部切换链接 -->
<div <div class="flex items-center justify-center pt-1">
v-if="!importText"
class="flex-1 flex items-center justify-center"
>
<button <button
v-if="!showManualInput" v-if="!showManualInput"
class="text-xs text-muted-foreground hover:text-foreground transition-colors" class="text-sm text-muted-foreground hover:text-foreground transition-colors"
@click="showManualInput = true" @click="showManualInput = true"
> >
或手动粘贴 Refresh Token 或手动粘贴 Refresh Token
</button> </button>
<button <button
v-else v-else
class="text-xs text-muted-foreground hover:text-foreground transition-colors" class="text-sm text-muted-foreground hover:text-foreground transition-colors"
@click="showManualInput = false" @click="showManualInput = false; importText = ''"
> >
或选择 JSON 文件导入 或选择 JSON 文件导入
</button> </button>
</div> </div>
<!-- 已有内容文件导入后显示文本框 -->
<div
v-if="importText"
class="space-y-2"
>
<div class="flex items-center justify-between">
<span class="text-xs text-muted-foreground">{{ importFileName || '已粘贴内容' }}</span>
<button
class="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
:disabled="importing"
@click="clearImport"
>
清除
</button>
</div>
<Textarea
v-model="importText"
:disabled="importing"
class="min-h-[160px] text-xs font-mono break-all !rounded-xl"
spellcheck="false"
/>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -692,8 +661,6 @@ let countdownTimer: ReturnType<typeof setInterval> | null = null
// 导入状态 // 导入状态
const importText = ref('') const importText = ref('')
const importFileName = ref('')
const manualPasteText = ref('')
const importing = ref(false) const importing = ref(false)
const isDragging = ref(false) const isDragging = ref(false)
const showManualInput = ref(false) const showManualInput = ref(false)
@@ -721,8 +688,7 @@ const canCompleteOAuth = computed(() => {
}) })
const canImport = computed(() => { const canImport = computed(() => {
const text = importText.value || manualPasteText.value return importText.value.trim().length > 0 && !importing.value
return text.trim().length > 0 && !importing.value
}) })
function stopDevicePolling() { function stopDevicePolling() {
@@ -753,8 +719,6 @@ function resetForm() {
totp.stop() totp.stop()
device.value = createInitialDeviceState() device.value = createInitialDeviceState()
importText.value = '' importText.value = ''
importFileName.value = ''
manualPasteText.value = ''
importing.value = false importing.value = false
isDragging.value = false isDragging.value = false
showManualInput.value = false showManualInput.value = false
@@ -766,16 +730,6 @@ function resetForm() {
} }
} }
function clearImport() {
importText.value = ''
importFileName.value = ''
manualPasteText.value = ''
showManualInput.value = false
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
function switchMode(newMode: DialogMode) { function switchMode(newMode: DialogMode) {
if (mode.value === newMode) return if (mode.value === newMode) return
@@ -845,11 +799,11 @@ async function handleCompleteOAuth() {
// 检测是否为批量导入格式 // 检测是否为批量导入格式
function isBatchImport(text: string): boolean { function isBatchImport(text: string): boolean {
const trimmed = text.trim() const trimmed = text.trim()
// JSON 数组 // JSON 数组(含单元素数组)
if (trimmed.startsWith('[')) { if (trimmed.startsWith('[')) {
try { try {
const parsed = JSON.parse(trimmed) const parsed = JSON.parse(trimmed)
return Array.isArray(parsed) && parsed.length > 1 return Array.isArray(parsed) && parsed.length >= 1
} catch { } catch {
return false return false
} }
@@ -897,38 +851,87 @@ function parseImportText(text: string): { refresh_token: string; name?: string }
return { refresh_token: trimmed } return { refresh_token: trimmed }
} }
function readFile(file: File) { function readFileAsText(file: File): Promise<string> {
if (!file.name.endsWith('.json') && !file.name.endsWith('.txt') && file.type !== 'application/json' && file.type !== 'text/plain') { return new Promise((resolve, reject) => {
showError('仅支持 .json 或 .txt 文件', '格式错误')
return
}
importFileName.value = file.name
const reader = new FileReader() const reader = new FileReader()
reader.onload = (e) => { reader.onload = (e) => {
const content = e.target?.result const content = e.target?.result
if (typeof content === 'string') { if (typeof content === 'string') resolve(content)
importText.value = content else reject(new Error('读取失败'))
}
} }
reader.onerror = () => reject(new Error('读取失败'))
reader.readAsText(file) reader.readAsText(file)
})
}
function isValidFileType(file: File): boolean {
return file.name.endsWith('.json') || file.name.endsWith('.txt')
|| file.type === 'application/json' || file.type === 'text/plain'
}
/** 合并多个文件内容为统一的凭据文本JSON 数组) */
function mergeFileContents(contents: string[]): string {
const items: unknown[] = []
for (const raw of contents) {
const trimmed = raw.trim()
if (!trimmed) continue
try {
const parsed = JSON.parse(trimmed)
if (Array.isArray(parsed)) {
items.push(...parsed)
} else {
items.push(parsed)
}
continue
} catch {
// not JSON
}
// 按行拆分(纯 Token
const lines = trimmed.split('\n').filter(l => l.trim() && !l.trim().startsWith('#'))
items.push(...lines.map(l => l.trim()))
}
if (items.length === 1) {
// 单条:保持原始格式
return typeof items[0] === 'string' ? items[0] : JSON.stringify(items[0], null, 2)
}
return JSON.stringify(items, null, 2)
}
async function readFiles(files: File[]) {
const validFiles = files.filter(isValidFileType)
if (validFiles.length === 0) {
showError('仅支持 .json 或 .txt 文件', '格式错误')
return
}
if (validFiles.length < files.length) {
showError(`已忽略 ${files.length - validFiles.length} 个不支持的文件`, '提示')
}
try {
const contents = await Promise.all(validFiles.map(readFileAsText))
const merged = validFiles.length === 1 ? contents[0] : mergeFileContents(contents)
importText.value = merged
showManualInput.value = true
} catch {
showError('文件读取失败', '错误')
}
} }
function handleFileSelect(event: Event) { function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement const input = event.target as HTMLInputElement
const file = input.files?.[0] const files = input.files
if (file) readFile(file) if (files && files.length > 0) readFiles(Array.from(files))
} }
function handleFileDrop(event: DragEvent) { function handleFileDrop(event: DragEvent) {
isDragging.value = false isDragging.value = false
const file = event.dataTransfer?.files?.[0] const files = event.dataTransfer?.files
if (file) readFile(file) if (files && files.length > 0) readFiles(Array.from(files))
} }
async function handleImport() { async function handleImport() {
if (!canImport.value || !props.providerId) return if (!canImport.value || !props.providerId) return
const inputText = (importText.value || manualPasteText.value).trim() const inputText = importText.value.trim()
if (!inputText) { if (!inputText) {
showError('请输入凭据数据', '格式错误') showError('请输入凭据数据', '格式错误')
return return

View File

@@ -1214,8 +1214,10 @@ def _parse_tokens_input(raw_input: str) -> list[str]:
支持的格式: 支持的格式:
1. 单个 Token 字符串 1. 单个 Token 字符串
2. JSON 数组: ["token1", "token2", ...] 2. JSON 字符串数组: ["token1", "token2", ...]
3. 纯 Token 导入(一行一个): "token1\\ntoken2\\ntoken3" 3. JSON 对象数组: [{"refresh_token": "token1", ...}, ...]
4. 单个 JSON 对象: {"refresh_token": "token1", ...}
5. 纯 Token 导入(一行一个): "token1\\ntoken2\\ntoken3"
返回: Token 字符串列表 返回: Token 字符串列表
""" """
@@ -1233,10 +1235,25 @@ def _parse_tokens_input(raw_input: str) -> list[str]:
for item in parsed: for item in parsed:
if isinstance(item, str) and item.strip(): if isinstance(item, str) and item.strip():
result.append(item.strip()) result.append(item.strip())
elif isinstance(item, dict):
token = item.get("refresh_token", "")
if isinstance(token, str) and token.strip():
result.append(token.strip())
return result return result
except json.JSONDecodeError: except json.JSONDecodeError:
pass # 不是有效 JSON继续尝试其他格式 pass # 不是有效 JSON继续尝试其他格式
# 单个 JSON 对象
if raw.startswith("{"):
try:
parsed = json.loads(raw)
if isinstance(parsed, dict):
token = parsed.get("refresh_token", "")
if isinstance(token, str) and token.strip():
return [token.strip()]
except json.JSONDecodeError:
pass
# 纯 Token 导入(一行一个) # 纯 Token 导入(一行一个)
lines = raw.splitlines() lines = raw.splitlines()
for line in lines: for line in lines: