Pārlūkot izejas kodu

feat(checkin): add check-in functionality with status retrieval and user quota rewards

CaIon 2 mēneši atpakaļ
vecāks
revīzija
8abfbe372f

+ 72 - 0
controller/checkin.go

@@ -0,0 +1,72 @@
+package controller
+
+import (
+	"fmt"
+	"net/http"
+	"time"
+
+	"github.com/QuantumNous/new-api/common"
+	"github.com/QuantumNous/new-api/logger"
+	"github.com/QuantumNous/new-api/model"
+	"github.com/QuantumNous/new-api/setting/operation_setting"
+	"github.com/gin-gonic/gin"
+)
+
+// GetCheckinStatus 获取用户签到状态和历史记录
+func GetCheckinStatus(c *gin.Context) {
+	setting := operation_setting.GetCheckinSetting()
+	if !setting.Enabled {
+		common.ApiErrorMsg(c, "签到功能未启用")
+		return
+	}
+	userId := c.GetInt("id")
+	// 获取月份参数,默认为当前月份
+	month := c.DefaultQuery("month", time.Now().Format("2006-01"))
+
+	stats, err := model.GetUserCheckinStats(userId, month)
+	if err != nil {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": err.Error(),
+		})
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"data": gin.H{
+			"enabled":   setting.Enabled,
+			"min_quota": setting.MinQuota,
+			"max_quota": setting.MaxQuota,
+			"stats":     stats,
+		},
+	})
+}
+
+// DoCheckin 执行用户签到
+func DoCheckin(c *gin.Context) {
+	setting := operation_setting.GetCheckinSetting()
+	if !setting.Enabled {
+		common.ApiErrorMsg(c, "签到功能未启用")
+		return
+	}
+
+	userId := c.GetInt("id")
+
+	checkin, err := model.UserCheckin(userId)
+	if err != nil {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": err.Error(),
+		})
+		return
+	}
+	model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("用户签到,获得额度 %s", logger.LogQuota(checkin.QuotaAwarded)))
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "签到成功",
+		"data": gin.H{
+			"quota_awarded": checkin.QuotaAwarded,
+			"checkin_date":  checkin.CheckinDate},
+	})
+}

+ 1 - 0
controller/misc.go

@@ -114,6 +114,7 @@ func GetStatus(c *gin.Context) {
 		"setup":                       constant.Setup,
 		"user_agreement_enabled":      legalSetting.UserAgreement != "",
 		"privacy_policy_enabled":      legalSetting.PrivacyPolicy != "",
+		"checkin_enabled":             operation_setting.GetCheckinSetting().Enabled,
 	}
 
 	// 根据启用状态注入可选内容

+ 179 - 0
model/checkin.go

