CheckinCalendar.jsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  1. /*
  2. Copyright (C) 2025 QuantumNous
  3. This program is free software: you can redistribute it and/or modify
  4. it under the terms of the GNU Affero General Public License as
  5. published by the Free Software Foundation, either version 3 of the
  6. License, or (at your option) any later version.
  7. This program is distributed in the hope that it will be useful,
  8. but WITHOUT ANY WARRANTY; without even the implied warranty of
  9. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  10. GNU Affero General Public License for more details.
  11. You should have received a copy of the GNU Affero General Public License
  12. along with this program. If not, see <https://www.gnu.org/licenses/>.
  13. For commercial licensing, please contact support@quantumnous.com
  14. */
  15. import React, { useState, useEffect, useMemo } from 'react';
  16. import {
  17. Card,
  18. Calendar,
  19. Button,
  20. Typography,
  21. Avatar,
  22. Spin,
  23. Tooltip,
  24. Collapsible,
  25. } from '@douyinfe/semi-ui';
  26. import {
  27. CalendarCheck,
  28. Gift,
  29. Check,
  30. ChevronDown,
  31. ChevronUp,
  32. } from 'lucide-react';
  33. import { API, showError, showSuccess, renderQuota } from '../../../../helpers';
  34. const CheckinCalendar = ({ t, status }) => {
  35. const [loading, setLoading] = useState(false);
  36. const [checkinLoading, setCheckinLoading] = useState(false);
  37. const [checkinData, setCheckinData] = useState({
  38. enabled: false,
  39. stats: {
  40. checked_in_today: false,
  41. total_checkins: 0,
  42. total_quota: 0,
  43. checkin_count: 0,
  44. records: [],
  45. },
  46. });
  47. const [currentMonth, setCurrentMonth] = useState(
  48. new Date().toISOString().slice(0, 7),
  49. );
  50. // 折叠状态:如果已签到则默认折叠
  51. const [isCollapsed, setIsCollapsed] = useState(true);
  52. // 创建日期到额度的映射,方便快速查找
  53. const checkinRecordsMap = useMemo(() => {
  54. const map = {};
  55. const records = checkinData.stats?.records || [];
  56. records.forEach((record) => {
  57. map[record.checkin_date] = record.quota_awarded;
  58. });
  59. return map;
  60. }, [checkinData.stats?.records]);
  61. // 计算本月获得的额度
  62. const monthlyQuota = useMemo(() => {
  63. const records = checkinData.stats?.records || [];
  64. return records.reduce(
  65. (sum, record) => sum + (record.quota_awarded || 0),
  66. 0,
  67. );
  68. }, [checkinData.stats?.records]);
  69. // 获取签到状态
  70. const fetchCheckinStatus = async (month) => {
  71. setLoading(true);
  72. try {
  73. const res = await API.get(`/api/user/checkin?month=${month}`);
  74. const { success, data, message } = res.data;
  75. if (success) {
  76. setCheckinData(data);
  77. } else {
  78. showError(message || t('获取签到状态失败'));
  79. }
  80. } catch (error) {
  81. showError(t('获取签到状态失败'));
  82. } finally {
  83. setLoading(false);
  84. }
  85. };
  86. // 执行签到
  87. const doCheckin = async () => {
  88. setCheckinLoading(true);
  89. try {
  90. const res = await API.post('/api/user/checkin');
  91. const { success, data, message } = res.data;
  92. if (success) {
  93. showSuccess(
  94. t('签到成功!获得') + ' ' + renderQuota(data.quota_awarded),
  95. );
  96. // 刷新签到状态
  97. fetchCheckinStatus(currentMonth);
  98. } else {
  99. showError(message || t('签到失败'));
  100. }
  101. } catch (error) {
  102. showError(t('签到失败'));
  103. } finally {
  104. setCheckinLoading(false);
  105. }
  106. };
  107. useEffect(() => {
  108. if (status?.checkin_enabled) {
  109. fetchCheckinStatus(currentMonth);
  110. }
  111. }, [status?.checkin_enabled, currentMonth]);
  112. // 当签到状态加载完成后,根据是否已签到设置折叠状态
  113. useEffect(() => {
  114. if (checkinData.stats?.checked_in_today) {
  115. setIsCollapsed(true);
  116. } else {
  117. setIsCollapsed(false);
  118. }
  119. }, [checkinData.stats?.checked_in_today]);
  120. // 如果签到功能未启用,不显示组件
  121. if (!status?.checkin_enabled) {
  122. return null;
  123. }
  124. // 日期渲染函数 - 显示签到状态和获得的额度
  125. const dateRender = (dateString) => {
  126. // Semi Calendar 传入的 dateString 是 Date.toString() 格式
  127. // 需要转换为 YYYY-MM-DD 格式来匹配后端数据
  128. const date = new Date(dateString);
  129. if (isNaN(date.getTime())) {
  130. return null;
  131. }
  132. // 使用本地时间格式化,避免时区问题
  133. const year = date.getFullYear();
  134. const month = String(date.getMonth() + 1).padStart(2, '0');
  135. const day = String(date.getDate()).padStart(2, '0');
  136. const formattedDate = `${year}-${month}-${day}`; // YYYY-MM-DD
  137. const quotaAwarded = checkinRecordsMap[formattedDate];
  138. const isCheckedIn = quotaAwarded !== undefined;
  139. if (isCheckedIn) {
  140. return (
  141. <Tooltip
  142. content={`${t('获得')} ${renderQuota(quotaAwarded)}`}
  143. position='top'
  144. >
  145. <div className='absolute inset-0 flex flex-col items-center justify-center cursor-pointer'>
  146. <div className='w-6 h-6 rounded-full bg-green-500 flex items-center justify-center mb-0.5 shadow-sm'>
  147. <Check size={14} className='text-white' strokeWidth={3} />
  148. </div>
  149. <div className='text-[10px] font-medium text-green-600 dark:text-green-400 leading-none'>
  150. {renderQuota(quotaAwarded)}
  151. </div>
  152. </div>
  153. </Tooltip>
  154. );
  155. }
  156. return null;
  157. };
  158. // 处理月份变化
  159. const handleMonthChange = (date) => {
  160. const month = date.toISOString().slice(0, 7);
  161. setCurrentMonth(month);
  162. };
  163. return (
  164. <Card className='!rounded-2xl'>
  165. {/* 卡片头部 */}
  166. <div className='flex items-center justify-between'>
  167. <div
  168. className='flex items-center flex-1 cursor-pointer'
  169. onClick={() => setIsCollapsed(!isCollapsed)}
  170. >
  171. <Avatar size='small' color='green' className='mr-3 shadow-md'>
  172. <CalendarCheck size={16} />
  173. </Avatar>
  174. <div className='flex-1'>
  175. <div className='flex items-center gap-2'>
  176. <Typography.Text className='text-lg font-medium'>
  177. {t('每日签到')}
  178. </Typography.Text>
  179. {isCollapsed ? (
  180. <ChevronDown size={16} className='text-gray-400' />
  181. ) : (
  182. <ChevronUp size={16} className='text-gray-400' />
  183. )}
  184. </div>
  185. <div className='text-xs text-gray-500 dark:text-gray-400'>
  186. {checkinData.stats?.checked_in_today
  187. ? t('今日已签到,累计签到') +
  188. ` ${checkinData.stats?.total_checkins || 0} ` +
  189. t('天')
  190. : t('每日签到可获得随机额度奖励')}
  191. </div>
  192. </div>
  193. </div>
  194. <Button
  195. type='primary'
  196. theme='solid'
  197. icon={<Gift size={16} />}
  198. onClick={doCheckin}
  199. loading={checkinLoading}
  200. disabled={checkinData.stats?.checked_in_today}
  201. className='!bg-green-600 hover:!bg-green-700'
  202. >
  203. {checkinData.stats?.checked_in_today
  204. ? t('今日已签到')
  205. : t('立即签到')}
  206. </Button>
  207. </div>
  208. {/* 可折叠内容 */}
  209. <Collapsible isOpen={!isCollapsed} keepDOM>
  210. {/* 签到统计 */}
  211. <div className='grid grid-cols-3 gap-3 mb-4 mt-4'>
  212. <div className='text-center p-2.5 bg-slate-50 dark:bg-slate-800 rounded-lg'>
  213. <div className='text-xl font-bold text-green-600'>
  214. {checkinData.stats?.total_checkins || 0}
  215. </div>
  216. <div className='text-xs text-gray-500'>{t('累计签到')}</div>
  217. </div>
  218. <div className='text-center p-2.5 bg-slate-50 dark:bg-slate-800 rounded-lg'>
  219. <div className='text-xl font-bold text-orange-600'>
  220. {renderQuota(monthlyQuota, 6)}
  221. </div>
  222. <div className='text-xs text-gray-500'>{t('本月获得')}</div>
  223. </div>
  224. <div className='text-center p-2.5 bg-slate-50 dark:bg-slate-800 rounded-lg'>
  225. <div className='text-xl font-bold text-blue-600'>
  226. {renderQuota(checkinData.stats?.total_quota || 0, 6)}
  227. </div>
  228. <div className='text-xs text-gray-500'>{t('累计获得')}</div>
  229. </div>
  230. </div>
  231. {/* 签到日历 - 使用更紧凑的样式 */}
  232. <Spin spinning={loading}>
  233. <div className='border rounded-lg overflow-hidden checkin-calendar'>
  234. <style>{`
  235. .checkin-calendar .semi-calendar {
  236. font-size: 13px;
  237. }
  238. .checkin-calendar .semi-calendar-month-header {
  239. padding: 8px 12px;
  240. }
  241. .checkin-calendar .semi-calendar-month-week-row {
  242. height: 28px;
  243. }
  244. .checkin-calendar .semi-calendar-month-week-row th {
  245. font-size: 12px;
  246. padding: 4px 0;
  247. }
  248. .checkin-calendar .semi-calendar-month-grid-row {
  249. height: auto;
  250. }
  251. .checkin-calendar .semi-calendar-month-grid-row td {
  252. height: 56px;
  253. padding: 2px;
  254. }
  255. .checkin-calendar .semi-calendar-month-grid-row-cell {
  256. position: relative;
  257. height: 100%;
  258. }
  259. .checkin-calendar .semi-calendar-month-grid-row-cell-day {
  260. position: absolute;
  261. top: 4px;
  262. left: 50%;
  263. transform: translateX(-50%);
  264. font-size: 12px;
  265. z-index: 1;
  266. }
  267. .checkin-calendar .semi-calendar-month-same {
  268. background: transparent;
  269. }
  270. .checkin-calendar .semi-calendar-month-today .semi-calendar-month-grid-row-cell-day {
  271. background: var(--semi-color-primary);
  272. color: white;border-radius: 50%;
  273. width: 20px;
  274. height: 20px;
  275. display: flex;
  276. align-items: center;
  277. justify-content: center;}
  278. `}</style>
  279. <Calendar
  280. mode='month'
  281. onChange={handleMonthChange}
  282. dateGridRender={(dateString, date) => dateRender(dateString)}
  283. />
  284. </div>
  285. </Spin>
  286. {/* 签到说明 */}
  287. <div className='mt-3 p-2.5 bg-slate-50 dark:bg-slate-800 rounded-lg'>
  288. <Typography.Text type='tertiary' className='text-xs'>
  289. <ul className='list-disc list-inside space-y-0.5'>
  290. <li>{t('每日签到可获得随机额度奖励')}</li>
  291. <li>{t('签到奖励将直接添加到您的账户余额')}</li>
  292. <li>{t('每日仅可签到一次,请勿重复签到')}</li>
  293. </ul>
  294. </Typography.Text>
  295. </div>
  296. </Collapsible>
  297. </Card>
  298. );
  299. };
  300. export default CheckinCalendar;