@@ -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 - | 卡片上浮 |
卡片实测高约 86 px , 一半 43 px
- mt - 11 = 44 px , 故 pb 需 ≈ 43 + 44 = 87 px ≈ pb - 22 ( 88 px )
早期误用 pb - 16 ( 64 px ) 导致只切入 20 px ( 约卡片 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 」 按钮中线 12 px ) ; - mt - 11 时正好对齐按钮
中线 ( 切入比例 50 % , 与中线差值 0 px ) 。 头图的 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 ( 180 deg , # 000 0 % , transparent 85 % ) ;
}
/* 会员卡:VIP 为黑金,普通用户为珍珠白(固定色,亮暗下质感一致) */
/* 会员卡:VIP 为黑金专属色 ,普通用户为珍珠白(固定色,亮暗下质感一致) */
. member - card . is - vip {
background : linear - gradient ( 135 deg , # 2 b2418 0 % , # 1 b1710 60 % , # 2 d2516 100 % ) ;
border : 1 px solid rgb ( 246 196 69 / 0.35 ) ;
@@ -1046,13 +1050,24 @@ async function handleRedeemInvite() {
color : # 8 a5a00 ;
background : linear - gradient ( 135 deg , # 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 ( 135 deg , # 7 a5bdb , # 6 a4bcb ) ;
}
/* 兑换弹窗内的「没有 CDK?立即购买」按钮:
与外层购买入口同为珊瑚渐变,形成视觉呼应。
注意必须显式声明 color/background —— 之前只在 NoRunsDialog 的 scoped
样式里定义过同名类,这里缺样式会导致白字落在白底上「看不见」。 */
. cdk - goto - btn {
color : # fff ;
background : linear - gradient ( 135 deg , # ff9a3d 0 % , # ff5d5d 55 % , # f43f5e 100 % ) ;
box - shadow : 0 6 px 16 px - 8 px 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 : # 5 b3a00 ;
background : linear - gradient ( 135 deg , # ffe9a8 0 % , # f6c445 45 % , # e8a020 100 % ) ;
box - shadow : 0 2 px 8 px 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 ( 135 deg , # 1 e2c28 0 % , # 18221 f 100 % ) ;
border - color : rgb ( 255 255 255 / 0.06 ) ;