Compare commits

..

7 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
5 changed files with 131 additions and 25 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() {
+53 -22
View File
@@ -394,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
}
@@ -457,9 +472,12 @@ async function handleRedeemInvite() {
<template>
<div class="flex flex-col min-h-full">
<!-- ======== 顶部:品牌渐变背景 + 个人信息(左对齐)+ 会员卡 ======== -->
<!-- 头图:高度以「卡片上浮量 + 卡片内容一半」为准,使渐变色边界正好落在
「兑换 CDK」按钮的中线,而不是切在卡片中上部 -->
<div class="relative overflow-hidden hero-clip bg-brand-gradient px-5 pb-16 text-white flex-shrink-0" style="padding-top: calc(var(--safe-top) + 14px)">
<!-- 头图绿色背景:高度需让渐变边界切到会员卡(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" />
@@ -478,15 +496,17 @@ async function handleRedeemInvite() {
<div class="relative flex items-center gap-3">
<!-- 头像(原顶部头图内容整合至此处) -->
<div class="relative flex-shrink-0">
<div class="w-11 h-11 rounded-full overflow-hidden ring-2 ring-white/70 shadow-sm bg-white/30 flex items-center justify-center">
<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="'text-amber-800'">{{ userInfo?.realName?.[0] || userInfo?.username?.[0] || '?' }}</span>
<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 v-if="isVip" class="vip-badge absolute -right-1.5 -bottom-1 z-10 whitespace-nowrap">VIP</div>
</div>
<div class="min-w-0 flex-1">
<!-- 姓名 + 会员身份 -->
<p class="member-title text-base font-bold tracking-tight truncate">{{ userInfo?.realName || userInfo?.username || '--' }}</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>开通 VIP 即可不限次数</template>
@@ -1008,21 +1028,29 @@ async function handleRedeemInvite() {
mask-image: linear-gradient(180deg, #000 0%, transparent 85%);
}
/* 会员卡:统一为 VIP 专属黑金色(普通用户与 VIP 同一套质感,
区别只在文案与角标;固定色,亮暗主题下表现一致) */
.member-card,
.member-card.is-vip,
.member-card.is-free {
/* 会员卡:VIP 为黑金专属色,普通用户为珍珠白(固定色,亮暗下质感一致) */
.member-card.is-vip {
background: linear-gradient(135deg, #2b2418 0%, #1b1710 60%, #2d2516 100%);
border: 1px solid rgb(246 196 69 / 0.35);
}
.member-card .member-title { color: #ffe39a; }
.member-card .member-sub { color: rgb(255 227 154 / 0.75); }
.member-card .member-icon {
.member-card.is-vip .member-title { color: #ffe39a; }
.member-card.is-vip .member-sub { color: rgb(255 227 154 / 0.75); }
.member-card.is-vip .member-icon {
color: #5b3a00;
background: linear-gradient(135deg, #ffe9a8, #f6c445 55%, #e8a020);
}
.member-card .member-card-glow { background: radial-gradient(circle, rgb(246 196 69 / 0.35), transparent 70%); }
.member-card.is-vip .member-card-glow { background: radial-gradient(circle, rgb(246 196 69 / 0.35), transparent 70%); }
.member-card.is-free {
background: linear-gradient(135deg, #ffffff 0%, #f7fbf9 100%);
border: 1px solid rgb(14 40 34 / 0.06);
}
.member-card.is-free .member-title { color: #17221f; }
.member-card.is-free .member-sub { color: #66756f; }
.member-card.is-free .member-icon {
color: #8a5a00;
background: linear-gradient(135deg, #fff1c7, #ffe08a);
}
.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 {
@@ -1147,11 +1175,14 @@ async function handleRedeemInvite() {
<style>
/* 暗色覆盖:非 scoped(scoped 中 :global(.dark) .x 会编译成仅 .dark 而失效);html.dark 提高优先级压过 .x[data-v] */
/* 会员卡暗色:两种身份统一为黑金,仅微调边框透明度;
原先 is-free 的墨绿覆盖已移除,避免破坏统一的金色质感 */
html.dark .member-card,
html.dark .member-card.is-vip,
html.dark .member-card.is-free { border-color: rgb(246 196 69 / 0.28); }
/* 会员卡暗色:VIP 微调金色边框透明度;普通用户在暗色下用墨绿底(原亮色白底在暗色刺眼) */
html.dark .member-card.is-free {
background: linear-gradient(135deg, #1e2c28 0%, #18221f 100%);
border-color: rgb(255 255 255 / 0.06);
}
html.dark .member-card.is-free .member-title { color: #eaf2ef; }
html.dark .member-card.is-free .member-sub { color: #98aba4; }
html.dark .member-card.is-vip { border-color: rgb(246 196 69 / 0.28); }
html.dark .invite-card {
background: linear-gradient(135deg, #262040 0%, #211b38 100%);
border-color: rgb(185 165 255 / 0.16);