- 登录后不再强制跳转设备绑定页(MobileLayout 移除拦截) - 新建签到任务时检测未绑定设备码,弹窗提示「请先在个人中心绑定设备码」并支持跳转 - 个人中心设备码栏新增「随机」按钮:随机生成 UUID 设备码直接绑定, 前端连续两次风险确认(随机后现有手机会提示频繁切换设备无法签到) - 后端新增 POST /device/random-bind(登录态,随机 UUID v4 绑定,mobile_type=manual) - 新手引导在「新建每日签到任务」前新增「先绑定设备码」步骤
This commit is contained in:
@@ -96,6 +96,7 @@ func main() {
|
||||
device.Post("/generate", middleware.AuthRequired(), handler.GenerateDeviceQr)
|
||||
device.Post("/submit", handler.SubmitDeviceCode)
|
||||
device.Get("/status", middleware.AuthRequired(), handler.GetDeviceStatus)
|
||||
device.Post("/random-bind", middleware.AuthRequired(), handler.RandomBindDevice)
|
||||
|
||||
// 管理后台 API
|
||||
admin := api.Group("/admin")
|
||||
|
||||
@@ -137,6 +137,32 @@ func SubmitDeviceCode(c *fiber.Ctx) error {
|
||||
return utils.Success(c, fiber.Map{"status": "bound"})
|
||||
}
|
||||
|
||||
// RandomBindDevice 随机生成设备码并绑定到当前用户(个人中心「随机」按钮)
|
||||
// 风险:与真实手机绑定的设备码不一致,执行签到时现有手机会提示「频繁切换设备」导致无法签到,
|
||||
// 前端已做连续两次确认;此处不再重复校验,直接生成 UUID(大写,与真实设备码格式一致)绑定。
|
||||
func RandomBindDevice(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return utils.FailServer(c, "生成设备码失败")
|
||||
}
|
||||
// 标准 UUID v4 格式(大写),与真实设备码样式一致
|
||||
b[6] = (b[6] & 0x0f) | 0x40
|
||||
b[8] = (b[8] & 0x3f) | 0x80
|
||||
deviceCode := fmt.Sprintf("%X-%X-%X-%X-%X", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
||||
|
||||
if err := database.DB.Model(&model.SysUser{}).Where("id = ?", userID).Updates(map[string]interface{}{
|
||||
"mobile_code": deviceCode,
|
||||
"mobile_type": "manual",
|
||||
}).Error; err != nil {
|
||||
return utils.FailServer(c, "绑定失败")
|
||||
}
|
||||
|
||||
slog.Info("随机绑定设备码", "userId", userID, "deviceCode", deviceCode)
|
||||
return utils.Success(c, fiber.Map{"deviceCode": deviceCode})
|
||||
}
|
||||
|
||||
// ClosePage 授权完成页(当 WebView 无法关闭时跳转至此)
|
||||
func ClosePage(c *fiber.Ctx) error {
|
||||
c.Type("html")
|
||||
|
||||
@@ -121,6 +121,11 @@ export function getDeviceStatus(token: string) {
|
||||
})
|
||||
}
|
||||
|
||||
/** 随机生成设备码并绑定到当前账号(个人中心「随机」按钮,需连续两次风险确认) */
|
||||
export function randomBindDevice() {
|
||||
return api.post<{ data: { deviceCode: string } }>('/device/random-bind')
|
||||
}
|
||||
|
||||
/** 新 CDK 兑换(需验证码,由管理后台生成的 CDK;次数 CDK 加次数 / VIP CDK 延长 VIP) */
|
||||
export function redeemCDK(code: string, captchaId: string, captchaCode: string) {
|
||||
return api.post<{
|
||||
|
||||
@@ -57,6 +57,13 @@ const steps: GuideStep[] = [
|
||||
descByKind: () =>
|
||||
'新用户注册即送奖励积分,邀请好友也能赚积分。\n积分满 1 后,点这里「兑换」按钮即可换成签到次数,1 积分 = 1 次签到,可重复兑换。',
|
||||
},
|
||||
{
|
||||
tab: 'profile',
|
||||
title: '先绑定设备码',
|
||||
targets: ['[data-guide="profile-device-code"]'],
|
||||
descByKind: () =>
|
||||
'签到任务依赖设备码执行。到这一栏点「重新绑定」用手机扫码授权,或点「随机」直接生成设备码。\n注意:随机设备码与真实手机不一致,执行签到时现有手机会提示「频繁切换设备」无法签到,建议优先扫码授权。',
|
||||
},
|
||||
{
|
||||
tab: 'home',
|
||||
title: '新建每日签到任务',
|
||||
|
||||
@@ -212,10 +212,7 @@ onMounted(async () => {
|
||||
const data = res.data.data
|
||||
userInfo.value = data
|
||||
|
||||
if (!data.mobileCode) {
|
||||
router.replace('/bind-device')
|
||||
return
|
||||
}
|
||||
// 未绑定设备码不再强制跳转绑定页(新建任务时会在入口处弹窗引导)
|
||||
// 新手引导:数据库判定该用户未看过当前版本引导则展示(跨设备一致);
|
||||
// 本次若来自 ?guide=1 强播则优先展示(guideForced 已消费,下次加载不再生效)
|
||||
if (guideForced.value || needGuide(data)) {
|
||||
|
||||
@@ -88,9 +88,19 @@ async function removeTaskLog(id: number) {
|
||||
}
|
||||
}
|
||||
|
||||
// ======== 设备码绑定状态(新建任务入口拦截用) ========
|
||||
const deviceCode = ref('')
|
||||
const showBindDeviceDialog = ref(false)
|
||||
|
||||
// ======== 初始化 ========
|
||||
onMounted(async () => {
|
||||
// MobileLayout 已加载用户信息并处理绑设备检查
|
||||
// 拉取设备码绑定状态(新建任务入口检测未绑定时弹窗引导)
|
||||
try {
|
||||
const res = await authApi.getUserInfo()
|
||||
deviceCode.value = res.data.data?.mobileCode || ''
|
||||
} catch {
|
||||
// 拉取失败不阻塞页面
|
||||
}
|
||||
// 这里直接加载任务数据和日志
|
||||
await loadTasks()
|
||||
await loadTaskLogs()
|
||||
@@ -142,6 +152,11 @@ async function loadTasks() {
|
||||
|
||||
// ======== Step 0: 选择任务标题 ========
|
||||
async function startCreateTask() {
|
||||
// 未绑定设备码:弹窗引导去个人中心绑定,不进入创建流程
|
||||
if (!deviceCode.value) {
|
||||
showBindDeviceDialog.value = true
|
||||
return
|
||||
}
|
||||
showStep.value = true
|
||||
step.value = 0
|
||||
stepTitle.value = '选择签到任务'
|
||||
@@ -1295,6 +1310,38 @@ function getGPSLocation(source: string) {
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- ======== 未绑定设备码弹窗(新建任务入口拦截) ======== -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showBindDeviceDialog"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/40" @click="showBindDeviceDialog = false" />
|
||||
<div class="relative w-[85%] sm:max-w-sm bg-white rounded-2xl shadow-xl overflow-hidden">
|
||||
<div class="p-6 text-center">
|
||||
<div class="w-14 h-14 rounded-full bg-indigo-100 flex items-center justify-center mx-auto mb-4">
|
||||
<Smartphone class="w-7 h-7 text-indigo-500" />
|
||||
</div>
|
||||
<h3 class="text-base font-semibold text-gray-900 mb-2">未绑定设备码</h3>
|
||||
<p class="text-sm text-gray-500 leading-relaxed">
|
||||
请先在个人中心绑定设备码,绑定后才能创建和执行签到任务
|
||||
</p>
|
||||
<div class="flex gap-2 mt-5">
|
||||
<Button variant="outline" class="flex-1 h-10 text-sm" @click="showBindDeviceDialog = false">
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
class="flex-1 h-10 text-sm font-medium"
|
||||
@click="showBindDeviceDialog = false; router.push('/profile')"
|
||||
>
|
||||
去绑定
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- ======== 操作频繁弹窗(限流) ======== -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { User, IdCard, LogOut, RefreshCw, ClipboardCheck, Gift, Share2, X, MessageSquare } from 'lucide-vue-next'
|
||||
import { User, IdCard, LogOut, RefreshCw, ClipboardCheck, Gift, Share2, X, MessageSquare, Dices, TriangleAlert } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
@@ -135,6 +135,40 @@ function goRebind() {
|
||||
router.push('/bind-device')
|
||||
}
|
||||
|
||||
// ---- 随机绑定设备码(连续两次风险确认) ----
|
||||
// 0=关闭 1=第一次确认 2=第二次确认
|
||||
const randBindStep = ref(0)
|
||||
const randBinding = ref(false)
|
||||
|
||||
function openRandomBind() {
|
||||
randBindStep.value = 1
|
||||
}
|
||||
|
||||
function closeRandomBind() {
|
||||
randBindStep.value = 0
|
||||
}
|
||||
|
||||
async function confirmRandomBind() {
|
||||
if (randBindStep.value === 1) {
|
||||
// 第一次确认 → 进入第二次确认
|
||||
randBindStep.value = 2
|
||||
return
|
||||
}
|
||||
randBinding.value = true
|
||||
try {
|
||||
const res = await authApi.randomBindDevice()
|
||||
if (userInfo.value) {
|
||||
userInfo.value.mobileCode = res.data.data.deviceCode
|
||||
}
|
||||
randBindStep.value = 0
|
||||
message.success('随机设备码绑定成功')
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message || e?.message || '绑定失败')
|
||||
} finally {
|
||||
randBinding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 兑换弹窗 ----
|
||||
function openRedeemModal(kind: 'count' | 'vip' = 'count') {
|
||||
redeemKind.value = kind
|
||||
@@ -419,7 +453,7 @@ async function handleRedeemInvite() {
|
||||
</div>
|
||||
</div>
|
||||
<!-- 设备码 -->
|
||||
<div class="flex items-center justify-between px-4 py-3.5">
|
||||
<div data-guide="profile-device-code" class="flex items-center justify-between px-4 py-3.5">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-indigo-50 flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
@@ -433,13 +467,22 @@ async function handleRedeemInvite() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="text-xs text-blue-500 flex items-center gap-1 hover:text-blue-600"
|
||||
@click="goRebind"
|
||||
>
|
||||
<RefreshCw class="w-3 h-3" />
|
||||
重新绑定
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="text-xs text-amber-600 flex items-center gap-1 hover:text-amber-700"
|
||||
@click="openRandomBind"
|
||||
>
|
||||
<Dices class="w-3 h-3" />
|
||||
随机
|
||||
</button>
|
||||
<button
|
||||
class="text-xs text-blue-500 flex items-center gap-1 hover:text-blue-600"
|
||||
@click="goRebind"
|
||||
>
|
||||
<RefreshCw class="w-3 h-3" />
|
||||
重新绑定
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -531,6 +574,66 @@ async function handleRedeemInvite() {
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- 随机绑定设备码:连续两次风险确认弹窗 -->
|
||||
<Teleport to="body">
|
||||
<div v-if="randBindStep > 0" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4" @click.self="closeRandomBind">
|
||||
<div class="bg-white rounded-2xl shadow-xl w-full max-w-sm overflow-hidden">
|
||||
<!-- 第一次确认 -->
|
||||
<template v-if="randBindStep === 1">
|
||||
<div class="px-5 pt-5 pb-2 flex items-center justify-between">
|
||||
<h3 class="text-base font-semibold text-gray-900">随机生成设备码</h3>
|
||||
<button class="w-7 h-7 rounded-full bg-gray-100 flex items-center justify-center text-gray-400 hover:text-gray-600" @click="closeRandomBind">
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="px-5 pb-5">
|
||||
<div class="flex items-start gap-2.5 bg-amber-50 border border-amber-200 rounded-lg p-3">
|
||||
<TriangleAlert class="w-4 h-4 text-amber-500 flex-shrink-0 mt-0.5" />
|
||||
<p class="text-xs text-amber-700 leading-relaxed">
|
||||
随机设备码与你真实手机不一致。若你已有绑定的手机在用本系统,随机后该手机执行签到时会出现
|
||||
<strong>「频繁切换设备」提示,无法正常签到</strong>。
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-3">仅建议从未绑定过手机、或明确了解风险的账号使用。</p>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<Button variant="outline" class="flex-1 h-10 text-sm" @click="closeRandomBind">取消</Button>
|
||||
<Button class="flex-1 h-10 text-sm font-medium" @click="confirmRandomBind">继续</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 第二次确认 -->
|
||||
<template v-else>
|
||||
<div class="px-5 pt-5 pb-2 flex items-center justify-between">
|
||||
<h3 class="text-base font-semibold text-red-600">再次确认:确定随机?</h3>
|
||||
<button class="w-7 h-7 rounded-full bg-gray-100 flex items-center justify-center text-gray-400 hover:text-gray-600" @click="closeRandomBind">
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="px-5 pb-5">
|
||||
<p class="text-sm text-gray-600 leading-relaxed">
|
||||
随机后,你现有手机登录 App 执行签到时将提示
|
||||
<strong class="text-red-500">「频繁切换设备」而无法正常签到</strong>,
|
||||
需要重新扫码授权绑定才能恢复。
|
||||
</p>
|
||||
<p class="text-xs text-gray-400 mt-2">这是最后一次确认,确定要随机生成设备码吗?</p>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<Button variant="outline" class="flex-1 h-10 text-sm" @click="closeRandomBind">取消</Button>
|
||||
<Button
|
||||
class="flex-1 h-10 text-sm font-medium bg-red-500 hover:bg-red-600"
|
||||
:disabled="randBinding"
|
||||
@click="confirmRandomBind"
|
||||
>
|
||||
<RefreshCw v-if="randBinding" class="h-4 w-4 animate-spin" />
|
||||
{{ randBinding ? '绑定中...' : '确定随机' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user