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 withNextIntl = createNextIntlPlugin('./src/lib/i18n.ts');
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
reactStrictMode: false,
/* config options here */ /* config options here */
images: { images: {
remotePatterns: [ remotePatterns: [

View File

@ -1,15 +1,16 @@
'use client' 'use client';
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox';
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils';
import Image from 'next/image' import Image from 'next/image';
import { useTranslations } from 'next-intl';
interface CheckInCardProps { interface CheckInCardProps {
day: number day: number;
coinNum: number coinNum: number;
signIn: boolean signIn: boolean;
isToday: boolean isToday: boolean;
loading?: boolean loading?: boolean;
className?: string className?: string;
} }
export function CheckInCard({ export function CheckInCard({
@ -20,27 +21,28 @@ export function CheckInCard({
loading, loading,
className, className,
}: CheckInCardProps) { }: CheckInCardProps) {
const isChecked = signIn const t = useTranslations('crushcoin');
const canCheckIn = isToday && !signIn const isChecked = signIn;
const isSignInLoading = isToday && loading;
// 根据状态决定样式 // 根据状态决定样式
const getCardStyles = () => { const getCardStyles = () => {
if (isChecked) { if (isChecked) {
// 已签到 - 灰色背景 // 已签到 - 灰色背景
return 'bg-[#282233] border-outline-normal' return 'bg-[#282233] border-outline-normal';
} else { } 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 = () => { const getDayLabelStyles = () => {
if (isChecked) { if (isChecked) {
return 'bg-surface-nest-disabled' return 'bg-surface-nest-disabled';
} else { } else {
return 'bg-gradient-to-b from-[#ff9156] to-[#bf00ff]' return 'bg-gradient-to-b from-[#ff9156] to-[#bf00ff]';
} }
} };
return ( return (
<div <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={`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> </div>
{/* 内容区域 */} {/* 内容区域 */}
@ -72,9 +74,13 @@ export function CheckInCard({
className="h-full w-full" className="h-full w-full"
/> />
</div> </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 <Image
src="/images/crushcoin/icon-star.svg" src="/images/crushcoin/icon-star.svg"
className="h-full w-full" className="h-full w-full"
@ -88,14 +94,18 @@ export function CheckInCard({
{isChecked ? ( {isChecked ? (
<div className="mt-2 flex items-center gap-2"> <div className="mt-2 flex items-center gap-2">
<Checkbox checked disabled /> <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>
) : ( ) : (
<div className="txt-label-s text-txt-primary-specialmap-disable mt-2 text-center"> <div className="txt-label-s text-txt-primary-specialmap-disable mt-2 text-center">
Not Started {t('notStarted')}
</div> </div>
)} )}
</div> </div>
</div> </div>
) );
} }

View File

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

View File

@ -1,9 +1,9 @@
'use client' 'use client';
const CrushcoinBackground = () => { const CrushcoinBackground = () => {
return ( return (
<div className="bg-background-default absolute inset-0 overflow-hidden"> <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 <div
className="absolute inset-0" className="absolute inset-0"
style={{ style={{
@ -15,7 +15,7 @@ const CrushcoinBackground = () => {
/> />
</div> </div>
</div> </div>
) );
} };
export default CrushcoinBackground export default CrushcoinBackground;

View File

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

View File

@ -12,53 +12,58 @@ const Header = React.memo(() => {
const t = useTranslations('home'); const t = useTranslations('home');
return ( return (
// <Link href="/crushcoin"> <Link href="/crushcoin">
<div <div
className="h-25 sm:h-50 rounded-2xl sm:rounded-4xl px-6 mb-12 flex items-center justify-between" className="h-25 sm:h-50 rounded-2xl sm:rounded-4xl px-6 mb-12 flex items-center justify-between"
style={{ style={{
background: background:
'linear-gradient(90deg, rgba(255, 255, 255, 0.1) 0%, rgba(202, 153, 255, 0.2) 100%)', 'linear-gradient(90deg, rgba(255, 255, 255, 0.1) 0%, rgba(202, 153, 255, 0.2) 100%)',
}} }}
> >
<div className="flex gap-3 items-center"> <div className="flex gap-3 items-center">
<img <img
src="/images/home/icon-crush-free.png" src="/images/home/icon-crush-free.png"
className="h-15 w-15 sm:h-30 sm:w-30 object-cover" className="h-15 w-15 sm:h-30 sm:w-30 object-cover"
alt="header-bg" alt="header-bg"
/> />
<div> <div>
<div className="flex gap-5 txt-display-m sm:txt-display-l"> <div className="flex gap-5 txt-display-m sm:txt-display-l">
{t('check_in')}{' '} {t('check_in')}{' '}
<Image <Image
src="/images/home/left-star.png" src="/images/home/left-star.png"
className="h-6 w-6 sm:h-12 sm:w-12 object-cover" className="h-6 w-6 sm:h-12 sm:w-12 object-cover"
alt="header-bg" alt="header-bg"
width={48} width={48}
height={48} height={48}
/> />
</div> </div>
<div className="flex gap-5 items-center"> <div className="flex gap-5 items-center">
<span <span
className="txt-body-m sm:txt-headline-s bg-clip-text text-transparent" className="txt-body-m sm:txt-headline-s bg-clip-text text-transparent"
style={{ style={{
background: background:
'linear-gradient(109.2deg, rgba(211, 123, 235, 1) 37.08%, rgba(147, 123, 235, 1) 128.91%)', 'linear-gradient(109.2deg, rgba(211, 123, 235, 1) 37.08%, rgba(147, 123, 235, 1) 128.91%)',
WebkitBackgroundClip: 'text', WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent', WebkitTextFillColor: 'transparent',
backgroundClip: 'text', backgroundClip: 'text',
}} }}
> >
{t('check_in_desc')} {t('check_in_desc')}
</span> </span>
<IconButton iconfont="icon-arrow-right-border" size="small" variant="primary" /> <IconButton iconfont="icon-arrow-right-border" size="small" variant="primary" />
</div>
</div> </div>
</div> </div>
{response?.lg && (
<Image
src="/images/home/banner-header.png"
alt="banner-header"
width={250}
height={250}
/>
)}
</div> </div>
{response?.lg && ( </Link>
<Image src="/images/home/banner-header.png" alt="banner-header" width={250} height={250} />
)}
</div>
// </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 { export function GradientDivider() {
className?: string
}
export function GradientDivider({ className }: GradientDividerProps) {
return ( 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" /> <img src="/images/crushcoin/divider.png" className="absolute -top-[24px] mx-auto w-full" />
</div> </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); 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 { @utility txt-bodySemibold-l {
font-family: var(--font-poppins); font-family: var(--font-poppins);
font-size: var(--glo-font-size-16); 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', 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: { footer: {
slogan: "Grow your love story with Spicyxx.AI AI—From 'Hi' to 'I Do', sparked by every chat", slogan: "Grow your love story with Spicyxx.AI AI—From 'Hi' to 'I Do', sparked by every chat",
features: 'Features', features: 'Features',

View File

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

View File

@ -114,9 +114,12 @@ function Topbar() {
return ( return (
<header <header
className={cn('flex h-16 w-full items-center justify-between px-4 sm:px-8 transition-all', { className={cn(
'backdrop-blur-[10px]': shouldBlur, '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" />} {shouldBlur && <div className="bg-background-default absolute inset-0 opacity-85" />}
<div className="relative inset-0 flex w-full items-center justify-between"> <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 }, '/profile/account': { hideOnMobile: true },
'/character/:id': { hideOnMobile: true }, '/character/:id': { hideOnMobile: true },
'/chat/:id': { enableBlur: true }, '/chat/:id': { enableBlur: true },
'/crushcoin': { hideOnMobile: true },
}; };
/** /**

View File

@ -1,5 +1,5 @@
import { editorRequest } from '@/lib/client'; import { editorRequest } from '@/lib/client';
import { LikeObjectParamsType } from './type'; import { LikeObjectParamsType, SignInListType } from './type';
export async function fetchCharacters({ index, limit, query }: any) { export async function fetchCharacters({ index, limit, query }: any) {
const { data } = await editorRequest('/api/character/list', { 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'>) { export async function getLikeStatus(params: Pick<LikeObjectParamsType, 'objectId' | 'userId'>) {
return editorRequest('/api/like/getLikeStatus', { method: 'POST', data: params }); 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; [x: string]: any;
}; };
// 点赞点踩
export enum LikeTargetType { export enum LikeTargetType {
Character = 0, Character = 0,
Story = 1, Story = 1,
@ -36,3 +37,13 @@ export type LikeObjectParamsType = {
userId?: number; userId?: number;
likeType?: LikeType; likeType?: LikeType;
}; };
// 签到
export type SignInListType = {
continuousDays: number;
list: {
coinNum: number;
dayStr: string;
signIn: boolean;
}[];
};