Compare commits

...

18 Commits

Author SHA1 Message Date
JustSong
443a22b75d feat: able to test channels now (#59) 2023-05-15 10:48:52 +08:00
JustSong
b44f0519a0 feat: double check before deletion 2023-05-15 10:41:48 +08:00
JustSong
4a0e81fe83 fix: fix quota not consumed 2023-05-14 20:36:28 +08:00
JustSong
976c29ea9f docs: update README 2023-05-14 19:37:15 +08:00
JustSong
926951ee03 feat: able to customize system name & logo now 2023-05-14 19:29:02 +08:00
JustSong
2cdc718fde feat: able to use any link as about page (#60) 2023-05-14 18:58:54 +08:00
JustSong
57cb150177 perf: load cached about content first (#60) 2023-05-14 16:13:42 +08:00
JustSong
6167e20b34 style: hide scroll bar 2023-05-14 16:02:40 +08:00
JustSong
8835d8302e chore: fix typo 2023-05-14 16:01:04 +08:00
JustSong
224bebe67a feat: able to customize home page with link (close #60) 2023-05-14 15:34:14 +08:00
JustSong
cf6883778e perf: use slice to improve efficiency (#57) 2023-05-14 12:53:03 +08:00
JustSong
246b981e23 fix: fix "[DONE is not valid JSON" (#57) 2023-05-14 12:48:42 +08:00
JustSong
2edd52e851 fix: fix Azure channel not working in stream mode (#57) 2023-05-14 09:39:42 +08:00
JustSong
e123c66bc7 fix: fix SMTPFrom not updated in some cases (close #34) 2023-05-13 22:04:36 +08:00
JustSong
9edc82bde0 fix: fix garbled email subject (#34) 2023-05-13 21:41:52 +08:00
JustSong
d84c2f5c70 feat: able to customize home page now (#24) 2023-05-13 21:27:49 +08:00
JustSong
46e77389a4 fix: support smtp server with port 465 2023-05-13 18:57:27 +08:00
JustSong
f5f4e6fbc6 feat: able to configure smtp from now (close #34) 2023-05-13 18:33:41 +08:00
22 changed files with 501 additions and 144 deletions

View File

@@ -43,27 +43,28 @@ _✨ All in one 的 OpenAI 接口,整合各种 API 访问方式,开箱即用
## 功能
1. 支持多种 API 访问渠道,欢迎 PR 或提 issue 添加更多渠道:
+ [x] OpenAI 官方通道
+ [x] **Azure OpenAI API**
+ [x] [API2D](https://api2d.com/r/197971)
+ [x] Azure OpenAI API
+ [x] [CloseAI](https://console.openai-asia.com)
+ [x] [OpenAI-SB](https://openai-sb.com)
+ [x] [OpenAI Max](https://openaimax.com)
+ [x] [OhMyGPT](https://www.ohmygpt.com)
+ [x] 自定义渠道:例如使用自行搭建的 OpenAI 代理
2. 支持通过负载均衡的方式访问多个渠道。
3. 支持单个访问渠道设置多个 API Key利用起来你的多个 API Key
4. 支持 HTTP SSE可以通过流式传输实现打字机效果
5. 支持设置令牌的过期时间和使用次数
6. 支持批量生成和导出兑换码,可使用兑换码为令牌进行充值
7. 支持为新用户设置初始配额
8. 支持发布公告,在线修改关于页面,设置充值链接,自定义页脚。
2. 支持通过**负载均衡**的方式访问多个渠道。
3. 支持 **stream 模式**,可以通过流式传输实现打字机效果
4. 支持**令牌管理**,设置令牌的过期时间和使用次数
5. 支持**兑换码管理**,支持批量生成和导出兑换码,可使用兑换码为令牌进行充值
6. 支持**通道管理**,批量创建通道
7. 支持发布公告,设置充值链接,设置新用户初始额度
8. 支持丰富的**自定义**设置,
1. 支持自定义系统名称logo 以及页脚。
2. 支持自定义首页和关于页面,可以选择使用 HTML & Markdown 代码进行自定义,或者使用一个单独的网页通过 iframe 嵌入。
9. 支持通过系统访问令牌访问管理 API。
10. 多种用户登录注册方式:
10. 支持用户管理,支持**多种用户登录注册方式**
+ 邮箱登录注册以及通过邮箱进行密码重置。
+ [GitHub 开放授权](https://github.com/settings/applications/new)。
+ 微信公众号授权(需要额外部署 [WeChat Server](https://github.com/songquanpeng/wechat-server))。
11. 支持用户管理
12. 未来其他大模型开放 API 后,将第一时间支持,并将其封装成同样的 API 访问方式。
11. 未来其他大模型开放 API 后,将第一时间支持,并将其封装成同样的 API 访问方式
## 部署
### 基于 Docker 进行部署

View File

@@ -11,6 +11,7 @@ var Version = "v0.0.0" // this hard coding will be replaced automatic
var SystemName = "One API"
var ServerAddress = "http://localhost:3000"
var Footer = ""
var Logo = ""
var TopUpLink = ""
var UsingSQLite = false
@@ -36,6 +37,7 @@ var RegisterEnabled = true
var SMTPServer = ""
var SMTPPort = 587
var SMTPAccount = ""
var SMTPFrom = ""
var SMTPToken = ""
var GitHubClientId = ""

View File

@@ -1,20 +1,67 @@
package common
import (
"crypto/tls"
"encoding/base64"
"fmt"
"net/smtp"
"strings"
)
func SendEmail(subject string, receiver string, content string) error {
if SMTPFrom == "" { // for compatibility
SMTPFrom = SMTPAccount
}
encodedSubject := fmt.Sprintf("=?UTF-8?B?%s?=", base64.StdEncoding.EncodeToString([]byte(subject)))
mail := []byte(fmt.Sprintf("To: %s\r\n"+
"From: %s<%s>\r\n"+
"Subject: %s\r\n"+
"Content-Type: text/html; charset=UTF-8\r\n\r\n%s\r\n",
receiver, SystemName, SMTPAccount, subject, content))
receiver, SystemName, SMTPFrom, encodedSubject, content))
auth := smtp.PlainAuth("", SMTPAccount, SMTPToken, SMTPServer)
addr := fmt.Sprintf("%s:%d", SMTPServer, SMTPPort)
to := strings.Split(receiver, ";")
err := smtp.SendMail(addr, auth, SMTPAccount, to, mail)
var err error
if SMTPPort == 465 {
tlsConfig := &tls.Config{
InsecureSkipVerify: true,
ServerName: SMTPServer,
}
conn, err := tls.Dial("tcp", fmt.Sprintf("%s:%d", SMTPServer, SMTPPort), tlsConfig)
if err != nil {
return err
}
client, err := smtp.NewClient(conn, SMTPServer)
if err != nil {
return err
}
defer client.Close()
if err = client.Auth(auth); err != nil {
return err
}
if err = client.Mail(SMTPFrom); err != nil {
return err
}
receiverEmails := strings.Split(receiver, ";")
for _, receiver := range receiverEmails {
if err = client.Rcpt(receiver); err != nil {
return err
}
}
w, err := client.Data()
if err != nil {
return err
}
_, err = w.Write(mail)
if err != nil {
return err
}
err = w.Close()
if err != nil {
return err
}
} else {
err = smtp.SendMail(addr, auth, SMTPAccount, to, mail)
}
return err
}

View File

@@ -1,12 +1,17 @@
package controller
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"github.com/gin-gonic/gin"
"net/http"
"one-api/common"
"one-api/model"
"strconv"
"strings"
"time"
)
func GetAllChannels(c *gin.Context) {
@@ -153,3 +158,97 @@ func UpdateChannel(c *gin.Context) {
})
return
}
func testChannel(channel *model.Channel, request *ChatRequest) error {
if request.Model == "" {
request.Model = "gpt-3.5-turbo"
if channel.Type == common.ChannelTypeAzure {
request.Model = "gpt-35-turbo"
}
}
requestURL := common.ChannelBaseURLs[channel.Type]
if channel.Type == common.ChannelTypeAzure {
requestURL = fmt.Sprintf("%s/openai/deployments/%s/chat/completions?api-version=2023-03-15-preview", channel.BaseURL, request.Model)
} else {
if channel.Type == common.ChannelTypeCustom {
requestURL = channel.BaseURL
}
requestURL += "/v1/chat/completions"
}
jsonData, err := json.Marshal(request)
if err != nil {
return err
}
req, err := http.NewRequest("POST", requestURL, bytes.NewBuffer(jsonData))
if err != nil {
return err
}
if channel.Type == common.ChannelTypeAzure {
req.Header.Set("api-key", channel.Key)
} else {
req.Header.Set("Authorization", "Bearer "+channel.Key)
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
var response TextResponse
err = json.NewDecoder(resp.Body).Decode(&response)
if err != nil {
return err
}
if response.Error.Type != "" {
return errors.New(fmt.Sprintf("type %s, code %s, message %s", response.Error.Type, response.Error.Code, response.Error.Message))
}
return nil
}
func TestChannel(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
channel, err := model.GetChannelById(id, true)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
model_ := c.Query("model")
chatRequest := &ChatRequest{
Model: model_,
}
testMessage := Message{
Role: "user",
Content: "echo hi",
}
chatRequest.Messages = append(chatRequest.Messages, testMessage)
tik := time.Now()
err = testChannel(channel, chatRequest)
tok := time.Now()
consumedTime := float64(tok.Sub(tik).Milliseconds()) / 1000.0
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
"time": consumedTime,
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"time": consumedTime,
})
return
}

View File

@@ -20,6 +20,7 @@ func GetStatus(c *gin.Context) {
"github_oauth": common.GitHubOAuthEnabled,
"github_client_id": common.GitHubClientId,
"system_name": common.SystemName,
"logo": common.Logo,
"footer_html": common.Footer,
"wechat_qrcode": common.WeChatAccountQRCodeImageURL,
"wechat_login": common.WeChatAuthEnabled,
@@ -54,6 +55,17 @@ func GetAbout(c *gin.Context) {
return
}
func GetHomePageContent(c *gin.Context) {
common.OptionMapRWMutex.RLock()
defer common.OptionMapRWMutex.RUnlock()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": common.OptionMap["HomePageContent"],
})
return
}
func SendEmailVerification(c *gin.Context) {
email := c.Query("email")
if err := common.Validate.Var(email, "required,email"); err != nil {

View File

@@ -19,6 +19,11 @@ type Message struct {
Content string `json:"content"`
}
type ChatRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
}
type TextRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
@@ -32,8 +37,16 @@ type Usage struct {
TotalTokens int `json:"total_tokens"`
}
type OpenAIError struct {
Message string `json:"message"`
Type string `json:"type"`
Param string `json:"param"`
Code string `json:"code"`
}
type TextResponse struct {
Usage `json:"usage"`
Error OpenAIError `json:"error"`
}
type StreamResponse struct {
@@ -94,10 +107,12 @@ func relayHelper(c *gin.Context) error {
if channelType == common.ChannelTypeAzure {
// https://learn.microsoft.com/en-us/azure/cognitive-services/openai/chatgpt-quickstart?pivots=rest-api&tabs=command-line#rest-api
query := c.Request.URL.Query()
if query.Get("api-version") == "" {
apiVersion := c.GetString("api_version")
requestURL = fmt.Sprintf("%s?api-version=%s", requestURL, apiVersion)
apiVersion := query.Get("api-version")
if apiVersion == "" {
apiVersion = c.GetString("api_version")
}
requestURL := strings.Split(requestURL, "?")[0]
requestURL = fmt.Sprintf("%s?api-version=%s", requestURL, apiVersion)
baseURL = c.GetString("base_url")
task := strings.TrimPrefix(requestURL, "/v1/")
model_ := textRequest.Model
@@ -186,7 +201,7 @@ func relayHelper(c *gin.Context) error {
data := scanner.Text()
dataChan <- data
data = data[6:]
if data != "[DONE]" {
if !strings.HasPrefix(data, "[DONE]") {
var streamResponse StreamResponse
err = json.Unmarshal([]byte(data), &streamResponse)
if err != nil {
@@ -207,6 +222,9 @@ func relayHelper(c *gin.Context) error {
c.Stream(func(w io.Writer) bool {
select {
case data := <-dataChan:
if strings.HasPrefix(data, "data: [DONE]") {
data = data[:12]
}
c.Render(-1, common.CustomEvent{Data: data})
return true
case <-stopChan:

View File

@@ -111,14 +111,9 @@ func TokenAuth() func(c *gin.Context) {
c.Set("id", token.UserId)
c.Set("token_id", token.Id)
requestURL := c.Request.URL.String()
consumeQuota := false
switch requestURL {
case "/v1/chat/completions":
consumeQuota = !token.UnlimitedQuota
case "/v1/completions":
consumeQuota = !token.UnlimitedQuota
case "/v1/edits":
consumeQuota = !token.UnlimitedQuota
consumeQuota := !token.UnlimitedQuota
if strings.HasPrefix(requestURL, "/models") {
consumeQuota = false
}
c.Set("consume_quota", consumeQuota)
if len(parts) > 1 {

View File

@@ -33,12 +33,16 @@ func InitOptionMap() {
common.OptionMap["TurnstileCheckEnabled"] = strconv.FormatBool(common.TurnstileCheckEnabled)
common.OptionMap["RegisterEnabled"] = strconv.FormatBool(common.RegisterEnabled)
common.OptionMap["SMTPServer"] = ""
common.OptionMap["SMTPFrom"] = ""
common.OptionMap["SMTPPort"] = strconv.Itoa(common.SMTPPort)
common.OptionMap["SMTPAccount"] = ""
common.OptionMap["SMTPToken"] = ""
common.OptionMap["Notice"] = ""
common.OptionMap["About"] = ""
common.OptionMap["HomePageContent"] = ""
common.OptionMap["Footer"] = common.Footer
common.OptionMap["SystemName"] = common.SystemName
common.OptionMap["Logo"] = common.Logo
common.OptionMap["ServerAddress"] = ""
common.OptionMap["GitHubClientId"] = ""
common.OptionMap["GitHubClientSecret"] = ""
@@ -120,6 +124,8 @@ func updateOptionMap(key string, value string) (err error) {
common.SMTPPort = intValue
case "SMTPAccount":
common.SMTPAccount = value
case "SMTPFrom":
common.SMTPFrom = value
case "SMTPToken":
common.SMTPToken = value
case "ServerAddress":
@@ -130,6 +136,10 @@ func updateOptionMap(key string, value string) (err error) {
common.GitHubClientSecret = value
case "Footer":
common.Footer = value
case "SystemName":
common.SystemName = value
case "Logo":
common.Logo = value
case "WeChatServerAddress":
common.WeChatServerAddress = value
case "WeChatServerToken":

View File

@@ -15,6 +15,7 @@ func SetApiRouter(router *gin.Engine) {
apiRouter.GET("/status", controller.GetStatus)
apiRouter.GET("/notice", controller.GetNotice)
apiRouter.GET("/about", controller.GetAbout)
apiRouter.GET("/home_page_content", controller.GetHomePageContent)
apiRouter.GET("/verification", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendEmailVerification)
apiRouter.GET("/reset_password", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendPasswordResetEmail)
apiRouter.POST("/user/reset", middleware.CriticalRateLimit(), controller.ResetPassword)
@@ -62,6 +63,7 @@ func SetApiRouter(router *gin.Engine) {
channelRoute.GET("/", controller.GetAllChannels)
channelRoute.GET("/search", controller.SearchChannels)
channelRoute.GET("/:id", controller.GetChannel)
channelRoute.GET("/test/:id", controller.TestChannel)
channelRoute.POST("/", controller.AddChannel)
channelRoute.PUT("/", controller.UpdateChannel)
channelRoute.DELETE("/:id", controller.DeleteChannel)

View File

@@ -42,6 +42,8 @@ function App() {
if (success) {
localStorage.setItem('status', JSON.stringify(data));
statusDispatch({ type: 'set', payload: data });
localStorage.setItem('system_name', data.system_name);
localStorage.setItem('logo', data.logo);
localStorage.setItem('footer_html', data.footer_html);
if (
data.version !== process.env.REACT_APP_VERSION &&

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react';
import { Button, Form, Label, Pagination, Table } from 'semantic-ui-react';
import { Button, Form, Label, Pagination, Popup, Table } from 'semantic-ui-react';
import { Link } from 'react-router-dom';
import { API, copy, showError, showSuccess, timestamp2string } from '../helpers';
import { API, copy, showError, showInfo, showSuccess, timestamp2string } from '../helpers';
import { CHANNEL_OPTIONS, ITEMS_PER_PAGE } from '../constants';
@@ -139,6 +139,16 @@ const ChannelsTable = () => {
setSearching(false);
};
const testChannel = async (id, name) => {
const res = await API.get(`/api/channel/test/${id}/`);
const { success, message, time } = res.data;
if (success) {
showInfo(`通道 ${name} 测试成功,耗时 ${time} 秒。`);
} else {
showError(message);
}
}
const handleKeywordChange = async (e, { value }) => {
setSearchKeyword(value.trim());
};
@@ -246,13 +256,32 @@ const ChannelsTable = () => {
<div>
<Button
size={'small'}
negative
positive
onClick={() => {
manageChannel(channel.id, 'delete', idx);
testChannel(channel.id, channel.name);
}}
>
删除
测试
</Button>
<Popup
trigger={
<Button size='small' negative>
删除
</Button>
}
on='click'
flowing
hoverable
>
<Button
negative
onClick={() => {
manageChannel(channel.id, 'delete', idx);
}}
>
删除渠道 {channel.name}
</Button>
</Popup>
<Button
size={'small'}
onClick={() => {

View File

@@ -1,40 +1,37 @@
import React, { useEffect, useState } from 'react';
import React from 'react';
import { Container, Segment } from 'semantic-ui-react';
import { getFooterHTML, getSystemName } from '../helpers';
const Footer = () => {
const [Footer, setFooter] = useState('');
useEffect(() => {
let savedFooter = localStorage.getItem('footer_html');
if (!savedFooter) savedFooter = '';
setFooter(savedFooter);
});
const systemName = getSystemName();
const footer = getFooterHTML();
return (
<Segment vertical>
<Container textAlign="center">
{Footer === '' ? (
<div className="custom-footer">
<Container textAlign='center'>
{footer ? (
<div
className='custom-footer'
dangerouslySetInnerHTML={{ __html: footer }}
></div>
) : (
<div className='custom-footer'>
<a
href="https://github.com/songquanpeng/one-api"
target="_blank"
href='https://github.com/songquanpeng/one-api'
target='_blank'
>
One API {process.env.REACT_APP_VERSION}{' '}
{systemName} {process.env.REACT_APP_VERSION}{' '}
</a>
{' '}
<a href="https://github.com/songquanpeng" target="_blank">
<a href='https://github.com/songquanpeng' target='_blank'>
JustSong
</a>{' '}
构建源代码遵循{' '}
<a href="https://opensource.org/licenses/mit-license.php">
<a href='https://opensource.org/licenses/mit-license.php'>
MIT 协议
</a>
</div>
) : (
<div
className="custom-footer"
dangerouslySetInnerHTML={{ __html: Footer }}
></div>
)}
</Container>
</Segment>

View File

@@ -3,7 +3,7 @@ import { Link, useNavigate } from 'react-router-dom';
import { UserContext } from '../context/User';
import { Button, Container, Dropdown, Icon, Menu, Segment } from 'semantic-ui-react';
import { API, isAdmin, isMobile, showSuccess } from '../helpers';
import { API, getLogo, getSystemName, isAdmin, isMobile, showSuccess } from '../helpers';
import '../index.css';
// Header Buttons
@@ -53,6 +53,8 @@ const Header = () => {
let navigate = useNavigate();
const [showSidebar, setShowSidebar] = useState(false);
const systemName = getSystemName();
const logo = getLogo();
async function logout() {
setShowSidebar(false);
@@ -111,12 +113,12 @@ const Header = () => {
<Container>
<Menu.Item as={Link} to='/'>
<img
src='/logo.png'
src={logo}
alt='logo'
style={{ marginRight: '0.75em' }}
/>
<div style={{ fontSize: '20px' }}>
<b>One API</b>
<b>{systemName}</b>
</div>
</Menu.Item>
<Menu.Menu position='right'>
@@ -168,9 +170,9 @@ const Header = () => {
<Menu borderless style={{ borderTop: 'none' }}>
<Container>
<Menu.Item as={Link} to='/' className={'hide-on-mobile'}>
<img src='/logo.png' alt='logo' style={{ marginRight: '0.75em' }} />
<img src={logo} alt='logo' style={{ marginRight: '0.75em' }} />
<div style={{ fontSize: '20px' }}>
<b>One API</b>
<b>{systemName}</b>
</div>
</Menu.Item>
{renderButtons(false)}

View File

@@ -12,7 +12,7 @@ import {
} from 'semantic-ui-react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { UserContext } from '../context/User';
import { API, showError, showSuccess } from '../helpers';
import { API, getLogo, showError, showSuccess } from '../helpers';
const LoginForm = () => {
const [inputs, setInputs] = useState({
@@ -27,6 +27,7 @@ const LoginForm = () => {
let navigate = useNavigate();
const [status, setStatus] = useState({});
const logo = getLogo();
useEffect(() => {
if (searchParams.get("expired")) {
@@ -95,7 +96,7 @@ const LoginForm = () => {
<Grid textAlign="center" style={{ marginTop: '48px' }}>
<Grid.Column style={{ maxWidth: 450 }}>
<Header as="h2" color="" textAlign="center">
<Image src="/logo.png" /> 用户登录
<Image src={logo} /> 用户登录
</Header>
<Form size="large">
<Segment>

View File

@@ -8,6 +8,9 @@ const OtherSetting = () => {
Footer: '',
Notice: '',
About: '',
SystemName: '',
Logo: '',
HomePageContent: '',
});
let originInputs = {};
let [loading, setLoading] = useState(false);
@@ -65,10 +68,22 @@ const OtherSetting = () => {
await updateOption('Footer', inputs.Footer);
};
const submitSystemName = async () => {
await updateOption('SystemName', inputs.SystemName);
};
const submitLogo = async () => {
await updateOption('Logo', inputs.Logo);
};
const submitAbout = async () => {
await updateOption('About', inputs.About);
};
const submitOption = async (key) => {
await updateOption(key, inputs[key]);
};
const openGitHubRelease = () => {
window.location =
'https://github.com/songquanpeng/one-api/releases/latest';
@@ -109,10 +124,42 @@ const OtherSetting = () => {
<Form.Button onClick={submitNotice}>保存公告</Form.Button>
<Divider />
<Header as='h3'>个性化设置</Header>
<Form.Group widths='equal'>
<Form.Input
label='系统名称'
placeholder='在此输入系统名称'
value={inputs.SystemName}
name='SystemName'
onChange={handleInputChange}
/>
</Form.Group>
<Form.Button onClick={submitSystemName}>设置系统名称</Form.Button>
<Form.Group widths='equal'>
<Form.Input
label='Logo 图片地址'
placeholder='在此输入 Logo 图片地址'
value={inputs.Logo}
name='Logo'
type='url'
onChange={handleInputChange}
/>
</Form.Group>
<Form.Button onClick={submitLogo}>设置 Logo</Form.Button>
<Form.Group widths='equal'>
<Form.TextArea
label='首页内容'
placeholder='在此输入首页内容,支持 Markdown & HTML 代码,设置后首页的状态信息将不再显示。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页。'
value={inputs.HomePageContent}
name='HomePageContent'
onChange={handleInputChange}
style={{ minHeight: 150, fontFamily: 'JetBrains Mono, Consolas' }}
/>
</Form.Group>
<Form.Button onClick={()=>submitOption('HomePageContent')}>保存首页内容</Form.Button>
<Form.Group widths='equal'>
<Form.TextArea
label='关于'
placeholder='在此输入新的关于内容,支持 Markdown & HTML 代码'
placeholder='在此输入新的关于内容,支持 Markdown & HTML 代码。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为关于页面。'
value={inputs.About}
name='About'
onChange={handleInputChange}

View File

@@ -9,7 +9,7 @@ import {
Segment,
} from 'semantic-ui-react';
import { Link, useNavigate } from 'react-router-dom';
import { API, showError, showInfo, showSuccess } from '../helpers';
import { API, getLogo, showError, showInfo, showSuccess } from '../helpers';
import Turnstile from 'react-turnstile';
const RegisterForm = () => {
@@ -26,6 +26,7 @@ const RegisterForm = () => {
const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
const [turnstileToken, setTurnstileToken] = useState('');
const [loading, setLoading] = useState(false);
const logo = getLogo();
useEffect(() => {
let status = localStorage.getItem('status');
@@ -100,7 +101,7 @@ const RegisterForm = () => {
<Grid textAlign='center' style={{ marginTop: '48px' }}>
<Grid.Column style={{ maxWidth: 450 }}>
<Header as='h2' color='' textAlign='center'>
<Image src='/logo.png' /> 新用户注册
<Image src={logo} /> 新用户注册
</Header>
<Form size='large'>
<Segment>

View File

@@ -14,6 +14,7 @@ const SystemSetting = () => {
SMTPServer: '',
SMTPPort: '',
SMTPAccount: '',
SMTPFrom: '',
SMTPToken: '',
ServerAddress: '',
Footer: '',
@@ -129,6 +130,9 @@ const SystemSetting = () => {
if (originInputs['SMTPAccount'] !== inputs.SMTPAccount) {
await updateOption('SMTPAccount', inputs.SMTPAccount);
}
if (originInputs['SMTPFrom'] !== inputs.SMTPFrom) {
await updateOption('SMTPFrom', inputs.SMTPFrom);
}
if (
originInputs['SMTPPort'] !== inputs.SMTPPort &&
inputs.SMTPPort !== ''
@@ -298,7 +302,7 @@ const SystemSetting = () => {
配置 SMTP
<Header.Subheader>用以支持系统的邮件发送</Header.Subheader>
</Header>
<Form.Group widths={4}>
<Form.Group widths={3}>
<Form.Input
label='SMTP 服务器地址'
name='SMTPServer'
@@ -323,6 +327,16 @@ const SystemSetting = () => {
value={inputs.SMTPAccount}
placeholder='通常是邮箱地址'
/>
</Form.Group>
<Form.Group widths={3}>
<Form.Input
label='SMTP 发送者邮箱'
name='SMTPFrom'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.SMTPFrom}
placeholder='通常和邮箱地址保持一致'
/>
<Form.Input
label='SMTP 访问凭证'
name='SMTPToken'

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { Button, Form, Label, Modal, Pagination, Table } from 'semantic-ui-react';
import { Button, Form, Label, Modal, Pagination, Popup, Table } from 'semantic-ui-react';
import { Link } from 'react-router-dom';
import { API, copy, showError, showSuccess, showWarning, timestamp2string } from '../helpers';
@@ -283,15 +283,25 @@ const TokensTable = () => {
}}>
充值
</Button>
<Button
size={'small'}
negative
onClick={() => {
manageToken(token.id, 'delete', idx);
}}
<Popup
trigger={
<Button size='small' negative>
删除
</Button>
}
on='click'
flowing
hoverable
>
删除
</Button>
<Button
negative
onClick={() => {
manageToken(token.id, 'delete', idx);
}}
>
删除令牌 {token.name}
</Button>
</Popup>
<Button
size={'small'}
onClick={() => {

View File

@@ -15,6 +15,22 @@ export function isRoot() {
return user.role >= 100;
}
export function getSystemName() {
let system_name = localStorage.getItem('system_name');
if (!system_name) return 'One API';
return system_name;
}
export function getLogo() {
let logo = localStorage.getItem('logo');
if (!logo) return '/logo.png';
return logo
}
export function getFooterHTML() {
return localStorage.getItem('footer_html');
}
export async function copy(text) {
let okay = true;
try {

View File

@@ -5,6 +5,11 @@ body {
font-family: Lato, 'Helvetica Neue', Arial, Helvetica, "Microsoft YaHei", sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
scrollbar-width: none;
}
body::-webkit-scrollbar {
display: none;
}
code {

View File

@@ -5,18 +5,24 @@ import { marked } from 'marked';
const About = () => {
const [about, setAbout] = useState('');
const [aboutLoaded, setAboutLoaded] = useState(false);
const displayAbout = async () => {
setAbout(localStorage.getItem('about') || '');
const res = await API.get('/api/about');
const { success, message, data } = res.data;
if (success) {
let HTMLAbout = marked.parse(data);
localStorage.setItem('about', HTMLAbout);
setAbout(HTMLAbout);
let aboutContent = data;
if (!data.startsWith('https://')) {
aboutContent = marked.parse(data);
}
setAbout(aboutContent);
localStorage.setItem('about', aboutContent);
} else {
showError(message);
setAbout('加载关于内容失败...');
}
setAboutLoaded(true);
};
useEffect(() => {
@@ -25,20 +31,27 @@ const About = () => {
return (
<>
<Segment>
{
about === '' ? <>
{
aboutLoaded && about === '' ? <>
<Segment>
<Header as='h3'>关于</Header>
<p>可在设置页面设置关于内容支持 HTML & Markdown</p>
项目仓库地址
<a href="https://github.com/songquanpeng/one-api">
<a href='https://github.com/songquanpeng/one-api'>
https://github.com/songquanpeng/one-api
</a>
</> : <>
<div dangerouslySetInnerHTML={{ __html: about}}></div>
</>
}
</Segment>
</Segment>
</> : <>
{
about.startsWith('https://') ? <iframe
src={about}
style={{ width: '100%', height: '100vh', border: 'none' }}
/> : <Segment>
<div style={{ fontSize: 'larger' }} dangerouslySetInnerHTML={{ __html: about }}></div>
</Segment>
}
</>
}
</>
);
};

View File

@@ -1,10 +1,13 @@
import React, { useContext, useEffect } from 'react';
import React, { useContext, useEffect, useState } from 'react';
import { Card, Grid, Header, Segment } from 'semantic-ui-react';
import { API, showError, showNotice, timestamp2string } from '../../helpers';
import { StatusContext } from '../../context/Status';
import { marked } from 'marked';
const Home = () => {
const [statusState, statusDispatch] = useContext(StatusContext);
const [homePageContentLoaded, setHomePageContentLoaded] = useState(false);
const [homePageContent, setHomePageContent] = useState('');
const displayNotice = async () => {
const res = await API.get('/api/notice');
@@ -20,6 +23,24 @@ const Home = () => {
}
};
const displayHomePageContent = async () => {
setHomePageContent(localStorage.getItem('home_page_content') || '');
const res = await API.get('/api/home_page_content');
const { success, message, data } = res.data;
if (success) {
let content = data;
if (!data.startsWith('https://')) {
content = marked.parse(data);
}
setHomePageContent(content);
localStorage.setItem('home_page_content', content);
} else {
showError(message);
setHomePageContent('加载首页内容失败...');
}
setHomePageContentLoaded(true);
};
const getStartTimeString = () => {
const timestamp = statusState?.status?.start_time;
return timestamp2string(timestamp);
@@ -27,70 +48,83 @@ const Home = () => {
useEffect(() => {
displayNotice().then();
displayHomePageContent().then();
}, []);
return (
<>
<Segment>
<Header as='h3'>系统状况</Header>
<Grid columns={2} stackable>
<Grid.Column>
<Card fluid>
<Card.Content>
<Card.Header>系统信息</Card.Header>
<Card.Meta>系统信息总览</Card.Meta>
<Card.Description>
<p>名称{statusState?.status?.system_name}</p>
<p>版本{statusState?.status?.version}</p>
<p>
源码
<a
href='https://github.com/songquanpeng/one-api'
target='_blank'
>
https://github.com/songquanpeng/one-api
</a>
</p>
<p>启动时间{getStartTimeString()}</p>
</Card.Description>
</Card.Content>
</Card>
</Grid.Column>
<Grid.Column>
<Card fluid>
<Card.Content>
<Card.Header>系统配置</Card.Header>
<Card.Meta>系统配置总览</Card.Meta>
<Card.Description>
<p>
邮箱验证
{statusState?.status?.email_verification === true
? '已启用'
: '未启用'}
</p>
<p>
GitHub 身份验证
{statusState?.status?.github_oauth === true
? '已启用'
: '未启用'}
</p>
<p>
微信身份验证
{statusState?.status?.wechat_login === true
? '已启用'
: '未启用'}
</p>
<p>
Turnstile 用户校验
{statusState?.status?.turnstile_check === true
? '已启用'
: '未启用'}
</p>
</Card.Description>
</Card.Content>
</Card>
</Grid.Column>
</Grid>
</Segment>
{
homePageContentLoaded && homePageContent === '' ? <>
<Segment>
<Header as='h3'>系统状况</Header>
<Grid columns={2} stackable>
<Grid.Column>
<Card fluid>
<Card.Content>
<Card.Header>系统信息</Card.Header>
<Card.Meta>系统信息总览</Card.Meta>
<Card.Description>
<p>名称{statusState?.status?.system_name}</p>
<p>版本{statusState?.status?.version}</p>
<p>
源码
<a
href='https://github.com/songquanpeng/one-api'
target='_blank'
>
https://github.com/songquanpeng/one-api
</a>
</p>
<p>启动时间{getStartTimeString()}</p>
</Card.Description>
</Card.Content>
</Card>
</Grid.Column>
<Grid.Column>
<Card fluid>
<Card.Content>
<Card.Header>系统配置</Card.Header>
<Card.Meta>系统配置总览</Card.Meta>
<Card.Description>
<p>
邮箱验证
{statusState?.status?.email_verification === true
? '已启用'
: '未启用'}
</p>
<p>
GitHub 身份验证
{statusState?.status?.github_oauth === true
? '已启用'
: '未启用'}
</p>
<p>
微信身份验证
{statusState?.status?.wechat_login === true
? '已启用'
: '未启用'}
</p>
<p>
Turnstile 用户校验
{statusState?.status?.turnstile_check === true
? '已启用'
: '未启用'}
</p>
</Card.Description>
</Card.Content>
</Card>
</Grid.Column>
</Grid>
</Segment>
</> : <>
{
homePageContent.startsWith('https://') ? <iframe
src={homePageContent}
style={{ width: '100%', height: '100vh', border: 'none' }}
/> : <div style={{ fontSize: 'larger' }} dangerouslySetInnerHTML={{ __html: homePageContent }}></div>
}
</>
}
</>
);
};