Compare commits

...

5 Commits

Author SHA1 Message Date
becomer a9dbc2661d feat: 设备码绑定改为非强制 + 随机绑定入口
ci/woodpecker/tag/build Pipeline was successful
- 登录后不再强制跳转设备绑定页(MobileLayout 移除拦截)
- 新建签到任务时检测未绑定设备码,弹窗提示「请先在个人中心绑定设备码」并支持跳转
- 个人中心设备码栏新增「随机」按钮:随机生成 UUID 设备码直接绑定,
  前端连续两次风险确认(随机后现有手机会提示频繁切换设备无法签到)
- 后端新增 POST /device/random-bind(登录态,随机 UUID v4 绑定,mobile_type=manual)
- 新手引导在「新建每日签到任务」前新增「先绑定设备码」步骤
2026-09-08 20:15:24 +08:00
becomer ebc94ec9a0 fix: 复制按钮与 toast 层级修复
ci/woodpecker/tag/build Pipeline was successful
- copyToClipboard 调整为先同步 execCommand(用户手势内最可靠),
  Clipboard API 仅作异步兜底;修复移动端先 await Clipboard API 被拒后
  手势丢失导致 execCommand 静默失败的问题(单行复制不生效)
- 单行复制成功也弹「已复制」toast,失败提示明确,不再无反馈
- toast 容器 z-50 提至 z-[100],不再被弹窗遮罩盖住
2026-09-08 18:27:23 +08:00
becomer 98cf8666f6 feat: 新手引导 VIP 步骤移到最后;CDK 列表展示类型与额度
ci/woodpecker/tag/build Pipeline was successful
- 引导顺序调整为:领取次数 → 新建任务 → 执行任务 → 邀请好友 → VIP 会员
- CDK 列表「次数」列拆为「类型」(次数卡/VIP周卡/VIP月卡徽标)+「额度」(N 次 / N 天)
- CDKCode 类型补充 type/vipDays 字段(后端已输出)
2026-09-08 17:51:34 +08:00
becomer ff3593f057 fix: VIP 展示与复制按钮等 7 项体验修复
ci/woodpecker/tag/build Pipeline was successful
- VIP 徽标:修复 position 覆盖导致徽标挤变形头像,加 z-10 浮于头像上层
- VIP 用户「可签到次数」显示无限次
- VIP 兑换弹窗标题统一「续费 VIP」,去掉提示文案;按钮统一「续费 VIP」
- 新增 utils/clipboard:兼容 HTTP/移动端的复制(Clipboard API + execCommand 回退),
  修复待适配学校密码复制、CDK 结果列表单个复制按钮不生效
