diff --git a/controller/checkin.go b/controller/checkin.go
new file mode 100644
index 000000000..cc8bf4f96
--- /dev/null
+++ b/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},
+ })
+}
diff --git a/controller/misc.go b/controller/misc.go
index 70415137a..4d299fc81 100644
--- a/controller/misc.go
+++ b/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,
}
// 根据启用状态注入可选内容
diff --git a/model/checkin.go b/model/checkin.go
new file mode 100644
index 000000000..71eb8eeae
--- /dev/null
+++ b/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
+}
diff --git a/model/main.go b/model/main.go
index 8dcedad0b..586eaa353 100644
--- a/model/main.go
+++ b/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))
diff --git a/router/api-router.go b/router/api-router.go
index e8266ef3f..e02e1c3f3 100644
--- a/router/api-router.go
+++ b/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("/")
diff --git a/setting/operation_setting/checkin_setting.go b/setting/operation_setting/checkin_setting.go
new file mode 100644
index 000000000..dd4e35945
--- /dev/null
+++ b/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
+}
diff --git a/web/src/components/settings/OperationSetting.jsx b/web/src/components/settings/OperationSetting.jsx
index 9f4f584a5..92591db45 100644
--- a/web/src/components/settings/OperationSetting.jsx
+++ b/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 = () => {
+ {/* 签到设置 */}
+
+
+
>
);
diff --git a/web/src/components/settings/PersonalSetting.jsx b/web/src/components/settings/PersonalSetting.jsx
index 6a889356d..e70b997cd 100644
--- a/web/src/components/settings/PersonalSetting.jsx
+++ b/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 = () => {
{/* 顶部用户信息区域 */}
+ {/* 签到日历 - 仅在启用时显示 */}
+ {status?.checkin_enabled && (
+
+
+
+ )}
+
{/* 账户管理和其他设置 */}
{/* 左侧:账户管理设置 */}
diff --git a/web/src/components/settings/personal/cards/CheckinCalendar.jsx b/web/src/components/settings/personal/cards/CheckinCalendar.jsx
new file mode 100644
index 000000000..4b6266ee4
--- /dev/null
+++ b/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
.
+
+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 (
+
+
+
+
+
+
+ {renderQuota(quotaAwarded)}
+
+
+
+ );
+ }
+ return null;
+ };
+
+ // 处理月份变化
+ const handleMonthChange = (date) => {
+ const month = date.toISOString().slice(0, 7);
+ setCurrentMonth(month);
+ };
+
+ return (
+
+ {/* 卡片头部 */}
+
+
setIsCollapsed(!isCollapsed)}
+ >
+
+
+
+
+
+
+ {t('每日签到')}
+
+ {isCollapsed ? (
+
+ ) : (
+
+ )}
+
+
+ {checkinData.stats?.checked_in_today
+ ? t('今日已签到,累计签到') +
+ ` ${checkinData.stats?.total_checkins || 0} ` +
+ t('天')
+ : t('每日签到可获得随机额度奖励')}
+
+
+
+
}
+ 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('立即签到')}
+
+
+
+ {/* 可折叠内容 */}
+
+ {/* 签到统计 */}
+
+
+
+ {checkinData.stats?.total_checkins || 0}
+
+
{t('累计签到')}
+
+
+
+ {renderQuota(monthlyQuota, 6)}
+
+
{t('本月获得')}
+
+
+
+ {renderQuota(checkinData.stats?.total_quota || 0, 6)}
+
+
{t('累计获得')}
+
+
+
+ {/* 签到日历 - 使用更紧凑的样式 */}
+
+
+
+ dateRender(dateString)}
+ />
+
+
+
+ {/* 签到说明 */}
+
+
+
+ - {t('每日签到可获得随机额度奖励')}
+ - {t('签到奖励将直接添加到您的账户余额')}
+ - {t('每日仅可签到一次,请勿重复签到')}
+
+
+
+
+
+ );
+};
+
+export default CheckinCalendar;
diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json
index fb34544a4..e8a79ead8 100644
--- a/web/src/i18n/locales/en.json
+++ b/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"
}
}
diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json
index 39b06690e..d8f32661a 100644
--- a/web/src/i18n/locales/fr.json
+++ b/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"
}
}
diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json
index 22e7606dd..073142256 100644
--- a/web/src/i18n/locales/ja.json
+++ b/web/src/i18n/locales/ja.json
@@ -2133,6 +2133,29 @@
"随机种子 (留空为随机)": "ランダムシード(空欄でランダム)",
"跨分组重试": "グループ間リトライ",
"跨分组": "グループ間",
- "开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道": "有効にすると、現在のグループチャネルが失敗した場合、次のグループのチャネルを順番に試行します"
+ "开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道": "有効にすると、現在のグループチャネルが失敗した場合、次のグループのチャネルを順番に試行します",
+ "每日签到": "毎日のチェックイン",
+ "今日已签到,累计签到": "本日チェックイン済み、累計チェックイン",
+ "天": "日",
+ "每日签到可获得随机额度奖励": "毎日のチェックインでランダムなクォータ報酬を獲得できます",
+ "今日已签到": "本日チェックイン済み",
+ "立即签到": "今すぐチェックイン",
+ "获取签到状态失败": "チェックイン状態の取得に失敗しました",
+ "签到成功!获得": "チェックイン成功!獲得",
+ "签到失败": "チェックインに失敗しました",
+ "获得": "獲得",
+ "累计签到": "累計チェックイン",
+ "本月获得": "今月の獲得",
+ "累计获得": "累計獲得",
+ "签到奖励将直接添加到您的账户余额": "チェックイン報酬は直接アカウント残高に追加されます",
+ "每日仅可签到一次,请勿重复签到": "1日1回のみチェックイン可能です。重複チェックインはしないでください",
+ "签到设置": "チェックイン設定",
+ "签到功能允许用户每日签到获取随机额度奖励": "チェックイン機能により、ユーザーは毎日チェックインしてランダムなクォータ報酬を獲得できます",
+ "启用签到功能": "チェックイン機能を有効にする",
+ "签到最小额度": "チェックイン最小クォータ",
+ "签到奖励的最小额度": "チェックイン報酬の最小クォータ",
+ "签到最大额度": "チェックイン最大クォータ",
+ "签到奖励的最大额度": "チェックイン報酬の最大クォータ",
+ "保存签到设置": "チェックイン設定を保存"
}
}
diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json
index d71e12d1b..5235440e0 100644
--- a/web/src/i18n/locales/ru.json
+++ b/web/src/i18n/locales/ru.json
@@ -2244,6 +2244,29 @@
"随机种子 (留空为随机)": "Случайное зерно (оставьте пустым для случайного)",
"跨分组重试": "Повторная попытка между группами",
"跨分组": "Межгрупповой",
- "开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道": "После включения, когда канал текущей группы не работает, он будет пытаться использовать канал следующей группы по порядку"
+ "开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道": "После включения, когда канал текущей группы не работает, он будет пытаться использовать канал следующей группы по порядку",
+ "每日签到": "Ежедневная регистрация",
+ "今日已签到,累计签到": "Зарегистрирован сегодня, всего регистраций",
+ "天": "дней",
+ "每日签到可获得随机额度奖励": "Ежедневная регистрация награждает случайной квотой",
+ "今日已签到": "Зарегистрирован сегодня",
+ "立即签到": "Зарегистрироваться сейчас",
+ "获取签到状态失败": "Не удалось получить статус регистрации",
+ "签到成功!获得": "Регистрация успешна! Получено",
+ "签到失败": "Регистрация не удалась",
+ "获得": "Получено",
+ "累计签到": "Всего регистраций",
+ "本月获得": "В этом месяце",
+ "累计获得": "Всего получено",
+ "签到奖励将直接添加到您的账户余额": "Награды за регистрацию будут напрямую добавлены на баланс вашего счета",
+ "每日仅可签到一次,请勿重复签到": "Только одна регистрация в день, пожалуйста, не регистрируйтесь повторно",
+ "签到设置": "Настройки регистрации",
+ "签到功能允许用户每日签到获取随机额度奖励": "Функция регистрации позволяет пользователям регистрироваться ежедневно для получения случайных наград в виде квоты",
+ "启用签到功能": "Включить функцию регистрации",
+ "签到最小额度": "Минимальная квота регистрации",
+ "签到奖励的最小额度": "Минимальная квота для наград за регистрацию",
+ "签到最大额度": "Максимальная квота регистрации",
+ "签到奖励的最大额度": "Максимальная квота для наград за регистрацию",
+ "保存签到设置": "Сохранить настройки регистрации"
}
}
diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json
index 51113ff44..e37e30fa4 100644
--- a/web/src/i18n/locales/vi.json
+++ b/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"
}
}
diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json
index 35ec62ba1..3347d8c3c 100644
--- a/web/src/i18n/locales/zh.json
+++ b/web/src/i18n/locales/zh.json
@@ -2211,6 +2211,29 @@
"随机种子 (留空为随机)": "随机种子 (留空为随机)",
"跨分组重试": "跨分组重试",
"跨分组": "跨分组",
- "开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道": "开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道"
+ "开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道": "开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道",
+ "每日签到": "每日签到",
+ "今日已签到,累计签到": "今日已签到,累计签到",
+ "天": "天",
+ "每日签到可获得随机额度奖励": "每日签到可获得随机额度奖励",
+ "今日已签到": "今日已签到",
+ "立即签到": "立即签到",
+ "获取签到状态失败": "获取签到状态失败",
+ "签到成功!获得": "签到成功!获得",
+ "签到失败": "签到失败",
+ "获得": "获得",
+ "累计签到": "累计签到",
+ "本月获得": "本月获得",
+ "累计获得": "累计获得",
+ "签到奖励将直接添加到您的账户余额": "签到奖励将直接添加到您的账户余额",
+ "每日仅可签到一次,请勿重复签到": "每日仅可签到一次,请勿重复签到",
+ "签到设置": "签到设置",
+ "签到功能允许用户每日签到获取随机额度奖励": "签到功能允许用户每日签到获取随机额度奖励",
+ "启用签到功能": "启用签到功能",
+ "签到最小额度": "签到最小额度",
+ "签到奖励的最小额度": "签到奖励的最小额度",
+ "签到最大额度": "签到最大额度",
+ "签到奖励的最大额度": "签到奖励的最大额度",
+ "保存签到设置": "保存签到设置"
}
}
diff --git a/web/src/pages/Setting/Operation/SettingsCheckin.jsx b/web/src/pages/Setting/Operation/SettingsCheckin.jsx
new file mode 100644
index 000000000..1ce5faa72
--- /dev/null
+++ b/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
.
+
+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 (
+ <>
+
+
+
+ {t('签到功能允许用户每日签到获取随机额度奖励')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}