feat: 新增Droid cli支持

This commit is contained in:
shaw
2025-10-09 23:05:09 +08:00
parent 4de2ea3d17
commit 2fc84a6aca
13 changed files with 2734 additions and 36 deletions

View File

@@ -464,6 +464,170 @@
</div>
</div>
<!-- Droid OAuth流程 -->
<div v-else-if="platform === 'droid'">
<div
class="rounded-lg border border-cyan-200 bg-cyan-50 p-6 dark:border-cyan-700 dark:bg-cyan-900/30"
>
<div class="flex items-start gap-4">
<div
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-cyan-500"
>
<i class="fas fa-robot text-white" />
</div>
<div class="flex-1">
<h4 class="mb-3 font-semibold text-cyan-900 dark:text-cyan-200">Droid 账户授权</h4>
<p class="mb-4 text-sm text-cyan-800 dark:text-cyan-300">
请按照以下步骤完成 Factory (Droid) 账户的授权
</p>
<div class="space-y-4">
<!-- 步骤1: 生成授权链接 -->
<div
class="rounded-lg border border-cyan-300 bg-white/80 p-4 dark:border-cyan-600 dark:bg-gray-800/80"
>
<div class="flex items-start gap-3">
<div
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-cyan-600 text-xs font-bold text-white"
>
1
</div>
<div class="flex-1">
<p class="mb-2 font-medium text-cyan-900 dark:text-cyan-200">
点击下方按钮生成授权链接
</p>
<button
v-if="!authUrl"
class="btn btn-primary px-4 py-2 text-sm"
:disabled="loading"
@click="generateAuthUrl"
>
<i v-if="!loading" class="fas fa-link mr-2" />
<div v-else class="loading-spinner mr-2" />
{{ loading ? '生成中...' : '生成授权链接' }}
</button>
<div v-else class="space-y-4">
<div class="space-y-2">
<label class="text-xs font-semibold text-gray-600 dark:text-gray-300"
>授权链接</label
>
<div
class="flex flex-col gap-2 rounded-md border border-cyan-200 bg-white p-3 dark:border-cyan-700 dark:bg-gray-800"
>
<div class="flex items-center gap-2">
<input
class="form-input flex-1 bg-gray-50 font-mono text-xs dark:bg-gray-700"
readonly
type="text"
:value="authUrl"
/>
<button
class="rounded-lg bg-gray-100 px-3 py-2 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600"
title="复制链接"
@click="copyAuthUrl"
>
<i :class="copied ? 'fas fa-check text-green-500' : 'fas fa-copy'" />
</button>
</div>
<div class="flex flex-wrap items-center gap-2">
<button
class="inline-flex items-center gap-1 rounded-md border border-cyan-200 bg-white px-3 py-1.5 text-xs font-medium text-cyan-600 shadow-sm transition-colors hover:border-cyan-300 hover:bg-cyan-50 dark:border-cyan-700 dark:bg-cyan-900/40 dark:text-cyan-200 dark:hover:border-cyan-500 dark:hover:bg-cyan-900/60"
@click="openVerificationPage"
>
<i class="fas fa-external-link-alt text-xs" /> 在新标签中打开
</button>
<button
class="inline-flex items-center gap-1 rounded-md px-3 py-1.5 text-xs font-medium text-cyan-600 transition-colors hover:text-cyan-700 dark:text-cyan-300 dark:hover:text-cyan-200"
@click="regenerateAuthUrl"
>
<i class="fas fa-sync-alt text-xs" />重新生成
</button>
</div>
</div>
</div>
<div class="space-y-2">
<label class="text-xs font-semibold text-gray-600 dark:text-gray-300"
>授权验证码</label
>
<div
class="flex items-center justify-between rounded-md border border-cyan-200 bg-cyan-50 px-4 py-3 dark:border-cyan-700 dark:bg-cyan-900/30"
>
<span
class="font-mono text-xl font-semibold text-cyan-700 dark:text-cyan-200"
>
{{ userCode || '------' }}
</span>
<button
class="rounded-lg bg-white px-3 py-1 text-sm text-cyan-600 transition-colors hover:bg-cyan-100 dark:bg-cyan-800 dark:text-cyan-200 dark:hover:bg-cyan-700"
@click="copyUserCode"
>
<i class="fas fa-copy mr-1" />复制
</button>
</div>
</div>
<div
class="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400"
>
<span>
<i class="fas fa-hourglass-half mr-1 text-cyan-500" />
剩余有效期{{ formattedCountdown }}
</span>
</div>
</div>
</div>
</div>
</div>
<!-- 步骤2: 访问链接并授权 -->
<div
class="rounded-lg border border-cyan-300 bg-white/80 p-4 dark:border-cyan-600 dark:bg-gray-800/80"
>
<div class="flex items-start gap-3">
<div
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-cyan-600 text-xs font-bold text-white"
>
2
</div>
<div class="flex-1">
<p class="mb-2 font-medium text-cyan-900 dark:text-cyan-200">
在浏览器中打开链接并完成授权
</p>
<div class="space-y-2 text-sm text-cyan-700 dark:text-cyan-300">
<p>
在浏览器中打开授权页面输入上方验证码并登录 Factory / Droid
账户最后点击允许授权
</p>
</div>
</div>
</div>
</div>
<!-- 步骤3: 输入授权结果 -->
<div
class="rounded-lg border border-cyan-300 bg-white/80 p-4 dark:border-cyan-600 dark:bg-gray-800/80"
>
<div class="flex items-start gap-3">
<div
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-cyan-600 text-xs font-bold text-white"
>
3
</div>
<div class="flex-1">
<p class="mb-2 font-medium text-cyan-900 dark:text-cyan-200">
完成授权后点击下方完成授权按钮系统会自动获取访问令牌
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
若提示授权仍在等待确认请稍候片刻后系统会自动重试
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="flex gap-3 pt-4">
<button
class="flex-1 rounded-xl bg-gray-100 px-6 py-3 font-semibold text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
@@ -486,7 +650,7 @@
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { ref, computed, watch, onBeforeUnmount } from 'vue'
import { showToast } from '@/utils/toast'
import { useAccountsStore } from '@/stores/accounts'
@@ -512,14 +676,59 @@ const authUrl = ref('')
const authCode = ref('')
const copied = ref(false)
const sessionId = ref('') // 保存sessionId用于后续交换
const userCode = ref('')
const verificationUri = ref('')
const verificationUriComplete = ref('')
const pollInterval = ref(5)
const remainingSeconds = ref(0)
let countdownTimer = null
let pendingTimer = null
// 计算是否可以交换code
const canExchange = computed(() => {
if (props.platform === 'droid') {
return !!sessionId.value
}
return authUrl.value && authCode.value.trim()
})
const formattedCountdown = computed(() => {
if (!remainingSeconds.value || remainingSeconds.value <= 0) {
return '00:00'
}
const minutes = Math.floor(remainingSeconds.value / 60)
const seconds = remainingSeconds.value % 60
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
})
const startCountdown = (seconds) => {
stopCountdown()
if (!seconds || seconds <= 0) {
remainingSeconds.value = 0
return
}
remainingSeconds.value = Math.floor(seconds)
countdownTimer = setInterval(() => {
if (remainingSeconds.value <= 1) {
remainingSeconds.value = 0
stopCountdown()
} else {
remainingSeconds.value -= 1
}
}, 1000)
}
const stopCountdown = () => {
if (countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
}
}
// 监听授权码输入自动提取URL中的code参数
watch(authCode, (newValue) => {
if (props.platform === 'droid') return
if (!newValue || typeof newValue !== 'string') return
const trimmedValue = newValue.trim()
@@ -579,6 +788,20 @@ watch(authCode, (newValue) => {
// 生成授权URL
const generateAuthUrl = async () => {
if (pendingTimer) {
clearTimeout(pendingTimer)
pendingTimer = null
}
stopCountdown()
authUrl.value = ''
authCode.value = ''
userCode.value = ''
verificationUri.value = ''
verificationUriComplete.value = ''
remainingSeconds.value = 0
sessionId.value = ''
pollInterval.value = 5
copied.value = false
loading.value = true
try {
const proxyConfig = props.proxy?.enabled
@@ -605,6 +828,15 @@ const generateAuthUrl = async () => {
const result = await accountsStore.generateOpenAIAuthUrl(proxyConfig)
authUrl.value = result.authUrl
sessionId.value = result.sessionId
} else if (props.platform === 'droid') {
const result = await accountsStore.generateDroidAuthUrl(proxyConfig)
authUrl.value = result.verificationUriComplete || result.verificationUri
verificationUri.value = result.verificationUri
verificationUriComplete.value = result.verificationUriComplete || result.verificationUri
userCode.value = result.userCode
pollInterval.value = Number(result.interval || 5) || 5
startCountdown(result.expiresIn || 300)
sessionId.value = result.sessionId
}
} catch (error) {
showToast(error.message || '生成授权链接失败', 'error')
@@ -615,13 +847,27 @@ const generateAuthUrl = async () => {
// 重新生成授权URL
const regenerateAuthUrl = () => {
stopCountdown()
if (pendingTimer) {
clearTimeout(pendingTimer)
pendingTimer = null
}
authUrl.value = ''
authCode.value = ''
userCode.value = ''
verificationUri.value = ''
verificationUriComplete.value = ''
remainingSeconds.value = 0
sessionId.value = ''
generateAuthUrl()
}
// 复制授权URL
const copyAuthUrl = async () => {
if (!authUrl.value) {
showToast('请先生成授权链接', 'warning')
return
}
try {
await navigator.clipboard.writeText(authUrl.value)
copied.value = true
@@ -645,6 +891,33 @@ const copyAuthUrl = async () => {
}
}
const copyUserCode = async () => {
if (!userCode.value) {
showToast('请先生成授权验证码', 'warning')
return
}
try {
await navigator.clipboard.writeText(userCode.value)
showToast('验证码已复制', 'success')
} catch (error) {
const input = document.createElement('input')
input.value = userCode.value
document.body.appendChild(input)
input.select()
document.execCommand('copy')
document.body.removeChild(input)
showToast('验证码已复制', 'success')
}
}
const openVerificationPage = () => {
if (verificationUriComplete.value) {
window.open(verificationUriComplete.value, '_blank', 'noopener')
} else if (verificationUri.value) {
window.open(verificationUri.value, '_blank', 'noopener')
}
}
// 交换授权码
const exchangeCode = async () => {
if (!canExchange.value) return
@@ -671,6 +944,10 @@ const exchangeCode = async () => {
code: authCode.value.trim(),
sessionId: sessionId.value
}
} else if (props.platform === 'droid') {
data = {
sessionId: sessionId.value
}
}
// 添加代理配置(如果启用)
@@ -691,6 +968,38 @@ const exchangeCode = async () => {
tokenInfo = await accountsStore.exchangeGeminiCode(data)
} else if (props.platform === 'openai') {
tokenInfo = await accountsStore.exchangeOpenAICode(data)
} else if (props.platform === 'droid') {
const response = await accountsStore.exchangeDroidCode(data)
if (!response.success) {
if (response.pending) {
const retrySeconds = Number(response.retryAfter || pollInterval.value || 5)
const message = response.message || '授权尚未完成,请在浏览器确认后稍候再次尝试。'
showToast(message, 'info')
if (typeof response.expiresIn === 'number' && response.expiresIn >= 0) {
startCountdown(response.expiresIn)
}
// 等待建议的轮询间隔后自动重试
if (Number.isFinite(retrySeconds) && retrySeconds > 0) {
if (pendingTimer) {
clearTimeout(pendingTimer)
}
pendingTimer = setTimeout(() => {
pendingTimer = null
if (!exchanging.value) {
exchangeCode()
}
}, retrySeconds * 1000)
}
return
}
throw new Error(response.message || '授权失败,请重试')
}
tokenInfo = response.data
stopCountdown()
if (pendingTimer) {
clearTimeout(pendingTimer)
pendingTimer = null
}
}
emit('success', tokenInfo)
@@ -700,4 +1009,12 @@ const exchangeCode = async () => {
exchanging.value = false
}
}
onBeforeUnmount(() => {
stopCountdown()
if (pendingTimer) {
clearTimeout(pendingTimer)
pendingTimer = null
}
})
</script>