@@ -0,0 +1,179 @@
+package model
+
+import (
+	"errors"
+	"math/rand"
+	"time"
+
+	"github.com/QuantumNous/new-api/common"
+	"github.com/QuantumNous/new-api/setting/operation_setting"
+	"gorm.io/gorm"
+)
+
+// Checkin 签到记录
+type Checkin struct {
+	Id           int    `json:"id" gorm:"primaryKey;autoIncrement"`
+	UserId       int    `json:"user_id" gorm:"not null;uniqueIndex:idx_user_checkin_date"`
+	CheckinDate  string `json:"checkin_date" gorm:"type:varchar(10);not null;uniqueIndex:idx_user_checkin_date"` // 格式: YYYY-MM-DD
+	QuotaAwarded int    `json:"quota_awarded" gorm:"not null"`
+	CreatedAt    int64  `json:"created_at" gorm:"bigint"`
+}
+
+// CheckinRecord 用于API返回的签到记录(不包含敏感字段)
+type CheckinRecord struct {
+	CheckinDate  string `json:"checkin_date"`
+	QuotaAwarded int    `json:"quota_awarded"`
+}
+
+func (Checkin) TableName() string {
+	return "checkins"
+}
+
+// GetUserCheckinRecords 获取用户在指定日期范围内的签到记录
+func GetUserCheckinRecords(userId int, startDate, endDate string) ([]Checkin, error) {
+	var records []Checkin
+	err := DB.Where("user_id = ? AND checkin_date >= ? AND checkin_date <= ?",
+		userId, startDate, endDate).
+		Order("checkin_date DESC").
+		Find(&records).Error
+	return records, err
+}
+
+// HasCheckedInToday 检查用户今天是否已签到
+func HasCheckedInToday(userId int) (bool, error) {
+	today := time.Now().Format("2006-01-02")
+	var count int64
+	err := DB.Model(&Checkin{}).
+		Where("user_id = ? AND checkin_date = ?", userId, today).
+		Count(&count).Error
+	return count > 0, err
+}
+
+// UserCheckin 执行用户签到
+// MySQL 和 PostgreSQL 使用事务保证原子性
+// SQLite 不支持嵌套事务,使用顺序操作 + 手动回滚
+func UserCheckin(userId int) (*Checkin, error) {
+	setting := operation_setting.GetCheckinSetting()
+	if !setting.Enabled {
+		return nil, errors.New("签到功能未启用")
+	}
+
+	// 检查今天是否已签到
+	hasChecked, err := HasCheckedInToday(userId)
+	if err != nil {
+		return nil, err
+	}
+	if hasChecked {
+		return nil, errors.New("今日已签到")
+	}
+
+	// 计算随机额度奖励
+	quotaAwarded := setting.MinQuota
+	if setting.MaxQuota > setting.MinQuota {
+		quotaAwarded = setting.MinQuota + rand.Intn(setting.MaxQuota-setting.MinQuota+1)
+	}
+
+	today := time.Now().Format("2006-01-02")
+	checkin := &Checkin{
+		UserId:       userId,
+		CheckinDate:  today,
+		QuotaAwarded: quotaAwarded,
+		CreatedAt:    time.Now().Unix(),
+	}
+
+	// 根据数据库类型选择不同的策略
+	if common.UsingSQLite {
+		// SQLite 不支持嵌套事务,使用顺序操作 + 手动回滚
+		return userCheckinWithoutTransaction(checkin, userId, quotaAwarded)
+	}
+
+	// MySQL 和 PostgreSQL 支持事务,使用事务保证原子性
+	return userCheckinWithTransaction(checkin, userId, quotaAwarded)
+}
+
+// userCheckinWithTransaction 使用事务执行签到(适用于 MySQL 和 PostgreSQL)
+func userCheckinWithTransaction(checkin *Checkin, userId int, quotaAwarded int) (*Checkin, error) {
+	err := DB.Transaction(func(tx *gorm.DB) error {
+		// 步骤1: 创建签到记录
+		// 数据库有唯一约束 (user_id, checkin_date),可以防止并发重复签到
+		if err := tx.Create(checkin).Error; err != nil {
+			return errors.New("签到失败,请稍后重试")
+		}
+
+		// 步骤2: 在事务中增加用户额度
+		if err := tx.Model(&User{}).Where("id = ?", userId).
+			Update("quota", gorm.Expr("quota + ?", quotaAwarded)).Error; err != nil {
+			return errors.New("签到失败:更新额度出错")
+		}
+
+		return nil
+	})
+
+	if err != nil {
+		return nil, err
+	}
+
+	// 事务成功后,异步更新缓存
+	go func() {
+		_ = cacheIncrUserQuota(userId, int64(quotaAwarded))
+	}()
+
+	return checkin, nil
+}
+
+// userCheckinWithoutTransaction 不使用事务执行签到(适用于 SQLite)
+func userCheckinWithoutTransaction(checkin *Checkin, userId int, quotaAwarded int) (*Checkin, error) {
+	// 步骤1: 创建签到记录
+	// 数据库有唯一约束 (user_id, checkin_date),可以防止并发重复签到
+	if err := DB.Create(checkin).Error; err != nil {
+		return nil, errors.New("签到失败,请稍后重试")
+	}
+
+	// 步骤2: 增加用户额度
+	// 使用 db=true 强制直接写入数据库,不使用批量更新
+	if err := IncreaseUserQuota(userId, quotaAwarded, true); err != nil {
+		// 如果增加额度失败,需要回滚签到记录
+		DB.Delete(checkin)
+		return nil, errors.New("签到失败:更新额度出错")
+	}
+
+	return checkin, nil
+}
+
+// GetUserCheckinStats 获取用户签到统计信息
+func GetUserCheckinStats(userId int, month string) (map[string]interface{}, error) {
+	// 获取指定月份的所有签到记录
+	startDate := month + "-01"
+	endDate := month + "-31"
+
+	records, err := GetUserCheckinRecords(userId, startDate, endDate)
+	if err != nil {
+		return nil, err
+	}
+
+	// 转换为不包含敏感字段的记录
+	checkinRecords := make([]CheckinRecord, len(records))
+	for i, r := range records {
+		checkinRecords[i] = CheckinRecord{
+			CheckinDate:  r.CheckinDate,
+			QuotaAwarded: r.QuotaAwarded,
+		}
+	}
+
+	// 检查今天是否已签到
+	hasCheckedToday, _ := HasCheckedInToday(userId)
+
+	// 获取用户所有时间的签到统计
+	var totalCheckins int64
+	var totalQuota int64
+	DB.Model(&Checkin{}).Where("user_id = ?", userId).Count(&totalCheckins)
+	DB.Model(&Checkin{}).Where("user_id = ?", userId).Select("COALESCE(SUM(quota_awarded), 0)").Scan(&totalQuota)
+
+	return map[string]interface{}{
+		"total_quota":      totalQuota,      // 所有时间累计获得的额度
+		"total_checkins":   totalCheckins,   // 所有时间累计签到次数
+		"checkin_count":    len(records),    // 本月签到次数
+		"checked_in_today": hasCheckedToday, // 今天是否已签到
+		"records":          checkinRecords,  // 本月签到记录详情(不含id和user_id)
+	}, nil
+}