- 新手引导新增一步:VIP 会员与普通签到次数的区别
2026-09-08 13:25:13 +08:00
becomer 74422b1848 fix: VIP 卡默认价改为周卡 30 元 / 月卡 100 元(对齐单次扣费 6 元的定价逻辑)
ci/woodpecker/tag/build Pipeline was successful
2026-09-08 12:40:20 +08:00
15 changed files with 305 additions and 85 deletions
+1
View File
@@ -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")
+4 -3
View File
@@ -39,7 +39,8 @@ func getBaseDeduction() float64 {
}
// getVipCardPrice 获取 VIP 卡价格(元/张,系统设置)
// key: vip_price_week(周卡7天,默认5元)/ vip_price_month(月卡30天,默认15元)
// key: vip_price_week(周卡7天,默认30元)/ vip_price_month(月卡30天,默认100元)
// (单次运行基础扣费 6 元,周卡/月卡按次折算后再给折扣)
func getVipCardPrice(key string, def float64) float64 {
var cfg model.SystemConfig
if database.DB.Where("`key` = ?", key).First(&cfg).Error == nil {
@@ -50,8 +51,8 @@ func getVipCardPrice(key string, def float64) float64 {
return def
}
func getVipWeekPrice() float64 { return getVipCardPrice("vip_price_week", 5.00) }
func getVipMonthPrice() float64 { return getVipCardPrice("vip_price_month", 15.00) }
func getVipWeekPrice() float64 { return getVipCardPrice("vip_price_week", 30.00) }
func getVipMonthPrice() float64 { return getVipCardPrice("vip_price_month", 100.00) }
// GenerateCDK 生成CDK
func GenerateCDK(c *fiber.Ctx) error {
+26
View File
@@ -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")
+2 -2
View File
@@ -35,10 +35,10 @@ func GetSettings(c *fiber.Ctx) error {
settings["register_reward"] = "1.00"
}
if _, ok := settings["vip_price_week"]; !ok {
settings["vip_price_week"] = "5.00"
settings["vip_price_week"] = "30.00"
}
if _, ok := settings["vip_price_month"]; !ok {
settings["vip_price_month"] = "15.00"
settings["vip_price_month"] = "100.00"
}
return utils.Success(c, settings)
+1 -1
View File
@@ -28,7 +28,7 @@ watch(
<!-- Toast 容器 -->
<Teleport to="body">
<div class="fixed top-4 right-4 z-50 flex flex-col gap-2 max-w-sm">
<div class="fixed top-4 right-4 z-[100] flex flex-col gap-2 max-w-sm">
<TransitionGroup name="toast">
<div
v-for="toast in toasts"
+4
View File
@@ -138,6 +138,10 @@ export interface CDKCode {
costPrice: number
agentId: number
maxRuns: number
/** CDK类型:count=次数卡 / vip=VIP时长卡 */
type: 'count' | 'vip'
/** VIP 时长天数(type=vip 有效:7=周卡 30=月卡) */
vipDays: number
status: string
userId: number | null
usedAt: string | null
+5
View File
@@ -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<{
+14
View File
@@ -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: '新建每日签到任务',
@@ -90,6 +97,13 @@ const steps: GuideStep[] = [
return '把链接发给同学,TA 通过你的链接注册登录后,你和 TA 都能获得一定积分奖励,多邀多得,随时可兑换签到次数。'
},
},
{
tab: 'profile',
title: 'VIP 会员:签到不限次',
targets: ['[data-guide="profile-vip-btn"]'],
descByKind: () =>
'普通签到次数:每执行一次任务扣 1 次,用完需要再兑换。\nVIP 会员:有效期内执行任务不限次数(无限次),不再扣签到次数。\n点「续费 VIP」输入 VIP 兑换码即可开通,到期时间自动顺延。',
},
]
const current = computed(() => steps[step.value])
+1 -4
View File
@@ -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)) {
+42
View File
@@ -0,0 +1,42 @@
/**
* 复制文本到剪贴板(兼容 HTTP / HTTPS / 移动端 WebView)
*
* 执行顺序刻意为先同步 execCommand:
* document.execCommand('copy') 必须在「用户手势调用栈」内同步执行才可靠,
* 若先 await Clipboard API 被拒(移动端 WebView 常见 NotAllowedError),
* 手势上下文已丢失,后续 execCommand 会静默失败(返回 false 但剪贴板无内容)。
*
* 返回是否复制成功
*/
export async function copyToClipboard(text: string): Promise<boolean> {
// 1) 同步路径:隐藏 textarea + execCommand(用户手势内,兼容性最好)
const ta = document.createElement('textarea')
ta.value = text
// 必须可见且在屏幕内(不可 display:none / 移出视口),否则 iOS Safari 复制失败;
// font-size ≥ 16px 避免 iOS focus 时触发页面缩放
ta.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;font-size:16px;border:0;padding:0;margin:0;'
ta.setAttribute('readonly', '')
document.body.appendChild(ta)
ta.focus()
ta.select()
ta.setSelectionRange(0, text.length)
let ok = false
try {
ok = document.execCommand('copy')
} catch {
ok = false
}
document.body.removeChild(ta)
if (ok) return true
// 2) 异步回退:Clipboard API(仅 HTTPS/localhost;注意此时手势可能已丢失,仅作兜底)
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
// 忽略,走失败返回
}
}
return false
}
+27 -36
View File
@@ -18,6 +18,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useMessage } from '@/composables/useMessage'
import { copyToClipboard } from '@/utils/clipboard'
import * as adminApi from '@/api/admin'
const message = useMessage()
@@ -170,57 +171,34 @@ function closeGenerateModal() {
}
async function copyCode(code: string, index: number) {
try {
await navigator.clipboard.writeText(code)
copiedIndex.value = index
setTimeout(() => { copiedIndex.value = null }, 2000)
} catch {
// fallback
const ta = document.createElement('textarea')
ta.value = code
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
if (await copyToClipboard(code)) {
copiedIndex.value = index
setTimeout(() => { copiedIndex.value = null }, 2000)
message.success('已复制')
} else {
message.error('复制失败,请长按 CDK 码手动复制')
}
}
async function copyResultCode(code: string, index: number) {
try {
await navigator.clipboard.writeText(code)
resultCopiedIdx.value = index
setTimeout(() => { resultCopiedIdx.value = null }, 2000)
} catch {
const ta = document.createElement('textarea')
ta.value = code
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
if (await copyToClipboard(code)) {
resultCopiedIdx.value = index
setTimeout(() => { resultCopiedIdx.value = null }, 2000)
message.success('已复制')
} else {
message.error('复制失败,请长按 CDK 码手动复制')
}
}
async function copyAllCodes() {
if (resultCdks.value.length === 0) return
const all = resultCdks.value.map((c) => c.code).join('\n')
try {
await navigator.clipboard.writeText(all)
if (await copyToClipboard(all)) {
copiedAll.value = true
message.success(`已复制全部 ${resultCdks.value.length} 个CDK`)
setTimeout(() => { copiedAll.value = false }, 2000)
} catch {
const ta = document.createElement('textarea')
ta.value = all
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
copiedAll.value = true
setTimeout(() => { copiedAll.value = false }, 2000)
} else {
message.error('复制失败,请长按 CDK 码手动复制')
}
}
@@ -344,7 +322,8 @@ function statusClass(s: string) {
<th class="px-4 py-3 font-medium">CDK码</th>
<th v-if="isAdmin" class="px-4 py-3 font-medium">生成代理</th>
<th class="px-4 py-3 font-medium">售价</th>
<th class="px-4 py-3 font-medium">次数</th>
<th class="px-4 py-3 font-medium">类型</th>
<th class="px-4 py-3 font-medium">额度</th>
<th class="px-4 py-3 font-medium">状态</th>
<th class="px-4 py-3 font-medium">创建时间</th>
<th class="px-4 py-3 font-medium">操作</th>
@@ -364,7 +343,19 @@ function statusClass(s: string) {
{{ (cdk as any).agent?.username || cdk.agentId }}
</td>
<td class="px-4 py-3 font-mono whitespace-nowrap">{{ cdk.price.toFixed(2) }} 元</td>
<td class="px-4 py-3 text-xs text-gray-600 whitespace-nowrap">{{ cdk.maxRuns ?? 1 }}</td>
<td class="px-4 py-3 whitespace-nowrap">
<span
v-if="cdk.type === 'vip'"
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700 whitespace-nowrap"
>⭐ VIP{{ cdk.vipDays === 7 ? '周卡' : '月卡' }}</span>
<span
v-else
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-700 whitespace-nowrap"
>次数卡</span>
</td>
<td class="px-4 py-3 text-xs text-gray-600 whitespace-nowrap">
{{ cdk.type === 'vip' ? `${cdk.vipDays} 天` : `${cdk.maxRuns ?? 1} 次` }}
</td>
<td class="px-4 py-3 whitespace-nowrap">
<span
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium whitespace-nowrap"
@@ -4,6 +4,7 @@ import { Shield, RefreshCw, CheckCircle, Search, Eye, EyeOff, Copy } from 'lucid
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useMessage } from '@/composables/useMessage'
import { copyToClipboard } from '@/utils/clipboard'
import api from '@/api/index'
interface PendingSchoolItem {
@@ -102,10 +103,13 @@ async function handleMarkDone(record: PendingSchoolItem) {
}
}
function copyText(text: string) {
navigator.clipboard.writeText(text).then(() => {
async function copyText(text: string) {
const ok = await copyToClipboard(text)
if (ok) {
message.success('已复制')
})
} else {
message.error('复制失败,请长按手动复制')
}
}
</script>
+2 -2
View File
@@ -41,8 +41,8 @@ async function loadSettings() {
const res = await adminApi.getSettings()
const settings = res.data?.data || {}
baseDeduction.value = settings['base_deduction_per_run'] || '1.00'
vipWeekPrice.value = settings['vip_price_week'] || '5.00'
vipMonthPrice.value = settings['vip_price_month'] || '15.00'
vipWeekPrice.value = settings['vip_price_week'] || '30.00'
vipMonthPrice.value = settings['vip_price_month'] || '100.00'
inviterReward.value = settings['inviter_reward_per_invite'] || '0.50'
inviteeReward.value = settings['invitee_reward_per_invite'] || '0.50'
registerReward.value = settings['register_reward'] || '1.00'
+48 -1
View File
@@ -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
+121 -33
View File
@@ -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'
@@ -61,8 +61,6 @@ const redeemKind = ref<'count' | 'vip'>('count')
// VIP 信息
const isVip = ref(false)
const vipExpireAt = ref('')
const vipWeekPrice = ref('')
const vipMonthPrice = ref('')
// 验证码
const captchaId = ref('')
const captchaImage = ref('')
@@ -75,14 +73,6 @@ const vipExpireShort = computed(() => {
return vipExpireAt.value.slice(0, 10)
})
// 开通按钮价格文案:周卡 ¥5 · 月卡 ¥15(仅展示已配置的档位)
const vipPriceLabel = computed(() => {
const parts: string[] = []
if (vipWeekPrice.value) parts.push(`周¥${vipWeekPrice.value}`)
if (vipMonthPrice.value) parts.push(`月¥${vipMonthPrice.value}`)
return parts.join(' · ')
})
// 从 axios 错误中提取后端提示文案(429 限流/服务端错误等),失败兜底通用文案
function captchaFailMsg(e: unknown): string {
const err = e as { response?: { data?: { message?: string } } }
@@ -130,8 +120,6 @@ onMounted(async () => {
const vipRes = await authApi.getVipInfo()
isVip.value = vipRes.data.data.isVip
vipExpireAt.value = vipRes.data.data.vipExpireAt
vipWeekPrice.value = vipRes.data.data.vipWeekPrice || ''
vipMonthPrice.value = vipRes.data.data.vipMonthPrice || vipRes.data.data.vipPrice || ''
} catch {
// VIP 信息加载失败不阻塞页面
}
@@ -147,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
@@ -167,8 +189,6 @@ async function reloadVipInfo() {
const vipRes = await authApi.getVipInfo()
isVip.value = vipRes.data.data.isVip
vipExpireAt.value = vipRes.data.data.vipExpireAt
vipWeekPrice.value = vipRes.data.data.vipWeekPrice || ''
vipMonthPrice.value = vipRes.data.data.vipMonthPrice || vipRes.data.data.vipPrice || ''
} catch {
// ignore
}
@@ -279,7 +299,7 @@ async function handleRedeemInvite() {
<img v-show="userInfo?.avatar && !avatarFailed" :src="userInfo?.avatar" class="w-full h-full object-cover" @error="avatarFailed = true" />
<span v-if="!userInfo?.avatar || avatarFailed" class="text-3xl font-bold">{{ userInfo?.realName?.[0] || userInfo?.username?.[0] || '?' }}</span>
</div>
<div v-if="isVip" class="vip-badge absolute -right-3 -top-1.5">VIP</div>
<div v-if="isVip" class="vip-badge absolute -right-3 -top-1.5 z-10 whitespace-nowrap">VIP</div>
</div>
<p class="text-xl font-bold">{{ userInfo?.realName || userInfo?.username || '--' }}</p>
<p class="text-sm text-white/80 mt-1">{{ userInfo?.schoolName || '--' }}</p>
@@ -294,10 +314,11 @@ async function handleRedeemInvite() {
</span>
<span v-else class="text-xs bg-white/20 rounded-full px-3 py-1.5 text-white/95">未开通 VIP</span>
<button
data-guide="profile-vip-btn"
class="vip-btn text-xs font-semibold px-3.5 py-1.5 rounded-full text-amber-900 shadow"
@click="openRedeemModal('vip')"
>
{{ isVip ? '续费 VIP' : `开通 VIP${vipPriceLabel ? ` ${vipPriceLabel}` : ''}` }}
续费 VIP
</button>
</div>
</div>
@@ -312,8 +333,8 @@ async function handleRedeemInvite() {
<ClipboardCheck class="w-4 h-4 text-green-500" />
</div>
<div>
<p class="text-xs text-gray-400">可签到次数</p>
<p class="text-sm font-medium text-gray-900">{{ (userInfo?.signCount ?? 0) }} 次</p>
<p class="text-xs text-gray-400">{{ isVip ? 'VIP 签到次数' : '可签到次数' }}</p>
<p class="text-sm font-medium text-gray-900">{{ isVip ? '无限次' : `${userInfo?.signCount ?? 0} 次` }}</p>
</div>
</div>
<button
@@ -432,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">
@@ -446,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>
@@ -478,7 +508,7 @@ async function handleRedeemInvite() {
<!-- 弹窗头部 -->
<div class="flex items-center justify-between px-5 pt-5 pb-2">
<h3 class="text-base font-semibold text-gray-900">
{{ redeemKind === 'vip' ? '开通 / 续费 VIP' : '兑换签到次数' }}
{{ redeemKind === 'vip' ? '续费 VIP' : '兑换签到次数' }}
</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="closeRedeemModal">
<X class="w-4 h-4" />
@@ -487,9 +517,6 @@ async function handleRedeemInvite() {
<!-- 弹窗内容 -->
<div class="px-5 pb-5 space-y-4">
<p v-if="redeemKind === 'vip'" class="text-xs text-gray-400 leading-relaxed -mt-1">
输入 VIP 兑换码即可开通/续费会员(默认一个月),VIP 有效期内执行任务不限次数、不再扣除签到次数
</p>
<!-- 兑换码输入 -->
<div>
<label class="block text-xs font-medium text-gray-500 mb-1.5">{{ redeemKind === 'vip' ? 'VIP 兑换码' : '兑换码' }}</label>
@@ -547,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>
@@ -560,9 +647,10 @@ async function handleRedeemInvite() {
opacity: 0;
}
/* VIP 徽标:金色渐变 + 反光条扫过动画 */
/* VIP 徽标:金色渐变 + 反光条扫过动画
注意:不可设置 position,徽标由模板上的 Tailwind absolute 类控制浮层定位,
若此处声明 position:relative 会覆盖 absolute,导致徽标挤进头像布局使其变形 */
.vip-badge {
position: relative;
overflow: hidden;
padding: 2px 8px;
border-radius: 999px;