mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
feat(oauth): 优化凭据导入,支持多文件选择与更多 JSON 格式
前端:简化导入界面状态管理,支持多文件拖拽/选择并自动合并内容, 移除冗余的 importFileName/manualPasteText 状态。 后端:_parse_tokens_input 新增支持 JSON 对象数组和单个 JSON 对象格式解析。
This commit is contained in:
@@ -401,103 +401,72 @@
|
|||||||
|
|
||||||
<!-- ===== 导入授权 ===== -->
|
<!-- ===== 导入授权 ===== -->
|
||||||
<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
|
<div
|
||||||
v-if="!importText"
|
v-if="!showManualInput"
|
||||||
class="mt-3"
|
class="rounded-xl border-2 border-dashed transition-colors cursor-pointer"
|
||||||
|
:class="isDragging
|
||||||
|
? 'border-primary bg-primary/5'
|
||||||
|
: 'border-border hover:border-muted-foreground/40'"
|
||||||
|
@click="fileInputRef?.click()"
|
||||||
|
@dragover.prevent="isDragging = true"
|
||||||
|
@dragleave.prevent="isDragging = false"
|
||||||
|
@drop.prevent="handleFileDrop"
|
||||||
>
|
>
|
||||||
<!-- 拖拽模式 -->
|
<div class="flex flex-col items-center justify-center py-12 gap-2">
|
||||||
<div
|
<div class="w-9 h-9 rounded-full bg-muted/60 flex items-center justify-center">
|
||||||
v-if="!showManualInput"
|
<Upload class="w-4 h-4 text-muted-foreground" />
|
||||||
class="rounded-xl border-2 border-dashed transition-colors cursor-pointer"
|
</div>
|
||||||
:class="isDragging
|
<div class="text-center">
|
||||||
? 'border-primary bg-primary/5'
|
<p class="text-xs font-medium">
|
||||||
: 'border-border hover:border-muted-foreground/40'"
|
拖入授权文件或点击选择
|
||||||
@click="fileInputRef?.click()"
|
</p>
|
||||||
@dragover.prevent="isDragging = true"
|
<p class="text-[11px] text-muted-foreground mt-0.5">
|
||||||
@dragleave.prevent="isDragging = false"
|
支持 .json / .txt,可多选
|
||||||
@drop.prevent="handleFileDrop"
|
</p>
|
||||||
>
|
|
||||||
<div class="flex flex-col items-center justify-center py-10 gap-2">
|
|
||||||
<div class="w-8 h-8 rounded-full bg-muted/60 flex items-center justify-center">
|
|
||||||
<Upload class="w-4 h-4 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
<div class="text-center">
|
|
||||||
<p class="text-xs font-medium">
|
|
||||||
拖入授权文件或点击选择
|
|
||||||
</p>
|
|
||||||
<p class="text-[10px] text-muted-foreground mt-0.5">
|
|
||||||
支持 .json 格式
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 粘贴模式 -->
|
|
||||||
<Textarea
|
|
||||||
v-else
|
|
||||||
v-model="manualPasteText"
|
|
||||||
:disabled="importing"
|
|
||||||
placeholder="粘贴 Refresh Token 或 JSON 内容"
|
|
||||||
class="min-h-[168px] text-xs font-mono break-all !rounded-xl"
|
|
||||||
spellcheck="false"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 底部切换链接:占满剩余空间居中 -->
|
<!-- 粘贴模式 -->
|
||||||
<div
|
<Textarea
|
||||||
v-if="!importText"
|
v-else
|
||||||
class="flex-1 flex items-center justify-center"
|
v-model="importText"
|
||||||
>
|
:disabled="importing"
|
||||||
|
placeholder="粘贴 Refresh Token 或 JSON 内容"
|
||||||
|
class="min-h-[200px] text-xs font-mono break-all !rounded-xl"
|
||||||
|
spellcheck="false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 底部切换链接 -->
|
||||||
|
<div class="flex items-center justify-center pt-1">
|
||||||
<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) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const content = e.target?.result
|
||||||
|
if (typeof content === 'string') resolve(content)
|
||||||
|
else reject(new Error('读取失败'))
|
||||||
|
}
|
||||||
|
reader.onerror = () => reject(new Error('读取失败'))
|
||||||
|
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 文件', '格式错误')
|
showError('仅支持 .json 或 .txt 文件', '格式错误')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
importFileName.value = file.name
|
if (validFiles.length < files.length) {
|
||||||
const reader = new FileReader()
|
showError(`已忽略 ${files.length - validFiles.length} 个不支持的文件`, '提示')
|
||||||
reader.onload = (e) => {
|
}
|
||||||
const content = e.target?.result
|
try {
|
||||||
if (typeof content === 'string') {
|
const contents = await Promise.all(validFiles.map(readFileAsText))
|
||||||
importText.value = content
|
const merged = validFiles.length === 1 ? contents[0] : mergeFileContents(contents)
|
||||||
}
|
importText.value = merged
|
||||||
|
showManualInput.value = true
|
||||||
|
} catch {
|
||||||
|
showError('文件读取失败', '错误')
|
||||||
}
|
}
|
||||||
reader.readAsText(file)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user