+ 43 - 41
model/main.go

@@ -248,26 +248,27 @@ func InitLogDB() (err error) {
 }
 
 func migrateDB() error {
-    err := DB.AutoMigrate(
-        &Channel{},
-        &Token{},
-        &User{},
-        &PasskeyCredential{},
+	err := DB.AutoMigrate(
+		&Channel{},
+		&Token{},
+		&User{},
+		&PasskeyCredential{},
 		&Option{},
-        &Redemption{},
-        &Ability{},
-        &Log{},
-        &Midjourney{},
-        &TopUp{},
-        &QuotaData{},
-        &Task{},
-        &Model{},
-        &Vendor{},
-        &PrefillGroup{},
-        &Setup{},
-        &TwoFA{},
-        &TwoFABackupCode{},
-    )
+		&Redemption{},
+		&Ability{},
+		&Log{},
+		&Midjourney{},
+		&TopUp{},
+		&QuotaData{},
+		&Task{},
+		&Model{},
+		&Vendor{},
+		&PrefillGroup{},
+		&Setup{},
+		&TwoFA{},
+		&TwoFABackupCode{},
+		&Checkin{},
+	)
 	if err != nil {
 		return err
 	}
@@ -278,29 +279,30 @@ func migrateDBFast() error {
 
 	var wg sync.WaitGroup
 
-    migrations := []struct {
-        model interface{}
-        name  string
-    }{
-        {&Channel{}, "Channel"},
-        {&Token{}, "Token"},
-        {&User{}, "User"},
-        {&PasskeyCredential{}, "PasskeyCredential"},
+	migrations := []struct {
+		model interface{}
+		name  string
+	}{
+		{&Channel{}, "Channel"},
+		{&Token{}, "Token"},
+		{&User{}, "User"},
+		{&PasskeyCredential{}, "PasskeyCredential"},
 		{&Option{}, "Option"},
-        {&Redemption{}, "Redemption"},
-        {&Ability{}, "Ability"},
-        {&Log{}, "Log"},
-        {&Midjourney{}, "Midjourney"},
-        {&TopUp{}, "TopUp"},
-        {&QuotaData{}, "QuotaData"},
-        {&Task{}, "Task"},
-        {&Model{}, "Model"},
-        {&Vendor{}, "Vendor"},
-        {&PrefillGroup{}, "PrefillGroup"},
-        {&Setup{}, "Setup"},
-        {&TwoFA{}, "TwoFA"},
-        {&TwoFABackupCode{}, "TwoFABackupCode"},
-    }
+		{&Redemption{}, "Redemption"},
+		{&Ability{}, "Ability"},
+		{&Log{}, "Log"},
+		{&Midjourney{}, "Midjourney"},
+		{&TopUp{}, "TopUp"},
+		{&QuotaData{}, "QuotaData"},
+		{&Task{}, "Task"},
+		{&Model{}, "Model"},
+		{&Vendor{}, "Vendor"},
+		{&PrefillGroup{}, "PrefillGroup"},
+		{&Setup{}, "Setup"},
+		{&TwoFA{}, "TwoFA"},
+		{&TwoFABackupCode{}, "TwoFABackupCode"},
+		{&Checkin{}, "Checkin"},
+	}
 	// 动态计算migration数量,确保errChan缓冲区足够大
 	errChan := make(chan error, len(migrations))
 

+ 4 - 0
router/api-router.go

@@ -93,6 +93,10 @@ func SetApiRouter(router *gin.Engine) {
 				selfRoute.POST("/2fa/enable", controller.Enable2FA)
 				selfRoute.POST("/2fa/disable", controller.Disable2FA)
 				selfRoute.POST("/2fa/backup_codes", controller.RegenerateBackupCodes)
+
+				// Check-in routes
+				selfRoute.GET("/checkin", controller.GetCheckinStatus)
+				selfRoute.POST("/checkin", controller.DoCheckin)
 			}
 
 			adminRoute := userRoute.Group("/")

+ 37 - 0
setting/operation_setting/checkin_setting.go

@@ -0,0 +1,37 @@
+package operation_setting
+
+import "github.com/QuantumNous/new-api/setting/config"
+
+// CheckinSetting 签到功能配置
+type CheckinSetting struct {
+	Enabled  bool `json:"enabled"`   // 是否启用签到功能
+	MinQuota int  `json:"min_quota"` // 签到最小额度奖励
+	MaxQuota int  `json:"max_quota"` // 签到最大额度奖励
+}
+
+// 默认配置
+var checkinSetting = CheckinSetting{
+	Enabled:  false, // 默认关闭
+	MinQuota: 1000,  // 默认最小额度 1000 (约 0.002 USD)
+	MaxQuota: 10000, // 默认最大额度 10000 (约 0.02 USD)
+}
+
+func init() {
+	// 注册到全局配置管理器
+	config.GlobalConfig.Register("checkin_setting", &checkinSetting)
+}
+
+// GetCheckinSetting 获取签到配置
+func GetCheckinSetting() *CheckinSetting {
+	return &checkinSetting
+}
+
+// IsCheckinEnabled 是否启用签到功能
+func IsCheckinEnabled() bool {
+	return checkinSetting.Enabled
+}
+
+// GetCheckinQuotaRange 获取签到额度范围
+func GetCheckinQuotaRange() (min, max int) {
+	return checkinSetting.MinQuota, checkinSetting.MaxQuota
+}

+ 9 - 1
web/src/components/settings/OperationSetting.jsx

@@ -26,6 +26,7 @@ import SettingsSensitiveWords from '../../pages/Setting/Operation/SettingsSensit
 import SettingsLog from '../../pages/Setting/Operation/SettingsLog';
 import SettingsMonitoring from '../../pages/Setting/Operation/SettingsMonitoring';
 import SettingsCreditLimit from '../../pages/Setting/Operation/SettingsCreditLimit';
+import SettingsCheckin from '../../pages/Setting/Operation/SettingsCheckin';
 import { API, showError, toBoolean } from '../../helpers';
 
 const OperationSetting = () => {
@@ -70,7 +71,10 @@ const OperationSetting = () => {
     AutomaticEnableChannelEnabled: false,
     AutomaticDisableKeywords: '',
     'monitor_setting.auto_test_channel_enabled': false,
-    'monitor_setting.auto_test_channel_minutes': 10,
+    'monitor_setting.auto_test_channel_minutes': 10 /* 签到设置 */,
+    'checkin_setting.enabled': false,
+    'checkin_setting.min_quota': 1000,
+    'checkin_setting.max_quota': 10000,
   });
 
   let [loading, setLoading] = useState(false);
@@ -140,6 +144,10 @@ const OperationSetting = () => {
         <Card style={{ marginTop: '10px' }}>
           <SettingsCreditLimit options={inputs} refresh={onRefresh} />
         </Card>
+        {/* 签到设置 */}
+        <Card style={{ marginTop: '10px' }}>
+          <SettingsCheckin options={inputs} refresh={onRefresh} />
+        </Card>
       </Spin>
     </>
   );

+ 8 - 0
web/src/components/settings/PersonalSetting.jsx

@@ -39,6 +39,7 @@ import { useTranslation } from 'react-i18next';
 import UserInfoHeader from './personal/components/UserInfoHeader';
 import AccountManagement from './personal/cards/AccountManagement';
 import NotificationSettings from './personal/cards/NotificationSettings';
+import CheckinCalendar from './personal/cards/CheckinCalendar';
 import EmailBindModal from './personal/modals/EmailBindModal';
 import WeChatBindModal from './personal/modals/WeChatBindModal';
 import AccountDeleteModal from './personal/modals/AccountDeleteModal';
@@ -447,6 +448,13 @@ const PersonalSetting = () => {
           {/* 顶部用户信息区域 */}
           <UserInfoHeader t={t} userState={userState} />
 
+          {/* 签到日历 - 仅在启用时显示 */}
+          {status?.checkin_enabled && (
+            <div className='mt-4 md:mt-6'>
+              <CheckinCalendar t={t} status={status} />
+            </div>
+          )}
+
           {/* 账户管理和其他设置 */}
           <div className='grid grid-cols-1 xl:grid-cols-2 items-start gap-4 md:gap-6 mt-4 md:mt-6'>
             {/* 左侧:账户管理设置 */}

+ 321 - 0
web/src/components/settings/personal/cards/CheckinCalendar.jsx

@@ -0,0 +1,321 @@
+/*
+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;

+ 24 - 1
web/src/i18n/locales/en.json

@@ -2185,6 +2185,29 @@
     "默认补全倍率": "Default completion ratio",
     "跨分组重试": "Cross-group retry",
     "跨分组": "Cross-group",
-    "开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道": "After enabling, when the current group channel fails, it will try the next group's channel in order"
+    "开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道": "After enabling, when the current group channel fails, it will try the next group's channel in order",
+    "每日签到": "Daily Check-in",
+    "今日已签到,累计签到": "Checked in today, total check-ins",
+    "天": "days",
+    "每日签到可获得随机额度奖励": "Daily check-in rewards random quota",
+    "今日已签到": "Checked in today",
+    "立即签到": "Check in now",
+    "获取签到状态失败": "Failed to get check-in status",
+    "签到成功!获得": "Check-in successful! Received",
+    "签到失败": "Check-in failed",
+    "获得": "Received",
+    "累计签到": "Total check-ins",
+    "本月获得": "This month",
+    "累计获得": "Total received",
+    "签到奖励将直接添加到您的账户余额": "Check-in rewards will be directly added to your account balance",
+    "每日仅可签到一次,请勿重复签到": "Only one check-in per day, please do not check in repeatedly",
+    "签到设置": "Check-in Settings",
+    "签到功能允许用户每日签到获取随机额度奖励": "Check-in feature allows users to check in daily to receive random quota rewards",
+    "启用签到功能": "Enable check-in feature",
+    "签到最小额度": "Minimum check-in quota",
+    "签到奖励的最小额度": "Minimum quota for check-in rewards",
+    "签到最大额度": "Maximum check-in quota",
+    "签到奖励的最大额度": "Maximum quota for check-in rewards",
+    "保存签到设置": "Save check-in settings"
   }
 }

+ 24 - 1
web/src/i18n/locales/fr.json

@@ -2234,6 +2234,29 @@
     "默认补全倍率": "Taux de complétion par défaut",
     "跨分组重试": "Nouvelle tentative inter-groupes",
     "跨分组": "Inter-groupes",
-    "开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道": "Après activation, lorsque le canal du groupe actuel échoue, il essaiera le canal du groupe suivant dans l'ordre"
+    "开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道": "Après activation, lorsque le canal du groupe actuel échoue, il essaiera le canal du groupe suivant dans l'ordre",
+    "每日签到": "Enregistrement quotidien",
+    "今日已签到,累计签到": "Enregistré aujourd'hui, total des enregistrements",
+    "天": "jours",
+    "每日签到可获得随机额度奖励": "L'enregistrement quotidien récompense un quota aléatoire",
+    "今日已签到": "Enregistré aujourd'hui",
+    "立即签到": "S'enregistrer maintenant",
+    "获取签到状态失败": "Échec de la récupération du statut d'enregistrement",
+    "签到成功!获得": "Enregistrement réussi ! Reçu",
+    "签到失败": "Échec de l'enregistrement",
+    "获得": "Reçu",
+    "累计签到": "Total des enregistrements",
+    "本月获得": "Ce mois-ci",
+    "累计获得": "Total reçu",
+    "签到奖励将直接添加到您的账户余额": "Les récompenses d'enregistrement seront directement ajoutées à votre solde de compte",
+    "每日仅可签到一次,请勿重复签到": "Un seul enregistrement par jour, veuillez ne pas vous enregistrer plusieurs fois",
+    "签到设置": "Paramètres d'enregistrement",
+    "签到功能允许用户每日签到获取随机额度奖励": "La fonction d'enregistrement permet aux utilisateurs de s'enregistrer quotidiennement pour recevoir des récompenses de quota aléatoires",
+    "启用签到功能": "Activer la fonction d'enregistrement",
+    "签到最小额度": "Quota minimum d'enregistrement",
+    "签到奖励的最小额度": "Quota minimum pour les récompenses d'enregistrement",
+    "签到最大额度": "Quota maximum d'enregistrement",
+    "签到奖励的最大额度": "Quota maximum pour les récompenses d'enregistrement",
+    "保存签到设置": "Enregistrer les paramètres d'enregistrement"
   }
 }

+ 24 - 1
web/src/i18n/locales/ja.json

@@ -2133,6 +2133,29 @@
     "随机种子 (留空为随机)": "ランダムシード(空欄でランダム)",
     "跨分组重试": "グループ間リトライ",
     "跨分组": "グループ間",
-    "开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道": "有効にすると、現在のグループチャネルが失敗した場合、次のグループのチャネルを順番に試行します"
+    "开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道": "有効にすると、現在のグループチャネルが失敗した場合、次のグループのチャネルを順番に試行します",
+    "每日签到": "毎日のチェックイン",
+    "今日已签到,累计签到": "本日チェックイン済み、累計チェックイン",
+    "天": "日",
+    "每日签到可获得随机额度奖励": "毎日のチェックインでランダムなクォータ報酬を獲得できます",
+    "今日已签到": "本日チェックイン済み",
+    "立即签到": "今すぐチェックイン",
+    "获取签到状态失败": "チェックイン状態の取得に失敗しました",
+    "签到成功!获得": "チェックイン成功!獲得",
+    "签到失败": "チェックインに失敗しました",
+    "获得": "獲得",
+    "累计签到": "累計チェックイン",
+    "本月获得": "今月の獲得",
+    "累计获得": "累計獲得",
+    "签到奖励将直接添加到您的账户余额": "チェックイン報酬は直接アカウント残高に追加されます",
+    "每日仅可签到一次,请勿重复签到": "1日1回のみチェックイン可能です。重複チェックインはしないでください",
+    "签到设置": "チェックイン設定",
+    "签到功能允许用户每日签到获取随机额度奖励": "チェックイン機能により、ユーザーは毎日チェックインしてランダムなクォータ報酬を獲得できます",
+    "启用签到功能": "チェックイン機能を有効にする",
+    "签到最小额度": "チェックイン最小クォータ",
+    "签到奖励的最小额度": "チェックイン報酬の最小クォータ",
+    "签到最大额度": "チェックイン最大クォータ",
+    "签到奖励的最大额度": "チェックイン報酬の最大クォータ",
+    "保存签到设置": "チェックイン設定を保存"
   }
 }

+ 24 - 1
web/src/i18n/locales/ru.json

@@ -2244,6 +2244,29 @@
     "随机种子 (留空为随机)": "Случайное зерно (оставьте пустым для случайного)",
     "跨分组重试": "Повторная попытка между группами",
     "跨分组": "Межгрупповой",
-    "开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道": "После включения, когда канал текущей группы не работает, он будет пытаться использовать канал следующей группы по порядку"
+    "开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道": "После включения, когда канал текущей группы не работает, он будет пытаться использовать канал следующей группы по порядку",
+    "每日签到": "Ежедневная регистрация",
+    "今日已签到,累计签到": "Зарегистрирован сегодня, всего регистраций",
+    "天": "дней",
+    "每日签到可获得随机额度奖励": "Ежедневная регистрация награждает случайной квотой",
+    "今日已签到": "Зарегистрирован сегодня",
+    "立即签到": "Зарегистрироваться сейчас",
+    "获取签到状态失败": "Не удалось получить статус регистрации",
+    "签到成功!获得": "Регистрация успешна! Получено",
+    "签到失败": "Регистрация не удалась",
+    "获得": "Получено",
+    "累计签到": "Всего регистраций",
+    "本月获得": "В этом месяце",
+    "累计获得": "Всего получено",
+    "签到奖励将直接添加到您的账户余额": "Награды за регистрацию будут напрямую добавлены на баланс вашего счета",
+    "每日仅可签到一次,请勿重复签到": "Только одна регистрация в день, пожалуйста, не регистрируйтесь повторно",
+    "签到设置": "Настройки регистрации",
+    "签到功能允许用户每日签到获取随机额度奖励": "Функция регистрации позволяет пользователям регистрироваться ежедневно для получения случайных наград в виде квоты",
+    "启用签到功能": "Включить функцию регистрации",
+    "签到最小额度": "Минимальная квота регистрации",
+    "签到奖励的最小额度": "Минимальная квота для наград за регистрацию",
+    "签到最大额度": "Максимальная квота регистрации",
+    "签到奖励的最大额度": "Максимальная квота для наград за регистрацию",
+    "保存签到设置": "Сохранить настройки регистрации"
   }
 }

+ 24 - 1
web/src/i18n/locales/vi.json

@@ -2744,6 +2744,29 @@
     "随机种子 (留空为随机)": "Hạt giống ngẫu nhiên (để trống cho ngẫu nhiên)",
     "跨分组重试": "Thử lại giữa các nhóm",
     "跨分组": "Giữa các nhóm",
-    "开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道": "Sau khi bật, khi kênh nhóm hiện tại thất bại, nó sẽ thử kênh của nhóm tiếp theo theo thứ tự"
+    "开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道": "Sau khi bật, khi kênh nhóm hiện tại thất bại, nó sẽ thử kênh của nhóm tiếp theo theo thứ tự",
+    "每日签到": "Đăng nhập hàng ngày",
+    "今日已签到,累计签到": "Đã đăng nhập hôm nay, tổng số lần đăng nhập",
+    "天": "ngày",
+    "每日签到可获得随机额度奖励": "Đăng nhập hàng ngày để nhận phần thưởng hạn mức ngẫu nhiên",
+    "今日已签到": "Đã đăng nhập hôm nay",
+    "立即签到": "Đăng nhập ngay",
+    "获取签到状态失败": "Không thể lấy trạng thái đăng nhập",
+    "签到成功!获得": "Đăng nhập thành công! Đã nhận",
+    "签到失败": "Đăng nhập thất bại",
+    "获得": "Đã nhận",
+    "累计签到": "Tổng số lần đăng nhập",
+    "本月获得": "Tháng này",
+    "累计获得": "Tổng đã nhận",
+    "签到奖励将直接添加到您的账户余额": "Phần thưởng đăng nhập sẽ được thêm trực tiếp vào số dư tài khoản của bạn",
+    "每日仅可签到一次,请勿重复签到": "Chỉ có thể đăng nhập một lần mỗi ngày, vui lòng không đăng nhập lặp lại",
+    "签到设置": "Cài đặt đăng nhập",
+    "签到功能允许用户每日签到获取随机额度奖励": "Tính năng đăng nhập cho phép người dùng đăng nhập hàng ngày để nhận phần thưởng hạn mức ngẫu nhiên",
+    "启用签到功能": "Bật tính năng đăng nhập",
+    "签到最小额度": "Hạn mức đăng nhập tối thiểu",
+    "签到奖励的最小额度": "Hạn mức tối thiểu cho phần thưởng đăng nhập",
+    "签到最大额度": "Hạn mức đăng nhập tối đa",
+    "签到奖励的最大额度": "Hạn mức tối đa cho phần thưởng đăng nhập",
+    "保存签到设置": "Lưu cài đặt đăng nhập"
   }
 }

+ 24 - 1
web/src/i18n/locales/zh.json

@@ -2211,6 +2211,29 @@
     "随机种子 (留空为随机)": "随机种子 (留空为随机)",
     "跨分组重试": "跨分组重试",
     "跨分组": "跨分组",
-    "开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道": "开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道"
+    "开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道": "开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道",
+    "每日签到": "每日签到",
+    "今日已签到,累计签到": "今日已签到,累计签到",
+    "天": "天",
+    "每日签到可获得随机额度奖励": "每日签到可获得随机额度奖励",
+    "今日已签到": "今日已签到",
+    "立即签到": "立即签到",
+    "获取签到状态失败": "获取签到状态失败",
+    "签到成功!获得": "签到成功!获得",
+    "签到失败": "签到失败",
+    "获得": "获得",
+    "累计签到": "累计签到",
+    "本月获得": "本月获得",
+    "累计获得": "累计获得",
+    "签到奖励将直接添加到您的账户余额": "签到奖励将直接添加到您的账户余额",
+    "每日仅可签到一次,请勿重复签到": "每日仅可签到一次,请勿重复签到",
+    "签到设置": "签到设置",
+    "签到功能允许用户每日签到获取随机额度奖励": "签到功能允许用户每日签到获取随机额度奖励",
+    "启用签到功能": "启用签到功能",
+    "签到最小额度": "签到最小额度",
+    "签到奖励的最小额度": "签到奖励的最小额度",
+    "签到最大额度": "签到最大额度",
+    "签到奖励的最大额度": "签到奖励的最大额度",
+    "保存签到设置": "保存签到设置"
   }
 }

+ 152 - 0
web/src/pages/Setting/Operation/SettingsCheckin.jsx

@@ -0,0 +1,152 @@
+/*
+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, { useEffect, useState, useRef } from 'react';
+import { Button, Col, Form, Row, Spin, Typography } from '@douyinfe/semi-ui';
+import {
+  compareObjects,
+  API,
+  showError,
+  showSuccess,
+  showWarning,
+} from '../../../helpers';
+import { useTranslation } from 'react-i18next';
+
+export default function SettingsCheckin(props) {
+  const { t } = useTranslation();
+  const [loading, setLoading] = useState(false);
+  const [inputs, setInputs] = useState({
+    'checkin_setting.enabled': false,
+    'checkin_setting.min_quota': 1000,
+    'checkin_setting.max_quota': 10000,
+  });
+  const refForm = useRef();
+  const [inputsRow, setInputsRow] = useState(inputs);
+
+  function handleFieldChange(fieldName) {
+    return (value) => {
+      setInputs((inputs) => ({ ...inputs, [fieldName]: value }));
+    };
+  }
+
+  function onSubmit() {
+    const updateArray = compareObjects(inputs, inputsRow);
+    if (!updateArray.length) return showWarning(t('你似乎并没有修改什么'));
+    const requestQueue = updateArray.map((item) => {
+      let value = '';
+      if (typeof inputs[item.key] === 'boolean') {
+        value = String(inputs[item.key]);
+      } else {
+        value = String(inputs[item.key]);
+      }
+      return API.put('/api/option/', {
+        key: item.key,
+        value,
+      });
+    });
+    setLoading(true);
+    Promise.all(requestQueue)
+      .then((res) => {
+        if (requestQueue.length === 1) {
+          if (res.includes(undefined)) return;
+        } else if (requestQueue.length > 1) {
+          if (res.includes(undefined))
+            return showError(t('部分保存失败,请重试'));
+        }
+        showSuccess(t('保存成功'));
+        props.refresh();
+      })
+      .catch(() => {
+        showError(t('保存失败,请重试'));
+      })
+      .finally(() => {
+        setLoading(false);
+      });
+  }
+
+  useEffect(() => {
+    const currentInputs = {};
+    for (let key in props.options) {
+      if (Object.keys(inputs).includes(key)) {
+        currentInputs[key] = props.options[key];
+      }
+    }
+    setInputs(currentInputs);
+    setInputsRow(structuredClone(currentInputs));
+    refForm.current.setValues(currentInputs);
+  }, [props.options]);
+
+  return (
+    <>
+      <Spin spinning={loading}>
+        <Form
+          values={inputs}
+          getFormApi={(formAPI) => (refForm.current = formAPI)}
+          style={{ marginBottom: 15 }}
+        >
+          <Form.Section text={t('签到设置')}>
+            <Typography.Text
+              type='tertiary'
+              style={{ marginBottom: 16, display: 'block' }}
+            >
+              {t('签到功能允许用户每日签到获取随机额度奖励')}
+            </Typography.Text>
+            <Row gutter={16}>
+              <Col xs={24} sm={12} md={8} lg={8} xl={8}>
+                <Form.Switch
+                  field={'checkin_setting.enabled'}
+                  label={t('启用签到功能')}
+                  size='default'
+                  checkedText='|'
+                  uncheckedText='〇'
+                  onChange={handleFieldChange('checkin_setting.enabled')}
+                />
+              </Col>
+              <Col xs={24} sm={12} md={8} lg={8} xl={8}>
+                <Form.InputNumber
+                  field={'checkin_setting.min_quota'}
+                  label={t('签到最小额度')}
+                  placeholder={t('签到奖励的最小额度')}
+                  onChange={handleFieldChange('checkin_setting.min_quota')}
+                  min={0}
+                  disabled={!inputs['checkin_setting.enabled']}
+                />
+              </Col>
+              <Col xs={24} sm={12} md={8} lg={8} xl={8}>
+                <Form.InputNumber
+                  field={'checkin_setting.max_quota'}
+                  label={t('签到最大额度')}
+                  placeholder={t('签到奖励的最大额度')}
+                  onChange={handleFieldChange('checkin_setting.max_quota')}
+                  min={0}
+                  disabled={!inputs['checkin_setting.enabled']}
+                />
+              </Col>
+            </Row>
+            <Row>
+              <Button size='default' onClick={onSubmit}>
+                {t('保存签到设置')}
+              </Button>
+            </Row>
+          </Form.Section>
+        </Form>
+      </Spin>
+    </>
+  );
+}