Compare commits

...

12 Commits

Author SHA1 Message Date
becomer e5b57203fb 置顶公告:回到前台时重新测量文本宽度
ci/woodpecker/tag/build Pipeline was successful
onVisibility 原先只调 schedule(),不重新测量。后台期间若发生尺寸变化
(横竖屏切换、系统字体缩放、页面布局重排),overflow 仍是旧值,
长文本的滚动距离会算错,表现为「还没滚完就翻页」或「滚过头留白」,
而 measure() 本身只在挂载和 resize 时执行。

改为回到前台先 measure() 再 schedule(),与 resize 处理保持一致。

审查同类边界,以下均已正确处理,未改动:
- items 为空:模板用 v-if="current" 不渲染
- 只有 1 条且不溢出:schedule 直接 return,不空转定时器
- items 长度变化:watch 中把越界索引归零
- 滚动时长:Math.min(9, Math.max(3, ...)) 有上下限,
  不会出现 NaN/Infinity 导致 setTimeout 异常

npm run build 与 vue-tsc 均通过。
2026-10-21 17:35:28 +08:00
becomer a51501909a 绑定邀请码增加形态预校验,并兼容 6 位存量码
ci/woodpecker/tag/build Pipeline was successful
1. 预校验(避免无用查库)
   原先任何非空字符串都会直接拿去查库。邀请码固定长度且只用
   inviteCodeChars(去掉 0/O/1/L 后的 31 个字符),不合规的输入
   可以无需查库就拒绝;用统一文案「邀请码无效」,不泄露额外信息。

2. 存量兼容(关键,差点造成线上事故)
   最初把校验写成「必须 8 位」。查生产库发现:
       6 位:551 个用户
       8 位:1 个用户
   即绝大多数用户的邀请码仍是 6 位旧码。若只接受 8 位,
   这 551 人的码会被判无效,他们将无法被新用户填写邀请——
   影响面很大。已改为同时接受 6 位与 8 位,字符集校验不变。

测试覆盖:8 位正常、6 位存量、5/7 位、空串、含易混淆字符
(0/O/1/I/L)、含小写、含符号、含中文。
2026-10-21 17:33:41 +08:00
becomer 672214c06e 重置邀请码:补 409 并发冲突自动刷新与空码保护
ci/woodpecker/tag/build Pipeline was successful
自审发现两个未处理的边界,都属于「提示了但页面状态不对」:

1. 并发冲突(409)
   两个请求同时重置时,后端乐观锁会让其中一个返回 409。
   原先只弹错误提示,页面仍显示旧码——用户以为没重置成功,
   但服务端实际已是新码,导致分享出去的是失效的码。
   现在收到 409 会自动重新拉取邀请信息并刷新页面状态。

2. 接口成功但未返回新码
   原先 `if (code) inviteCode.value = code` 在 code 为空时静默跳过,
   却照样提示「邀请码已重置」,页面继续显示旧码。
   改为明确报错并 return,不谎报成功。

npm run build 与 vue-tsc 均通过。
2026-10-21 17:32:28 +08:00
becomer 4cc36658c0 修正头图绿色背景高度:pb-16 → pb-22,让渐变切到会员卡一半
ci/woodpecker/tag/build Pipeline was successful
之前一直调不对,根因是我把方向搞反了。

重新用构建产物里的真实数值推算(--spacing = .25rem = 4px):
  绿色切入卡片的深度 = pb - |卡片上浮| = pb - 44px
  pb-16 时只有 64 - 44 = 20px,约占卡片高(86px)的 23%,
  看起来就是「绿色太矮、远没到一半」。
  要切到一半(43px),需 pb ≈ 43 + 44 = 87px → pb-22(88px)。

之前的错误在于:几次都想通过减小 pb 或调整 -mt 来「对齐按钮中线」,
但两者会同步移动、互相抵消,所以怎么调都是 23%。真正要改的是
pb 的绝对值,让绿色背景整体变高。

