| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321 |
- /*
- Copyright (C) 2025 QuantumNous
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <https://www.gnu.org/licenses/>.
- For commercial licensing, please contact support@quantumnous.com
- */
- import React, { useState, useEffect, useMemo } from 'react';
- import {
- Card,
- Calendar,
- Button,
- Typography,
- Avatar,
- Spin,
- Tooltip,
- Collapsible,
- } from '@douyinfe/semi-ui';
- import {
- CalendarCheck,
- Gift,
- Check,
- ChevronDown,
- ChevronUp,
- } from 'lucide-react';
- import { API, showError, showSuccess, renderQuota } from '../../../../helpers';
- const CheckinCalendar = ({ t, status }) => {
- const [loading, setLoading] = useState(false);
- const [checkinLoading, setCheckinLoading] = useState(false);
- const [checkinData, setCheckinData] = useState({
- enabled: false,
- stats: {
- checked_in_today: false,
- total_checkins: 0,
- total_quota: 0,
- checkin_count: 0,
- records: [],
- },
- });
- const [currentMonth, setCurrentMonth] = useState(
- new Date().toISOString().slice(0, 7),
- );
- // 折叠状态:如果已签到则默认折叠
- const [isCollapsed, setIsCollapsed] = useState(true);
- // 创建日期到额度的映射,方便快速查找
- const checkinRecordsMap = useMemo(() => {
- const map = {};
- const records = checkinData.stats?.records || [];
- records.forEach((record) => {
- map[record.checkin_date] = record.quota_awarded;
- });
- return map;
- }, [checkinData.stats?.records]);
- // 计算本月获得的额度
- const monthlyQuota = useMemo(() => {
- const records = checkinData.stats?.records || [];
- return records.reduce(
- (sum, record) => sum + (record.quota_awarded || 0),
- 0,
- );
- }, [checkinData.stats?.records]);
- // 获取签到状态
- const fetchCheckinStatus = async (month) => {
- setLoading(true);
- try {
- const res = await API.get(`/api/user/checkin?month=${month}`);
- const { success, data, message } = res.data;
- if (success) {
- setCheckinData(data);
- } else {
- showError(message || t('获取签到状态失败'));
- }
- } catch (error) {
- showError(t('获取签到状态失败'));
- } finally {
- setLoading(false);
- }
- };
- // 执行签到
- const doCheckin = async () => {
- setCheckinLoading(true);
- try {
- const res = await API.post('/api/user/checkin');
- const { success, data, message } = res.data;
- if (success) {
- showSuccess(
- t('签到成功!获得') + ' ' + renderQuota(data.quota_awarded),
- );
- // 刷新签到状态
- fetchCheckinStatus(currentMonth);
- } else {
- showError(message || t('签到失败'));
- }
- } catch (error) {
- showError(t('签到失败'));
- } finally {
- setCheckinLoading(false);
- }
- };
- useEffect(() => {
- if (status?.checkin_enabled) {
- fetchCheckinStatus(currentMonth);
- }
- }, [status?.checkin_enabled, currentMonth]);
- // 当签到状态加载完成后,根据是否已签到设置折叠状态
- useEffect(() => {
- if (checkinData.stats?.checked_in_today) {
- setIsCollapsed(true);
- } else {
- setIsCollapsed(false);
- }
- }, [checkinData.stats?.checked_in_today]);
- // 如果签到功能未启用,不显示组件
- if (!status?.checkin_enabled) {
- return null;
- }
- // 日期渲染函数 - 显示签到状态和获得的额度
- const dateRender = (dateString) => {
- // Semi Calendar 传入的 dateString 是 Date.toString() 格式
- // 需要转换为 YYYY-MM-DD 格式来匹配后端数据
- const date = new Date(dateString);
- if (isNaN(date.getTime())) {
- return null;
- }
- // 使用本地时间格式化,避免时区问题
- const year = date.getFullYear();
- const month = String(date.getMonth() + 1).padStart(2, '0');
- const day = String(date.getDate()).padStart(2, '0');
- const formattedDate = `${year}-${month}-${day}`; // YYYY-MM-DD
- const quotaAwarded = checkinRecordsMap[formattedDate];
- const isCheckedIn = quotaAwarded !== undefined;
- if (isCheckedIn) {
- return (
- <Tooltip
- content={`${t('获得')} ${renderQuota(quotaAwarded)}`}
- position='top'
- >
- <div className='absolute inset-0 flex flex-col items-center justify-center cursor-pointer'>
- <div className='w-6 h-6 rounded-full bg-green-500 flex items-center justify-center mb-0.5 shadow-sm'>
- <Check size={14} className='text-white' strokeWidth={3} />
- </div>
- <div className='text-[10px] font-medium text-green-600 dark:text-green-400 leading-none'>
- {renderQuota(quotaAwarded)}
- </div>
- </div>
- </Tooltip>
- );
- }
- return null;
- };
- // 处理月份变化
- const handleMonthChange = (date) => {
- const month = date.toISOString().slice(0, 7);
- setCurrentMonth(month);
- };
- return (
- <Card className='!rounded-2xl'>
- {/* 卡片头部 */}
- <div className='flex items-center justify-between'>
- <div
- className='flex items-center flex-1 cursor-pointer'
- onClick={() => setIsCollapsed(!isCollapsed)}
- >
- <Avatar size='small' color='green' className='mr-3 shadow-md'>
- <CalendarCheck size={16} />
- </Avatar>
- <div className='flex-1'>
- <div className='flex items-center gap-2'>
- <Typography.Text className='text-lg font-medium'>
- {t('每日签到')}
- </Typography.Text>
- {isCollapsed ? (
- <ChevronDown size={16} className='text-gray-400' />
- ) : (
- <ChevronUp size={16} className='text-gray-400' />
- )}
- </div>
- <div className='text-xs text-gray-500 dark:text-gray-400'>
- {checkinData.stats?.checked_in_today
- ? t('今日已签到,累计签到') +
- ` ${checkinData.stats?.total_checkins || 0} ` +
- t('天')
- : t('每日签到可获得随机额度奖励')}
- </div>
- </div>
- </div>
- <Button
- type='primary'
- theme='solid'
- icon={<Gift size={16} />}
- onClick={doCheckin}
- loading={checkinLoading}
- disabled={checkinData.stats?.checked_in_today}
- className='!bg-green-600 hover:!bg-green-700'
- >
- {checkinData.stats?.checked_in_today
- ? t('今日已签到')
- : t('立即签到')}
- </Button>
- </div>
- {/* 可折叠内容 */}
- <Collapsible isOpen={!isCollapsed} keepDOM>
- {/* 签到统计 */}
- <div className='grid grid-cols-3 gap-3 mb-4 mt-4'>
- <div className='text-center p-2.5 bg-slate-50 dark:bg-slate-800 rounded-lg'>
- <div className='text-xl font-bold text-green-600'>
- {checkinData.stats?.total_checkins || 0}
- </div>
- <div className='text-xs text-gray-500'>{t('累计签到')}</div>
- </div>
- <div className='text-center p-2.5 bg-slate-50 dark:bg-slate-800 rounded-lg'>
- <div className='text-xl font-bold text-orange-600'>
- {renderQuota(monthlyQuota, 6)}
- </div>
- <div className='text-xs text-gray-500'>{t('本月获得')}</div>
- </div>
- <div className='text-center p-2.5 bg-slate-50 dark:bg-slate-800 rounded-lg'>
- <div className='text-xl font-bold text-blue-600'>
- {renderQuota(checkinData.stats?.total_quota || 0, 6)}
- </div>
- <div className='text-xs text-gray-500'>{t('累计获得')}</div>
- </div>
- </div>
- {/* 签到日历 - 使用更紧凑的样式 */}
- <Spin spinning={loading}>
- <div className='border rounded-lg overflow-hidden checkin-calendar'>
- <style>{`
- .checkin-calendar .semi-calendar {
- font-size: 13px;
- }
- .checkin-calendar .semi-calendar-month-header {
- padding: 8px 12px;
- }
- .checkin-calendar .semi-calendar-month-week-row {
- height: 28px;
- }
- .checkin-calendar .semi-calendar-month-week-row th {
- font-size: 12px;
- padding: 4px 0;
- }
- .checkin-calendar .semi-calendar-month-grid-row {
- height: auto;
- }
- .checkin-calendar .semi-calendar-month-grid-row td {
- height: 56px;
- padding: 2px;
- }
- .checkin-calendar .semi-calendar-month-grid-row-cell {
- position: relative;
- height: 100%;
- }
- .checkin-calendar .semi-calendar-month-grid-row-cell-day {
- position: absolute;
- top: 4px;
- left: 50%;
- transform: translateX(-50%);
- font-size: 12px;
- z-index: 1;
- }
- .checkin-calendar .semi-calendar-month-same {
- background: transparent;
- }
- .checkin-calendar .semi-calendar-month-today .semi-calendar-month-grid-row-cell-day {
- background: var(--semi-color-primary);
- color: white;border-radius: 50%;
- width: 20px;
- height: 20px;
- display: flex;
- align-items: center;
- justify-content: center;}
- `}</style>
- <Calendar
- mode='month'
- onChange={handleMonthChange}
- dateGridRender={(dateString, date) => dateRender(dateString)}
- />
- </div>
- </Spin>
- {/* 签到说明 */}
- <div className='mt-3 p-2.5 bg-slate-50 dark:bg-slate-800 rounded-lg'>
- <Typography.Text type='tertiary' className='text-xs'>
- <ul className='list-disc list-inside space-y-0.5'>
- <li>{t('每日签到可获得随机额度奖励')}</li>
- <li>{t('签到奖励将直接添加到您的账户余额')}</li>
- <li>{t('每日仅可签到一次,请勿重复签到')}</li>
- </ul>
- </Typography.Text>
- </div>
- </Collapsible>
- </Card>
- );
- };
- export default CheckinCalendar;
|