From 8abfbe372f2965dc50918b1472bf97fe0b649bac Mon Sep 17 00:00:00 2001 From: CaIon Date: Fri, 2 Jan 2026 23:00:33 +0800 Subject: [PATCH] feat(checkin): add check-in functionality with status retrieval and user quota rewards --- controller/checkin.go | 72 ++++ controller/misc.go | 1 + model/checkin.go | 179 ++++++++++ model/main.go | 84 ++--- router/api-router.go | 4 + setting/operation_setting/checkin_setting.go | 37 ++ .../components/settings/OperationSetting.jsx | 10 +- .../components/settings/PersonalSetting.jsx | 8 + .../personal/cards/CheckinCalendar.jsx | 321 ++++++++++++++++++ web/src/i18n/locales/en.json | 25 +- web/src/i18n/locales/fr.json | 25 +- web/src/i18n/locales/ja.json | 25 +- web/src/i18n/locales/ru.json | 25 +- web/src/i18n/locales/vi.json | 25 +- web/src/i18n/locales/zh.json | 25 +- .../Setting/Operation/SettingsCheckin.jsx | 152 +++++++++ 16 files changed, 970 insertions(+), 48 deletions(-) create mode 100644 controller/checkin.go create mode 100644 model/checkin.go create mode 100644 setting/operation_setting/checkin_setting.go create mode 100644 web/src/components/settings/personal/cards/CheckinCalendar.jsx create mode 100644 web/src/pages/Setting/Operation/SettingsCheckin.jsx 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('每日签到可获得随机额度奖励')} +
+
+
+ +
+ + {/* 可折叠内容 */} + + {/* 签到统计 */} +
+
+
+ {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 ( + <> + +
(refForm.current = formAPI)} + style={{ marginBottom: 15 }} + > + + + {t('签到功能允许用户每日签到获取随机额度奖励')} + + + + + + + + + + + + + + + + +
+
+ + ); +}