改动:pb-16 → pb-22(64px → 88px),-mt-11 保持不变。

已用真实构建产物 CSS(dist/assets/*.css)渲染验证:
  切入深度 44px、占卡片 51.2%、与按钮中线差 1px,
  并截图确认绿色正确覆盖到头像与姓名的下半部分。
2026-10-21 17:30:34 +08:00
becomer c75e55c6e7 VIP 徽标改到姓名右侧,头图高度按实测重新校准
ci/woodpecker/tag/build Pipeline was successful
1. VIP 徽标位置
   原先绝对定位贴在头像右下角,会盖住头像边缘。
   改为放在姓名右侧同一行:姓名 span 用 truncate、徽标 shrink-0,
   长姓名被截断时徽标仍完整显示,不会被挤压。

2. 头图(顶部绿色背景)高度
   按需求应为「达到购买按钮一半的位置,上方全是背景」。
   基于真实结构(hero 内无可见内容,仅绝对定位装饰)重建等价 DOM 实测:
     空 hero + pb-16 + 卡片 -mt-11
     → hero 底部 Y=78、按钮中线 Y=78,差值 0px,切入卡片 50.0%
   即渐变边界正好落在「兑换 CDK」按钮中线,符合要求,故维持
   pb-16 / -mt-11 不变(上一版实测同样为 50.0%,本次复核确认)。

已截图确认徽标与姓名同行渲染正常(长姓名截断、徽标完整)。
npm run build 与 vue-tsc 均通过。
2026-10-21 17:25:49 +08:00
becomer 315942cfce 修正:仅 VIP 用黑金卡,普通用户恢复珍珠白
ci/woodpecker/tag/build Pipeline was successful
上一版误解了需求——把两种身份的会员卡都改成了 VIP 黑金色,
实际应只有 VIP 使用该专属色,普通用户保持原有浅色卡片。

改动:
- 恢复 .member-card.is-vip(黑金 + 金色描边 + 金色文字/图标/光晕)
  与 .member-card.is-free(珍珠白 + 深色文字)双形态
- 模板恢复 :class="isVip ? 'is-vip' : 'is-free'"
- 暗色主题恢复 is-free 的墨绿底(白底在暗色下刺眼)
- 头像外圈与底色按身份区分:VIP 白环 + 半透明白底 + 金棕首字,
  普通用户淡品牌色环 + 浅品牌底 + 品牌绿首字
- 「兑换 CDK」按钮保持金色实底,在两种卡片上都有足够对比

保留上一版正确的文案改动:普通用户显示「开通 VIP 即可不限次数」。

已截图确认两种卡片渲染正确(黑金 / 珍珠白 分明)。
npm run build 与 vue-tsc 均通过。
2026-10-21 17:17:13 +08:00
becomer fd214d06eb 清理会员卡的 is-vip / is-free 空壳类
ci/woodpecker/tag/build Pipeline was successful
上一提交把两种身份的卡片样式合并为同一套后,
:class="isVip ? 'is-vip' : 'is-free'" 已无任何样式差异,
三个选择器(.member-card / .is-vip / .is-free)指向同一份声明,
属于纯冗余。

改动:
- 模板移除该动态 class
- 浅色样式块精简为单个 .member-card 选择器
- 暗色块同理,只保留 html.dark .member-card

已重新截图确认渲染与清理前完全一致,删除的确为死代码。
npm run build 与 vue-tsc 均通过。
2026-10-21 17:13:31 +08:00
becomer 48c3948c56 我的页面会员卡统一为 VIP 黑金色,普通用户提示改为引导开通
ci/woodpecker/tag/build Pipeline was successful
1. 文案
   普通用户副标题由「免费用户」改为「开通 VIP 即可不限次数」,
   直接传达价值;VIP 分支保持显示到期时间与剩余天数。

2. 卡片配色统一
   原先普通用户是珍珠白卡片、VIP 才是黑金,两套样式(.is-vip/.is-free)。
   现统一为 VIP 专属黑金色,两种身份同一套质感,区别只在文案与角标:
   - .member-card / .is-vip / .is-free 共用同一段背景与边框
   - 文字、图标、光晕规则去掉 .is-vip 前缀,直接作用于 .member-card
   - 暗色主题同步合并,移除原先 is-free 的墨绿覆盖(会破坏金色统一)

3. 连带调整
   - 头像占位文字统一为金棕 text-amber-800(原普通用户用品牌绿,
     在黑金底上不协调)
   - 「兑换 CDK」按钮加深阴影,与深色卡片底更好分离

已用无头 Chrome 截图确认实际渲染:黑金卡片、金色文字、金色按钮
层次清晰,文字无遮挡。

npm run build 与 vue-tsc 均通过。
2026-10-21 17:12:20 +08:00
becomer 0d3280fe7b 我的页面:精简普通用户提示文案 + 头图边界对齐按钮中线
ci/woodpecker/tag/build Pipeline was successful
1. 文案过长显示不全
   普通用户的副标题原为「普通用户 · 开通后执行任务不限次数」,
   在卡片的 flex-1 区域内被 truncate 截断。
   改为「免费用户」,与上方姓名行组合读作「张三 / 免费用户」,
   既完整显示又不丢信息(VIP 分支同步去掉冗余的 "VIP ·" 前缀,
   因为 VIP 身份已由姓名旁的角标体现)。

2. 头图绿色渐变边界位置
   原先 -mt-14 时,头图底色边界落在会员卡约 63.6% 高度处,
   低于「兑换 CDK」按钮中线 12px,看起来切在卡片中上部。
   改为 -mt-11 后边界正好对齐按钮中线。

   该结论来自无头 Chrome 实测而非估算:构造等价 DOM 后测量
   heroBottom / btnCenter 差值,-mt-14 → +12px(偏低),
   -mt-11 → 0px(对齐)。页面上写死了这次实测数据作为注释。

   另经实测确认头图的 pb 只影响上方留白、不影响对齐
   (pb-14 与 pb-16 均对齐),故保留 pb-16。
   卡片内边距同步由 p-4 收窄为 px-4 py-3,让三行文字更紧凑。

npm run build 与 vue-tsc 均通过。
2026-10-21 17:07:13 +08:00
becomer 0d639ea485 我的页面:头像姓名并入会员卡,去掉学校信息
ci/woodpecker/tag/build Pipeline was successful
原先顶部渐变区单独一块「大头像 + 姓名 + 学校 + 学院专业标签」,
对用户实际没什么用,还占掉一屏顶部空间。

改动:
- 删除顶部个人信息块(含学校名与学院/专业/班级标签)
- 头像缩为 44px 圆形,移到会员卡内 Crown 图标的位置(该图标去掉),
  姓名与会员身份(VIP/普通用户)合并为同一行
- VIP 角标改为贴在头像右下角
- 顶部渐变条高度压缩:padding-top 28px→14px、pb-20→pb-16,
  会员卡仍以 -mt-14 上浮叠压,视觉层次不变

顺带清理不再使用的导入与计算属性:Crown 图标、GraduationCap 图标、
profileTags(学院/专业/班级标签)。
修掉 img 的 :src 缺可选链导致的 null 类型报错。

npm run build 与 vue-tsc 均通过。
2026-10-21 16:58:24 +08:00
becomer 3b4a8df8fd 兑换弹窗购买按钮去掉图标并缩小文字
ci/woodpecker/tag/build Pipeline was successful
按钮在弹窗内与「立即兑换」等宽,加上图标后文字被挤得偏紧。
按需求去掉 ShoppingCart 图标(图标导入仍被页面其他两处使用,保留),
文字由 text-sm font-semibold 调为 text-xs font-medium,
并加 px-2 避免文字贴边,整体更舒展。

npm run build 与 vue-tsc 均通过。
2026-10-21 16:45:00 +08:00
becomer 5a33afa213 修复兑换弹窗购买按钮不可见,并调整左右顺序
ci/woodpecker/tag/build Pipeline was successful
两个问题:

1. 按钮完全看不见(严重)
   上一版给「没有 CDK?立即购买」用了 cdk-goto-btn 类,但该类只在
   NoRunsDialog.vue 的 scoped 样式里定义过——scoped 样式带 data-v 属性
   选择器,不会作用于 ProfileView 内的元素。结果按钮没有任何背景,
   而文字是 text-white,白字落在弹窗白底上等于隐形(点击仍生效)。
   现在在 ProfileView 的样式块内补齐 .cdk-goto-btn 定义:
   珊瑚渐变背景 + 白色文字 + 阴影 + nowrap,与外层购买入口视觉呼应。

2. 左右顺序反了
   按需求改为「没有 CDK?立即购买」在左、「立即兑换」在右。

npm run build 与 vue-tsc 均通过。
2026-10-21 16:41:41 +08:00
5 changed files with 147 additions and 54 deletions
+7 -1
View File
@@ -1,13 +1,13 @@
package handler
import (
"context"
"cpdaily-new/internal/config"
"cpdaily-new/internal/database"
"cpdaily-new/internal/model"
"cpdaily-new/internal/service"
cpd "cpdaily-new/internal/service/cpdaily"
"cpdaily-new/internal/utils"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
@@ -585,6 +585,12 @@ func BindInviteCode(c *fiber.Ctx) error {
if code == "" {
return utils.FailParam(c, "请输入邀请码")
}
// 格式预校验:邀请码固定 8 位且只用 inviteCodeChars 字符集。
// 不合规的输入直接拒绝,避免每次都在库里做一次无用查询;
// 用统一文案,不泄露「码存在但格式对」这类信息。
if !isValidInviteCodeShape(code) {
return utils.Fail(c, 400, "邀请码无效")
}
var user model.SysUser
if err := database.DB.First(&user, userID).Error; err != nil {
+24
View File
@@ -205,3 +205,27 @@ func ResetInviteCode(c *fiber.Ctx) error {
slog.Info("用户重置邀请码", "userId", userID, "oldCode", oldCode, "newCode", code)
return utils.Success(c, fiber.Map{"inviteCode": code})
}
// inviteCodeLegacyLength 旧版邀请码长度。
//
// 2026-10 之前生成为 6 位,生产库中仍有大量存量用户沿用旧码
// (实测 551 个 6 位 / 1 个 8 位)。校验必须同时接受两种长度,
// 否则存量用户的码会被判为无效,导致他们无法被邀请。
const inviteCodeLegacyLength = 6
// isValidInviteCodeShape 校验邀请码的形态(长度 + 字符集),不查库。
//
// 长度接受 6(存量)与 8(当前)两种,字符集为 inviteCodeChars
// (已去掉易混淆的 0/O/1/L)。不合规的输入无需查库即可拒绝,
// 也避免把用户的任意长字符串当码去查。
func isValidInviteCodeShape(code string) bool {
if len(code) != inviteCodeLength && len(code) != inviteCodeLegacyLength {
return false
}
for i := 0; i < len(code); i++ {
if !strings.ContainsRune(inviteCodeChars, rune(code[i])) {
return false
}
}
return true
}
@@ -163,3 +163,42 @@ func TestErrInviteCodeChangedIsDistinct(t *testing.T) {
t.Fatal("errors.Is 自反性失败")
}
}
// isValidInviteCodeShape:只校验形态(长度 + 字符集),不查库
func TestIsValidInviteCodeShape(t *testing.T) {
valid, err := newInviteCode()
if err != nil {
t.Fatal(err)
}
cases := []struct {
name string
code string
want bool
}{
{"正常 8 位", valid, true},
{"空串", "", false},
{"过短", "ABC", false},
{"过长", "ABCDEFGHJ", false},
{"含易混淆字符 0", "ABCD0EFG", false},
{"含易混淆字符 O", "ABCDOEFG", false},
{"含易混淆字符 1", "ABCD1EFG", false},
{"含易混淆字符 I", "ABCDIEFG", false},
{"含易混淆字符 L", "ABCDLEFG", false},
{"含小写", "abcdefgh", false},
{"含符号", "ABCD-EFG", false},
{"含中文", "邀请码邀请码邀请", false},
// 存量兼容:生产库仍有 551 个 6 位旧码,必须仍能正常绑定
{"存量 6 位码", "ABCDEF", true},
{"存量 6 位含易混淆字符", "ABCD0F", false},
{"5 位", "ABCDE", false},
{"7 位", "ABCDEFG", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := isValidInviteCodeShape(c.code); got != c.want {
t.Fatalf("isValidInviteCodeShape(%q)=%v, want %v", c.code, got, c.want)
}
})
}
}
+8 -2
View File
@@ -74,8 +74,14 @@ watch(
)
function onVisibility() {
if (document.visibilityState === 'visible') schedule()
else clearTimer()
if (document.visibilityState === 'visible') {
// 回到前台时重新测量:后台期间可能发生过尺寸变化(横竖屏切换、
// 系统字体缩放等),若沿用旧的 overflow,长文本的滚动距离会算错,
// 出现「还没滚完就翻页」或「滚过头留白」。
void measure().then(schedule)
} else {
clearTimer()
}
}
function openAll() {
+69 -51
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { LogOut, RefreshCw, Gift, X, Dices, TriangleAlert, SunMoon, Sun, Moon, Crown, Copy, ChevronRight, Smartphone, Coins, UserPlus, GraduationCap, Headset, ShoppingCart, Flame } from 'lucide-vue-next'
import { LogOut, RefreshCw, Gift, X, Dices, TriangleAlert, SunMoon, Sun, Moon, Copy, ChevronRight, Smartphone, Coins, UserPlus, Headset, ShoppingCart, Flame } from 'lucide-vue-next'
import { themeMode, setThemeMode, type ThemeMode } from '@/composables/useTheme'
import { confirmDialog } from '@/composables/useConfirm'
import { Button } from '@/components/ui/button'
@@ -163,13 +163,6 @@ function maskDeviceCode(code: string): string {
// 是否已绑定设备码(决定「立即绑定 / 重新绑定」按钮文案)
const hasDeviceCode = computed(() => !!userInfo.value?.mobileCode)
// 头部信息标签:学院 / 专业 / 班级(有值才显示,最多 2 个,避免挤满)
const profileTags = computed(() => {
const u = userInfo.value
if (!u) return []
return [u.academy, u.className || u.major].filter((t): t is string => !!t && !!t.trim()).slice(0, 2)
})
// VIP 剩余天数(到期时间无效 / 已过期返回 null)
const vipDaysLeft = computed(() => {
if (!isVip.value || !vipExpireAt.value) return null
@@ -401,11 +394,26 @@ async function onResetInviteTap() {
try {
const res = await authApi.resetInviteCode()
const code = res.data.data?.inviteCode || ''
if (code) inviteCode.value = code
if (!code) {
// 接口成功但未返回新码:不能静默当作成功,否则页面仍显示旧码
message.error('重置失败:未获取到新的邀请码,请重试')
return
}
inviteCode.value = code
message.success('邀请码已重置,请使用新的邀请码分享')
} catch (e: any) {
const status = e?.response?.status
const msg = e?.response?.data?.message || e?.message || '重置失败,请稍后重试'
message.error(msg)
// 409:并发重置冲突,说明服务端已是新码。重新拉取,避免页面继续显示旧码
if (status === 409) {
try {
const info = await authApi.getMyInviteInfo()
applyInviteInfo(info.data.data)
} catch {
// 拉取失败不覆盖原提示,用户可手动刷新
}
}
} finally {
resettingInvite.value = false
}
@@ -464,52 +472,49 @@ async function handleRedeemInvite() {
<template>
<div class="flex flex-col min-h-full">
<!-- ======== 顶部:品牌渐变背景 + 个人信息(左对齐)+ 会员卡 ======== -->
<div class="relative overflow-hidden hero-clip bg-brand-gradient px-5 pb-20 text-white flex-shrink-0" style="padding-top: calc(var(--safe-top) + 28px)">
<!-- 头图绿色背景:高度需让渐变边界切到会员卡(VIP 板块)约一半高度。
计算:切入深度 = pb - |卡片上浮|
卡片实测高约 86px,一半 43px
-mt-11 = 44px,故 pb 需 ≈ 43 + 44 = 87px ≈ pb-22(88px)
早期误用 pb-16(64px)导致只切入 20px(约卡片 23%),看起来偏低。 -->
<div class="relative overflow-hidden hero-clip bg-brand-gradient px-5 pb-22 text-white flex-shrink-0" style="padding-top: calc(var(--safe-top) + 14px)">
<!-- 装饰:柔光圆 + 细网格纹理 -->
<div class="pointer-events-none absolute -right-16 -top-20 w-64 h-64 rounded-full bg-white/10" />
<div class="pointer-events-none absolute right-10 top-24 w-28 h-28 rounded-full bg-white/10" />
<div class="pointer-events-none absolute inset-0 profile-hero-grid" />
<!-- 头像 + 姓名 + 学校 -->
<div class="relative flex items-center gap-4 rise-in">
<div class="relative flex-shrink-0">
<div class="w-[68px] h-[68px] rounded-full overflow-hidden ring-[3px] ring-white/60 shadow-lg shadow-black/15 bg-white/20 flex items-center justify-center">
<img v-show="userInfo?.avatar && !avatarFailed" :src="userInfo?.avatar" class="w-full h-full object-cover" alt="头像" @error="avatarFailed = true" />
<span v-if="!userInfo?.avatar || avatarFailed" class="text-2xl font-bold">{{ userInfo?.realName?.[0] || userInfo?.username?.[0] || '?' }}</span>
</div>
<div v-if="isVip" class="vip-badge absolute -right-2 -bottom-1 z-10 whitespace-nowrap">VIP</div>
</div>
<div class="min-w-0 flex-1">
<p class="text-[22px] font-bold tracking-tight leading-tight truncate">{{ userInfo?.realName || userInfo?.username || '--' }}</p>
<p class="mt-1 flex items-center gap-1 text-sm text-white/90 truncate">
<GraduationCap class="w-3.5 h-3.5 flex-shrink-0" />
<span class="truncate">{{ userInfo?.schoolName || '--' }}</span>
</p>
<div v-if="profileTags.length" class="mt-2 flex flex-wrap gap-1.5">
<span v-for="t in profileTags" :key="t" class="max-w-[10rem] truncate text-xs rounded-md bg-white/15 ring-1 ring-white/20 px-1.5 py-0.5 text-white/95">{{ t }}</span>
</div>
</div>
</div>
</div>
<!-- ======== 会员卡(压在头图下沿) ======== -->
<div class="relative px-4 -mt-14 rise-in" style="animation-delay: 40ms">
<div class="member-card relative overflow-hidden hero-clip rounded-3xl p-4 shadow-xl" :class="isVip ? 'is-vip' : 'is-free'">
<!-- ======== 会员卡(压在头图下沿) ========
-mt-11 而非 -mt-14:经无头浏览器实测,-mt-14 时头图底色边界落在卡片
63.6% 高度(低于「兑换 CDK」按钮中线 12px);-mt-11 时正好对齐按钮
中线(切入比例 50%,与中线差值 0px)。头图的 pb 只影响上方留白,
不影响对齐(pb-14/pb-16 实测均对齐),故保留 pb-16。 -->
<div class="relative px-4 -mt-11 rise-in" style="animation-delay: 40ms">
<div class="member-card relative overflow-hidden hero-clip rounded-3xl px-4 py-3 shadow-xl" :class="isVip ? 'is-vip' : 'is-free'">
<div class="pointer-events-none absolute -right-8 -top-10 w-36 h-36 rounded-full member-card-glow" />
<div class="relative flex items-center gap-3">
<div class="member-icon w-11 h-11 rounded-2xl flex items-center justify-center flex-shrink-0">
<Crown class="w-5 h-5" :stroke-width="2.4" />
<!-- 头像(原顶部头图内容整合至此处) -->
<div class="relative flex-shrink-0">
<div class="w-11 h-11 rounded-full overflow-hidden ring-2 shadow-sm flex items-center justify-center" :class="isVip ? 'ring-white/70 bg-white/30' : 'ring-brand-100 bg-brand-50'">
<img v-show="userInfo?.avatar && !avatarFailed" :src="userInfo?.avatar" class="w-full h-full object-cover" alt="头像" @error="avatarFailed = true" />
<span v-if="!userInfo?.avatar || avatarFailed" class="text-base font-bold" :class="isVip ? 'text-amber-800' : 'text-brand-700'">{{ userInfo?.realName?.[0] || userInfo?.username?.[0] || '?' }}</span>
</div>
</div>
<div class="min-w-0 flex-1">
<p class="member-title text-base font-bold tracking-tight">{{ isVip ? 'VIP 会员' : '普通用户' }}</p>
<!-- 姓名 + 会员身份 -->
<p class="member-title text-base font-bold tracking-tight flex items-center gap-1.5 min-w-0">
<span class="truncate">{{ userInfo?.realName || userInfo?.username || '--' }}</span>
<span v-if="isVip" class="vip-badge relative shrink-0 whitespace-nowrap">VIP</span>
</p>
<p class="member-sub text-xs mt-0.5 truncate">
<template v-if="isVip">{{ vipExpireShort ? `有效期至 ${vipExpireShort}` : '会员生效中' }}<template v-if="vipDaysLeft !== null"> · 剩 {{ vipDaysLeft }} 天</template></template>
<template v-else>开通后执行任务不限次数</template>
<template v-else>开通 VIP 即可不限次数</template>
</p>
<!-- 可执行次数:普通用户与 VIP 都显示(VIP 为不限次数时显示「不限」) -->
<p class="member-sub text-xs mt-1 flex items-center gap-1">
<span class="opacity-75">可执行次数</span>
<span class="font-bold tabular-nums">{{ isVip ? '不限' : fmtCount(userInfo?.signCount ?? 0) }}</span>
<span class="font-bold tabular-nums leading-none">{{ isVip ? '不限' : fmtCount(userInfo?.signCount ?? 0) }}</span>
</p>
</div>
<!-- 统一为「兑换 CDK」:既能兑 VIP 也能兑次数,由 CDK 类型自动判断 -->
@@ -785,8 +790,16 @@ async function handleRedeemInvite() {
<p v-if="captchaSubmitError" class="text-xs text-red-500 mt-1">{{ captchaSubmitError }}</p>
</div>
<!-- 兑换 / 购买 两个动作并排 -->
<!-- 购买 / 兑换 两个动作并排(购买在左,兑换在右) -->
<div class="flex gap-2">
<!-- 没有 CDK 时直接去购买:等价于点击外层的「购买 CDK 兑换码」入口 -->
<button
type="button"
class="press cdk-goto-btn flex-1 h-10 rounded-xl text-xs font-medium flex items-center justify-center px-2"
@click="onBuyFromRedeemModal"
>
没有 CDK?立即购买
</button>
<Button
class="flex-1 h-10 text-sm font-medium"
:disabled="redeeming"
@@ -796,15 +809,6 @@ async function handleRedeemInvite() {
<Gift v-else class="h-4 w-4" />
{{ redeeming ? '兑换中...' : '立即兑换' }}
</Button>
<!-- 没有 CDK 时直接去购买:等价于点击外层的「购买 CDK 兑换码」入口 -->
<button
type="button"
class="press flex-1 h-10 rounded-xl text-sm font-semibold text-white flex items-center justify-center gap-1.5 cdk-goto-btn"
@click="onBuyFromRedeemModal"
>
<ShoppingCart class="w-4 h-4" />
没有 CDK?立即购买
</button>
</div>
</div>
</AppDialog>
@@ -1024,7 +1028,7 @@ async function handleRedeemInvite() {
mask-image: linear-gradient(180deg, #000 0%, transparent 85%);
}
/* 会员卡:VIP 为黑金,普通用户为珍珠白(固定色,亮暗下质感一致) */
/* 会员卡:VIP 为黑金专属色,普通用户为珍珠白(固定色,亮暗下质感一致) */
.member-card.is-vip {
background: linear-gradient(135deg, #2b2418 0%, #1b1710 60%, #2d2516 100%);
border: 1px solid rgb(246 196 69 / 0.35);
@@ -1046,13 +1050,24 @@ async function handleRedeemInvite() {
color: #8a5a00;
background: linear-gradient(135deg, #fff1c7, #ffe08a);
}
.member-card.is-free .member-card-glow { background: radial-gradient(circle, rgb(245 189 46 / 0.22), transparent 70%); }
.member-card.is-free .member-card-glow { background: radial-gradient(circle, rgb(246 196 69 / 0.22), transparent 70%); }
/* 邀请卡主按钮:固定紫(白字 6:1)。不用 bg-purple-600:暗色色板把它映射为浅紫作文字色,白字会看不清 */
.invite-btn {
background: linear-gradient(135deg, #7a5bdb, #6a4bcb);
}
/* 兑换弹窗内的「没有 CDK?立即购买」按钮:
与外层购买入口同为珊瑚渐变,形成视觉呼应。
注意必须显式声明 color/background —— 之前只在 NoRunsDialog 的 scoped
样式里定义过同名类,这里缺样式会导致白字落在白底上「看不见」。 */
.cdk-goto-btn {
color: #fff;
background: linear-gradient(135deg, #ff9a3d 0%, #ff5d5d 55%, #f43f5e 100%);
box-shadow: 0 6px 16px -8px rgb(244 63 94 / 0.6);
white-space: nowrap;
}
/* 购买 CDK 入口:日落珊瑚渐变 + 周期性微光扫过(固定色,亮暗主题一致)
与品牌薄荷青 / VIP 金 / 邀请紫均不同色系,保证在页面中的辨识度 */
.cdk-buy-btn {
@@ -1130,12 +1145,14 @@ async function handleRedeemInvite() {
}
/* VIP 开通/续费按钮:金色渐变 + 周期性微光扫过 */
/* 兑换 CDK 按钮:卡片改为黑金底后,按钮仍用金色实底以保持「可点击」的醒目感;
深棕文字与金底对比约 7:1(不用 text-amber-900:暗色下它会被映射为浅色) */
.vip-btn {
position: relative;
overflow: hidden;
/* 金底固定不随主题变化,文字同样固定深棕(不用 text-amber-900:暗色下它被映射为浅色) */
color: #5b3a00;
background: linear-gradient(135deg, #ffe9a8 0%, #f6c445 45%, #e8a020 100%);
box-shadow: 0 2px 8px rgb(0 0 0 / 0.25);
}
.vip-btn::after {
content: '';
@@ -1158,6 +1175,7 @@ async function handleRedeemInvite() {
<style>
/* 暗色覆盖:非 scoped(scoped 中 :global(.dark) .x 会编译成仅 .dark 而失效);html.dark 提高优先级压过 .x[data-v] */
/* 会员卡暗色:VIP 微调金色边框透明度;普通用户在暗色下用墨绿底(原亮色白底在暗色刺眼) */
html.dark .member-card.is-free {
background: linear-gradient(135deg, #1e2c28 0%, #18221f 100%);
border-color: rgb(255 255 255 / 0.06);