feat: 增加签到功能

This commit is contained in:
liuyonghe0111 2025-12-24 16:28:21 +08:00
parent e6b28751bd
commit f477cc95f8
17 changed files with 240 additions and 203 deletions

View File

@ -4,6 +4,7 @@ import createNextIntlPlugin from 'next-intl/plugin';
const withNextIntl = createNextIntlPlugin('./src/lib/i18n.ts');
const nextConfig: NextConfig = {
reactStrictMode: false,
/* config options here */
images: {
remotePatterns: [

View File

@ -1,15 +1,16 @@
'use client'
import { Checkbox } from '@/components/ui/checkbox'
import { cn } from '@/lib/utils'
import Image from 'next/image'
'use client';
import { Checkbox } from '@/components/ui/checkbox';
import { cn } from '@/lib/utils';
import Image from 'next/image';
import { useTranslations } from 'next-intl';
interface CheckInCardProps {
day: number
coinNum: number
signIn: boolean
isToday: boolean
loading?: boolean
className?: string
day: number;
coinNum: number;
signIn: boolean;
isToday: boolean;
loading?: boolean;
className?: string;
}
export function CheckInCard({
@ -20,27 +21,28 @@ export function CheckInCard({
loading,
className,
}: CheckInCardProps) {
const isChecked = signIn
const canCheckIn = isToday && !signIn
const t = useTranslations('crushcoin');
const isChecked = signIn;
const isSignInLoading = isToday && loading;
// 根据状态决定样式
const getCardStyles = () => {
if (isChecked) {
// 已签到 - 灰色背景
return 'bg-[#282233] border-outline-normal'
return 'bg-[#282233] border-outline-normal';
} else {
// 未来日期 - 渐变背景
return 'bg-gradient-to-bl from-[#f157ff] to-[#3337ff] border-[#ffd7b8]'
}
return 'bg-gradient-to-bl from-[#f157ff] to-[#3337ff] border-[#ffd7b8]';
}
};
const getDayLabelStyles = () => {
if (isChecked) {
return 'bg-surface-nest-disabled'
return 'bg-surface-nest-disabled';
} else {
return 'bg-gradient-to-b from-[#ff9156] to-[#bf00ff]'
}
return 'bg-gradient-to-b from-[#ff9156] to-[#bf00ff]';
}
};
return (
<div
@ -56,7 +58,7 @@ export function CheckInCard({
{/* 天数标签 */}
<div className={`absolute top-0 left-0 rounded-br-lg px-2 py-1 ${getDayLabelStyles()}`}>
<div className="txt-label-s text-center">{`Day ${day}`}</div>
<div className="txt-label-s text-center">{t('day', { day })}</div>
</div>
{/* 内容区域 */}
@ -72,9 +74,13 @@ export function CheckInCard({
className="h-full w-full"
/>
</div>
<div className={cn('txt-numDisplay-m', isChecked && 'text-txt-primary-specialmap-disable')}>{coinNum / 100}</div>
<div
className={cn('txt-numDisplay-m', isChecked && 'text-txt-primary-specialmap-disable')}
>
{coinNum / 100}
</div>
{/* 装饰星星 */}
<div className="absolute top-[-4px] right-[-28px] h-7 w-6">
<div className="absolute hidden sm:block top-[-4px] right-[-28px] h-7 w-6">
<Image
src="/images/crushcoin/icon-star.svg"
className="h-full w-full"
@ -88,14 +94,18 @@ export function CheckInCard({
{isChecked ? (
<div className="mt-2 flex items-center gap-2">
<Checkbox checked disabled />
<div className="txt-label-s text-txt-primary-specialmap-disable">Checked</div>
<div className="txt-label-s text-txt-primary-specialmap-disable">{t('checked')}</div>
</div>
) : isSignInLoading ? (
<div className="txt-label-s text-txt-primary-specialmap-disable mt-2 text-center">
{t('loading')}
</div>
) : (
<div className="txt-label-s text-txt-primary-specialmap-disable mt-2 text-center">
Not Started
{t('notStarted')}
</div>
)}
</div>
</div>
)
);
}

View File

@ -1,54 +1,20 @@
'use client';
import { useGetSevenDaysSignList, useSignIn } from '@/hooks/useHome';
import { SignInListOutput } from '@/services/home/types';
import { useQueryClient } from '@tanstack/react-query';
import { homeKeys } from '@/lib/query-keys';
import { CheckInCard } from './CheckInCard';
import { useEffect, useRef } from 'react';
import { toast } from 'sonner';
import { useEffect } from 'react';
import { useSignIn } from '@/hooks/services/signin';
export function CheckInGrid() {
const queryClient = useQueryClient();
const { data: signListData, isLoading } = useGetSevenDaysSignList();
const signInMutation = useSignIn();
const hasSignRef = useRef(false);
const { signInListData, fetchSignInListLoading, handleSignIn, signInLoading } = useSignIn();
useEffect(() => {
const initializeCheckIn = async () => {
if (hasSignRef.current) return;
hasSignRef.current = true;
try {
// 先进行签到
const resp = await signInMutation.mutateAsync();
if (resp) {
toast.success('Check-in Successful');
if (signInListData?.list?.length) {
handleSignIn();
}
// 签到成功后再获取列表数据
await queryClient.invalidateQueries({
queryKey: homeKeys.getSevenDaysSignList(),
});
await queryClient.invalidateQueries({
queryKey: ['wallet'],
});
} catch (error) {
console.error('初始化签到失败:', error);
// 即使签到失败,也要获取列表数据显示界面
queryClient.invalidateQueries({
queryKey: homeKeys.getSevenDaysSignList(),
});
queryClient.invalidateQueries({
queryKey: ['wallet'],
});
}
};
}, [signInListData?.list?.length]);
if (signListData) {
initializeCheckIn();
}
}, [signListData]);
if (isLoading) {
if (fetchSignInListLoading) {
return (
<div className="grid h-[328px] grid-cols-3 grid-rows-2 gap-4">
{Array.from({ length: 6 }).map((_, index) => (
@ -61,7 +27,7 @@ export function CheckInGrid() {
);
}
const signList = signListData?.list || [];
const signList = signInListData?.list || [];
const today = new Date();
const todayStr = today.toISOString().split('T')[0]; // yyyy-MM-dd 格式
@ -91,23 +57,12 @@ export function CheckInGrid() {
const currentDayIndex =
todayIndex >= 0 ? todayIndex : fullSignList.findIndex((item) => !item.signIn);
const day7 = fullSignList[6];
return (
<div className="grid grid-cols-4 grid-rows-2 gap-4">
{fullSignList.map((item, index) => {
if (index === 3) {
return (
<CheckInCard
key={fullSignList[6].day}
day={fullSignList[6].day}
coinNum={fullSignList[6].coinNum || 0}
signIn={fullSignList[6].signIn || false}
isToday={fullSignList[6].dayStr === todayStr}
loading={signInMutation.isPending}
className="col-span-1 row-span-2"
/>
);
}
if (index < 3) {
<div className="flex flex-col gap-4 sm:flex-row">
<div className="grid flex-1 sm:flex-[3] grid-cols-3 gap-4">
{fullSignList.slice(0, 6).map((item, index) => {
return (
<CheckInCard
key={item.day}
@ -115,25 +70,22 @@ export function CheckInGrid() {
coinNum={item.coinNum || 0}
signIn={item.signIn || false}
isToday={index === currentDayIndex}
loading={signInMutation.isPending}
loading={signInLoading}
className="col-span-1 row-span-1"
/>
);
} else {
return (
<CheckInCard
key={fullSignList[index - 1].day}
day={fullSignList[index - 1].day}
coinNum={fullSignList[index - 1].coinNum || 0}
signIn={fullSignList[index - 1].signIn || false}
isToday={fullSignList[index - 1].dayStr === todayStr}
loading={signInMutation.isPending}
className="col-span-1 row-span-1"
/>
);
}
})}
</div>
<CheckInCard
key={day7.day}
day={day7.day}
coinNum={day7.coinNum || 0}
signIn={day7.signIn || false}
isToday={day7.dayStr === todayStr}
loading={signInLoading}
className="col-span-1 sm:flex-1 row-span-1"
/>
</div>
);
}

View File

@ -1,9 +1,9 @@
'use client'
'use client';
const CrushcoinBackground = () => {
return (
<div className="bg-background-default absolute inset-0 overflow-hidden">
<div className="absolute top-0 right-0 left-1/2 h-[240px] min-w-[1230px] -translate-x-1/2">
<div className="absolute top-0 right-0 left-1/2 h-30 sm:h-60 min-w-300 -translate-x-1/2">
<div
className="absolute inset-0"
style={{
@ -15,7 +15,7 @@ const CrushcoinBackground = () => {
/>
</div>
</div>
)
}
);
};
export default CrushcoinBackground
export default CrushcoinBackground;

View File

@ -1,42 +1,49 @@
'use client'
'use client';
import CrushcoinBackground from './components/CrushcoinBackground'
import { GradientDivider } from '@/components/ui/gradient-divider'
import { useGetSevenDaysSignList } from '@/hooks/useHome'
import CheckInGrid from './components/CheckInGrid'
import CrushcoinBackground from './components/CrushcoinBackground';
import { GradientDivider } from '@/components/ui/gradient-divider';
import CheckInGrid from './components/CheckInGrid';
import { IconButton } from '@/components/ui/button';
import { useRouter } from 'next/navigation';
import { useSignIn } from '@/hooks/services/signin';
import { useTranslations } from 'next-intl';
const CrushCoinPage = () => {
const { data: signListData } = useGetSevenDaysSignList()
// 计算连续签到天数
const consecutiveDays = signListData?.continuousDays || 0
const { signInListData } = useSignIn();
const router = useRouter();
const t = useTranslations('crushcoin');
return (
<div className="px-16">
<div className="px-4 sm:px-16">
<CrushcoinBackground />
<div className="relative z-1">
<h1 className="txt-display-xl mt-14 text-center">Daily Free CrushCoins</h1>
<div className="txt-title-m mt-10 py-2 text-center">
{/* You have checked in for {consecutiveDays} Consecutive days Consecutive days */}
Youve checked in for {consecutiveDays} consecutive days.
<div className="relative max-w-[752px] flex flex-col mx-auto">
<IconButton
variant="ghost"
size="large"
onClick={() => router.back()}
className="absolute top-1 left-0 z-10"
>
<i className="iconfont icon-arrow-left !text-[16px]" />
</IconButton>
<h1 className="txt-display-m sm:txt-display-xl mt-14 text-center">{t('title')}</h1>
<div className="txt-title-xs sm:txt-title-m mt-10 py-2 text-center">
{t('consecutiveDays', { days: signInListData?.continuousDays || '-' })}
</div>
{/* 渐变分割线 */}
<GradientDivider />
{/* 签到网格 */}
<div className="mx-auto mt-8 max-w-[752px]">
<div className="mt-8">
<CheckInGrid />
<div className="txt-body-m text-center text-txt-primary-normal mt-4">
<p>Diamonds can be used to pay for chat services and unlock other items. </p>
<p>
If you miss a check-in, the check-in count will reset and start again from day one.
</p>
<p>{t('description1')}</p>
<p>{t('description2')}</p>
</div>
</div>
</div>
</div>
)
}
);
};
export default CrushCoinPage
export default CrushCoinPage;

View File

@ -12,7 +12,7 @@ const Header = React.memo(() => {
const t = useTranslations('home');
return (
// <Link href="/crushcoin">
<Link href="/crushcoin">
<div
className="h-25 sm:h-50 rounded-2xl sm:rounded-4xl px-6 mb-12 flex items-center justify-between"
style={{
@ -55,10 +55,15 @@ const Header = React.memo(() => {
</div>
</div>
{response?.lg && (
<Image src="/images/home/banner-header.png" alt="banner-header" width={250} height={250} />
<Image
src="/images/home/banner-header.png"
alt="banner-header"
width={250}
height={250}
/>
)}
</div>
// </Link>
</Link>
);
});

View File

View File

@ -1,36 +1,11 @@
'use client'
'use client';
import React from 'react'
import { cn } from '@/lib/utils'
import { cn } from '@/lib/utils';
interface GradientDividerProps {
className?: string
}
export function GradientDivider({ className }: GradientDividerProps) {
export function GradientDivider() {
return (
<div className={cn('relative mx-auto max-w-[660px]', className)}>
<div className={cn('relative mx-auto max-w-[660px]')}>
<img src="/images/crushcoin/divider.png" className="absolute -top-[24px] mx-auto w-full" />
</div>
)
return (
<div className={cn('relative mx-auto my-4 h-[3px] max-w-[598px]', className)}>
<div
className="absolute inset-0 rounded-full blur-lg"
style={{
background: '#D668F2',
}}
/>
<div className="absolute inset-0 px-[20px]">
<div
className="absolute inset-0 rounded-full blur-sm"
style={{
background:
'linear-gradient(90deg, rgba(255, 222, 222, 0.00) 0%, #FFE8E8 49.04%, rgba(255, 227, 227, 0.00) 100%)',
}}
/>
</div>
</div>
)
);
}

View File

@ -651,6 +651,13 @@
line-height: var(--glo-font-lineheight-size16);
}
@utility txt-title-xs {
font-family: var(--font-poppins);
font-size: var(--glo-font-size-14);
font-weight: var(--glo-font-weight-semibold);
line-height: var(--glo-font-lineheight-size14);
}
@utility txt-bodySemibold-l {
font-family: var(--font-poppins);
font-size: var(--glo-font-size-16);

View File

@ -0,0 +1,33 @@
import { getSevenDaysSignList, signIn } from '@/services/editor';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useCurrentUser } from '../auth';
import { toast } from 'sonner';
import { useTranslations } from 'next-intl';
export function useSignIn() {
const { data: user } = useCurrentUser();
const t = useTranslations('crushcoin');
const queryClient = useQueryClient();
const { data: signInListData, isLoading: fetchSignInListLoading } = useQuery({
queryKey: ['signInList'],
enabled: !!user?.userId,
queryFn: () => getSevenDaysSignList({ userId: user?.userId }),
});
const { mutate: handleSignIn, isPending: signInLoading } = useMutation({
mutationFn: () => signIn({ userId: user?.userId }),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['signInList'],
});
toast.success(t('checked_success'));
},
});
return {
signInListData,
fetchSignInListLoading,
signInLoading,
handleSignIn,
};
}

View File

@ -152,6 +152,18 @@ export default {
female: 'Female',
},
},
crushcoin: {
title: 'Daily Free CrushCoins',
consecutiveDays: "You've checked in for {days} consecutive days.",
description1: 'Diamonds can be used to pay for chat services and unlock other items.',
description2:
'If you miss a check-in, the check-in count will reset and start again from day one.',
day: 'Day {day}',
checked: 'Checked',
notStarted: 'Not Started',
loading: 'Signing in...',
checked_success: 'Check-in successful',
},
footer: {
slogan: "Grow your love story with Spicyxx.AI AI—From 'Hi' to 'I Do', sparked by every chat",
features: 'Features',

View File

@ -150,6 +150,17 @@ export default {
female: '女性',
},
},
crushcoin: {
title: '每日免费金币',
consecutiveDays: '你已经连续签到 {days} 天',
description1: '金币可用于支付聊天服务和解锁其他物品。',
description2: '如果错过签到,签到计数将重置并从第一天重新开始。',
day: '第 {day} 天',
checked: '已签到',
notStarted: '未开始',
loading: '签到中ing',
checked_success: '签到成功',
},
footer: {
slogan: '用 Spicyxx.AI 成长你的爱情故事——从"你好"到"我愿意",每一次对话都点燃火花',
features: '功能',

View File

@ -114,9 +114,12 @@ function Topbar() {
return (
<header
className={cn('flex h-16 w-full items-center justify-between px-4 sm:px-8 transition-all', {
className={cn(
'z-10 flex h-16 w-full items-center justify-between px-4 sm:px-8 transition-all',
{
'backdrop-blur-[10px]': shouldBlur,
})}
}
)}
>
{shouldBlur && <div className="bg-background-default absolute inset-0 opacity-85" />}
<div className="relative inset-0 flex w-full items-center justify-between">

View File

@ -22,6 +22,7 @@ export const topbarRouteConfigs: Record<string, TopbarRouteConfig> = {
'/profile/account': { hideOnMobile: true },
'/character/:id': { hideOnMobile: true },
'/chat/:id': { enableBlur: true },
'/crushcoin': { hideOnMobile: true },
};
/**

View File

@ -1,5 +1,5 @@
import { editorRequest } from '@/lib/client';
import { LikeObjectParamsType } from './type';
import { LikeObjectParamsType, SignInListType } from './type';
export async function fetchCharacters({ index, limit, query }: any) {
const { data } = await editorRequest('/api/character/list', {
@ -25,3 +25,12 @@ export async function thmubObject(params: LikeObjectParamsType) {
export async function getLikeStatus(params: Pick<LikeObjectParamsType, 'objectId' | 'userId'>) {
return editorRequest('/api/like/getLikeStatus', { method: 'POST', data: params });
}
export async function getSevenDaysSignList(params: any = {}): Promise<SignInListType> {
const { data } = await editorRequest('/api/sign/list', { method: 'POST', data: params });
return data;
}
export async function signIn(params: any = {}) {
return editorRequest('/api/sign/asi', { method: 'POST', data: params });
}

View File

@ -22,6 +22,7 @@ export type CharacterType = {
[x: string]: any;
};
// 点赞点踩
export enum LikeTargetType {
Character = 0,
Story = 1,
@ -36,3 +37,13 @@ export type LikeObjectParamsType = {
userId?: number;
likeType?: LikeType;
};
// 签到
export type SignInListType = {
continuousDays: number;
list: {
coinNum: number;
dayStr: string;
signIn: boolean;
}[];
};