Compare commits

...

9 Commits

Author SHA1 Message Date
JustSong
3da119efba perf: reuse http client to reduce delay 2023-07-23 15:18:58 +08:00
ckt
dccd66b852 feat: add access_until field for subscription api (#295)
* 支持从计费渠道端点返回日期

* fix: fix wrong git base

---------

Co-authored-by: JustSong <songquanpeng@foxmail.com>
2023-07-23 13:49:09 +08:00
ckt
2fcd6852e0 feat: able to delete account by self (#294)
* feat: support account deletion

* chore: update style

---------

Co-authored-by: JustSong <songquanpeng@foxmail.com>
2023-07-23 13:37:32 +08:00
Yolo°
9b4d1964d4 chore: optimize frontend (#293)
* main

* chore: update style

---------

Co-authored-by: JustSong <songquanpeng@foxmail.com>
2023-07-23 13:25:28 +08:00
JustSong
806bf8241c chore: update prompts of channel config page 2023-07-23 12:46:41 +08:00
JustSong
ce93c9b6b2 chore: adjust channel config page 2023-07-23 12:20:42 +08:00
JustSong
4ec4289565 docs: update README 2023-07-23 12:01:49 +08:00
JustSong
3dc5a0f91d docs: update README 2023-07-23 11:56:51 +08:00
JustSong
80a846673a docs: update README 2023-07-23 11:55:33 +08:00
19 changed files with 286 additions and 147 deletions

View File

@@ -10,7 +10,7 @@
# One API # One API
_✨ An OpenAI key management & redistribution system, easy to deploy & use ✨_ _✨ Access all LLM through the standard OpenAI API format, easy to deploy & use ✨_
</div> </div>

View File

@@ -11,7 +11,7 @@
# One API # One API
_✨ All in one 的 OpenAI 接口,整合各种 API 访问方式开箱即用✨_ _✨ 通过标准的 OpenAI API 格式访问所有的大模型,开箱即用 ✨_
</div> </div>
@@ -58,13 +58,13 @@ _✨ All in one 的 OpenAI 接口,整合各种 API 访问方式,开箱即用
> **Warning**:从 `v0.3` 版本升级到 `v0.4` 版本需要手动迁移数据库,请手动执行[数据库迁移脚本](./bin/migration_v0.3-v0.4.sql)。 > **Warning**:从 `v0.3` 版本升级到 `v0.4` 版本需要手动迁移数据库,请手动执行[数据库迁移脚本](./bin/migration_v0.3-v0.4.sql)。
## 功能 ## 功能
1. 支持多种 API 访问渠道 1. 支持多种大模型
+ [x] OpenAI 官方通道(支持配置镜像 + [x] [OpenAI ChatGPT 系列模型](https://platform.openai.com/docs/guides/gpt/chat-completions-api)(支持 [Azure OpenAI API](https://learn.microsoft.com/en-us/azure/ai-services/openai/reference)
+ [x] **Azure OpenAI API**
+ [x] [Anthropic Claude 系列模型](https://anthropic.com) + [x] [Anthropic Claude 系列模型](https://anthropic.com)
+ [x] [Google PaLM2 系列模型](https://developers.generativeai.google) + [x] [Google PaLM2 系列模型](https://developers.generativeai.google)
+ [x] [百度文心一言系列模型](https://cloud.baidu.com/doc/WENXINWORKSHOP/index.html) + [x] [百度文心一言系列模型](https://cloud.baidu.com/doc/WENXINWORKSHOP/index.html)
+ [x] [智谱 ChatGLM 系列模型](https://bigmodel.cn) + [x] [智谱 ChatGLM 系列模型](https://bigmodel.cn)
2. 支持配置镜像以及众多第三方代理服务:
+ [x] [API Distribute](https://api.gptjk.top/register?aff=QGxj) + [x] [API Distribute](https://api.gptjk.top/register?aff=QGxj)
+ [x] [OpenAI-SB](https://openai-sb.com) + [x] [OpenAI-SB](https://openai-sb.com)
+ [x] [API2D](https://api2d.com/r/197971) + [x] [API2D](https://api2d.com/r/197971)
@@ -72,27 +72,27 @@ _✨ All in one 的 OpenAI 接口,整合各种 API 访问方式,开箱即用
+ [x] [AI Proxy](https://aiproxy.io/?i=OneAPI) (邀请码:`OneAPI` + [x] [AI Proxy](https://aiproxy.io/?i=OneAPI) (邀请码:`OneAPI`
+ [x] [CloseAI](https://console.closeai-asia.com/r/2412) + [x] [CloseAI](https://console.closeai-asia.com/r/2412)
+ [x] 自定义渠道:例如各种未收录的第三方代理服务 + [x] 自定义渠道:例如各种未收录的第三方代理服务
2. 支持通过**负载均衡**的方式访问多个渠道。 3. 支持通过**负载均衡**的方式访问多个渠道。
3. 支持 **stream 模式**,可以通过流式传输实现打字机效果。 4. 支持 **stream 模式**,可以通过流式传输实现打字机效果。
4. 支持**多机部署**[详见此处](#多机部署)。 5. 支持**多机部署**[详见此处](#多机部署)。
5. 支持**令牌管理**,设置令牌的过期时间和额度。 6. 支持**令牌管理**,设置令牌的过期时间和额度。
6. 支持**兑换码管理**,支持批量生成和导出兑换码,可使用兑换码为账户进行充值。 7. 支持**兑换码管理**,支持批量生成和导出兑换码,可使用兑换码为账户进行充值。
7. 支持**通道管理**,批量创建通道。 8. 支持**通道管理**,批量创建通道。
8. 支持**用户分组**以及**渠道分组**,支持为不同分组设置不同的倍率。 9. 支持**用户分组**以及**渠道分组**,支持为不同分组设置不同的倍率。
9. 支持渠道**设置模型列表**。 10. 支持渠道**设置模型列表**。
10. 支持**查看额度明细**。 11. 支持**查看额度明细**。
11. 支持**用户邀请奖励**。 12. 支持**用户邀请奖励**。
12. 支持以美元为单位显示额度。 13. 支持以美元为单位显示额度。
13. 支持发布公告,设置充值链接,设置新用户初始额度。 14. 支持发布公告,设置充值链接,设置新用户初始额度。
14. 支持模型映射,重定向用户的请求模型。 15. 支持模型映射,重定向用户的请求模型。
15. 支持失败自动重试。 16. 支持失败自动重试。
16. 支持绘图接口。 17. 支持绘图接口。
17. 支持丰富的**自定义**设置, 18. 支持丰富的**自定义**设置,
1. 支持自定义系统名称logo 以及页脚。 1. 支持自定义系统名称logo 以及页脚。
2. 支持自定义首页和关于页面,可以选择使用 HTML & Markdown 代码进行自定义,或者使用一个单独的网页通过 iframe 嵌入。 2. 支持自定义首页和关于页面,可以选择使用 HTML & Markdown 代码进行自定义,或者使用一个单独的网页通过 iframe 嵌入。
18. 支持通过系统访问令牌访问管理 API。 19. 支持通过系统访问令牌访问管理 API。
19. 支持 Cloudflare Turnstile 用户校验。 20. 支持 Cloudflare Turnstile 用户校验。
20. 支持用户管理,支持**多种用户登录注册方式** 21. 支持用户管理,支持**多种用户登录注册方式**
+ 邮箱登录注册以及通过邮箱进行密码重置。 + 邮箱登录注册以及通过邮箱进行密码重置。
+ [GitHub 开放授权](https://github.com/settings/applications/new)。 + [GitHub 开放授权](https://github.com/settings/applications/new)。
+ 微信公众号授权(需要额外部署 [WeChat Server](https://github.com/songquanpeng/wechat-server))。 + 微信公众号授权(需要额外部署 [WeChat Server](https://github.com/songquanpeng/wechat-server))。

View File

@@ -11,9 +11,11 @@ func GetSubscription(c *gin.Context) {
var usedQuota int var usedQuota int
var err error var err error
var token *model.Token var token *model.Token
var expiredTime int64
if common.DisplayTokenStatEnabled { if common.DisplayTokenStatEnabled {
tokenId := c.GetInt("token_id") tokenId := c.GetInt("token_id")
token, err = model.GetTokenById(tokenId) token, err = model.GetTokenById(tokenId)
expiredTime = token.ExpiredTime
remainQuota = token.RemainQuota remainQuota = token.RemainQuota
usedQuota = token.UsedQuota usedQuota = token.UsedQuota
} else { } else {
@@ -21,6 +23,9 @@ func GetSubscription(c *gin.Context) {
remainQuota, err = model.GetUserQuota(userId) remainQuota, err = model.GetUserQuota(userId)
usedQuota, err = model.GetUserUsedQuota(userId) usedQuota, err = model.GetUserUsedQuota(userId)
} }
if expiredTime <= 0 {
expiredTime = 0
}
if err != nil { if err != nil {
openAIError := OpenAIError{ openAIError := OpenAIError{
Message: err.Error(), Message: err.Error(),
@@ -45,6 +50,7 @@ func GetSubscription(c *gin.Context) {
SoftLimitUSD: amount, SoftLimitUSD: amount,
HardLimitUSD: amount, HardLimitUSD: amount,
SystemHardLimitUSD: amount, SystemHardLimitUSD: amount,
AccessUntil: expiredTime,
} }
c.JSON(200, subscription) c.JSON(200, subscription)
return return

View File

@@ -22,6 +22,7 @@ type OpenAISubscriptionResponse struct {
SoftLimitUSD float64 `json:"soft_limit_usd"` SoftLimitUSD float64 `json:"soft_limit_usd"`
HardLimitUSD float64 `json:"hard_limit_usd"` HardLimitUSD float64 `json:"hard_limit_usd"`
SystemHardLimitUSD float64 `json:"system_hard_limit_usd"` SystemHardLimitUSD float64 `json:"system_hard_limit_usd"`
AccessUntil int64 `json:"access_until"`
} }
type OpenAIUsageDailyCost struct { type OpenAIUsageDailyCost struct {
@@ -84,7 +85,6 @@ func GetAuthHeader(token string) http.Header {
} }
func GetResponseBody(method, url string, channel *model.Channel, headers http.Header) ([]byte, error) { func GetResponseBody(method, url string, channel *model.Channel, headers http.Header) ([]byte, error) {
client := &http.Client{}
req, err := http.NewRequest(method, url, nil) req, err := http.NewRequest(method, url, nil)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -92,10 +92,13 @@ func GetResponseBody(method, url string, channel *model.Channel, headers http.He
for k := range headers { for k := range headers {
req.Header.Add(k, headers.Get(k)) req.Header.Add(k, headers.Get(k))
} }
res, err := client.Do(req) res, err := httpClient.Do(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("status code: %d", res.StatusCode)
}
body, err := io.ReadAll(res.Body) body, err := io.ReadAll(res.Body)
if err != nil { if err != nil {
return nil, err return nil, err

View File

@@ -45,8 +45,7 @@ func testChannel(channel *model.Channel, request ChatRequest) (error, *OpenAIErr
req.Header.Set("Authorization", "Bearer "+channel.Key) req.Header.Set("Authorization", "Bearer "+channel.Key)
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
client := &http.Client{} resp, err := httpClient.Do(req)
resp, err := client.Do(req)
if err != nil { if err != nil {
return err, nil return err, nil
} }

View File

@@ -127,8 +127,9 @@ func SendPasswordResetEmail(c *gin.Context) {
link := fmt.Sprintf("%s/user/reset?email=%s&token=%s", common.ServerAddress, email, code) link := fmt.Sprintf("%s/user/reset?email=%s&token=%s", common.ServerAddress, email, code)
subject := fmt.Sprintf("%s密码重置", common.SystemName) subject := fmt.Sprintf("%s密码重置", common.SystemName)
content := fmt.Sprintf("<p>您好,你正在进行%s密码重置。</p>"+ content := fmt.Sprintf("<p>您好,你正在进行%s密码重置。</p>"+
"<p>点击<a href='%s'>此处</a>进行密码重置。</p>"+ "<p>点击 <a href='%s'>此处</a> 进行密码重置。</p>"+
"<p>重置链接 %d 分钟内有效,如果不是本人操作,请忽略。</p>", common.SystemName, link, common.VerificationValidMinutes) "<p>如果链接无法点击,请尝试点击下面的链接或将其复制到浏览器中打开:<br> %s </p>"+
"<p>重置链接 %d 分钟内有效,如果不是本人操作,请忽略。</p>", common.SystemName, link, link, common.VerificationValidMinutes)
err := common.SendEmail(subject, email, content) err := common.SendEmail(subject, email, content)
if err != nil { if err != nil {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{

View File

@@ -109,8 +109,7 @@ func relayImageHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode
req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type")) req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type"))
req.Header.Set("Accept", c.Request.Header.Get("Accept")) req.Header.Set("Accept", c.Request.Header.Get("Accept"))
client := &http.Client{} resp, err := httpClient.Do(req)
resp, err := client.Do(req)
if err != nil { if err != nil {
return errorWrapper(err, "do_request_failed", http.StatusInternalServerError) return errorWrapper(err, "do_request_failed", http.StatusInternalServerError)
} }

View File

@@ -115,7 +115,7 @@ func openaiHandler(c *gin.Context, resp *http.Response, consumeQuota bool) (*Ope
} }
// We shouldn't set the header before we parse the response body, because the parse part may fail. // We shouldn't set the header before we parse the response body, because the parse part may fail.
// And then we will have to send an error response, but in this case, the header has already been set. // And then we will have to send an error response, but in this case, the header has already been set.
// So the client will be confused by the response. // So the httpClient will be confused by the response.
// For example, Postman will report error, and we cannot check the response at all. // For example, Postman will report error, and we cannot check the response at all.
for k, v := range resp.Header { for k, v := range resp.Header {
c.Writer.Header().Set(k, v[0]) c.Writer.Header().Set(k, v[0])

View File

@@ -22,6 +22,12 @@ const (
APITypeZhipu APITypeZhipu
) )
var httpClient *http.Client
func init() {
httpClient = &http.Client{}
}
func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode { func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode {
channelType := c.GetInt("channel") channelType := c.GetInt("channel")
tokenId := c.GetInt("token_id") tokenId := c.GetInt("token_id")
@@ -244,8 +250,7 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode {
req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type")) req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type"))
req.Header.Set("Accept", c.Request.Header.Get("Accept")) req.Header.Set("Accept", c.Request.Header.Get("Accept"))
//req.Header.Set("Connection", c.Request.Header.Get("Connection")) //req.Header.Set("Connection", c.Request.Header.Get("Connection"))
client := &http.Client{} resp, err := httpClient.Do(req)
resp, err := client.Do(req)
if err != nil { if err != nil {
return errorWrapper(err, "do_request_failed", http.StatusInternalServerError) return errorWrapper(err, "do_request_failed", http.StatusInternalServerError)
} }

View File

@@ -3,12 +3,13 @@ package controller
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"net/http" "net/http"
"one-api/common" "one-api/common"
"one-api/model" "one-api/model"
"strconv" "strconv"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
) )
type LoginRequest struct { type LoginRequest struct {
@@ -477,6 +478,16 @@ func DeleteUser(c *gin.Context) {
func DeleteSelf(c *gin.Context) { func DeleteSelf(c *gin.Context) {
id := c.GetInt("id") id := c.GetInt("id")
user, _ := model.GetUserById(id, false)
if user.Role == common.RoleRootUser {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "不能删除超级管理员账户",
})
return
}
err := model.DeleteUserById(id) err := model.DeleteUserById(id)
if err != nil { if err != nil {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{

View File

@@ -36,7 +36,7 @@ func SetApiRouter(router *gin.Engine) {
{ {
selfRoute.GET("/self", controller.GetSelf) selfRoute.GET("/self", controller.GetSelf)
selfRoute.PUT("/self", controller.UpdateSelf) selfRoute.PUT("/self", controller.UpdateSelf)
selfRoute.DELETE("/self", controller.DeleteSelf) selfRoute.DELETE("/self", middleware.TurnstileCheck(), controller.DeleteSelf)
selfRoute.GET("/token", controller.GenerateAccessToken) selfRoute.GET("/token", controller.GenerateAccessToken)
selfRoute.GET("/aff", controller.GetAffCode) selfRoute.GET("/aff", controller.GetAffCode)
selfRoute.POST("/topup", controller.TopUp) selfRoute.POST("/topup", controller.TopUp)

View File

@@ -1,36 +1,25 @@
import React, { useContext, useEffect, useState } from 'react'; import React, { useContext, useEffect, useState } from 'react';
import { import { Button, Divider, Form, Grid, Header, Image, Message, Modal, Segment } from 'semantic-ui-react';
Button,
Divider,
Form,
Grid,
Header,
Image,
Message,
Modal,
Segment,
} from 'semantic-ui-react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { UserContext } from '../context/User'; import { UserContext } from '../context/User';
import { API, getLogo, showError, showSuccess, showInfo } from '../helpers'; import { API, getLogo, showError, showSuccess } from '../helpers';
const LoginForm = () => { const LoginForm = () => {
const [inputs, setInputs] = useState({ const [inputs, setInputs] = useState({
username: '', username: '',
password: '', password: '',
wechat_verification_code: '', wechat_verification_code: ''
}); });
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [submitted, setSubmitted] = useState(false); const [submitted, setSubmitted] = useState(false);
const { username, password } = inputs; const { username, password } = inputs;
const [userState, userDispatch] = useContext(UserContext); const [userState, userDispatch] = useContext(UserContext);
let navigate = useNavigate(); let navigate = useNavigate();
const [status, setStatus] = useState({}); const [status, setStatus] = useState({});
const logo = getLogo(); const logo = getLogo();
useEffect(() => { useEffect(() => {
if (searchParams.get("expired")) { if (searchParams.get('expired')) {
showError('未登录或登录已过期,请重新登录!'); showError('未登录或登录已过期,请重新登录!');
} }
let status = localStorage.getItem('status'); let status = localStorage.getItem('status');
@@ -78,7 +67,7 @@ const LoginForm = () => {
if (username && password) { if (username && password) {
const res = await API.post(`/api/user/login`, { const res = await API.post(`/api/user/login`, {
username, username,
password, password
}); });
const { success, message, data } = res.data; const { success, message, data } = res.data;
if (success) { if (success) {
@@ -93,44 +82,44 @@ const LoginForm = () => {
} }
return ( return (
<Grid textAlign="center" style={{ marginTop: '48px' }}> <Grid textAlign='center' style={{ marginTop: '48px' }}>
<Grid.Column style={{ maxWidth: 450 }}> <Grid.Column style={{ maxWidth: 450 }}>
<Header as="h2" color="" textAlign="center"> <Header as='h2' color='' textAlign='center'>
<Image src={logo} /> 用户登录 <Image src={logo} /> 用户登录
</Header> </Header>
<Form size="large"> <Form size='large'>
<Segment> <Segment>
<Form.Input <Form.Input
fluid fluid
icon="user" icon='user'
iconPosition="left" iconPosition='left'
placeholder="用户名" placeholder='用户名'
name="username" name='username'
value={username} value={username}
onChange={handleChange} onChange={handleChange}
/> />
<Form.Input <Form.Input
fluid fluid
icon="lock" icon='lock'
iconPosition="left" iconPosition='left'
placeholder="密码" placeholder='密码'
name="password" name='password'
type="password" type='password'
value={password} value={password}
onChange={handleChange} onChange={handleChange}
/> />
<Button color="" fluid size="large" onClick={handleSubmit}> <Button color='green' fluid size='large' onClick={handleSubmit}>
登录 登录
</Button> </Button>
</Segment> </Segment>
</Form> </Form>
<Message> <Message>
忘记密码 忘记密码
<Link to="/reset" className="btn btn-link"> <Link to='/reset' className='btn btn-link'>
点击重置 点击重置
</Link> </Link>
没有账户 没有账户
<Link to="/register" className="btn btn-link"> <Link to='/register' className='btn btn-link'>
点击注册 点击注册
</Link> </Link>
</Message> </Message>
@@ -140,8 +129,8 @@ const LoginForm = () => {
{status.github_oauth ? ( {status.github_oauth ? (
<Button <Button
circular circular
color="black" color='black'
icon="github" icon='github'
onClick={onGitHubOAuthClicked} onClick={onGitHubOAuthClicked}
/> />
) : ( ) : (
@@ -150,8 +139,8 @@ const LoginForm = () => {
{status.wechat_login ? ( {status.wechat_login ? (
<Button <Button
circular circular
color="green" color='green'
icon="wechat" icon='wechat'
onClick={onWeChatLoginClicked} onClick={onWeChatLoginClicked}
/> />
) : ( ) : (
@@ -175,18 +164,18 @@ const LoginForm = () => {
微信扫码关注公众号输入验证码获取验证码三分钟内有效 微信扫码关注公众号输入验证码获取验证码三分钟内有效
</p> </p>
</div> </div>
<Form size="large"> <Form size='large'>
<Form.Input <Form.Input
fluid fluid
placeholder="验证码" placeholder='验证码'
name="wechat_verification_code" name='wechat_verification_code'
value={inputs.wechat_verification_code} value={inputs.wechat_verification_code}
onChange={handleChange} onChange={handleChange}
/> />
<Button <Button
color="" color=''
fluid fluid
size="large" size='large'
onClick={onSubmitWeChatVerificationCode} onClick={onSubmitWeChatVerificationCode}
> >
登录 登录

View File

@@ -12,6 +12,11 @@ const PasswordResetConfirm = () => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [disableButton, setDisableButton] = useState(false);
const [countdown, setCountdown] = useState(30);
const [newPassword, setNewPassword] = useState('');
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
useEffect(() => { useEffect(() => {
let token = searchParams.get('token'); let token = searchParams.get('token');
@@ -22,7 +27,21 @@ const PasswordResetConfirm = () => {
}); });
}, []); }, []);
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);
}, [disableButton, countdown]);
async function handleSubmit(e) { async function handleSubmit(e) {
setDisableButton(true);
if (!email) return; if (!email) return;
setLoading(true); setLoading(true);
const res = await API.post(`/api/user/reset`, { const res = await API.post(`/api/user/reset`, {
@@ -32,14 +51,15 @@ const PasswordResetConfirm = () => {
const { success, message } = res.data; const { success, message } = res.data;
if (success) { if (success) {
let password = res.data.data; let password = res.data.data;
setNewPassword(password);
await copy(password); await copy(password);
showNotice(`密码已重置并已复制到剪贴板:${password}`); showNotice(`密码已复制到剪贴板:${password}`);
} else { } else {
showError(message); showError(message);
} }
setLoading(false); setLoading(false);
} }
return ( return (
<Grid textAlign='center' style={{ marginTop: '48px' }}> <Grid textAlign='center' style={{ marginTop: '48px' }}>
<Grid.Column style={{ maxWidth: 450 }}> <Grid.Column style={{ maxWidth: 450 }}>
@@ -57,20 +77,37 @@ const PasswordResetConfirm = () => {
value={email} value={email}
readOnly readOnly
/> />
{newPassword && (
<Form.Input
fluid
icon='lock'
iconPosition='left'
placeholder='新密码'
name='newPassword'
value={newPassword}
readOnly
onClick={(e) => {
e.target.select();
navigator.clipboard.writeText(newPassword);
showNotice(`密码已复制到剪贴板:${newPassword}`);
}}
/>
)}
<Button <Button
color='' color='green'
fluid fluid
size='large' size='large'
onClick={handleSubmit} onClick={handleSubmit}
loading={loading} loading={loading}
disabled={disableButton}
> >
提交 {disableButton ? `密码重置完成` : '提交'}
</Button> </Button>
</Segment> </Segment>
</Form> </Form>
</Grid.Column> </Grid.Column>
</Grid> </Grid>
); );
}; };
export default PasswordResetConfirm; export default PasswordResetConfirm;

View File

@@ -5,7 +5,7 @@ import Turnstile from 'react-turnstile';
const PasswordResetForm = () => { const PasswordResetForm = () => {
const [inputs, setInputs] = useState({ const [inputs, setInputs] = useState({
email: '', email: ''
}); });
const { email } = inputs; const { email } = inputs;
@@ -13,24 +13,29 @@ const PasswordResetForm = () => {
const [turnstileEnabled, setTurnstileEnabled] = useState(false); const [turnstileEnabled, setTurnstileEnabled] = useState(false);
const [turnstileSiteKey, setTurnstileSiteKey] = useState(''); const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
const [turnstileToken, setTurnstileToken] = useState(''); const [turnstileToken, setTurnstileToken] = useState('');
const [disableButton, setDisableButton] = useState(false);
const [countdown, setCountdown] = useState(30);
useEffect(() => { useEffect(() => {
let status = localStorage.getItem('status'); let countdownInterval = null;
if (status) { if (disableButton && countdown > 0) {
status = JSON.parse(status); countdownInterval = setInterval(() => {
if (status.turnstile_check) { setCountdown(countdown - 1);
setTurnstileEnabled(true); }, 1000);
setTurnstileSiteKey(status.turnstile_site_key); } else if (countdown === 0) {
} setDisableButton(false);
setCountdown(30);
} }
}, []); return () => clearInterval(countdownInterval);
}, [disableButton, countdown]);
function handleChange(e) { function handleChange(e) {
const { name, value } = e.target; const { name, value } = e.target;
setInputs((inputs) => ({ ...inputs, [name]: value })); setInputs(inputs => ({ ...inputs, [name]: value }));
} }
async function handleSubmit(e) { async function handleSubmit(e) {
setDisableButton(true);
if (!email) return; if (!email) return;
if (turnstileEnabled && turnstileToken === '') { if (turnstileEnabled && turnstileToken === '') {
showInfo('请稍后几秒重试Turnstile 正在检查用户环境!'); showInfo('请稍后几秒重试Turnstile 正在检查用户环境!');
@@ -78,13 +83,14 @@ const PasswordResetForm = () => {
<></> <></>
)} )}
<Button <Button
color='' color='green'
fluid fluid
size='large' size='large'
onClick={handleSubmit} onClick={handleSubmit}
loading={loading} loading={loading}
disabled={disableButton}
> >
提交 {disableButton ? `重试 (${countdown})` : '提交'}
</Button> </Button>
</Segment> </Segment>
</Form> </Form>

View File

@@ -1,22 +1,30 @@
import React, { useEffect, useState } from 'react'; import React, { useContext, useEffect, useState } from 'react';
import { Button, Divider, Form, Header, Image, Message, Modal } from 'semantic-ui-react'; import { Button, Divider, Form, Header, Image, Message, Modal } from 'semantic-ui-react';
import { Link } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { API, copy, showError, showInfo, showNotice, showSuccess } from '../helpers'; import { API, copy, showError, showInfo, showNotice, showSuccess } from '../helpers';
import Turnstile from 'react-turnstile'; import Turnstile from 'react-turnstile';
import { UserContext } from '../context/User';
const PersonalSetting = () => { const PersonalSetting = () => {
const [userState, userDispatch] = useContext(UserContext);
let navigate = useNavigate();
const [inputs, setInputs] = useState({ const [inputs, setInputs] = useState({
wechat_verification_code: '', wechat_verification_code: '',
email_verification_code: '', email_verification_code: '',
email: '', email: '',
self_account_deletion_confirmation: ''
}); });
const [status, setStatus] = useState({}); const [status, setStatus] = useState({});
const [showWeChatBindModal, setShowWeChatBindModal] = useState(false); const [showWeChatBindModal, setShowWeChatBindModal] = useState(false);
const [showEmailBindModal, setShowEmailBindModal] = useState(false); const [showEmailBindModal, setShowEmailBindModal] = useState(false);
const [showAccountDeleteModal, setShowAccountDeleteModal] = useState(false);
const [turnstileEnabled, setTurnstileEnabled] = useState(false); const [turnstileEnabled, setTurnstileEnabled] = useState(false);
const [turnstileSiteKey, setTurnstileSiteKey] = useState(''); const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
const [turnstileToken, setTurnstileToken] = useState(''); const [turnstileToken, setTurnstileToken] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [disableButton, setDisableButton] = useState(false);
const [countdown, setCountdown] = useState(30);
useEffect(() => { useEffect(() => {
let status = localStorage.getItem('status'); let status = localStorage.getItem('status');
@@ -30,6 +38,19 @@ const PersonalSetting = () => {
} }
}, []); }, []);
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 = (e, { name, value }) => { const handleInputChange = (e, { name, value }) => {
setInputs((inputs) => ({ ...inputs, [name]: value })); setInputs((inputs) => ({ ...inputs, [name]: value }));
}; };
@@ -57,6 +78,26 @@ const PersonalSetting = () => {
} }
}; };
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;
if (success) {
showSuccess('账户已删除!');
await API.get('/api/user/logout');
userDispatch({ type: 'logout' });
localStorage.removeItem('user');
navigate('/login');
} else {
showError(message);
}
};
const bindWeChat = async () => { const bindWeChat = async () => {
if (inputs.wechat_verification_code === '') return; if (inputs.wechat_verification_code === '') return;
const res = await API.get( const res = await API.get(
@@ -78,6 +119,7 @@ const PersonalSetting = () => {
}; };
const sendVerificationCode = async () => { const sendVerificationCode = async () => {
setDisableButton(true);
if (inputs.email === '') return; if (inputs.email === '') return;
if (turnstileEnabled && turnstileToken === '') { if (turnstileEnabled && turnstileToken === '') {
showInfo('请稍后几秒重试Turnstile 正在检查用户环境!'); showInfo('请稍后几秒重试Turnstile 正在检查用户环境!');
@@ -123,6 +165,9 @@ const PersonalSetting = () => {
</Button> </Button>
<Button onClick={generateAccessToken}>生成系统访问令牌</Button> <Button onClick={generateAccessToken}>生成系统访问令牌</Button>
<Button onClick={getAffLink}>复制邀请链接</Button> <Button onClick={getAffLink}>复制邀请链接</Button>
<Button onClick={() => {
setShowAccountDeleteModal(true);
}}>删除个人账户</Button>
<Divider /> <Divider />
<Header as='h3'>账号绑定</Header> <Header as='h3'>账号绑定</Header>
{ {
@@ -195,8 +240,8 @@ const PersonalSetting = () => {
name='email' name='email'
type='email' type='email'
action={ action={
<Button onClick={sendVerificationCode} disabled={loading}> <Button onClick={sendVerificationCode} disabled={disableButton || loading}>
获取验证码 {disableButton ? `重新发送(${countdown})` : '获取验证码'}
</Button> </Button>
} }
/> />
@@ -230,6 +275,47 @@ const PersonalSetting = () => {
</Modal.Description> </Modal.Description>
</Modal.Content> </Modal.Content>
</Modal> </Modal>
<Modal
onClose={() => setShowAccountDeleteModal(false)}
onOpen={() => setShowAccountDeleteModal(true)}
open={showAccountDeleteModal}
size={'tiny'}
style={{ maxWidth: '450px' }}
>
<Modal.Header>确认删除自己的帐户</Modal.Header>
<Modal.Content>
<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);
}}
/>
) : (
<></>
)}
<Button
color='red'
fluid
size='large'
onClick={deleteAccount}
loading={loading}
>
删除
</Button>
</Form>
</Modal.Description>
</Modal.Content>
</Modal>
</div> </div>
); );
}; };

View File

@@ -1,13 +1,5 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { import { Button, Form, Grid, Header, Image, Message, Segment } from 'semantic-ui-react';
Button,
Form,
Grid,
Header,
Image,
Message,
Segment,
} from 'semantic-ui-react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { API, getLogo, showError, showInfo, showSuccess } from '../helpers'; import { API, getLogo, showError, showInfo, showSuccess } from '../helpers';
import Turnstile from 'react-turnstile'; import Turnstile from 'react-turnstile';
@@ -18,7 +10,7 @@ const RegisterForm = () => {
password: '', password: '',
password2: '', password2: '',
email: '', email: '',
verification_code: '', verification_code: ''
}); });
const { username, password, password2 } = inputs; const { username, password, password2 } = inputs;
const [showEmailVerification, setShowEmailVerification] = useState(false); const [showEmailVerification, setShowEmailVerification] = useState(false);
@@ -178,7 +170,7 @@ const RegisterForm = () => {
<></> <></>
)} )}
<Button <Button
color='' color='green'
fluid fluid
size='large' size='large'
onClick={handleSubmit} onClick={handleSubmit}

View File

@@ -46,9 +46,7 @@ const About = () => {
about.startsWith('https://') ? <iframe about.startsWith('https://') ? <iframe
src={about} src={about}
style={{ width: '100%', height: '100vh', border: 'none' }} style={{ width: '100%', height: '100vh', border: 'none' }}
/> : <Segment> /> : <div style={{ fontSize: 'larger' }} dangerouslySetInnerHTML={{ __html: about }}></div>
<div style={{ fontSize: 'larger' }} dangerouslySetInnerHTML={{ __html: about }}></div>
</Segment>
} }
</> </>
} }

View File

@@ -215,26 +215,12 @@ const EditChannel = () => {
</Form.Field> </Form.Field>
) )
} }
{
inputs.type !== 3 && inputs.type !== 8 && (
<Form.Field>
<Form.Input
label='镜像'
name='base_url'
placeholder={'此项可选输入镜像站地址格式为https://domain.com'}
onChange={handleInputChange}
value={inputs.base_url}
autoComplete='new-password'
/>
</Form.Field>
)
}
<Form.Field> <Form.Field>
<Form.Input <Form.Input
label='名称' label='名称'
required required
name='name' name='name'
placeholder={'请输入名称'} placeholder={'请为渠道命名'}
onChange={handleInputChange} onChange={handleInputChange}
value={inputs.name} value={inputs.name}
autoComplete='new-password' autoComplete='new-password'
@@ -243,7 +229,7 @@ const EditChannel = () => {
<Form.Field> <Form.Field>
<Form.Dropdown <Form.Dropdown
label='分组' label='分组'
placeholder={'请选择分组'} placeholder={'请选择可以使用该渠道的分组'}
name='groups' name='groups'
required required
fluid fluid
@@ -260,7 +246,7 @@ const EditChannel = () => {
<Form.Field> <Form.Field>
<Form.Dropdown <Form.Dropdown
label='模型' label='模型'
placeholder={'请选择该道所支持的模型'} placeholder={'请选择该道所支持的模型'}
name='models' name='models'
required required
fluid fluid
@@ -312,7 +298,7 @@ const EditChannel = () => {
<Form.Field> <Form.Field>
<Form.TextArea <Form.TextArea
label='模型映射' label='模型映射'
placeholder={`此项可选,为一个 JSON 文本,键为用户请求模型名称,值为要替换的模型名称,例如:\n${JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2)}`} placeholder={`此项可选,用于修改请求体中的模型名称,为一个 JSON 字符串,键为请求模型名称,值为要替换的模型名称,例如:\n${JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2)}`}
name='model_mapping' name='model_mapping'
onChange={handleInputChange} onChange={handleInputChange}
value={inputs.model_mapping} value={inputs.model_mapping}
@@ -337,7 +323,7 @@ const EditChannel = () => {
label='密钥' label='密钥'
name='key' name='key'
required required
placeholder={inputs.type === 15 ? "请输入 access token当前版本暂不支持自动刷新请每 30 天更新一次" : '请输入密钥'} placeholder={inputs.type === 15 ? "请输入 access token当前版本暂不支持自动刷新请每 30 天更新一次" : '请输入渠道对应的鉴权密钥'}
onChange={handleInputChange} onChange={handleInputChange}
value={inputs.key} value={inputs.key}
autoComplete='new-password' autoComplete='new-password'
@@ -354,6 +340,20 @@ const EditChannel = () => {
/> />
) )
} }
{
inputs.type !== 3 && inputs.type !== 8 && (
<Form.Field>
<Form.Input
label='镜像'
name='base_url'
placeholder={'此项可选,用于通过镜像站来进行 API 调用请输入镜像站地址格式为https://domain.com'}
onChange={handleInputChange}
value={inputs.base_url}
autoComplete='new-password'
/>
</Form.Field>
)
}
<Button type={isEdit ? "button" : "submit"} positive onClick={submit}>提交</Button> <Button type={isEdit ? "button" : "submit"} positive onClick={submit}>提交</Button>
</Form> </Form>
</Segment> </Segment>

View File

@@ -7,24 +7,32 @@ const TopUp = () => {
const [redemptionCode, setRedemptionCode] = useState(''); const [redemptionCode, setRedemptionCode] = useState('');
const [topUpLink, setTopUpLink] = useState(''); const [topUpLink, setTopUpLink] = useState('');
const [userQuota, setUserQuota] = useState(0); const [userQuota, setUserQuota] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false);
const topUp = async () => { const topUp = async () => {
if (redemptionCode === '') { if (redemptionCode === '') {
showInfo('请输入充值码!') showInfo('请输入充值码!')
return; return;
} }
const res = await API.post('/api/user/topup', { setIsSubmitting(true);
key: redemptionCode try {
}); const res = await API.post('/api/user/topup', {
const { success, message, data } = res.data; key: redemptionCode
if (success) {
showSuccess('充值成功!');
setUserQuota((quota) => {
return quota + data;
}); });
setRedemptionCode(''); const { success, message, data } = res.data;
} else { if (success) {
showError(message); showSuccess('充值成功!');
setUserQuota((quota) => {
return quota + data;
});
setRedemptionCode('');
} else {
showError(message);
}
} catch (err) {
showError('请求失败');
} finally {
setIsSubmitting(false);
} }
}; };
@@ -74,8 +82,8 @@ const TopUp = () => {
<Button color='green' onClick={openTopUpLink}> <Button color='green' onClick={openTopUpLink}>
获取兑换码 获取兑换码
</Button> </Button>
<Button color='yellow' onClick={topUp}> <Button color='yellow' onClick={topUp} disabled={isSubmitting}>
充值 {isSubmitting ? '兑换中...' : '兑换'}
</Button> </Button>
</Form> </Form>
</Grid.Column> </Grid.Column>
@@ -92,5 +100,4 @@ const TopUp = () => {
); );
}; };
export default TopUp;
export default TopUp;