Files
douyin_cookie_yunsya/frontend/src/views/HomeView.vue
T
travel 9e0644095f 重构为 HTTP SSO 扫码方案并引入 Vue3 前端
移除 Playwright 浏览器自动化,改用 passport/SSO HTTP 接口获取二维码与轮询登录;后端模块化拆分,前端替换为 Vue3 SPA。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 10:47:55 +08:00

658 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="page-cloud">
<ToastMessage :visible="toast.show" :message="toast.msg" :is-error="toast.isError" />
<div class="cloud-workspace">
<header class="page-head">
<h1 class="page-title">扫码提取</h1>
<p class="page-desc">使用抖音 App 扫码登录一键导出 JSON 格式 Cookie</p>
</header>
<div class="stats-row">
<div class="stat-card">
<div class="stat-label">提取次数</div>
<div class="stat-value">{{ stats.total }}</div>
</div>
<div class="stat-card stat-success">
<div class="stat-label">成功</div>
<div class="stat-value">{{ stats.success }}</div>
</div>
<div class="stat-card stat-accent">
<div class="stat-label">平均耗时</div>
<div class="stat-value">{{ avgTime }}</div>
</div>
<div class="stat-card stat-muted">
<div class="stat-label">成功率</div>
<div class="stat-value">{{ successRate }}</div>
</div>
</div>
<div class="status-panel">
<div class="status-top">
<span class="status-badge" :class="'status-badge--' + sessionStatus">
<span class="status-dot" :class="'status-dot--' + sessionStatus"></span>
{{ statusLabel }}
<span v-if="isLiveSession" class="live-tag">
<span class="live-pulse"></span>实时
</span>
</span>
<span class="status-time" :title="lastRefreshTitle">{{ lastRefreshText }}</span>
</div>
<p class="status-msg">{{ statusText }}</p>
</div>
<div class="info-strip info-strip--primary">
通过抖音官方 SSO HTTP 接口获取二维码无需启动浏览器若提示 IP 被拦截请配置住宅代理 API 后重试
</div>
<div class="work-grid">
<section class="work-main card">
<div class="action-row">
<button type="button" class="btn btn-primary btn-lg" :disabled="loading" @click="startQR">
{{ loading ? '启动中…' : '获取二维码' }}
</button>
<button type="button" class="btn" title="重置会话" :disabled="loading" @click="resetSession">
重置
</button>
</div>
<div v-if="qrImage" class="qr-block">
<div class="qr-frame" :class="{ scanning }">
<img class="qr-img" :src="qrImage" alt="登录二维码" />
</div>
<span class="badge" :class="badgeClass">{{ badgeText }}</span>
<a class="qr-dl" href="/qrcode.png" target="_blank" rel="noopener">下载二维码图片</a>
</div>
<div v-if="apiMode" class="api-mode-tag">接口模式{{ apiMode }}</div>
<div v-if="cookies" class="cookie-block">
<div class="cookie-head">
<h3 class="col-title">Cookie 结果</h3>
<button type="button" class="btn btn-sm" :class="copied ? 'btn-soft' : 'btn-primary'" @click="copyCookies">
{{ copied ? '已复制' : '复制 JSON' }}
</button>
</div>
<pre class="cookie-pre">{{ cookieJson }}</pre>
</div>
</section>
<aside class="work-side card">
<h3 class="col-title">使用说明</h3>
<ol class="steps-list">
<li>点击获取二维码」(HTTP 接口</li>
<li>用抖音 App 扫描页面二维码</li>
<li>手机确认登录</li>
<li>页面自动轮询并完成 Cookie 导出</li>
</ol>
<h3 class="col-title side-gap">代理配置</h3>
<p class="field-label">代理 API选填</p>
<input v-model="proxyApi" class="inp full" placeholder="https://api.example.com/proxy" autocomplete="off" />
<p class="proxy-hint">{{ proxyApi ? '已配置自定义代理' : '当前:服务器默认 IP' }}</p>
<button type="button" class="btn btn-soft full" :disabled="testingProxy" @click="testProxy">
{{ testingProxy ? '测试中…' : '测试代理' }}
</button>
</aside>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { get, post, formatApiError } from '../api/http.js'
import { useLivePoll } from '../composables/useLivePoll.js'
import ToastMessage from '../components/ToastMessage.vue'
const ACTIVE_SESSION = new Set(['loading', 'qr_ready', 'scanning'])
const loading = ref(false)
const scanning = ref(false)
const qrImage = ref('')
const cookies = ref(null)
const copied = ref(false)
const proxyApi = ref('')
const testingProxy = ref(false)
const error = ref('')
const stats = reactive({
total: parseInt(localStorage.getItem('ck_total') || '0', 10),
success: parseInt(localStorage.getItem('ck_ok') || '0', 10),
totalTime: parseInt(localStorage.getItem('ck_time') || '0', 10),
})
const toast = reactive({ show: false, msg: '', isError: false })
let toastTimer = null
const sessionId = ref('')
const sessionStatus = ref('idle')
const sessionMsg = ref('')
const nickname = ref('')
let startTime = 0
const cookieCount = ref(0)
const loginMatched = ref(0)
const loginKeysFound = ref([])
const apiMode = ref('')
const now = ref(Date.now())
let clockTimer = null
const isLiveSession = computed(() => ACTIVE_SESSION.has(sessionStatus.value))
const livePoll = useLivePoll(liveTick, () => (isLiveSession.value ? 1000 : 3000))
const lastRefreshText = computed(() => {
if (!livePoll.lastTickAt.value) return now.value && new Date(now.value).toLocaleTimeString()
const sec = Math.max(0, Math.floor((now.value - livePoll.lastTickAt.value) / 1000))
if (sec === 0) return '刚刚更新'
if (sec < 60) return `${sec}s 前更新`
return new Date(livePoll.lastTickAt.value).toLocaleTimeString()
})
const lastRefreshTitle = computed(() => {
if (!livePoll.lastTickAt.value) return '等待首次刷新'
return `上次刷新: ${new Date(livePoll.lastTickAt.value).toLocaleString()}`
})
const statusLabel = computed(() => {
const m = {
idle: '就绪',
loading: '启动中',
scanning: '扫码中',
qr_ready: '二维码已就绪',
success: '成功',
error: '错误',
}
return m[sessionStatus.value] || '就绪'
})
const statusText = computed(() => {
if (error.value) return error.value
if (nickname.value && sessionStatus.value === 'success') {
return `${nickname.value},登录成功!`
}
if (sessionStatus.value === 'scanning') {
const keys = loginKeysFound.value.length ? ` [${loginKeysFound.value.join(', ')}]` : ''
return `已在手机扫码,等待确认… Cookie ${cookieCount.value} 个,登录匹配 ${loginMatched.value}${keys}`
}
if (sessionStatus.value === 'qr_ready') {
return '请使用抖音 App 扫描二维码'
}
return sessionMsg.value || '点击「获取二维码」开始'
})
const avgTime = computed(() => {
if (stats.success === 0) return '—'
return `${Math.round(stats.totalTime / stats.success)}s`
})
const successRate = computed(() => {
if (stats.total === 0) return '0%'
return `${Math.round((stats.success / stats.total) * 100)}%`
})
const cookieJson = computed(() => (cookies.value ? JSON.stringify(cookies.value, null, 2) : ''))
const badgeText = computed(() => {
if (cookies.value) return '登录成功'
if (sessionStatus.value === 'scanning') return '等待手机确认'
if (sessionStatus.value === 'qr_ready') return '请使用抖音扫码'
return '请获取二维码'
})
const badgeClass = computed(() => {
if (cookies.value) return 'badge-ok'
if (sessionStatus.value === 'scanning') return 'badge-info'
return 'badge-info'
})
function showToast(msg, isErr = false) {
toast.msg = msg
toast.isError = isErr
toast.show = true
clearTimeout(toastTimer)
toastTimer = setTimeout(() => {
toast.show = false
}, 4000)
}
function saveStats() {
localStorage.setItem('ck_total', String(stats.total))
localStorage.setItem('ck_ok', String(stats.success))
localStorage.setItem('ck_time', String(stats.totalTime))
}
async function liveTick() {
await pollStatus()
if (ACTIVE_SESSION.has(sessionStatus.value) || sessionId.value) {
await checkLogin()
}
}
function applyPollMeta(data) {
if (data.api_mode) apiMode.value = data.api_mode
}
async function pollStatus() {
try {
const data = await get('/api/status')
sessionMsg.value = data.message || ''
applyPollMeta(data)
if (data.status === 'error') {
error.value = data.message || ''
sessionStatus.value = 'error'
} else if (data.status === 'success') {
sessionStatus.value = 'success'
}
} catch {
/* ignore */
}
}
async function checkLogin() {
try {
const params = sessionId.value ? { session_id: sessionId.value } : {}
const data = await get('/api/check_login', params)
if (data.status === 'success') {
sessionStatus.value = 'success'
nickname.value = data.nickname || '用户'
cookies.value = data.cookies
scanning.value = false
loading.value = false
const elapsed = Math.round((Date.now() - startTime) / 1000)
showToast(`${nickname.value},登录成功!耗时 ${elapsed}s`)
stats.success++
stats.totalTime += elapsed
saveStats()
} else if (data.status === 'error') {
sessionStatus.value = 'error'
error.value = data.message || ''
scanning.value = false
loading.value = false
showToast(data.message || '登录失败', true)
} else if (data.status === 'qr_ready') {
sessionStatus.value = 'qr_ready'
scanning.value = false
cookieCount.value = data.cookie_count || 0
loginMatched.value = data.login_matched || 0
} else if (data.status === 'scanning') {
sessionStatus.value = 'scanning'
cookieCount.value = data.cookie_count || 0
loginMatched.value = data.login_matched || 0
loginKeysFound.value = data.login_keys_found || []
scanning.value = true
} else if (data.status === 'idle') {
showToast('会话已过期,请重新获取二维码', true)
}
} catch (e) {
console.error('checkLogin', e)
}
}
async function startQR() {
try {
const status = await get('/api/status')
if (status.status === 'loading' || status.status === 'scanning') {
showToast('已有任务正在执行', true)
return
}
} catch {
/* continue */
}
loading.value = true
scanning.value = false
qrImage.value = ''
cookies.value = null
error.value = ''
apiMode.value = ''
sessionStatus.value = 'loading'
startTime = Date.now()
stats.total++
saveStats()
try {
const data = await post('/api/start_qr', { proxy_api: proxyApi.value.trim() })
if (data.status === 'qr_ready') {
qrImage.value = data.qr_image
sessionId.value = data.session_id || ''
sessionStatus.value = 'qr_ready'
apiMode.value = data.api_mode || ''
loading.value = false
nickname.value = ''
if (data.message) showToast(data.message)
livePoll.restart()
} else {
error.value = data.message || '获取二维码失败'
sessionStatus.value = 'error'
loading.value = false
showToast(data.message || '获取二维码失败', true)
}
} catch (e) {
error.value = '连接服务器失败'
sessionStatus.value = 'error'
loading.value = false
showToast(formatApiError(e, '无法连接服务器'), true)
}
}
async function resetSession() {
try {
await post('/api/reset')
sessionId.value = ''
sessionStatus.value = 'idle'
sessionMsg.value = '会话已重置'
nickname.value = ''
qrImage.value = ''
cookies.value = null
scanning.value = false
loading.value = false
error.value = ''
apiMode.value = ''
showToast('已重置')
} catch (e) {
showToast(formatApiError(e, '重置失败'), true)
}
}
async function testProxy() {
const url = proxyApi.value.trim()
if (!url) {
showToast('请先输入代理 API', true)
return
}
testingProxy.value = true
try {
const data = await post('/api/test_proxy', { proxy_api: url })
if (data.status === 'success') {
showToast(`代理可用,IP: ${data.ip || 'ok'}`)
} else {
showToast(data.message || '代理失败', true)
}
} catch (e) {
showToast(formatApiError(e, '网络错误'), true)
} finally {
testingProxy.value = false
}
}
async function copyCookies() {
try {
await navigator.clipboard.writeText(cookieJson.value)
copied.value = true
showToast('已复制到剪贴板')
setTimeout(() => {
copied.value = false
}, 2000)
} catch {
showToast('复制失败', true)
}
}
function onGlobalRefresh() {
livePoll.runTick()
}
onMounted(() => {
livePoll.start()
clockTimer = setInterval(() => {
now.value = Date.now()
}, 1000)
window.addEventListener('douyin-refresh-all', onGlobalRefresh)
})
onUnmounted(() => {
livePoll.stop()
if (clockTimer) clearInterval(clockTimer)
clearTimeout(toastTimer)
window.removeEventListener('douyin-refresh-all', onGlobalRefresh)
})
</script>
<style scoped>
.page-title {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: var(--text-strong);
}
.page-desc {
margin: 0.35rem 0 0;
font-size: 0.875rem;
color: var(--muted);
}
.status-panel {
background: #fafafa;
border: 1px solid var(--border-light);
border-radius: var(--radius-sm);
padding: 0.75rem 0.85rem;
margin-bottom: 0.65rem;
}
.status-top {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.35rem;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.85rem;
font-weight: 600;
color: var(--text-strong);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--muted);
}
.status-dot--loading {
background: var(--warn);
animation: pulse 1.4s infinite;
}
.status-dot--scanning,
.status-dot--qr_ready {
background: var(--primary);
animation: pulse 1.4s infinite;
}
.status-dot--success {
background: var(--success);
}
.status-dot--error {
background: var(--danger);
}
@keyframes pulse {
0%,
100% {
opacity: 0.45;
transform: scale(0.85);
}
50% {
opacity: 1;
transform: scale(1.1);
}
}
.status-time {
font-size: 0.75rem;
color: var(--muted);
}
.live-tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin-left: 0.35rem;
padding: 0.1rem 0.4rem;
font-size: 0.68rem;
font-weight: 600;
color: var(--success);
background: var(--success-bg);
border-radius: 4px;
}
.live-pulse {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--success);
animation: pulse 1.2s infinite;
}
.status-msg {
margin: 0;
font-size: 0.875rem;
color: var(--text);
word-break: break-word;
}
.work-grid {
display: grid;
grid-template-columns: 1fr 240px;
gap: 0.65rem;
align-items: start;
}
.work-main,
.work-side {
margin: 0;
}
.action-row {
display: flex;
gap: 0.5rem;
margin-bottom: 0.85rem;
}
.btn-lg {
flex: 1;
min-height: 2.5rem;
}
.qr-block {
text-align: center;
padding: 1rem;
background: #fafbfc;
border: 1px solid var(--border-light);
border-radius: var(--radius-sm);
margin-bottom: 0.85rem;
}
.qr-frame {
position: relative;
display: inline-block;
padding: 0.5rem;
background: #fff;
border: 1px solid var(--border-light);
border-radius: var(--radius-sm);
margin-bottom: 0.5rem;
}
.qr-img {
display: block;
width: 220px;
max-width: 100%;
height: auto;
}
.qr-dl {
display: inline-block;
margin-top: 0.5rem;
font-size: 0.8rem;
}
.api-mode-tag {
font-size: 0.78rem;
color: var(--muted);
margin-bottom: 0.65rem;
}
.steps-list {
margin: 0 0 1rem;
padding-left: 1.2rem;
font-size: 0.82rem;
color: var(--text);
line-height: 1.6;
}
.side-gap {
margin-top: 0.5rem;
}
.cookie-block {
border-top: 1px solid var(--border-light);
padding-top: 0.85rem;
}
.cookie-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.col-title {
margin: 0;
font-size: 0.95rem;
font-weight: 600;
color: var(--text-strong);
}
.cookie-pre {
margin: 0;
padding: 0.75rem;
background: #1a1a1a;
color: #e8e8e8;
border-radius: var(--radius-sm);
font-size: 0.75rem;
max-height: 220px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
}
.field-label {
margin-bottom: 0.35rem;
}
.inp.full {
width: 100%;
margin-bottom: 0.5rem;
}
.proxy-hint {
font-size: 0.78rem;
color: var(--muted);
margin: 0.35rem 0 0.65rem;
}
.work-side .full {
width: 100%;
}
@media (max-width: 899px) {
.work-grid {
grid-template-columns: 1fr;
}
.qr-img {
width: 180px;
}
}
</style>