CaIon 2 лет назад
Родитель
Сommit
fd57a1df08

+ 59 - 0
controller/user.go

@@ -79,6 +79,7 @@ func setupLogin(user *model.User, c *gin.Context) {
 		DisplayName: user.DisplayName,
 		Role:        user.Role,
 		Status:      user.Status,
+		Group:       user.Group,
 	}
 	c.JSON(http.StatusOK, gin.H{
 		"message": "",
@@ -284,6 +285,42 @@ func GenerateAccessToken(c *gin.Context) {
 	return
 }
 
+type TransferAffQuotaRequest struct {
+	Quota int `json:"quota" binding:"required"`
+}
+
+func TransferAffQuota(c *gin.Context) {
+	id := c.GetInt("id")
+	user, err := model.GetUserById(id, true)
+	if err != nil {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": err.Error(),
+		})
+		return
+	}
+	tran := TransferAffQuotaRequest{}
+	if err := c.ShouldBindJSON(&tran); err != nil {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": err.Error(),
+		})
+		return
+	}
+	err = user.TransferAffQuotaToQuota(tran.Quota)
+	if err != nil {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "划转失败 " + err.Error(),
+		})
+		return
+	}
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "划转成功",
+	})
+}
+
 func GetAffCode(c *gin.Context) {
 	id := c.GetInt("id")
 	user, err := model.GetUserById(id, true)
@@ -330,6 +367,28 @@ func GetSelf(c *gin.Context) {
 	return
 }
 
+func GetUserModels(c *gin.Context) {
+	id, err := strconv.Atoi(c.Param("id"))
+	if err != nil {
+		id = c.GetInt("id")
+	}
+	user, err := model.GetUserById(id, true)
+	if err != nil {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": err.Error(),
+		})
+		return
+	}
+	models := model.GetGroupModels(user.Group)
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "",
+		"data":    models,
+	})
+	return
+}
+
 func UpdateUser(c *gin.Context) {
 	var updatedUser model.User
 	err := json.NewDecoder(c.Request.Body).Decode(&updatedUser)

+ 10 - 0
model/ability.go

@@ -13,6 +13,16 @@ type Ability struct {
 	Priority  *int64 `json:"priority" gorm:"bigint;default:0;index"`
 }
 
+func GetGroupModels(group string) []string {
+	var abilities []Ability
+	DB.Where("`group` = ?", group).Find(&abilities)
+	models := make([]string, 0, len(abilities))
+	for _, ability := range abilities {
+		models = append(models, ability.Model)
+	}
+	return models
+}
+
 func GetRandomSatisfiedChannel(group string, model string) (*Channel, error) {
 	ability := Ability{}
 	groupCol := "`group`"

+ 22 - 20
model/token.go

@@ -220,28 +220,30 @@ func PostConsumeTokenQuota(tokenId int, userQuota int, quota int, preConsumedQuo
 	}
 
 	if sendEmail {
-		quotaTooLow := userQuota >= common.QuotaRemindThreshold && userQuota-(quota+preConsumedQuota) < common.QuotaRemindThreshold
-		noMoreQuota := userQuota-(quota+preConsumedQuota) <= 0
-		if quotaTooLow || noMoreQuota {
-			go func() {
-				email, err := GetUserEmail(token.UserId)
-				if err != nil {
-					common.SysError("failed to fetch user email: " + err.Error())
-				}
-				prompt := "您的额度即将用尽"
-				if noMoreQuota {
-					prompt = "您的额度已用尽"
-				}
-				if email != "" {
-					topUpLink := fmt.Sprintf("%s/topup", common.ServerAddress)
-					err = common.SendEmail(prompt, email,
-						fmt.Sprintf("%s,当前剩余额度为 %d,为了不影响您的使用,请及时充值。<br/>充值链接:<a href='%s'>%s</a>", prompt, userQuota, topUpLink, topUpLink))
+		if (quota + preConsumedQuota) != 0 {
+			quotaTooLow := userQuota >= common.QuotaRemindThreshold && userQuota-(quota+preConsumedQuota) < common.QuotaRemindThreshold
+			noMoreQuota := userQuota-(quota+preConsumedQuota) <= 0
+			if quotaTooLow || noMoreQuota {
+				go func() {
+					email, err := GetUserEmail(token.UserId)
 					if err != nil {
-						common.SysError("failed to send email" + err.Error())
+						common.SysError("failed to fetch user email: " + err.Error())
 					}
-					common.SysLog("user quota is low, consumed quota: " + strconv.Itoa(quota) + ", user quota: " + strconv.Itoa(userQuota))
-				}
-			}()
+					prompt := "您的额度即将用尽"
+					if noMoreQuota {
+						prompt = "您的额度已用尽"
+					}
+					if email != "" {
+						topUpLink := fmt.Sprintf("%s/topup", common.ServerAddress)
+						err = common.SendEmail(prompt, email,
+							fmt.Sprintf("%s,当前剩余额度为 %d,为了不影响您的使用,请及时充值。<br/>充值链接:<a href='%s'>%s</a>", prompt, userQuota, topUpLink, topUpLink))
+						if err != nil {
+							common.SysError("failed to send email" + err.Error())
+						}
+						common.SysLog("user quota is low, consumed quota: " + strconv.Itoa(quota) + ", user quota: " + strconv.Itoa(userQuota))
+					}
+				}()
+			}
 		}
 	}
 

+ 53 - 1
model/user.go

@@ -27,6 +27,9 @@ type User struct {
 	RequestCount     int    `json:"request_count" gorm:"type:int;default:0;"`               // request number
 	Group            string `json:"group" gorm:"type:varchar(32);default:'default'"`
 	AffCode          string `json:"aff_code" gorm:"type:varchar(32);column:aff_code;uniqueIndex"`
+	AffCount         int    `json:"aff_count" gorm:"type:int;default:0;column:aff_count"`
+	AffQuota         int    `json:"aff_quota" gorm:"type:int;default:0;column:aff_quota"`           // 邀请剩余额度
+	AffHistoryQuota  int    `json:"aff_history_quota" gorm:"type:int;default:0;column:aff_history"` // 邀请历史额度
 	InviterId        int    `json:"inviter_id" gorm:"type:int;column:inviter_id;index"`
 }
 
@@ -77,6 +80,54 @@ func DeleteUserById(id int) (err error) {
 	return user.Delete()
 }
 
+func inviteUser(inviterId int) (err error) {
+	user, err := GetUserById(inviterId, true)
+	if err != nil {
+		return err
+	}
+	user.AffCount++
+	user.AffQuota += common.QuotaForInviter
+	user.AffHistoryQuota += common.QuotaForInviter
+	return DB.Save(user).Error
+}
+
+func (user *User) TransferAffQuotaToQuota(quota int) error {
+	// 检查quota是否小于最小额度
+	if float64(quota) < common.QuotaPerUnit {
+		return fmt.Errorf("转移额度最小为%s!", common.LogQuota(int(common.QuotaPerUnit)))
+	}
+
+	// 开始数据库事务
+	tx := DB.Begin()
+	if tx.Error != nil {
+		return tx.Error
+	}
+	defer tx.Rollback() // 确保在函数退出时事务能回滚
+
+	// 加锁查询用户以确保数据一致性
+	err := tx.Set("gorm:query_option", "FOR UPDATE").First(&user, user.Id).Error
+	if err != nil {
+		return err
+	}
+
+	// 再次检查用户的AffQuota是否足够
+	if user.AffQuota < quota {
+		return errors.New("邀请额度不足!")
+	}
+
+	// 更新用户额度
+	user.AffQuota -= quota
+	user.Quota += quota
+
+	// 保存用户状态
+	if err := tx.Save(user).Error; err != nil {
+		return err
+	}
+
+	// 提交事务
+	return tx.Commit().Error
+}
+
 func (user *User) Insert(inviterId int) error {
 	var err error
 	if user.Password != "" {
@@ -101,8 +152,9 @@ func (user *User) Insert(inviterId int) error {
 			RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("使用邀请码赠送 %s", common.LogQuota(common.QuotaForInvitee)))
 		}
 		if common.QuotaForInviter > 0 {
-			_ = IncreaseUserQuota(inviterId, common.QuotaForInviter)
+			//_ = IncreaseUserQuota(inviterId, common.QuotaForInviter)
 			RecordLog(inviterId, LogTypeSystem, fmt.Sprintf("邀请用户赠送 %s", common.LogQuota(common.QuotaForInviter)))
+			_ = inviteUser(inviterId)
 		}
 	}
 	return nil

+ 2 - 0
router/api-router.go

@@ -39,6 +39,7 @@ func SetApiRouter(router *gin.Engine) {
 			selfRoute.Use(middleware.UserAuth())
 			{
 				selfRoute.GET("/self", controller.GetSelf)
+				selfRoute.GET("/models", controller.GetUserModels)
 				selfRoute.PUT("/self", controller.UpdateSelf)
 				selfRoute.DELETE("/self", controller.DeleteSelf)
 				selfRoute.GET("/token", controller.GenerateAccessToken)
@@ -46,6 +47,7 @@ func SetApiRouter(router *gin.Engine) {
 				selfRoute.POST("/topup", controller.TopUp)
 				selfRoute.POST("/pay", controller.RequestEpay)
 				selfRoute.POST("/amount", controller.RequestAmount)
+				selfRoute.POST("/aff_transfer", controller.TransferAffQuota)
 			}
 
 			adminRoute := userRoute.Group("/")

+ 523 - 351
web/src/components/PersonalSetting.js

@@ -1,376 +1,548 @@
-import React, { useContext, useEffect, useState } from 'react';
-import { Button, Divider, Form, Header, Image, Message, Modal } from 'semantic-ui-react';
-import { Link, useNavigate } from 'react-router-dom';
-import { API, copy, showError, showInfo, showNotice, showSuccess } from '../helpers';
+import React, {useContext, useEffect, useState} from 'react';
+import {Form, Image, Message} from 'semantic-ui-react';
+import {Link, useNavigate} from 'react-router-dom';
+import {API, copy, isRoot, showError, showInfo, showNotice, showSuccess} from '../helpers';
 import Turnstile from 'react-turnstile';
-import { UserContext } from '../context/User';
-import { onGitHubOAuthClicked } from './utils';
+import {UserContext} from '../context/User';
+import {onGitHubOAuthClicked} from './utils';
+import {
+    Avatar, Banner,
+    Button,
+    Card,
+    Descriptions,
+    Divider,
+    Input, InputNumber,
+    Layout,
+    Modal,
+    Space,
+    Tag,
+    Typography
+} from "@douyinfe/semi-ui";
+import {getQuotaPerUnit, renderQuota, renderQuotaWithPrompt, stringToColor} from "../helpers/render";
+import EditToken from "../pages/Token/EditToken";
+import EditUser from "../pages/User/EditUser";
 
 const PersonalSetting = () => {
-  const [userState, userDispatch] = useContext(UserContext);
-  let navigate = useNavigate();
+    const [userState, userDispatch] = useContext(UserContext);
+    let navigate = useNavigate();
 
-  const [inputs, setInputs] = useState({
-    wechat_verification_code: '',
-    email_verification_code: '',
-    email: '',
-    self_account_deletion_confirmation: ''
-  });
-  const [status, setStatus] = useState({});
-  const [showWeChatBindModal, setShowWeChatBindModal] = useState(false);
-  const [showEmailBindModal, setShowEmailBindModal] = useState(false);
-  const [showAccountDeleteModal, setShowAccountDeleteModal] = useState(false);
-  const [turnstileEnabled, setTurnstileEnabled] = useState(false);
-  const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
-  const [turnstileToken, setTurnstileToken] = useState('');
-  const [loading, setLoading] = useState(false);
-  const [disableButton, setDisableButton] = useState(false);
-  const [countdown, setCountdown] = useState(30);
-  const [affLink, setAffLink] = useState("");
-  const [systemToken, setSystemToken] = useState("");
+    const [inputs, setInputs] = useState({
+        wechat_verification_code: '',
+        email_verification_code: '',
+        email: '',
+        self_account_deletion_confirmation: ''
+    });
+    const [status, setStatus] = useState({});
+    const [showWeChatBindModal, setShowWeChatBindModal] = useState(false);
+    const [showEmailBindModal, setShowEmailBindModal] = useState(false);
+    const [showAccountDeleteModal, setShowAccountDeleteModal] = useState(false);
+    const [turnstileEnabled, setTurnstileEnabled] = useState(false);
+    const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
+    const [turnstileToken, setTurnstileToken] = useState('');
+    const [loading, setLoading] = useState(false);
+    const [disableButton, setDisableButton] = useState(false);
+    const [countdown, setCountdown] = useState(30);
+    const [affLink, setAffLink] = useState("");
+    const [systemToken, setSystemToken] = useState("");
+    const [models, setModels] = useState([]);
+    const [openTransfer, setOpenTransfer] = useState(false);
+    const [transferAmount, setTransferAmount] = useState(0);
 
-  useEffect(() => {
-    let status = localStorage.getItem('status');
-    if (status) {
-      status = JSON.parse(status);
-      setStatus(status);
-      if (status.turnstile_check) {
-        setTurnstileEnabled(true);
-        setTurnstileSiteKey(status.turnstile_site_key);
-      }
-    }
-  }, []);
+    useEffect(() => {
+        // let user = localStorage.getItem('user');
+        // if (user) {
+        //   userDispatch({ type: 'login', payload: user });
+        // }
+        // console.log(localStorage.getItem('user'))
 
-  useEffect(() => {
-    let countdownInterval = null;
-    if (disableButton && countdown > 0) {
-      countdownInterval = setInterval(() => {
-        setCountdown(countdown - 1);
-      }, 1000);
-    } else if (countdown === 0) {
-      setDisableButton(false);
-      setCountdown(30);
-    }
-    return () => clearInterval(countdownInterval); // Clean up on unmount
-  }, [disableButton, countdown]);
+        let status = localStorage.getItem('status');
+        if (status) {
+            status = JSON.parse(status);
+            setStatus(status);
+            if (status.turnstile_check) {
+                setTurnstileEnabled(true);
+                setTurnstileSiteKey(status.turnstile_site_key);
+            }
+        }
+        getUserData().then(
+            (res) => {
+                console.log(userState)
+            }
+        );
+        loadModels().then();
+        getAffLink().then();
+        setTransferAmount(getQuotaPerUnit())
+    }, []);
+
+    useEffect(() => {
+        let countdownInterval = null;
+        if (disableButton && countdown > 0) {
+            countdownInterval = setInterval(() => {
+                setCountdown(countdown - 1);
+            }, 1000);
+        } else if (countdown === 0) {
+            setDisableButton(false);
+            setCountdown(30);
+        }
+        return () => clearInterval(countdownInterval); // Clean up on unmount
+    }, [disableButton, countdown]);
+
+    const handleInputChange = (name, value) => {
+        setInputs((inputs) => ({...inputs, [name]: value}));
+    };
+
+    const generateAccessToken = async () => {
+        const res = await API.get('/api/user/token');
+        const {success, message, data} = res.data;
+        if (success) {
+            setSystemToken(data);
+            await copy(data);
+            showSuccess(`令牌已重置并已复制到剪贴板`);
+        } else {
+            showError(message);
+        }
+    };
 
-  const handleInputChange = (e, { name, value }) => {
-    setInputs((inputs) => ({ ...inputs, [name]: value }));
-  };
+    const getAffLink = async () => {
+        const res = await API.get('/api/user/aff');
+        const {success, message, data} = res.data;
+        if (success) {
+            let link = `${window.location.origin}/register?aff=${data}`;
+            setAffLink(link);
+        } else {
+            showError(message);
+        }
+    };
 
-  const generateAccessToken = async () => {
-    const res = await API.get('/api/user/token');
-    const { success, message, data } = res.data;
-    if (success) {
-      setSystemToken(data);
-      setAffLink(""); 
-      await copy(data);
-      showSuccess(`令牌已重置并已复制到剪贴板`);
-    } else {
-      showError(message);
+    const getUserData = async () => {
+        let res = await API.get(`/api/user/self`);
+        const {success, message, data} = res.data;
+        if (success) {
+            userDispatch({type: 'login', payload: data});
+        } else {
+            showError(message);
+        }
     }
-  };
 
-  const getAffLink = async () => {
-    const res = await API.get('/api/user/aff');
-    const { success, message, data } = res.data;
-    if (success) {
-      let link = `${window.location.origin}/register?aff=${data}`;
-      setAffLink(link);
-      setSystemToken("");
-      await copy(link);
-      showSuccess(`邀请链接已复制到剪切板`);
-    } else {
-      showError(message);
+    const loadModels = async () => {
+        let res = await API.get(`/api/user/models`);
+        const {success, message, data} = res.data;
+        if (success) {
+            setModels(data);
+            console.log(data)
+        } else {
+            showError(message);
+        }
     }
-  };
 
-  const handleAffLinkClick = async (e) => {
-    e.target.select();
-    await copy(e.target.value);
-    showSuccess(`邀请链接已复制到剪切板`);
-  };
+    const handleAffLinkClick = async (e) => {
+        e.target.select();
+        await copy(e.target.value);
+        showSuccess(`邀请链接已复制到剪切板`);
+    };
 
-  const handleSystemTokenClick = async (e) => {
-    e.target.select();
-    await copy(e.target.value);
-    showSuccess(`系统令牌已复制到剪切板`);
-  };
+    const handleSystemTokenClick = async (e) => {
+        e.target.select();
+        await copy(e.target.value);
+        showSuccess(`系统令牌已复制到剪切板`);
+    };
 
-  const deleteAccount = async () => {
-    if (inputs.self_account_deletion_confirmation !== userState.user.username) {
-      showError('请输入你的账户名以确认删除!');
-      return;
-    }
+    const deleteAccount = async () => {
+        if (inputs.self_account_deletion_confirmation !== userState.user.username) {
+            showError('请输入你的账户名以确认删除!');
+            return;
+        }
 
-    const res = await API.delete('/api/user/self');
-    const { success, message } = res.data;
+        const res = await API.delete('/api/user/self');
+        const {success, message} = res.data;
 
-    if (success) {
-      showSuccess('账户已删除!');
-      await API.get('/api/user/logout');
-      userDispatch({ type: 'logout' });
-      localStorage.removeItem('user');
-      navigate('/login');
-    } else {
-      showError(message);
-    }
-  };
+        if (success) {
+            showSuccess('账户已删除!');
+            await API.get('/api/user/logout');
+            userDispatch({type: 'logout'});
+            localStorage.removeItem('user');
+            navigate('/login');
+        } else {
+            showError(message);
+        }
+    };
 
-  const bindWeChat = async () => {
-    if (inputs.wechat_verification_code === '') return;
-    const res = await API.get(
-      `/api/oauth/wechat/bind?code=${inputs.wechat_verification_code}`
-    );
-    const { success, message } = res.data;
-    if (success) {
-      showSuccess('微信账户绑定成功!');
-      setShowWeChatBindModal(false);
-    } else {
-      showError(message);
-    }
-  };
+    const bindWeChat = async () => {
+        if (inputs.wechat_verification_code === '') return;
+        const res = await API.get(
+            `/api/oauth/wechat/bind?code=${inputs.wechat_verification_code}`
+        );
+        const {success, message} = res.data;
+        if (success) {
+            showSuccess('微信账户绑定成功!');
+            setShowWeChatBindModal(false);
+        } else {
+            showError(message);
+        }
+    };
 
-  const sendVerificationCode = async () => {
-    setDisableButton(true);
-    if (inputs.email === '') return;
-    if (turnstileEnabled && turnstileToken === '') {
-      showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
-      return;
-    }
-    setLoading(true);
-    const res = await API.get(
-      `/api/verification?email=${inputs.email}&turnstile=${turnstileToken}`
-    );
-    const { success, message } = res.data;
-    if (success) {
-      showSuccess('验证码发送成功,请检查邮箱!');
-    } else {
-      showError(message);
+    const transfer = async () => {
+        if (transferAmount < getQuotaPerUnit()) {
+            showError('划转金额最低为' + renderQuota(getQuotaPerUnit()));
+            return;
+        }
+        const res = await API.post(
+            `/api/user/aff_transfer`,
+            {
+                quota: transferAmount
+            }
+        );
+        const {success, message} = res.data;
+        if (success) {
+            showSuccess(message);
+            setOpenTransfer(false);
+            getUserData().then();
+        } else {
+            showError(message);
+        }
+    };
+
+    const sendVerificationCode = async () => {
+        if (inputs.email === '') {
+            showError('请输入邮箱!');
+            return;
+        }
+        setDisableButton(true);
+        if (turnstileEnabled && turnstileToken === '') {
+            showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
+            return;
+        }
+        setLoading(true);
+        const res = await API.get(
+            `/api/verification?email=${inputs.email}&turnstile=${turnstileToken}`
+        );
+        const {success, message} = res.data;
+        if (success) {
+            showSuccess('验证码发送成功,请检查邮箱!');
+        } else {
+            showError(message);
+        }
+        setLoading(false);
+    };
+
+    const bindEmail = async () => {
+        if (inputs.email_verification_code === '') {
+            showError('请输入邮箱验证码!');
+            return;
+        }
+        setLoading(true);
+        const res = await API.get(
+            `/api/oauth/email/bind?email=${inputs.email}&code=${inputs.email_verification_code}`
+        );
+        const {success, message} = res.data;
+        if (success) {
+            showSuccess('邮箱账户绑定成功!');
+            setShowEmailBindModal(false);
+            userState.user.email = inputs.email;
+        } else {
+            showError(message);
+        }
+        setLoading(false);
+    };
+
+    const getUsername = () => {
+        if (userState.user) {
+            return userState.user.username;
+        } else {
+            return 'null';
+        }
     }
-    setLoading(false);
-  };
 
-  const bindEmail = async () => {
-    if (inputs.email_verification_code === '') return;
-    setLoading(true);
-    const res = await API.get(
-      `/api/oauth/email/bind?email=${inputs.email}&code=${inputs.email_verification_code}`
-    );
-    const { success, message } = res.data;
-    if (success) {
-      showSuccess('邮箱账户绑定成功!');
-      setShowEmailBindModal(false);
-    } else {
-      showError(message);
+    const handleCancel = () => {
+        setOpenTransfer(false);
     }
-    setLoading(false);
-  };
 
-  return (
-    <div style={{ lineHeight: '40px' }}>
-      <Header as='h3'>通用设置</Header>
-      <Message>
-        注意,此处生成的令牌用于系统管理,而非用于请求 OpenAI 相关的服务,请知悉。
-      </Message>
-      <Button as={Link} to={`/user/edit/`}>
-        更新个人信息
-      </Button>
-      <Button onClick={generateAccessToken}>生成系统访问令牌</Button>
-      <Button onClick={getAffLink}>复制邀请链接</Button>
-      <Button onClick={() => {
-        setShowAccountDeleteModal(true);
-      }}>删除个人账户</Button>
-      
-      {systemToken && (
-        <Form.Input 
-          fluid 
-          readOnly 
-          value={systemToken} 
-          onClick={handleSystemTokenClick}
-          style={{ marginTop: '10px' }}
-        />
-      )}
-      {affLink && (
-        <Form.Input 
-          fluid 
-          readOnly 
-          value={affLink} 
-          onClick={handleAffLinkClick}
-          style={{ marginTop: '10px' }}
-        />
-      )}
-      <Divider />
-      <Header as='h3'>账号绑定</Header>
-      {
-        status.wechat_login && (
-          <Button
-            onClick={() => {
-              setShowWeChatBindModal(true);
-            }}
-          >
-            绑定微信账号
-          </Button>
-        )
-      }
-      <Modal
-        onClose={() => setShowWeChatBindModal(false)}
-        onOpen={() => setShowWeChatBindModal(true)}
-        open={showWeChatBindModal}
-        size={'mini'}
-      >
-        <Modal.Content>
-          <Modal.Description>
-            <Image src={status.wechat_qrcode} fluid />
-            <div style={{ textAlign: 'center' }}>
-              <p>
-                微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)
-              </p>
-            </div>
-            <Form size='large'>
-              <Form.Input
-                fluid
-                placeholder='验证码'
-                name='wechat_verification_code'
-                value={inputs.wechat_verification_code}
-                onChange={handleInputChange}
-              />
-              <Button color='' fluid size='large' onClick={bindWeChat}>
-                绑定
-              </Button>
-            </Form>
-          </Modal.Description>
-        </Modal.Content>
-      </Modal>
-      {
-        status.github_oauth && (
-          <Button onClick={()=>{onGitHubOAuthClicked(status.github_client_id)}}>绑定 GitHub 账号</Button>
-        )
-      }
-      <Button
-        onClick={() => {
-          setShowEmailBindModal(true);
-        }}
-      >
-        绑定邮箱地址
-      </Button>
-      <Modal
-        onClose={() => setShowEmailBindModal(false)}
-        onOpen={() => setShowEmailBindModal(true)}
-        open={showEmailBindModal}
-        size={'tiny'}
-        style={{ maxWidth: '450px' }}
-      >
-        <Modal.Header>绑定邮箱地址</Modal.Header>
-        <Modal.Content>
-          <Modal.Description>
-            <Form size='large'>
-              <Form.Input
-                fluid
-                placeholder='输入邮箱地址'
-                onChange={handleInputChange}
-                name='email'
-                type='email'
-                action={
-                  <Button onClick={sendVerificationCode} disabled={disableButton || loading}>
-                    {disableButton ? `重新发送(${countdown})` : '获取验证码'}
-                  </Button>
-                }
-              />
-              <Form.Input
-                fluid
-                placeholder='验证码'
-                name='email_verification_code'
-                value={inputs.email_verification_code}
-                onChange={handleInputChange}
-              />
-              {turnstileEnabled ? (
-                <Turnstile
-                  sitekey={turnstileSiteKey}
-                  onVerify={(token) => {
-                    setTurnstileToken(token);
-                  }}
-                />
-              ) : (
-                <></>
-              )}
-              <div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '1rem' }}>
-              <Button
-                color=''
-                fluid
-                size='large'
-                onClick={bindEmail}
-                loading={loading}
-              >
-                确认绑定
-              </Button>
-              <div style={{ width: '1rem' }}></div> 
-              <Button
-                fluid
-                size='large'
-                onClick={() => setShowEmailBindModal(false)}
-              >
-                取消
-              </Button>
-              </div>
-            </Form>
-          </Modal.Description>
-        </Modal.Content>
-      </Modal>
-      <Modal
-        onClose={() => setShowAccountDeleteModal(false)}
-        onOpen={() => setShowAccountDeleteModal(true)}
-        open={showAccountDeleteModal}
-        size={'tiny'}
-        style={{ maxWidth: '450px' }}
-      >
-        <Modal.Header>危险操作</Modal.Header>
-        <Modal.Content>
-        <Message>您正在删除自己的帐户,将清空所有数据且不可恢复</Message>
-          <Modal.Description>
-            <Form size='large'>
-              <Form.Input
-                fluid
-                placeholder={`输入你的账户名 ${userState?.user?.username} 以确认删除`}
-                name='self_account_deletion_confirmation'
-                value={inputs.self_account_deletion_confirmation}
-                onChange={handleInputChange}
-              />
-              {turnstileEnabled ? (
-                <Turnstile
-                  sitekey={turnstileSiteKey}
-                  onVerify={(token) => {
-                    setTurnstileToken(token);
-                  }}
-                />
-              ) : (
-                <></>
-              )}
-              <div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '1rem' }}>
-                <Button
-                  color='red'
-                  fluid
-                  size='large'
-                  onClick={deleteAccount}
-                  loading={loading}
-                >
-                  确认删除
-                </Button>
-                <div style={{ width: '1rem' }}></div>
-                <Button
-                  fluid
-                  size='large'
-                  onClick={() => setShowAccountDeleteModal(false)}
-                >
-                  取消
-                </Button>
-              </div>
-            </Form>
-          </Modal.Description>
-        </Modal.Content>
-      </Modal>
-    </div>
-  );
+    return (
+        <div style={{lineHeight: '40px'}}>
+            <Layout>
+                <Layout.Content>
+                    <Modal
+                        title="请输入要划转的数量"
+                        visible={openTransfer}
+                        onOk={transfer}
+                        onCancel={handleCancel}
+                        maskClosable={false}
+                        size={'small'}
+                        centered={true}
+                    >
+                        <div style={{marginTop: 20}}>
+                            <Typography.Text>{`可用额度${renderQuotaWithPrompt(userState?.user?.aff_quota)}`}</Typography.Text>
+                            <Input style={{marginTop: 5}} value={userState?.user?.aff_quota} disabled={true}></Input>
+                        </div>
+                        <div style={{marginTop: 20}}>
+                            <Typography.Text>{`划转额度${renderQuotaWithPrompt(transferAmount)} 最低` + renderQuota(getQuotaPerUnit())}</Typography.Text>
+                            <div>
+                                <InputNumber min={0} style={{marginTop: 5}} value={transferAmount} onChange={(value)=>setTransferAmount(value)} disabled={false}></InputNumber>
+                            </div>
+                        </div>
+                    </Modal>
+                    <div style={{marginTop: 20}}>
+                        <Card
+                            title={
+                                <Card.Meta
+                                    avatar={<Avatar size="default" color={stringToColor(getUsername())}
+                                                    style={{marginRight: 4}}>
+                                        {typeof getUsername() === 'string' && getUsername().slice(0, 1)}
+                                    </Avatar>}
+                                    title={<Typography.Text>{getUsername()}</Typography.Text>}
+                                    description={isRoot()?<Tag color="red">管理员</Tag>:<Tag color="blue">普通用户</Tag>}
+                                ></Card.Meta>
+                            }
+                            headerExtraContent={
+                                <>
+                                    <Space vertical align="start">
+                                        <Tag color="green">{'ID: ' + userState?.user?.id}</Tag>
+                                        <Tag color="blue">{userState?.user?.group}</Tag>
+                                    </Space>
+                                </>
+                            }
+                            footer={
+                                <Descriptions row>
+                                    <Descriptions.Item itemKey="当前余额">{renderQuota(userState?.user?.quota)}</Descriptions.Item>
+                                    <Descriptions.Item itemKey="历史消耗">{renderQuota(userState?.user?.used_quota)}</Descriptions.Item>
+                                    <Descriptions.Item itemKey="请求次数">{userState.user?.request_count}</Descriptions.Item>
+                                </Descriptions>
+                            }
+                        >
+                            <Typography.Title heading={6}>可用模型</Typography.Title>
+                            <div style={{marginTop: 10}}>
+                                <Space wrap>
+                                    {models.map((model) => (
+                                        <Tag key={model} color="cyan">
+                                            {model}
+                                        </Tag>
+                                    ))}
+                                </Space>
+                            </div>
+
+                        </Card>
+                        <Card
+                            footer={
+                                <div>
+                                    <Typography.Text>邀请链接</Typography.Text>
+                                    <Input
+                                        style={{marginTop: 10}}
+                                        value={affLink}
+                                        onClick={handleAffLinkClick}
+                                        readOnly
+                                    />
+                                </div>
+                            }
+                        >
+                            <Typography.Title heading={6}>邀请信息</Typography.Title>
+                            <div style={{marginTop: 10}}>
+                                <Descriptions row>
+                                    <Descriptions.Item itemKey="待使用收益">
+                                        <span style={{color: 'rgba(var(--semi-red-5), 1)'}}>
+                                            {
+                                                renderQuota(userState?.user?.aff_quota)
+                                            }
+                                        </span>
+                                        <Button type={'secondary'} onClick={()=>setOpenTransfer(true)} size={'small'} style={{marginLeft: 10}}>划转</Button>
+                                    </Descriptions.Item>
+                                    <Descriptions.Item itemKey="总收益">{renderQuota(userState?.user?.aff_history_quota)}</Descriptions.Item>
+                                    <Descriptions.Item itemKey="邀请人数">{userState?.user?.aff_count}</Descriptions.Item>
+                                </Descriptions>
+                            </div>
+                        </Card>
+                        <Card>
+                            <Typography.Title heading={6}>个人信息</Typography.Title>
+                            <div style={{marginTop: 20}}>
+                                <Typography.Text strong>邮箱</Typography.Text>
+                                <div style={{display: 'flex', justifyContent: 'space-between'}}>
+                                    <div>
+                                        <Input
+                                            value={userState.user && userState.user.email !== ''?userState.user.email:'未绑定'}
+                                            readonly={true}
+                                        ></Input>
+                                    </div>
+                                    <div>
+                                        <Button onClick={()=>{setShowEmailBindModal(true)}} disabled={userState.user && userState.user.email !== ''}>绑定邮箱</Button>
+                                    </div>
+                                </div>
+                            </div>
+                            <div style={{marginTop: 10}}>
+                                <Typography.Text strong>微信</Typography.Text>
+                                <div style={{display: 'flex', justifyContent: 'space-between'}}>
+                                    <div>
+                                        <Input
+                                            value={userState.user && userState.user.wechat_id !== ''?'已绑定':'未绑定'}
+                                            readonly={true}
+                                        ></Input>
+                                    </div>
+                                    <div>
+                                        <Button disabled={(userState.user && userState.user.wechat_id !== '') || !status.wechat_login}>
+                                            {
+                                                status.wechat_login?'绑定':'未启用'
+                                            }
+                                        </Button>
+                                    </div>
+                                </div>
+                            </div>
+                            <div style={{marginTop: 10}}>
+                                <Typography.Text strong>GitHub</Typography.Text>
+                                <div style={{display: 'flex', justifyContent: 'space-between'}}>
+                                    <div>
+                                        <Input
+                                            value={userState.user && userState.user.github_id !== ''?userState.user.github_id:'未绑定'}
+                                            readonly={true}
+                                        ></Input>
+                                    </div>
+                                    <div>
+                                        <Button
+                                            onClick={() => {onGitHubOAuthClicked(status.github_client_id)}}
+                                            disabled={(userState.user && userState.user.github_id !== '') || !status.github_oauth}
+                                        >
+                                            {
+                                                status.github_oauth?'绑定':'未启用'
+                                            }
+                                        </Button>
+                                    </div>
+                                </div>
+                            </div>
+
+                            <div style={{marginTop: 10}}>
+                                <Space>
+                                    <Button onClick={generateAccessToken}>生成系统访问令牌</Button>
+                                    <Button onClick={() => {
+                                        setShowAccountDeleteModal(true);
+                                    }}>删除个人账户</Button>
+                                </Space>
+
+                                {systemToken && (
+                                    <Form.Input
+                                        fluid
+                                        readOnly
+                                        value={systemToken}
+                                        onClick={handleSystemTokenClick}
+                                        style={{marginTop: '10px'}}
+                                    />
+                                )}
+                                {
+                                    status.wechat_login && (
+                                        <Button
+                                            onClick={() => {
+                                                setShowWeChatBindModal(true);
+                                            }}
+                                        >
+                                            绑定微信账号
+                                        </Button>
+                                    )
+                                }
+                                <Modal
+                                    onCancel={() => setShowWeChatBindModal(false)}
+                                    // onOpen={() => setShowWeChatBindModal(true)}
+                                    visible={showWeChatBindModal}
+                                    size={'mini'}
+                                >
+                                    <Image src={status.wechat_qrcode} fluid/>
+                                    <div style={{textAlign: 'center'}}>
+                                        <p>
+                                            微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)
+                                        </p>
+                                    </div>
+                                    <Form size='large'>
+                                        <Form.Input
+                                            fluid
+                                            placeholder='验证码'
+                                            name='wechat_verification_code'
+                                            value={inputs.wechat_verification_code}
+                                            onChange={handleInputChange}
+                                        />
+                                        <Button color='' fluid size='large' onClick={bindWeChat}>
+                                            绑定
+                                        </Button>
+                                    </Form>
+                                </Modal>
+                            </div>
+                        </Card>
+                        <Modal
+                            onCancel={() => setShowEmailBindModal(false)}
+                            // onOpen={() => setShowEmailBindModal(true)}
+                            onOk={bindEmail}
+                            visible={showEmailBindModal}
+                            size={'small'}
+                            centered={true}
+                            maskClosable={false}
+                        >
+                            <Typography.Title heading={6}>绑定邮箱地址</Typography.Title>
+                            <div style={{marginTop: 20, display: 'flex', justifyContent: 'space-between'}}>
+                                <Input
+                                    fluid
+                                    placeholder='输入邮箱地址'
+                                    onChange={(value)=>handleInputChange('email', value)}
+                                    name='email'
+                                    type='email'
+                                />
+                                <Button onClick={sendVerificationCode}
+                                        disabled={disableButton || loading}>
+                                    {disableButton ? `重新发送(${countdown})` : '获取验证码'}
+                                </Button>
+                            </div>
+                            <div style={{marginTop: 10}}>
+                                <Input
+                                    fluid
+                                    placeholder='验证码'
+                                    name='email_verification_code'
+                                    value={inputs.email_verification_code}
+                                    onChange={(value)=>handleInputChange('email_verification_code', value)}
+                                />
+                            </div>
+                            {turnstileEnabled ? (
+                                <Turnstile
+                                    sitekey={turnstileSiteKey}
+                                    onVerify={(token) => {
+                                        setTurnstileToken(token);
+                                    }}
+                                />
+                            ) : (
+                                <></>
+                            )}
+                        </Modal>
+                        <Modal
+                            onCancel={() => setShowAccountDeleteModal(false)}
+                            visible={showAccountDeleteModal}
+                            size={'small'}
+                            centered={true}
+                            onOk={deleteAccount}
+                        >
+                            <div style={{marginTop: 20}}>
+                                <Banner
+                                    type="danger"
+                                    description="您正在删除自己的帐户,将清空所有数据且不可恢复"
+                                    closeIcon={null}
+                                />
+                            </div>
+                            <div style={{marginTop: 20}}>
+                                <Input
+                                    placeholder={`输入你的账户名 ${userState?.user?.username} 以确认删除`}
+                                    name='self_account_deletion_confirmation'
+                                    value={inputs.self_account_deletion_confirmation}
+                                    onChange={(value)=>handleInputChange('self_account_deletion_confirmation', value)}
+                                />
+                                {turnstileEnabled ? (
+                                    <Turnstile
+                                        sitekey={turnstileSiteKey}
+                                        onVerify={(token) => {
+                                            setTurnstileToken(token);
+                                        }}
+                                    />
+                                ) : (
+                                    <></>
+                                )}
+                            </div>
+                        </Modal>
+                    </div>
+
+                </Layout.Content>
+            </Layout>
+        </div>
+    );
 };
 
 export default PersonalSetting;

+ 6 - 0
web/src/helpers/render.js

@@ -37,6 +37,12 @@ export function renderNumber(num) {
   }
 }
 
+export function getQuotaPerUnit() {
+  let quotaPerUnit = localStorage.getItem('quota_per_unit');
+  quotaPerUnit = parseFloat(quotaPerUnit);
+  return quotaPerUnit;
+}
+
 export function renderQuota(quota, digits = 2) {
   let quotaPerUnit = localStorage.getItem('quota_per_unit');
   let displayInCurrency = localStorage.getItem('display_in_currency');

+ 41 - 43
web/src/pages/Setting/index.js

@@ -1,55 +1,53 @@
 import React from 'react';
-import { Segment, Tab } from 'semantic-ui-react';
 import SystemSetting from '../../components/SystemSetting';
-import { isRoot } from '../../helpers';
+import {isRoot} from '../../helpers';
 import OtherSetting from '../../components/OtherSetting';
 import PersonalSetting from '../../components/PersonalSetting';
 import OperationSetting from '../../components/OperationSetting';
+import {Layout, TabPane, Tabs} from "@douyinfe/semi-ui";
 
 const Setting = () => {
-  let panes = [
-    {
-      menuItem: '个人设置',
-      render: () => (
-        <Tab.Pane attached={false}>
-          <PersonalSetting />
-        </Tab.Pane>
-      )
-    }
-  ];
+    let panes = [
+        {
+            tab: '个人设置',
+            content: <PersonalSetting/>,
+            itemKey: '1'
+        }
+    ];
 
-  if (isRoot()) {
-    panes.push({
-      menuItem: '运营设置',
-      render: () => (
-        <Tab.Pane attached={false}>
-          <OperationSetting />
-        </Tab.Pane>
-      )
-    });
-    panes.push({
-      menuItem: '系统设置',
-      render: () => (
-        <Tab.Pane attached={false}>
-          <SystemSetting />
-        </Tab.Pane>
-      )
-    });
-    panes.push({
-      menuItem: '其他设置',
-      render: () => (
-        <Tab.Pane attached={false}>
-          <OtherSetting />
-        </Tab.Pane>
-      )
-    });
-  }
+    if (isRoot()) {
+        panes.push({
+            tab: '运营设置',
+            content: <OperationSetting/>,
+            itemKey: '2'
+        });
+        panes.push({
+            tab: '系统设置',
+            content: <SystemSetting/>,
+            itemKey: '3'
+        });
+        panes.push({
+            tab: '其他设置',
+            content: <OtherSetting/>,
+            itemKey: '4'
+        });
+    }
 
-  return (
-    <Segment>
-      <Tab menu={{ secondary: true, pointing: true }} panes={panes} />
-    </Segment>
-  );
+    return (
+        <div>
+            <Layout>
+                <Layout.Content>
+                    <Tabs type="line" defaultActiveKey="1">
+                        {panes.map(pane => (
+                            <TabPane itemKey={pane.itemKey} tab={pane.tab}>
+                                {pane.content}
+                            </TabPane>
+                        ))}
+                    </Tabs>
+                </Layout.Content>
+            </Layout>
+        </div>
+    );
 };
 
 export default Setting;

+ 90 - 71
web/src/pages/User/EditUser.js

@@ -1,10 +1,12 @@
 import React, { useEffect, useState } from 'react';
 import { Button, Form, Header, Segment } from 'semantic-ui-react';
 import { useParams, useNavigate } from 'react-router-dom';
-import { API, showError, showSuccess } from '../../helpers';
+import {API, isMobile, showError, showSuccess} from '../../helpers';
 import { renderQuota, renderQuotaWithPrompt } from '../../helpers/render';
+import Title from "@douyinfe/semi-ui/lib/es/typography/title";
+import {SideSheet, Space} from "@douyinfe/semi-ui";
 
-const EditUser = () => {
+const EditUser = (props) => {
   const params = useParams();
   const userId = params.id;
   const [loading, setLoading] = useState(true);
@@ -84,105 +86,122 @@ const EditUser = () => {
 
   return (
     <>
-      <Segment loading={loading}>
-        <Header as='h3'>更新用户信息</Header>
+      <SideSheet
+          placement={'left'}
+          title={<Title level={3}>更新用户信息</Title>}
+          headerStyle={{borderBottom: '1px solid var(--semi-color-border)'}}
+          bodyStyle={{borderBottom: '1px solid var(--semi-color-border)'}}
+          visible={props.visiable}
+          footer={
+            <div style={{display: 'flex', justifyContent: 'flex-end'}}>
+              <Space>
+                <Button theme='solid' size={'large'} onClick={submit}>提交</Button>
+                <Button theme='solid' size={'large'} type={'tertiary'} onClick={handleCancel}>取消</Button>
+              </Space>
+            </div>
+          }
+          closeIcon={null}
+          onCancel={() => handleCancel()}
+          width={isMobile() ? '100%' : 600}
+      >
         <Form autoComplete='new-password'>
           <Form.Field>
             <Form.Input
-              label='用户名'
-              name='username'
-              placeholder={'请输入新的用户名'}
-              onChange={handleInputChange}
-              value={username}
-              autoComplete='new-password'
+                label='用户名'
+                name='username'
+                placeholder={'请输入新的用户名'}
+                onChange={handleInputChange}
+                value={username}
+                autoComplete='new-password'
             />
           </Form.Field>
           <Form.Field>
             <Form.Input
-              label='密码'
-              name='password'
-              type={'password'}
-              placeholder={'请输入新的密码,最短 8 位'}
-              onChange={handleInputChange}
-              value={password}
-              autoComplete='new-password'
+                label='密码'
+                name='password'
+                type={'password'}
+                placeholder={'请输入新的密码,最短 8 位'}
+                onChange={handleInputChange}
+                value={password}
+                autoComplete='new-password'
             />
           </Form.Field>
           <Form.Field>
             <Form.Input
-              label='显示名称'
-              name='display_name'
-              placeholder={'请输入新的显示名称'}
-              onChange={handleInputChange}
-              value={display_name}
-              autoComplete='new-password'
+                label='显示名称'
+                name='display_name'
+                placeholder={'请输入新的显示名称'}
+                onChange={handleInputChange}
+                value={display_name}
+                autoComplete='new-password'
             />
           </Form.Field>
           {
-            userId && <>
-              <Form.Field>
-                <Form.Dropdown
-                  label='分组'
-                  placeholder={'请选择分组'}
-                  name='group'
-                  fluid
-                  search
-                  selection
-                  allowAdditions
-                  additionLabel={'请在系统设置页面编辑分组倍率以添加新的分组:'}
-                  onChange={handleInputChange}
-                  value={inputs.group}
-                  autoComplete='new-password'
-                  options={groupOptions}
-                />
-              </Form.Field>
-              <Form.Field>
-                <Form.Input
-                  label={`剩余额度${renderQuotaWithPrompt(quota)}`}
-                  name='quota'
-                  placeholder={'请输入新的剩余额度'}
-                  onChange={handleInputChange}
-                  value={quota}
-                  type={'number'}
-                  autoComplete='new-password'
-                />
-              </Form.Field>
-            </>
+              userId && <>
+                <Form.Field>
+                  <Form.Dropdown
+                      label='分组'
+                      placeholder={'请选择分组'}
+                      name='group'
+                      fluid
+                      search
+                      selection
+                      allowAdditions
+                      additionLabel={'请在系统设置页面编辑分组倍率以添加新的分组:'}
+                      onChange={handleInputChange}
+                      value={inputs.group}
+                      autoComplete='new-password'
+                      options={groupOptions}
+                  />
+                </Form.Field>
+                <Form.Field>
+                  <Form.Input
+                      label={`剩余额度${renderQuotaWithPrompt(quota)}`}
+                      name='quota'
+                      placeholder={'请输入新的剩余额度'}
+                      onChange={handleInputChange}
+                      value={quota}
+                      type={'number'}
+                      autoComplete='new-password'
+                  />
+                </Form.Field>
+              </>
           }
           <Form.Field>
             <Form.Input
-              label='已绑定的 GitHub 账户'
-              name='github_id'
-              value={github_id}
-              autoComplete='new-password'
-              placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改'
-              readOnly
+                label='已绑定的 GitHub 账户'
+                name='github_id'
+                value={github_id}
+                autoComplete='new-password'
+                placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改'
+                readOnly
             />
           </Form.Field>
           <Form.Field>
             <Form.Input
-              label='已绑定的微信账户'
-              name='wechat_id'
-              value={wechat_id}
-              autoComplete='new-password'
-              placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改'
-              readOnly
+                label='已绑定的微信账户'
+                name='wechat_id'
+                value={wechat_id}
+                autoComplete='new-password'
+                placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改'
+                readOnly
             />
           </Form.Field>
           <Form.Field>
             <Form.Input
-              label='已绑定的邮箱账户'
-              name='email'
-              value={email}
-              autoComplete='new-password'
-              placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改'
-              readOnly
+                label='已绑定的邮箱账户'
+                name='email'
+                value={email}
+                autoComplete='new-password'
+                placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改'
+                readOnly
             />
           </Form.Field>
           <Button onClick={handleCancel}>取消</Button>
           <Button positive onClick={submit}>提交</Button>
         </Form>
-      </Segment>
+
+      </SideSheet>
     </>
   );
 };