feat: 完成激活码兑换功能

This commit is contained in:
ccnetcore
2025-12-20 11:33:07 +08:00
parent 4326c41258
commit 3b71fe3135
8 changed files with 634 additions and 4 deletions

View File

@@ -0,0 +1,5 @@
import { post } from '@/utils/request';
export function redeemActivationCode(data: { code: string }) {
return post<any>('/activationCode/Redeem', data);
}

View File

@@ -142,6 +142,7 @@ function cleanupPayment() {
const tabs = [
{ key: 'member', label: '会员套餐' },
{ key: 'token', label: '尊享Token包' },
{ key: 'activation', label: '激活码' },
];
const benefitsData = {
@@ -210,8 +211,11 @@ function selectPackage(pkg: any) {
}
watch(activeTab, () => {
if (activeTab.value === 'activation') {
return;
}
const packages = packagesData.value[activeTab.value as 'member' | 'token'];
if (packages.length > 0) {
if (packages && packages.length > 0) {
const firstPackage = packages[0];
selectedId.value = firstPackage.id;
selectedPrice.value = firstPackage.price;
@@ -315,6 +319,11 @@ function close() {
function onClose() {
emit('close');
}
function goToActivation() {
close();
userStore.openUserCenter('activationCode');
}
</script>
<template>
@@ -386,8 +395,30 @@ function onClose() {
</div>
</div>
<!-- 激活码引导页 -->
<div v-if="activeTab === 'activation'" class="activation-guide-container">
<div class="activation-content">
<div class="guide-icon">🎁</div>
<h3 class="guide-title">激活码兑换</h3>
<p class="guide-desc">如果您持有意心AI的会员激活码或Token兑换码<br>请点击下方按钮前往控制台进行兑换</p>
<el-button
type="primary"
size="large"
class="redeem-jump-btn"
round
@click="goToActivation"
>
前往兑换中心
</el-button>
<div class="guide-tips">
<p>💡 兑换成功后权益将立即生效</p>
</div>
</div>
</div>
<!-- 移动端布局 -->
<div v-if="isMobile" class="mobile-layout">
<div v-else-if="isMobile" class="mobile-layout">
<!-- 商品加载状态无修改 -->
<div v-if="isLoadingGoods" class="loading-container">
<el-icon class="is-loading" :size="40">
@@ -824,6 +855,73 @@ function onClose() {
}
}
/* 激活码引导页样式 */
.activation-guide-container {
padding: 40px 20px;
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
background: linear-gradient(to bottom, #fff, #fdfdfd);
border-radius: 8px;
.activation-content {
text-align: center;
max-width: 400px;
.guide-icon {
font-size: 64px;
margin-bottom: 24px;
animation: float-icon 3s ease-in-out infinite;
}
.guide-title {
font-size: 24px;
font-weight: 800;
color: #2c3e50;
margin-bottom: 16px;
}
.guide-desc {
color: #606266;
line-height: 1.6;
margin-bottom: 32px;
font-size: 15px;
}
.redeem-jump-btn {
width: 200px;
height: 48px;
font-size: 16px;
font-weight: bold;
letter-spacing: 1px;
background: linear-gradient(135deg, #ff9a9e 0%, #fecfef 100%);
border: none;
box-shadow: 0 4px 12px rgba(255, 117, 140, 0.3);
transition: all 0.3s;
&:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(255, 117, 140, 0.4);
}
}
.guide-tips {
margin-top: 24px;
font-size: 13px;
color: #909399;
display: flex;
justify-content: center;
gap: 8px;
}
}
}
@keyframes float-icon {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
/* 移动端样式(核心新增:主价格/弱化价格样式) */
.mobile-layout {
display: flex; flex-direction: column; gap: 24px;

View File

@@ -0,0 +1,483 @@
<script lang="ts" setup>
import { ref, onMounted, onUnmounted, nextTick } from 'vue';
import { ElMessage } from 'element-plus';
import { redeemActivationCode } from '@/api/activationCode';
import { MagicStick } from '@element-plus/icons-vue';
const activationCode = ref('');
const loading = ref(false);
const canvasRef = ref<HTMLCanvasElement | null>(null);
const containerRef = ref<HTMLElement | null>(null);
let animationId: number;
// --- Advanced Physics & Visuals ---
class Particle {
x: number;
y: number;
vx: number;
vy: number;
alpha: number;
color: string;
hue: number;
size: number;
decay: number;
gravity: number;
friction: number;
brightness: number;
flicker: boolean;
constructor(x: number, y: number, hue: number) {
this.x = x;
this.y = y;
this.hue = hue;
// Explosive physics
const angle = Math.random() * Math.PI * 2;
const speed = Math.random() * 15 + 2;
this.vx = Math.cos(angle) * speed;
this.vy = Math.sin(angle) * speed;
this.alpha = 1;
this.decay = Math.random() * 0.015 + 0.005;
this.gravity = 0.05;
this.friction = 0.96;
this.size = Math.random() * 3 + 1;
this.brightness = 50; // Standard brightness for white bg visibility (0-100% HSL L value)
this.flicker = Math.random() > 0.5;
}
update() {
this.vx *= this.friction;
this.vy *= this.friction;
this.vy += this.gravity;
this.x += this.vx;
this.y += this.vy;
this.alpha -= this.decay;
this.hue += 0.5;
}
draw(ctx: CanvasRenderingContext2D) {
ctx.globalAlpha = this.alpha;
// On white background:
// We want high saturation (100%) and medium lightness (50%) to make colors pop against white.
// If lightness is too high (like 80-100), it fades into white.
const lightness = this.flicker ? Math.random() * 20 + 40 : this.brightness;
ctx.fillStyle = `hsla(${this.hue}, 100%, ${lightness}%, ${this.alpha})`;
ctx.beginPath();
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
}
}
class Shockwave {
x: number;
y: number;
radius: number;
alpha: number;
lineWidth: number;
hue: number;
constructor(x: number, y: number, hue: number) {
this.x = x;
this.y = y;
this.hue = hue;
this.radius = 0;
this.alpha = 1;
this.lineWidth = 10;
}
update() {
this.radius += 15;
this.alpha -= 0.05;
this.lineWidth -= 0.2;
}
draw(ctx: CanvasRenderingContext2D) {
if (this.alpha <= 0) return;
ctx.save();
ctx.beginPath();
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
// Darker/More saturated shockwave for white background
ctx.strokeStyle = `hsla(${this.hue}, 100%, 60%, ${this.alpha})`;
ctx.lineWidth = Math.max(0, this.lineWidth);
ctx.stroke();
ctx.restore();
}
}
let particles: Particle[] = [];
let shockwaves: Shockwave[] = [];
function createExplosion(x: number, y: number, hue: number) {
const particleCount = 120;
for (let i = 0; i < particleCount; i++) {
particles.push(new Particle(x, y, hue));
}
for (let i = 0; i < 60; i++) {
particles.push(new Particle(x, y, (hue + 180) % 360));
}
shockwaves.push(new Shockwave(x, y, hue));
}
function animate() {
const canvas = canvasRef.value;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Clear with transparent fade for trails on white
// 'destination-out' erases content.
// To leave a trail on a white background (canvas is transparent over white gradient):
// We need to gently erase what's there.
ctx.globalCompositeOperation = 'destination-out';
ctx.fillStyle = 'rgba(255, 255, 255, 0.1)'; // Alpha controls trail length
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Reset to default drawing
ctx.globalCompositeOperation = 'source-over';
for (let i = particles.length - 1; i >= 0; i--) {
particles[i].update();
particles[i].draw(ctx);
if (particles[i].alpha <= 0) particles.splice(i, 1);
}
for (let i = shockwaves.length - 1; i >= 0; i--) {
shockwaves[i].update();
shockwaves[i].draw(ctx);
if (shockwaves[i].alpha <= 0) shockwaves.splice(i, 1);
}
if (particles.length > 0 || shockwaves.length > 0) {
animationId = requestAnimationFrame(animate);
} else {
cancelAnimationFrame(animationId);
}
}
// Shake Effect
const isShaking = ref(false);
function triggerShake() {
isShaking.value = true;
setTimeout(() => isShaking.value = false, 500);
}
function triggerCelebration() {
const canvas = canvasRef.value;
if (!canvas) return;
const parent = canvas.parentElement;
if (parent) {
canvas.width = parent.clientWidth;
canvas.height = parent.clientHeight;
}
const cx = canvas.width / 2;
const cy = canvas.height / 2;
// 1. Initial Mega Explosion
triggerShake();
createExplosion(cx, cy, Math.random() * 360);
// Start loop
animate();
// 2. Machine Gun Fire sequence
let count = 0;
const timer = setInterval(() => {
count++;
const rx = cx + (Math.random() - 0.5) * canvas.width * 0.8;
const ry = cy + (Math.random() - 0.5) * canvas.height * 0.8;
createExplosion(rx, ry, Math.random() * 360);
if (count % 3 === 0) triggerShake();
if (count > 25) {
clearInterval(timer);
}
}, 120);
}
async function handleRedeem() {
if (!activationCode.value.trim()) {
ElMessage.warning('请输入激活码');
return;
}
loading.value = true;
try {
const res = await redeemActivationCode({ code: activationCode.value });
triggerCelebration();
ElMessage.success({
message: '兑换成功!奖励已到账',
type: 'success',
duration: 3000,
showClose: true,
});
activationCode.value = '';
} catch (error: any) {
// console.error(error);
} finally {
loading.value = false;
}
}
onUnmounted(() => {
cancelAnimationFrame(animationId);
});
</script>
<template>
<div ref="containerRef" class="activation-container" :class="{ 'shake-anim': isShaking }">
<!-- Removed Dark overlay -->
<canvas ref="canvasRef" class="fireworks-canvas"></canvas>
<div class="content-wrapper">
<div class="gift-icon-wrapper">
<div class="gift-box">🎁</div>
<div class="gift-glow"></div>
</div>
<h2 class="title">激活码兑换</h2>
<p class="subtitle">开启您的专属惊喜权益</p>
<div class="input-section">
<el-input
v-model="activationCode"
placeholder="请输入您的激活码"
class="activation-input"
size="large"
:prefix-icon="MagicStick"
clearable
@keyup.enter="handleRedeem"
/>
<el-button
class="redeem-btn"
:loading="loading"
@click="handleRedeem"
>
立即兑换
</el-button>
</div>
<div class="tips-section">
<div class="tip-item">
<span class="tip-dot"></span>
<span>若激活码内包含意心Ai会员物品激活后需重新登录生效</span>
</div>
<div class="tip-item">
<span class="tip-dot"></span>
<span>激活成功后可在充值记录中查看物品是否到账</span>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.activation-container {
position: relative;
width: 100%;
height: 100%;
min-height: 500px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: radial-gradient(circle at center, #fffbf0 0%, #fff 100%);
border-radius: 12px;
overflow: hidden;
transition: transform 0.1s;
}
.shake-anim {
animation: shake 0.5s cubic-bezier(.36,.07,.19,.97) both;
}
@keyframes shake {
10%, 90% { transform: translate3d(-2px, 0, 0); }
20%, 80% { transform: translate3d(4px, 0, 0); }
30%, 50%, 70% { transform: translate3d(-6px, 0, 0); }
40%, 60% { transform: translate3d(6px, 0, 0); }
}
.fireworks-canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 10;
}
.content-wrapper {
position: relative;
z-index: 11;
width: 100%;
max-width: 480px;
padding: 40px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.gift-icon-wrapper {
position: relative;
margin-bottom: 24px;
animation: float 3s ease-in-out infinite;
}
.gift-box {
font-size: 72px;
filter: drop-shadow(0 10px 15px rgba(255, 105, 180, 0.3));
position: relative;
z-index: 2;
}
.gift-glow {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 100px;
height: 100px;
background: radial-gradient(circle, rgba(255, 215, 0, 0.4) 0%, rgba(255, 255, 255, 0) 70%);
border-radius: 50%;
z-index: 1;
animation: pulse-glow 2s infinite;
}
@keyframes pulse-glow {
0% { transform: translate(-50%, -50%) scale(1); opacity: 0.5; }
50% { transform: translate(-50%, -50%) scale(1.5); opacity: 1; }
100% { transform: translate(-50%, -50%) scale(1); opacity: 0.5; }
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-12px); }
}
.title {
font-size: 32px;
font-weight: 800;
background: linear-gradient(45deg, #ff9a9e, #fad0c4, #fad0c4);
-webkit-background-clip: text;
background-clip: text;
color: #2c3e50;
margin: 0 0 8px 0;
letter-spacing: 2px;
}
.subtitle {
font-size: 15px;
color: #95a5a6;
margin-bottom: 36px;
}
.input-section {
width: 100%;
display: flex;
flex-direction: column;
gap: 24px;
margin-bottom: 40px;
}
:deep(.activation-input .el-input__wrapper) {
border-radius: 50px;
padding: 10px 24px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
border: 2px solid transparent;
background-image: linear-gradient(white, white), linear-gradient(to right, #e0e0e0, #e0e0e0);
background-origin: border-box;
background-clip: padding-box, border-box;
transition: all 0.3s ease;
}
:deep(.activation-input .el-input__wrapper:hover),
:deep(.activation-input .el-input__wrapper.is-focus) {
box-shadow: 0 8px 25px rgba(255, 105, 180, 0.15);
background-image: linear-gradient(white, white), linear-gradient(135deg, #ff9a9e, #a18cd1);
border: 2px solid transparent;
}
:deep(.activation-input .el-input__inner) {
text-align: center;
font-size: 18px;
letter-spacing: 2px;
color: #333;
font-weight: 600;
}
.redeem-btn {
width: 100%;
height: 54px;
border-radius: 50px;
font-size: 20px;
font-weight: 800;
letter-spacing: 4px;
color: white;
border: none;
background: linear-gradient(135deg, #ff9a9e 0%, #fecfef 50%, #a18cd1 100%);
background-size: 200% 200%;
animation: gradient-anim 5s ease infinite;
box-shadow: 0 8px 20px rgba(255, 117, 140, 0.4);
transition: all 0.3s ease;
}
@keyframes gradient-anim {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
.redeem-btn:hover {
transform: translateY(-3px) scale(1.02);
box-shadow: 0 12px 30px rgba(161, 140, 209, 0.6);
}
.redeem-btn:active {
transform: translateY(1px);
}
.tips-section {
width: 100%;
background: rgba(255, 255, 255, 0.8);
border-radius: 16px;
padding: 20px;
border: 1px dashed #dcdfe6;
backdrop-filter: blur(5px);
}
.tip-item {
display: flex;
align-items: flex-start;
gap: 10px;
font-size: 13px;
color: #606266;
line-height: 1.6;
margin-bottom: 6px;
text-align: left;
}
.tip-item:last-child {
margin-bottom: 0;
}
.tip-dot {
color: #ff9a9e;
font-weight: bold;
font-size: 18px;
line-height: 14px;
}
</style>

View File

@@ -86,12 +86,14 @@ const navItems = [
// { name: 'userInfo', label: '用户信息', icon: 'User' },
{ name: 'apiKey', label: 'API密钥', icon: 'Key' },
{ name: 'rechargeLog', label: '充值记录', icon: 'Document' },
{ name: 'usageStatistics', label: '用量统计', icon: 'Histogram' },
{ name: 'premiumService', label: '尊享服务', icon: 'ColdDrink' },
{ name: 'dailyTask', label: '每日任务(限时)', icon: 'Trophy' },
{ name: 'cardFlip', label: '每周邀请(限时)', icon: 'Present' },
// { name: 'usageStatistics2', label: '用量统计2', icon: 'Histogram' },
{ name: 'activationCode', label: '激活码兑换', icon: 'MagicStick' },
];
function openDialog() {
dialogVisible.value = true;
@@ -302,6 +304,24 @@ watch(() => guideTourStore.shouldStartUserCenterTour, (shouldStart) => {
}
});
// ============ 监听 Store 状态,控制用户中心弹窗 (新增) ============
watch(() => userStore.isUserCenterVisible, (val) => {
dialogVisible.value = val;
});
watch(() => userStore.userCenterActiveTab, (val) => {
if (val) {
activeNav.value = val;
}
});
// 监听本地 dialogVisible 变化,同步回 Store可选为了保持一致性
watch(dialogVisible, (val) => {
if (!val) {
userStore.closeUserCenter();
}
});
// ============ 暴露方法供外部调用 ============
defineExpose({
openDialog,
@@ -440,6 +460,9 @@ defineExpose({
<template #apiKey>
<APIKeyManagement />
</template>
<template #activationCode>
<activation-code />
</template>
<template #dailyTask>
<daily-task />
</template>

View File

@@ -46,6 +46,21 @@ export const useUserStore = defineStore(
isLoginDialogVisible.value = false;
};
// 新增:用户中心弹框状态和激活标签
const isUserCenterVisible = ref(false);
const userCenterActiveTab = ref('user');
// 新增:打开用户中心方法
const openUserCenter = (tab: string = 'user') => {
userCenterActiveTab.value = tab;
isUserCenterVisible.value = true;
};
// 新增:关闭用户中心方法
const closeUserCenter = () => {
isUserCenterVisible.value = false;
};
return {
token,
refreshToken,
@@ -59,6 +74,11 @@ export const useUserStore = defineStore(
isLoginDialogVisible,
openLoginDialog,
closeLoginDialog,
// 新增:暴露用户中心状态和方法
isUserCenterVisible,
userCenterActiveTab,
openUserCenter,
closeUserCenter,
};
},
{

View File

@@ -127,7 +127,8 @@ function jwtPlugin(): {
const data = await (error.response.json());
// 弹窗提示
ElMessage.error(data.error.message);
return Promise.reject(data);
// return Promise.reject(data);
return data;
}
if (error.status === 401) {
ElMessage.error('登录已过期,请重新登录!');

View File

@@ -9,6 +9,7 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
AccountPassword: typeof import('./../src/components/LoginDialog/components/FormLogin/AccountPassword.vue')['default']
ActivationCode: typeof import('./../src/components/userPersonalCenter/components/ActivationCode.vue')['default']
APIKeyManagement: typeof import('./../src/components/userPersonalCenter/components/APIKeyManagement.vue')['default']
CardFlipActivity: typeof import('./../src/components/userPersonalCenter/components/CardFlipActivity.vue')['default']
DailyTask: typeof import('./../src/components/userPersonalCenter/components/DailyTask.vue')['default']

View File

@@ -7,7 +7,6 @@ interface ImportMetaEnv {
readonly VITE_WEB_BASE_API: string;
readonly VITE_API_URL: string;
readonly VITE_FILE_UPLOAD_API: string;
readonly VITE_BUILD_COMPRESS: string;
readonly VITE_SSO_SEVER_URL: string;
readonly VITE_APP_VERSION: string;
}