feat: 抖音扫码登录,自动填写 Cookie 和 SecUserId
部署到群晖 / deploy (push) Successful in 32s

新增 DyQrCodeService,调用 sso.douyin.com 二维码接口;
ConfigController 新增 qrcode/qrstatus/qrbind 三个接口;
前端账号列表每行增加"扫码登录"按钮,展示二维码弹窗并自动轮询绑定。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
yuming
2026-04-27 08:56:56 +08:00
parent 6803b32b1b
commit 0848ebc272
4 changed files with 360 additions and 3 deletions
+100 -1
View File
@@ -4,7 +4,7 @@ import { FormInstance } from 'ant-design-vue';
import { reactive, ref, onMounted } from 'vue';
import dayjs from 'dayjs';
import { Dayjs } from 'dayjs';
import { EditFilled } from '@ant-design/icons-vue';
import { EditFilled, QrcodeOutlined } from '@ant-design/icons-vue';
import { useApiStore } from '@/store';
import type { UnwrapRef } from 'vue';
@@ -220,6 +220,77 @@ const removeRow = (index) => {
form.upSecUserIdsJson.splice(index, 1);
};
const rowCount = 4;
// 扫码登录
const showQrModal = ref(false);
const qrToken = ref('');
const qrBase64 = ref('');
const qrStatus = ref(0); // 0=加载中, 1=待扫码, 2=已扫码待确认, 3=登录成功, -1=已过期
const qrAccountId = ref('');
const qrAccountName = ref('');
const qrBindSuccess = ref(false);
let qrPollingTimer: ReturnType<typeof setInterval> | null = null;
const openQrModal = async (record: DataItem) => {
qrAccountId.value = record.id;
qrAccountName.value = record.userName;
qrStatus.value = 0;
qrBase64.value = '';
qrToken.value = '';
qrBindSuccess.value = false;
showQrModal.value = true;
try {
const res = await useApiStore().QrCodeGet();
if (res.code === 0) {
qrToken.value = res.data.token;
qrBase64.value = res.data.qrBase64;
qrStatus.value = 1;
startQrPolling();
} else {
qrStatus.value = -1;
}
} catch {
qrStatus.value = -1;
}
};
const startQrPolling = () => {
stopQrPolling();
qrPollingTimer = setInterval(async () => {
if (!qrToken.value) return;
try {
const res = await useApiStore().QrCodeStatus(qrToken.value);
if (res.code === 0) {
qrStatus.value = res.data.status;
if (res.data.status === 3) {
stopQrPolling();
const bindRes = await useApiStore().QrCodeBind(qrToken.value, qrAccountId.value);
if (bindRes.code === 0) {
qrBindSuccess.value = true;
setTimeout(() => {
showQrModal.value = false;
GetRecords();
}, 1500);
}
} else if (res.data.status === -1) {
stopQrPolling();
}
}
} catch { /* 忽略轮询网络错误 */ }
}, 2000);
};
const stopQrPolling = () => {
if (qrPollingTimer) {
clearInterval(qrPollingTimer);
qrPollingTimer = null;
}
};
const closeQrModal = () => {
stopQrPolling();
showQrModal.value = false;
};
</script>
<template>
<a-modal :title="form._isNew ? '新增Cookie' : '编辑Cookie'" v-model:visible="showModal" @ok="submit" @cancel="cancel" width="1000px">
@@ -374,6 +445,12 @@ const rowCount = 4;
</template>
删除
</a-button>
<a-button type="link" @click="openQrModal(record)" size="small">
<template #icon>
<QrcodeOutlined />
</template>
扫码登录
</a-button>
</template>
<div v-else class="text-subtext">
{{ text }}
@@ -381,6 +458,28 @@ const rowCount = 4;
</template>
</a-table>
<a-modal title="抖音扫码登录" :visible="showQrModal" :footer="null" @cancel="closeQrModal" width="360px">
<div style="text-align: center; padding: 16px 0;">
<div style="margin-bottom: 12px; color: #666;">账号:{{ qrAccountName }}</div>
<div v-if="qrStatus === 0" style="padding: 60px 0; color: #999;">加载中...</div>
<div v-else-if="qrStatus === -1" style="padding: 40px 0;">
<div style="color: #ff4d4f; margin-bottom: 12px;">二维码已过期</div>
<a-button @click="openQrModal({ id: qrAccountId, userName: qrAccountName })">刷新二维码</a-button>
</div>
<div v-else-if="qrBindSuccess" style="padding: 40px 0; color: #52c41a; font-size: 16px;">
登录成功,Cookie 已更新!
</div>
<div v-else>
<img v-if="qrBase64" :src="'data:image/png;base64,' + qrBase64" style="width: 200px; height: 200px;" />
<div style="margin-top: 12px; font-size: 13px; color: #666;">
<span v-if="qrStatus === 1">请用抖音 App 扫描二维码</span>
<span v-else-if="qrStatus === 2" style="color: #1890ff;">已扫码,请在手机上确认登录</span>
<span v-else-if="qrStatus === 3" style="color: #52c41a;">登录成功,正在保存...</span>
</div>
</div>
</div>
</a-modal>
<a-modal title="Cookie 详情" :visible="showCookiesModal" style="width:1200px;" @cancel="showCookiesModal = false" @ok="showCookiesModal = false">
<div class="cookie-content">
{{ showCookiesData || '无Cookie数据' }}