mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-04-05 02:13:55 +00:00
Compare commits
52 Commits
v0.9.5
...
revert-26-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00ba64d837 | ||
|
|
b9431dace1 | ||
|
|
721357b4a4 | ||
|
|
ff9f9fbbc9 | ||
|
|
268757a670 | ||
|
|
9b551d978d | ||
|
|
76ab8a480a | ||
|
|
f091f663c2 | ||
|
|
e8966c7374 | ||
|
|
5a7f498629 | ||
|
|
4c1f138c0a | ||
|
|
f4d7bde20b | ||
|
|
0c181395b4 | ||
|
|
6897a9ffd8 | ||
|
|
77130dfb87 | ||
|
|
614abc3441 | ||
|
|
2479da4986 | ||
|
|
7b732ec4b7 | ||
|
|
0fed791ad9 | ||
|
|
01135f430e | ||
|
|
7de02991a1 | ||
|
|
3c57cfbf71 | ||
|
|
fe9b305232 | ||
|
|
17dafa3b03 | ||
|
|
9b1056b13b | ||
|
|
df243204fd | ||
|
|
6be574accc | ||
|
|
63f9d11da9 | ||
|
|
4d0a9d9494 | ||
|
|
6891057647 | ||
|
|
a3e1402df8 | ||
|
|
de7661ea6d | ||
|
|
0a6695bbd3 | ||
|
|
6a933c2e73 | ||
|
|
fee71c798f | ||
|
|
8628994acf | ||
|
|
4a3e233711 | ||
|
|
7603458c1d | ||
|
|
0907fc4863 | ||
|
|
c3c4e9a2fc | ||
|
|
991ab6440b | ||
|
|
2f2e0638d4 | ||
|
|
85ecd08e1b | ||
|
|
408fff8de8 | ||
|
|
298bc12e51 | ||
|
|
dbf20c7967 | ||
|
|
b516ca88f2 | ||
|
|
fde92a5770 | ||
|
|
714fa11412 | ||
|
|
980108e907 | ||
|
|
d20ee1226e | ||
|
|
2488e6ab66 |
@@ -5,4 +5,5 @@
|
||||
.gitignore
|
||||
Makefile
|
||||
docs
|
||||
.eslintcache
|
||||
.eslintcache
|
||||
.gocache
|
||||
17
.github/workflows/sync.yml
vendored
Normal file
17
.github/workflows/sync.yml
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
# .github/workflows/sync.yml
|
||||
name: Sync Fork
|
||||
|
||||
on:
|
||||
push: # push 时触发, 主要是为了测试配置有没有问题
|
||||
schedule:
|
||||
- cron: '* */3 * * *' # 每3小时触发, 对于一些更新不那么频繁的项目可以设置为每天一次, 低碳一点
|
||||
jobs:
|
||||
repo-sync:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: TG908/fork-sync@v1.6.3
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
owner: QuantumNous # fork 的上游仓库 user
|
||||
head: main # fork 的上游仓库 branch
|
||||
base: main # 本地仓库 branch
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -13,6 +13,7 @@ new-api
|
||||
.DS_Store
|
||||
tiktoken_cache
|
||||
.eslintcache
|
||||
.gocache
|
||||
|
||||
electron/node_modules
|
||||
electron/dist
|
||||
@@ -59,6 +59,21 @@ func testChannel(channel *model.Channel, testModel string, endpointType string)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
testModel = strings.TrimSpace(testModel)
|
||||
if testModel == "" {
|
||||
if channel.TestModel != nil && *channel.TestModel != "" {
|
||||
testModel = strings.TrimSpace(*channel.TestModel)
|
||||
} else {
|
||||
models := channel.GetModels()
|
||||
if len(models) > 0 {
|
||||
testModel = strings.TrimSpace(models[0])
|
||||
}
|
||||
if testModel == "" {
|
||||
testModel = "gpt-4o-mini"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
requestPath := "/v1/chat/completions"
|
||||
|
||||
// 如果指定了端点类型,使用指定的端点类型
|
||||
@@ -90,18 +105,6 @@ func testChannel(channel *model.Channel, testModel string, endpointType string)
|
||||
Header: make(http.Header),
|
||||
}
|
||||
|
||||
if testModel == "" {
|
||||
if channel.TestModel != nil && *channel.TestModel != "" {
|
||||
testModel = *channel.TestModel
|
||||
} else {
|
||||
if len(channel.GetModels()) > 0 {
|
||||
testModel = channel.GetModels()[0]
|
||||
} else {
|
||||
testModel = "gpt-4o-mini"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cache, err := model.GetUserCache(1)
|
||||
if err != nil {
|
||||
return testResult{
|
||||
|
||||
@@ -43,6 +43,7 @@ func GetStatus(c *gin.Context) {
|
||||
defer common.OptionMapRWMutex.RUnlock()
|
||||
|
||||
passkeySetting := system_setting.GetPasskeySettings()
|
||||
legalSetting := system_setting.GetLegalSettings()
|
||||
|
||||
data := gin.H{
|
||||
"version": common.Version,
|
||||
@@ -108,6 +109,8 @@ func GetStatus(c *gin.Context) {
|
||||
"passkey_user_verification": passkeySetting.UserVerification,
|
||||
"passkey_attachment": passkeySetting.AttachmentPreference,
|
||||
"setup": constant.Setup,
|
||||
"user_agreement_enabled": legalSetting.UserAgreement != "",
|
||||
"privacy_policy_enabled": legalSetting.PrivacyPolicy != "",
|
||||
}
|
||||
|
||||
// 根据启用状态注入可选内容
|
||||
@@ -151,6 +154,24 @@ func GetAbout(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
func GetUserAgreement(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": system_setting.GetLegalSettings().UserAgreement,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func GetPrivacyPolicy(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": system_setting.GetLegalSettings().PrivacyPolicy,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func GetMidjourney(c *gin.Context) {
|
||||
common.OptionMapRWMutex.RLock()
|
||||
defer common.OptionMapRWMutex.RUnlock()
|
||||
|
||||
@@ -87,6 +87,12 @@ type GeneralOpenAIRequest struct {
|
||||
WebSearch json.RawMessage `json:"web_search,omitempty"`
|
||||
// doubao,zhipu_v4
|
||||
THINKING json.RawMessage `json:"thinking,omitempty"`
|
||||
// pplx Params
|
||||
SearchDomainFilter json.RawMessage `json:"search_domain_filter,omitempty"`
|
||||
SearchRecencyFilter string `json:"search_recency_filter,omitempty"`
|
||||
ReturnImages bool `json:"return_images,omitempty"`
|
||||
ReturnRelatedQuestions bool `json:"return_related_questions,omitempty"`
|
||||
SearchMode string `json:"search_mode,omitempty"`
|
||||
}
|
||||
|
||||
func (r *GeneralOpenAIRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
|
||||
@@ -174,7 +174,19 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
|
||||
relayMode := relayconstant.RelayModeUnknown
|
||||
if c.Request.Method == http.MethodPost {
|
||||
relayMode = relayconstant.RelayModeVideoSubmit
|
||||
modelRequest.Model = c.PostForm("model")
|
||||
form, err := common.ParseMultipartFormReusable(c)
|
||||
if err != nil {
|
||||
return nil, false, errors.New("无效的video请求, " + err.Error())
|
||||
}
|
||||
defer form.RemoveAll()
|
||||
if form != nil {
|
||||
if values, ok := form.Value["model"]; ok && len(values) > 0 {
|
||||
modelRequest.Model = values[0]
|
||||
}
|
||||
}
|
||||
} else if c.Request.Method == http.MethodGet {
|
||||
relayMode = relayconstant.RelayModeVideoFetchByID
|
||||
shouldSelectChannel = false
|
||||
}
|
||||
c.Set("relay_mode", relayMode)
|
||||
} else if strings.Contains(c.Request.URL.Path, "/v1/video/generations") {
|
||||
|
||||
@@ -46,7 +46,7 @@ type Channel struct {
|
||||
Setting *string `json:"setting" gorm:"type:text"` // 渠道额外设置
|
||||
ParamOverride *string `json:"param_override" gorm:"type:text"`
|
||||
HeaderOverride *string `json:"header_override" gorm:"type:text"`
|
||||
Remark string `json:"remark,omitempty" gorm:"type:varchar(255)" validate:"max=255"`
|
||||
Remark *string `json:"remark" gorm:"type:varchar(255)" validate:"max=255"`
|
||||
// add after v0.8.5
|
||||
ChannelInfo ChannelInfo `json:"channel_info" gorm:"type:json"`
|
||||
|
||||
|
||||
@@ -22,10 +22,9 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) {
|
||||
//TODO implement me
|
||||
panic("implement me")
|
||||
return nil, nil
|
||||
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) {
|
||||
adaptor := openai.Adaptor{}
|
||||
return adaptor.ConvertClaudeRequest(c, info, req)
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
|
||||
@@ -80,11 +79,8 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request
|
||||
}
|
||||
|
||||
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
|
||||
if info.IsStream {
|
||||
usage, err = openai.OaiStreamHandler(c, info, resp)
|
||||
} else {
|
||||
usage, err = openai.OpenaiHandler(c, info, resp)
|
||||
}
|
||||
adaptor := openai.Adaptor{}
|
||||
usage, err = adaptor.DoResponse(c, resp, info)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package perplexity
|
||||
|
||||
var ModelList = []string{
|
||||
"llama-3-sonar-small-32k-chat", "llama-3-sonar-small-32k-online", "llama-3-sonar-large-32k-chat", "llama-3-sonar-large-32k-online", "llama-3-8b-instruct", "llama-3-70b-instruct", "mixtral-8x7b-instruct",
|
||||
"sonar", "sonar-pro", "sonar-reasoning",
|
||||
}
|
||||
|
||||
var ChannelName = "perplexity"
|
||||
|
||||
@@ -11,11 +11,18 @@ func requestOpenAI2Perplexity(request dto.GeneralOpenAIRequest) *dto.GeneralOpen
|
||||
})
|
||||
}
|
||||
return &dto.GeneralOpenAIRequest{
|
||||
Model: request.Model,
|
||||
Stream: request.Stream,
|
||||
Messages: messages,
|
||||
Temperature: request.Temperature,
|
||||
TopP: request.TopP,
|
||||
MaxTokens: request.GetMaxTokens(),
|
||||
Model: request.Model,
|
||||
Stream: request.Stream,
|
||||
Messages: messages,
|
||||
Temperature: request.Temperature,
|
||||
TopP: request.TopP,
|
||||
MaxTokens: request.GetMaxTokens(),
|
||||
FrequencyPenalty: request.FrequencyPenalty,
|
||||
PresencePenalty: request.PresencePenalty,
|
||||
SearchDomainFilter: request.SearchDomainFilter,
|
||||
SearchRecencyFilter: request.SearchRecencyFilter,
|
||||
ReturnImages: request.ReturnImages,
|
||||
ReturnRelatedQuestions: request.ReturnRelatedQuestions,
|
||||
SearchMode: request.SearchMode,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,12 +32,9 @@ type ImageURL struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type responsePayload struct {
|
||||
ID string `json:"id"` // task_id
|
||||
}
|
||||
|
||||
type responseTask struct {
|
||||
ID string `json:"id"`
|
||||
TaskID string `json:"task_id,omitempty"` //兼容旧接口
|
||||
Object string `json:"object"`
|
||||
Model string `json:"model"`
|
||||
Status string `json:"status"`
|
||||
@@ -108,18 +105,22 @@ func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, _ *relayco
|
||||
_ = resp.Body.Close()
|
||||
|
||||
// Parse Sora response
|
||||
var dResp responsePayload
|
||||
var dResp responseTask
|
||||
if err := json.Unmarshal(responseBody, &dResp); err != nil {
|
||||
taskErr = service.TaskErrorWrapper(errors.Wrapf(err, "body: %s", responseBody), "unmarshal_response_body_failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if dResp.ID == "" {
|
||||
taskErr = service.TaskErrorWrapper(fmt.Errorf("task_id is empty"), "invalid_response", http.StatusInternalServerError)
|
||||
return
|
||||
if dResp.TaskID == "" {
|
||||
taskErr = service.TaskErrorWrapper(fmt.Errorf("task_id is empty"), "invalid_response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
dResp.ID = dResp.TaskID
|
||||
dResp.TaskID = ""
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"task_id": dResp.ID})
|
||||
c.JSON(http.StatusOK, dResp)
|
||||
return dResp.ID, responseBody, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ func SetApiRouter(router *gin.Engine) {
|
||||
apiRouter.GET("/models", middleware.UserAuth(), controller.DashboardListModels)
|
||||
apiRouter.GET("/status/test", middleware.AdminAuth(), controller.TestStatus)
|
||||
apiRouter.GET("/notice", controller.GetNotice)
|
||||
apiRouter.GET("/user-agreement", controller.GetUserAgreement)
|
||||
apiRouter.GET("/privacy-policy", controller.GetPrivacyPolicy)
|
||||
apiRouter.GET("/about", controller.GetAbout)
|
||||
//apiRouter.GET("/midjourney", controller.GetMidjourney)
|
||||
apiRouter.GET("/home_page_content", controller.GetHomePageContent)
|
||||
|
||||
@@ -19,6 +19,7 @@ func SetVideoRouter(router *gin.Engine) {
|
||||
// docs: https://platform.openai.com/docs/api-reference/videos/create
|
||||
{
|
||||
videoV1Router.POST("/videos", controller.RelayTask)
|
||||
videoV1Router.GET("/videos/:task_id", controller.RelayTask)
|
||||
}
|
||||
|
||||
klingV1Router := router.Group("/kling/v1")
|
||||
|
||||
@@ -75,6 +75,8 @@ func ShouldDisableChannel(channelType int, err *types.NewAPIError) bool {
|
||||
return true
|
||||
case "pre_consume_token_quota_failed":
|
||||
return true
|
||||
case "Arrearage":
|
||||
return true
|
||||
}
|
||||
switch oaiErr.Type {
|
||||
case "insufficient_quota":
|
||||
|
||||
@@ -45,7 +45,7 @@ func DoWorkerRequest(req *WorkerRequest) (*http.Response, error) {
|
||||
return nil, fmt.Errorf("failed to marshal worker payload: %v", err)
|
||||
}
|
||||
|
||||
return http.Post(workerUrl, "application/json", bytes.NewBuffer(workerPayload))
|
||||
return GetHttpClient().Post(workerUrl, "application/json", bytes.NewBuffer(workerPayload))
|
||||
}
|
||||
|
||||
func DoDownloadRequest(originUrl string, reason ...string) (resp *http.Response, err error) {
|
||||
@@ -64,6 +64,6 @@ func DoDownloadRequest(originUrl string, reason ...string) (resp *http.Response,
|
||||
}
|
||||
|
||||
common.SysLog(fmt.Sprintf("downloading from origin: %s, reason: %s", common.MaskSensitiveInfo(originUrl), strings.Join(reason, ", ")))
|
||||
return http.Get(originUrl)
|
||||
return GetHttpClient().Get(originUrl)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"one-api/common"
|
||||
"one-api/setting/system_setting"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -19,12 +20,27 @@ var (
|
||||
proxyClients = make(map[string]*http.Client)
|
||||
)
|
||||
|
||||
func checkRedirect(req *http.Request, via []*http.Request) error {
|
||||
fetchSetting := system_setting.GetFetchSetting()
|
||||
urlStr := req.URL.String()
|
||||
if err := common.ValidateURLWithFetchSetting(urlStr, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil {
|
||||
return fmt.Errorf("redirect to %s blocked: %v", urlStr, err)
|
||||
}
|
||||
if len(via) >= 10 {
|
||||
return fmt.Errorf("stopped after 10 redirects")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func InitHttpClient() {
|
||||
if common.RelayTimeout == 0 {
|
||||
httpClient = &http.Client{}
|
||||
httpClient = &http.Client{
|
||||
CheckRedirect: checkRedirect,
|
||||
}
|
||||
} else {
|
||||
httpClient = &http.Client{
|
||||
Timeout: time.Duration(common.RelayTimeout) * time.Second,
|
||||
Timeout: time.Duration(common.RelayTimeout) * time.Second,
|
||||
CheckRedirect: checkRedirect,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,6 +85,7 @@ func NewProxyHttpClient(proxyURL string) (*http.Client, error) {
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyURL(parsedURL),
|
||||
},
|
||||
CheckRedirect: checkRedirect,
|
||||
}
|
||||
client.Timeout = time.Duration(common.RelayTimeout) * time.Second
|
||||
proxyClientLock.Lock()
|
||||
@@ -102,6 +119,7 @@ func NewProxyHttpClient(proxyURL string) (*http.Client, error) {
|
||||
return dialer.Dial(network, addr)
|
||||
},
|
||||
},
|
||||
CheckRedirect: checkRedirect,
|
||||
}
|
||||
client.Timeout = time.Duration(common.RelayTimeout) * time.Second
|
||||
proxyClientLock.Lock()
|
||||
|
||||
21
setting/system_setting/legal.go
Normal file
21
setting/system_setting/legal.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package system_setting
|
||||
|
||||
import "one-api/setting/config"
|
||||
|
||||
type LegalSettings struct {
|
||||
UserAgreement string `json:"user_agreement"`
|
||||
PrivacyPolicy string `json:"privacy_policy"`
|
||||
}
|
||||
|
||||
var defaultLegalSettings = LegalSettings{
|
||||
UserAgreement: "",
|
||||
PrivacyPolicy: "",
|
||||
}
|
||||
|
||||
func init() {
|
||||
config.GlobalConfig.Register("legal", &defaultLegalSettings)
|
||||
}
|
||||
|
||||
func GetLegalSettings() *LegalSettings {
|
||||
return &defaultLegalSettings
|
||||
}
|
||||
35
web/functions/api/[[default]].js
Normal file
35
web/functions/api/[[default]].js
Normal file
@@ -0,0 +1,35 @@
|
||||
// functions/Pages_Functions.js
|
||||
// 该函数会将所有请求转发到环境变量 `TARGET_URL` 指定的地址
|
||||
|
||||
export async function onRequest(context) {
|
||||
// 从上下文中获取原始请求和环境变量
|
||||
const { request, env } = context;
|
||||
|
||||
// 解析原始请求的 URL,以获取路径和查询参数
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname;
|
||||
const search = url.search;
|
||||
|
||||
// 从环境变量中获取目标 URL,如果未设置则提供一个默认值
|
||||
const target = env.TARGET_URL || 'http://172.0.0.1';
|
||||
|
||||
// 构建目标 URL
|
||||
const targetUrl = `${target}${path}${search}`;
|
||||
|
||||
// 创建一个新的请求以转发到目标地址
|
||||
// 复制原始请求的方法、头部和主体
|
||||
const newRequest = new Request(targetUrl, {
|
||||
method: request.method,
|
||||
headers: request.headers,
|
||||
body: request.body,
|
||||
redirect: 'manual' // 防止 fetch 自动处理重定向
|
||||
});
|
||||
|
||||
// 执行转发请求并返回响应
|
||||
try {
|
||||
return await fetch(newRequest);
|
||||
} catch (error) {
|
||||
// 如果目标服务器无法访问,返回一个错误信息
|
||||
return new Response(`Error forwarding request: ${error.message}`, { status: 502 });
|
||||
}
|
||||
}
|
||||
35
web/functions/mj/[[default]].js
Normal file
35
web/functions/mj/[[default]].js
Normal file
@@ -0,0 +1,35 @@
|
||||
// functions/Pages_Functions.js
|
||||
// 该函数会将所有请求转发到环境变量 `TARGET_URL` 指定的地址
|
||||
|
||||
export async function onRequest(context) {
|
||||
// 从上下文中获取原始请求和环境变量
|
||||
const { request, env } = context;
|
||||
|
||||
// 解析原始请求的 URL,以获取路径和查询参数
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname;
|
||||
const search = url.search;
|
||||
|
||||
// 从环境变量中获取目标 URL,如果未设置则提供一个默认值
|
||||
const target = env.TARGET_URL || 'http://172.0.0.1';
|
||||
|
||||
// 构建目标 URL
|
||||
const targetUrl = `${target}${path}${search}`;
|
||||
|
||||
// 创建一个新的请求以转发到目标地址
|
||||
// 复制原始请求的方法、头部和主体
|
||||
const newRequest = new Request(targetUrl, {
|
||||
method: request.method,
|
||||
headers: request.headers,
|
||||
body: request.body,
|
||||
redirect: 'manual' // 防止 fetch 自动处理重定向
|
||||
});
|
||||
|
||||
// 执行转发请求并返回响应
|
||||
try {
|
||||
return await fetch(newRequest);
|
||||
} catch (error) {
|
||||
// 如果目标服务器无法访问,返回一个错误信息
|
||||
return new Response(`Error forwarding request: ${error.message}`, { status: 502 });
|
||||
}
|
||||
}
|
||||
35
web/functions/pg/[[default]].js
Normal file
35
web/functions/pg/[[default]].js
Normal file
@@ -0,0 +1,35 @@
|
||||
// functions/Pages_Functions.js
|
||||
// 该函数会将所有请求转发到环境变量 `TARGET_URL` 指定的地址
|
||||
|
||||
export async function onRequest(context) {
|
||||
// 从上下文中获取原始请求和环境变量
|
||||
const { request, env } = context;
|
||||
|
||||
// 解析原始请求的 URL,以获取路径和查询参数
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname;
|
||||
const search = url.search;
|
||||
|
||||
// 从环境变量中获取目标 URL,如果未设置则提供一个默认值
|
||||
const target = env.TARGET_URL || 'http://172.0.0.1';
|
||||
|
||||
// 构建目标 URL
|
||||
const targetUrl = `${target}${path}${search}`;
|
||||
|
||||
// 创建一个新的请求以转发到目标地址
|
||||
// 复制原始请求的方法、头部和主体
|
||||
const newRequest = new Request(targetUrl, {
|
||||
method: request.method,
|
||||
headers: request.headers,
|
||||
body: request.body,
|
||||
redirect: 'manual' // 防止 fetch 自动处理重定向
|
||||
});
|
||||
|
||||
// 执行转发请求并返回响应
|
||||
try {
|
||||
return await fetch(newRequest);
|
||||
} catch (error) {
|
||||
// 如果目标服务器无法访问,返回一个错误信息
|
||||
return new Response(`Error forwarding request: ${error.message}`, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
"dependencies": {
|
||||
"@douyinfe/semi-icons": "^2.63.1",
|
||||
"@douyinfe/semi-ui": "^2.69.1",
|
||||
"antd": "^5.25.2",
|
||||
"@lobehub/icons": "^2.0.0",
|
||||
"@visactor/react-vchart": "~1.8.8",
|
||||
"@visactor/vchart": "~1.8.8",
|
||||
@@ -44,7 +45,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build": "node --max-old-space-size=4096 ./node_modules/vite/bin/vite.js build",
|
||||
"lint": "prettier . --check",
|
||||
"lint:fix": "prettier . --write",
|
||||
"eslint": "bunx eslint \"**/*.{js,jsx}\" --cache",
|
||||
|
||||
@@ -51,6 +51,8 @@ import SetupCheck from './components/layout/SetupCheck';
|
||||
const Home = lazy(() => import('./pages/Home'));
|
||||
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
||||
const About = lazy(() => import('./pages/About'));
|
||||
const UserAgreement = lazy(() => import('./pages/UserAgreement'));
|
||||
const PrivacyPolicy = lazy(() => import('./pages/PrivacyPolicy'));
|
||||
|
||||
function App() {
|
||||
const location = useLocation();
|
||||
@@ -301,6 +303,22 @@ function App() {
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/user-agreement'
|
||||
element={
|
||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
||||
<UserAgreement />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/privacy-policy'
|
||||
element={
|
||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
||||
<PrivacyPolicy />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/console/chat/:id?'
|
||||
element={
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
isPasskeySupported,
|
||||
} from '../../helpers';
|
||||
import Turnstile from 'react-turnstile';
|
||||
import { Button, Card, Divider, Form, Icon, Modal } from '@douyinfe/semi-ui';
|
||||
import { Button, Card, Checkbox, Divider, Form, Icon, Modal } from '@douyinfe/semi-ui';
|
||||
import Title from '@douyinfe/semi-ui/lib/es/typography/title';
|
||||
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
|
||||
import TelegramLoginButton from 'react-telegram-login';
|
||||
@@ -84,6 +84,9 @@ const LoginForm = () => {
|
||||
const [showTwoFA, setShowTwoFA] = useState(false);
|
||||
const [passkeySupported, setPasskeySupported] = useState(false);
|
||||
const [passkeyLoading, setPasskeyLoading] = useState(false);
|
||||
const [agreedToTerms, setAgreedToTerms] = useState(false);
|
||||
const [hasUserAgreement, setHasUserAgreement] = useState(false);
|
||||
const [hasPrivacyPolicy, setHasPrivacyPolicy] = useState(false);
|
||||
|
||||
const logo = getLogo();
|
||||
const systemName = getSystemName();
|
||||
@@ -103,6 +106,10 @@ const LoginForm = () => {
|
||||
setTurnstileEnabled(true);
|
||||
setTurnstileSiteKey(status.turnstile_site_key);
|
||||
}
|
||||
|
||||
// 从 status 获取用户协议和隐私政策的启用状态
|
||||
setHasUserAgreement(status.user_agreement_enabled || false);
|
||||
setHasPrivacyPolicy(status.privacy_policy_enabled || false);
|
||||
}, [status]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -118,6 +125,10 @@ const LoginForm = () => {
|
||||
}, []);
|
||||
|
||||
const onWeChatLoginClicked = () => {
|
||||
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
|
||||
showInfo(t('请先阅读并同意用户协议和隐私政策'));
|
||||
return;
|
||||
}
|
||||
setWechatLoading(true);
|
||||
setShowWeChatLoginModal(true);
|
||||
setWechatLoading(false);
|
||||
@@ -157,6 +168,10 @@ const LoginForm = () => {
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
|
||||
showInfo(t('请先阅读并同意用户协议和隐私政策'));
|
||||
return;
|
||||
}
|
||||
if (turnstileEnabled && turnstileToken === '') {
|
||||
showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
|
||||
return;
|
||||
@@ -208,6 +223,10 @@ const LoginForm = () => {
|
||||
|
||||
// 添加Telegram登录处理函数
|
||||
const onTelegramLoginClicked = async (response) => {
|
||||
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
|
||||
showInfo(t('请先阅读并同意用户协议和隐私政策'));
|
||||
return;
|
||||
}
|
||||
const fields = [
|
||||
'id',
|
||||
'first_name',
|
||||
@@ -244,6 +263,10 @@ const LoginForm = () => {
|
||||
|
||||
// 包装的GitHub登录点击处理
|
||||
const handleGitHubClick = () => {
|
||||
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
|
||||
showInfo(t('请先阅读并同意用户协议和隐私政策'));
|
||||
return;
|
||||
}
|
||||
setGithubLoading(true);
|
||||
try {
|
||||
onGitHubOAuthClicked(status.github_client_id);
|
||||
@@ -255,6 +278,10 @@ const LoginForm = () => {
|
||||
|
||||
// 包装的OIDC登录点击处理
|
||||
const handleOIDCClick = () => {
|
||||
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
|
||||
showInfo(t('请先阅读并同意用户协议和隐私政策'));
|
||||
return;
|
||||
}
|
||||
setOidcLoading(true);
|
||||
try {
|
||||
onOIDCClicked(status.oidc_authorization_endpoint, status.oidc_client_id);
|
||||
@@ -266,6 +293,10 @@ const LoginForm = () => {
|
||||
|
||||
// 包装的LinuxDO登录点击处理
|
||||
const handleLinuxDOClick = () => {
|
||||
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
|
||||
showInfo(t('请先阅读并同意用户协议和隐私政策'));
|
||||
return;
|
||||
}
|
||||
setLinuxdoLoading(true);
|
||||
try {
|
||||
onLinuxDOOAuthClicked(status.linuxdo_client_id);
|
||||
@@ -283,6 +314,10 @@ const LoginForm = () => {
|
||||
};
|
||||
|
||||
const handlePasskeyLogin = async () => {
|
||||
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
|
||||
showInfo(t('请先阅读并同意用户协议和隐私政策'));
|
||||
return;
|
||||
}
|
||||
if (!passkeySupported) {
|
||||
showInfo('当前环境无法使用 Passkey 登录');
|
||||
return;
|
||||
@@ -486,6 +521,44 @@ const LoginForm = () => {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{(hasUserAgreement || hasPrivacyPolicy) && (
|
||||
<div className='mt-6'>
|
||||
<Checkbox
|
||||
checked={agreedToTerms}
|
||||
onChange={(e) => setAgreedToTerms(e.target.checked)}
|
||||
>
|
||||
<Text size='small' className='text-gray-600'>
|
||||
{t('我已阅读并同意')}
|
||||
{hasUserAgreement && (
|
||||
<>
|
||||
<a
|
||||
href='/user-agreement'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-blue-600 hover:text-blue-800 mx-1'
|
||||
>
|
||||
{t('用户协议')}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
{hasUserAgreement && hasPrivacyPolicy && t('和')}
|
||||
{hasPrivacyPolicy && (
|
||||
<>
|
||||
<a
|
||||
href='/privacy-policy'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-blue-600 hover:text-blue-800 mx-1'
|
||||
>
|
||||
{t('隐私政策')}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</Checkbox>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!status.self_use_mode_enabled && (
|
||||
<div className='mt-6 text-center text-sm'>
|
||||
<Text>
|
||||
@@ -554,6 +627,44 @@ const LoginForm = () => {
|
||||
prefix={<IconLock />}
|
||||
/>
|
||||
|
||||
{(hasUserAgreement || hasPrivacyPolicy) && (
|
||||
<div className='pt-4'>
|
||||
<Checkbox
|
||||
checked={agreedToTerms}
|
||||
onChange={(e) => setAgreedToTerms(e.target.checked)}
|
||||
>
|
||||
<Text size='small' className='text-gray-600'>
|
||||
{t('我已阅读并同意')}
|
||||
{hasUserAgreement && (
|
||||
<>
|
||||
<a
|
||||
href='/user-agreement'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-blue-600 hover:text-blue-800 mx-1'
|
||||
>
|
||||
{t('用户协议')}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
{hasUserAgreement && hasPrivacyPolicy && t('和')}
|
||||
{hasPrivacyPolicy && (
|
||||
<>
|
||||
<a
|
||||
href='/privacy-policy'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-blue-600 hover:text-blue-800 mx-1'
|
||||
>
|
||||
{t('隐私政策')}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</Checkbox>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='space-y-2 pt-2'>
|
||||
<Button
|
||||
theme='solid'
|
||||
@@ -562,6 +673,7 @@ const LoginForm = () => {
|
||||
htmlType='submit'
|
||||
onClick={handleSubmit}
|
||||
loading={loginLoading}
|
||||
disabled={(hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms}
|
||||
>
|
||||
{t('继续')}
|
||||
</Button>
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
setUserData,
|
||||
} from '../../helpers';
|
||||
import Turnstile from 'react-turnstile';
|
||||
import { Button, Card, Divider, Form, Icon, Modal } from '@douyinfe/semi-ui';
|
||||
import { Button, Card, Checkbox, Divider, Form, Icon, Modal } from '@douyinfe/semi-ui';
|
||||
import Title from '@douyinfe/semi-ui/lib/es/typography/title';
|
||||
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
|
||||
import {
|
||||
@@ -82,6 +82,9 @@ const RegisterForm = () => {
|
||||
const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false);
|
||||
const [disableButton, setDisableButton] = useState(false);
|
||||
const [countdown, setCountdown] = useState(30);
|
||||
const [agreedToTerms, setAgreedToTerms] = useState(false);
|
||||
const [hasUserAgreement, setHasUserAgreement] = useState(false);
|
||||
const [hasPrivacyPolicy, setHasPrivacyPolicy] = useState(false);
|
||||
|
||||
const logo = getLogo();
|
||||
const systemName = getSystemName();
|
||||
@@ -106,6 +109,10 @@ const RegisterForm = () => {
|
||||
setTurnstileEnabled(true);
|
||||
setTurnstileSiteKey(status.turnstile_site_key);
|
||||
}
|
||||
|
||||
// 从 status 获取用户协议和隐私政策的启用状态
|
||||
setHasUserAgreement(status.user_agreement_enabled || false);
|
||||
setHasPrivacyPolicy(status.privacy_policy_enabled || false);
|
||||
}, [status]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -505,6 +512,44 @@ const RegisterForm = () => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{(hasUserAgreement || hasPrivacyPolicy) && (
|
||||
<div className='pt-4'>
|
||||
<Checkbox
|
||||
checked={agreedToTerms}
|
||||
onChange={(e) => setAgreedToTerms(e.target.checked)}
|
||||
>
|
||||
<Text size='small' className='text-gray-600'>
|
||||
{t('我已阅读并同意')}
|
||||
{hasUserAgreement && (
|
||||
<>
|
||||
<a
|
||||
href='/user-agreement'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-blue-600 hover:text-blue-800 mx-1'
|
||||
>
|
||||
{t('用户协议')}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
{hasUserAgreement && hasPrivacyPolicy && t('和')}
|
||||
{hasPrivacyPolicy && (
|
||||
<>
|
||||
<a
|
||||
href='/privacy-policy'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-blue-600 hover:text-blue-800 mx-1'
|
||||
>
|
||||
{t('隐私政策')}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</Checkbox>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='space-y-2 pt-2'>
|
||||
<Button
|
||||
theme='solid'
|
||||
@@ -513,6 +558,7 @@ const RegisterForm = () => {
|
||||
htmlType='submit'
|
||||
onClick={handleSubmit}
|
||||
loading={registerLoading}
|
||||
disabled={(hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms}
|
||||
>
|
||||
{t('注册')}
|
||||
</Button>
|
||||
|
||||
243
web/src/components/common/DocumentRenderer/index.jsx
Normal file
243
web/src/components/common/DocumentRenderer/index.jsx
Normal file
@@ -0,0 +1,243 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { API, showError } from '../../../helpers';
|
||||
import { Empty, Card, Spin, Typography } from '@douyinfe/semi-ui';
|
||||
const { Title } = Typography;
|
||||
import {
|
||||
IllustrationConstruction,
|
||||
IllustrationConstructionDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import MarkdownRenderer from '../markdown/MarkdownRenderer';
|
||||
|
||||
// 检查是否为 URL
|
||||
const isUrl = (content) => {
|
||||
try {
|
||||
new URL(content.trim());
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 检查是否为 HTML 内容
|
||||
const isHtmlContent = (content) => {
|
||||
if (!content || typeof content !== 'string') return false;
|
||||
|
||||
// 检查是否包含HTML标签
|
||||
const htmlTagRegex = /<\/?[a-z][\s\S]*>/i;
|
||||
return htmlTagRegex.test(content);
|
||||
};
|
||||
|
||||
// 安全地渲染HTML内容
|
||||
const sanitizeHtml = (html) => {
|
||||
// 创建一个临时元素来解析HTML
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = html;
|
||||
|
||||
// 提取样式
|
||||
const styles = Array.from(tempDiv.querySelectorAll('style'))
|
||||
.map(style => style.innerHTML)
|
||||
.join('\n');
|
||||
|
||||
// 提取body内容,如果没有body标签则使用全部内容
|
||||
const bodyContent = tempDiv.querySelector('body');
|
||||
const content = bodyContent ? bodyContent.innerHTML : html;
|
||||
|
||||
return { content, styles };
|
||||
};
|
||||
|
||||
/**
|
||||
* 通用文档渲染组件
|
||||
* @param {string} apiEndpoint - API 接口地址
|
||||
* @param {string} title - 文档标题
|
||||
* @param {string} cacheKey - 本地存储缓存键
|
||||
* @param {string} emptyMessage - 空内容时的提示消息
|
||||
*/
|
||||
const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
|
||||
const { t } = useTranslation();
|
||||
const [content, setContent] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [htmlStyles, setHtmlStyles] = useState('');
|
||||
const [processedHtmlContent, setProcessedHtmlContent] = useState('');
|
||||
|
||||
const loadContent = async () => {
|
||||
// 先从缓存中获取
|
||||
const cachedContent = localStorage.getItem(cacheKey) || '';
|
||||
if (cachedContent) {
|
||||
setContent(cachedContent);
|
||||
processContent(cachedContent);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await API.get(apiEndpoint);
|
||||
const { success, message, data } = res.data;
|
||||
if (success && data) {
|
||||
setContent(data);
|
||||
processContent(data);
|
||||
localStorage.setItem(cacheKey, data);
|
||||
} else {
|
||||
if (!cachedContent) {
|
||||
showError(message || emptyMessage);
|
||||
setContent('');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cachedContent) {
|
||||
showError(emptyMessage);
|
||||
setContent('');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const processContent = (rawContent) => {
|
||||
if (isHtmlContent(rawContent)) {
|
||||
const { content: htmlContent, styles } = sanitizeHtml(rawContent);
|
||||
setProcessedHtmlContent(htmlContent);
|
||||
setHtmlStyles(styles);
|
||||
} else {
|
||||
setProcessedHtmlContent('');
|
||||
setHtmlStyles('');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadContent();
|
||||
}, []);
|
||||
|
||||
// 处理HTML样式注入
|
||||
useEffect(() => {
|
||||
const styleId = `document-renderer-styles-${cacheKey}`;
|
||||
|
||||
if (htmlStyles) {
|
||||
let styleEl = document.getElementById(styleId);
|
||||
if (!styleEl) {
|
||||
styleEl = document.createElement('style');
|
||||
styleEl.id = styleId;
|
||||
styleEl.type = 'text/css';
|
||||
document.head.appendChild(styleEl);
|
||||
}
|
||||
styleEl.innerHTML = htmlStyles;
|
||||
} else {
|
||||
const el = document.getElementById(styleId);
|
||||
if (el) el.remove();
|
||||
}
|
||||
|
||||
return () => {
|
||||
const el = document.getElementById(styleId);
|
||||
if (el) el.remove();
|
||||
};
|
||||
}, [htmlStyles, cacheKey]);
|
||||
|
||||
// 显示加载状态
|
||||
if (loading) {
|
||||
return (
|
||||
<div className='flex justify-center items-center min-h-screen'>
|
||||
<Spin size='large' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 如果没有内容,显示空状态
|
||||
if (!content || content.trim() === '') {
|
||||
return (
|
||||
<div className='flex justify-center items-center min-h-screen bg-gray-50'>
|
||||
<Empty
|
||||
title={t('管理员未设置' + title + '内容')}
|
||||
image={<IllustrationConstruction style={{ width: 150, height: 150 }} />}
|
||||
darkModeImage={<IllustrationConstructionDark style={{ width: 150, height: 150 }} />}
|
||||
className='p-8'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 如果是 URL,显示链接卡片
|
||||
if (isUrl(content)) {
|
||||
return (
|
||||
<div className='flex justify-center items-center min-h-screen bg-gray-50 p-4'>
|
||||
<Card className='max-w-md w-full'>
|
||||
<div className='text-center'>
|
||||
<Title heading={4} className='mb-4'>{title}</Title>
|
||||
<p className='text-gray-600 mb-4'>
|
||||
{t('管理员设置了外部链接,点击下方按钮访问')}
|
||||
</p>
|
||||
<a
|
||||
href={content.trim()}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
title={content.trim()}
|
||||
aria-label={`${t('访问' + title)}: ${content.trim()}`}
|
||||
className='inline-block px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors'
|
||||
>
|
||||
{t('访问' + title)}
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 如果是 HTML 内容,直接渲染
|
||||
if (isHtmlContent(content)) {
|
||||
const { content: htmlContent, styles } = sanitizeHtml(content);
|
||||
|
||||
// 设置样式(如果有的话)
|
||||
useEffect(() => {
|
||||
if (styles && styles !== htmlStyles) {
|
||||
setHtmlStyles(styles);
|
||||
}
|
||||
}, [content, styles, htmlStyles]);
|
||||
|
||||
return (
|
||||
<div className='min-h-screen bg-gray-50'>
|
||||
<div className='max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8'>
|
||||
<div className='bg-white rounded-lg shadow-sm p-8'>
|
||||
<Title heading={2} className='text-center mb-8'>{title}</Title>
|
||||
<div
|
||||
className='prose prose-lg max-w-none'
|
||||
dangerouslySetInnerHTML={{ __html: htmlContent }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 其他内容统一使用 Markdown 渲染器
|
||||
return (
|
||||
<div className='min-h-screen bg-gray-50'>
|
||||
<div className='max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8'>
|
||||
<div className='bg-white rounded-lg shadow-sm p-8'>
|
||||
<Title heading={2} className='text-center mb-8'>{title}</Title>
|
||||
<div className='prose prose-lg max-w-none'>
|
||||
<MarkdownRenderer content={content} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentRenderer;
|
||||
@@ -34,10 +34,15 @@ import { useTranslation } from 'react-i18next';
|
||||
import { StatusContext } from '../../context/Status';
|
||||
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
|
||||
|
||||
const LEGAL_USER_AGREEMENT_KEY = 'legal.user_agreement';
|
||||
const LEGAL_PRIVACY_POLICY_KEY = 'legal.privacy_policy';
|
||||
|
||||
const OtherSetting = () => {
|
||||
const { t } = useTranslation();
|
||||
let [inputs, setInputs] = useState({
|
||||
Notice: '',
|
||||
[LEGAL_USER_AGREEMENT_KEY]: '',
|
||||
[LEGAL_PRIVACY_POLICY_KEY]: '',
|
||||
SystemName: '',
|
||||
Logo: '',
|
||||
Footer: '',
|
||||
@@ -69,6 +74,8 @@ const OtherSetting = () => {
|
||||
|
||||
const [loadingInput, setLoadingInput] = useState({
|
||||
Notice: false,
|
||||
[LEGAL_USER_AGREEMENT_KEY]: false,
|
||||
[LEGAL_PRIVACY_POLICY_KEY]: false,
|
||||
SystemName: false,
|
||||
Logo: false,
|
||||
HomePageContent: false,
|
||||
@@ -96,6 +103,50 @@ const OtherSetting = () => {
|
||||
setLoadingInput((loadingInput) => ({ ...loadingInput, Notice: false }));
|
||||
}
|
||||
};
|
||||
// 通用设置 - UserAgreement
|
||||
const submitUserAgreement = async () => {
|
||||
try {
|
||||
setLoadingInput((loadingInput) => ({
|
||||
...loadingInput,
|
||||
[LEGAL_USER_AGREEMENT_KEY]: true,
|
||||
}));
|
||||
await updateOption(
|
||||
LEGAL_USER_AGREEMENT_KEY,
|
||||
inputs[LEGAL_USER_AGREEMENT_KEY],
|
||||
);
|
||||
showSuccess(t('用户协议已更新'));
|
||||
} catch (error) {
|
||||
console.error(t('用户协议更新失败'), error);
|
||||
showError(t('用户协议更新失败'));
|
||||
} finally {
|
||||
setLoadingInput((loadingInput) => ({
|
||||
...loadingInput,
|
||||
[LEGAL_USER_AGREEMENT_KEY]: false,
|
||||
}));
|
||||
}
|
||||
};
|
||||
// 通用设置 - PrivacyPolicy
|
||||
const submitPrivacyPolicy = async () => {
|
||||
try {
|
||||
setLoadingInput((loadingInput) => ({
|
||||
...loadingInput,
|
||||
[LEGAL_PRIVACY_POLICY_KEY]: true,
|
||||
}));
|
||||
await updateOption(
|
||||
LEGAL_PRIVACY_POLICY_KEY,
|
||||
inputs[LEGAL_PRIVACY_POLICY_KEY],
|
||||
);
|
||||
showSuccess(t('隐私政策已更新'));
|
||||
} catch (error) {
|
||||
console.error(t('隐私政策更新失败'), error);
|
||||
showError(t('隐私政策更新失败'));
|
||||
} finally {
|
||||
setLoadingInput((loadingInput) => ({
|
||||
...loadingInput,
|
||||
[LEGAL_PRIVACY_POLICY_KEY]: false,
|
||||
}));
|
||||
}
|
||||
};
|
||||
// 个性化设置
|
||||
const formAPIPersonalization = useRef();
|
||||
// 个性化设置 - SystemName
|
||||
@@ -324,6 +375,40 @@ const OtherSetting = () => {
|
||||
<Button onClick={submitNotice} loading={loadingInput['Notice']}>
|
||||
{t('设置公告')}
|
||||
</Button>
|
||||
<Form.TextArea
|
||||
label={t('用户协议')}
|
||||
placeholder={t(
|
||||
'在此输入用户协议内容,支持 Markdown & HTML 代码',
|
||||
)}
|
||||
field={LEGAL_USER_AGREEMENT_KEY}
|
||||
onChange={handleInputChange}
|
||||
style={{ fontFamily: 'JetBrains Mono, Consolas' }}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
helpText={t('填写用户协议内容后,用户注册时将被要求勾选已阅读用户协议')}
|
||||
/>
|
||||
<Button
|
||||
onClick={submitUserAgreement}
|
||||
loading={loadingInput[LEGAL_USER_AGREEMENT_KEY]}
|
||||
>
|
||||
{t('设置用户协议')}
|
||||
</Button>
|
||||
<Form.TextArea
|
||||
label={t('隐私政策')}
|
||||
placeholder={t(
|
||||
'在此输入隐私政策内容,支持 Markdown & HTML 代码',
|
||||
)}
|
||||
field={LEGAL_PRIVACY_POLICY_KEY}
|
||||
onChange={handleInputChange}
|
||||
style={{ fontFamily: 'JetBrains Mono, Consolas' }}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
helpText={t('填写隐私政策内容后,用户注册时将被要求勾选已阅读隐私政策')}
|
||||
/>
|
||||
<Button
|
||||
onClick={submitPrivacyPolicy}
|
||||
loading={loadingInput[LEGAL_PRIVACY_POLICY_KEY]}
|
||||
>
|
||||
{t('设置隐私政策')}
|
||||
</Button>
|
||||
</Form.Section>
|
||||
</Card>
|
||||
</Form>
|
||||
|
||||
@@ -705,8 +705,15 @@ const SystemSetting = () => {
|
||||
|
||||
<Card>
|
||||
<Form.Section text={t('代理设置')}>
|
||||
<Banner
|
||||
type='info'
|
||||
description={t(
|
||||
'此代理仅用于图片请求转发,Webhook通知发送等,AI API请求仍然由服务器直接发出,可在渠道设置中单独配置代理',
|
||||
)}
|
||||
style={{ marginBottom: 20, marginTop: 16 }}
|
||||
/>
|
||||
<Text>
|
||||
(支持{' '}
|
||||
{t('仅支持')}{' '}
|
||||
<a
|
||||
href='https://github.com/Calcium-Ion/new-api-worker'
|
||||
target='_blank'
|
||||
@@ -714,7 +721,7 @@ const SystemSetting = () => {
|
||||
>
|
||||
new-api-worker
|
||||
</a>
|
||||
)
|
||||
{' '}{t('或其兼容new-api-worker格式的其他版本')}
|
||||
</Text>
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
|
||||
@@ -91,7 +91,7 @@ const REGION_EXAMPLE = {
|
||||
|
||||
// 支持并且已适配通过接口获取模型列表的渠道类型
|
||||
const MODEL_FETCHABLE_TYPES = new Set([
|
||||
1, 4, 14, 34, 17, 26, 24, 47, 25, 20, 23, 31, 35, 40, 42, 48, 43,
|
||||
1, 4, 14, 34, 17, 26, 27, 24, 47, 25, 20, 23, 31, 35, 40, 42, 48, 43,
|
||||
]);
|
||||
|
||||
function type2secretPrompt(type) {
|
||||
|
||||
@@ -88,6 +88,11 @@ export const CHANNEL_OPTIONS = [
|
||||
color: 'purple',
|
||||
label: '智谱 GLM-4V',
|
||||
},
|
||||
{
|
||||
value: 27,
|
||||
color: 'blue',
|
||||
label: 'Perplexity',
|
||||
},
|
||||
{
|
||||
value: 24,
|
||||
color: 'orange',
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
FastGPT,
|
||||
Kling,
|
||||
Jimeng,
|
||||
Perplexity,
|
||||
} from '@lobehub/icons';
|
||||
|
||||
import {
|
||||
@@ -309,6 +310,8 @@ export function getChannelIcon(channelType) {
|
||||
return <Xinference.Color size={iconSize} />;
|
||||
case 25: // Moonshot
|
||||
return <Moonshot size={iconSize} />;
|
||||
case 27: // Perplexity
|
||||
return <Perplexity.Color size={iconSize} />;
|
||||
case 20: // OpenRouter
|
||||
return <OpenRouter size={iconSize} />;
|
||||
case 19: // 360 智脑
|
||||
|
||||
@@ -245,6 +245,8 @@
|
||||
"检查更新": "Check for updates",
|
||||
"公告": "Announcement",
|
||||
"在此输入新的公告内容,支持 Markdown & HTML 代码": "Enter the new announcement content here, supports Markdown & HTML code",
|
||||
"在此输入用户协议内容,支持 Markdown & HTML 代码": "Enter user agreement content here, supports Markdown & HTML code",
|
||||
"在此输入隐私政策内容,支持 Markdown & HTML 代码": "Enter privacy policy content here, supports Markdown & HTML code",
|
||||
"保存公告": "Save Announcement",
|
||||
"个性化设置": "Personalization Settings",
|
||||
"系统名称": "System Name",
|
||||
@@ -1261,6 +1263,8 @@
|
||||
"仅修改展示粒度,统计精确到小时": "Only modify display granularity, statistics accurate to the hour",
|
||||
"当运行通道全部测试时,超过此时间将自动禁用通道": "When running all channel tests, the channel will be automatically disabled when this time is exceeded",
|
||||
"设置公告": "Set notice",
|
||||
"设置用户协议": "Set user agreement",
|
||||
"设置隐私政策": "Set privacy policy",
|
||||
"设置 Logo": "Set Logo",
|
||||
"设置首页内容": "Set home page content",
|
||||
"设置关于": "Set about",
|
||||
@@ -2260,5 +2264,21 @@
|
||||
"补单成功": "Order completed successfully",
|
||||
"补单失败": "Failed to complete order",
|
||||
"确认补单": "Confirm Order Completion",
|
||||
"是否将该订单标记为成功并为用户入账?": "Mark this order as successful and credit the user?"
|
||||
"是否将该订单标记为成功并为用户入账?": "Mark this order as successful and credit the user?",
|
||||
"用户协议": "User Agreement",
|
||||
"隐私政策": "Privacy Policy",
|
||||
"用户协议更新失败": "Failed to update user agreement",
|
||||
"隐私政策更新失败": "Failed to update privacy policy",
|
||||
"管理员未设置用户协议内容": "Administrator has not set user agreement content",
|
||||
"管理员未设置隐私政策内容": "Administrator has not set privacy policy content",
|
||||
"加载用户协议内容失败...": "Failed to load user agreement content...",
|
||||
"加载隐私政策内容失败...": "Failed to load privacy policy content...",
|
||||
"我已阅读并同意": "I have read and agree to",
|
||||
"和": " and ",
|
||||
"请先阅读并同意用户协议和隐私政策": "Please read and agree to the user agreement and privacy policy first",
|
||||
"填写用户协议内容后,用户注册时将被要求勾选已阅读用户协议": "After filling in the user agreement content, users will be required to check that they have read the user agreement when registering",
|
||||
"填写隐私政策内容后,用户注册时将被要求勾选已阅读隐私政策": "After filling in the privacy policy content, users will be required to check that they have read the privacy policy when registering",
|
||||
"管理员设置了外部链接,点击下方按钮访问": "Administrator has set an external link, click the button below to access",
|
||||
"访问用户协议": "Access User Agreement",
|
||||
"访问隐私政策": "Access Privacy Policy"
|
||||
}
|
||||
|
||||
@@ -2252,5 +2252,27 @@
|
||||
"补单成功": "Commande complétée avec succès",
|
||||
"补单失败": "Échec de la complétion de la commande",
|
||||
"确认补单": "Confirmer la complétion",
|
||||
"是否将该订单标记为成功并为用户入账?": "Marquer cette commande comme réussie et créditer l'utilisateur ?"
|
||||
"是否将该订单标记为成功并为用户入账?": "Marquer cette commande comme réussie et créditer l'utilisateur ?",
|
||||
"用户协议": "Accord utilisateur",
|
||||
"隐私政策": "Politique de confidentialité",
|
||||
"在此输入用户协议内容,支持 Markdown & HTML 代码": "Saisissez ici le contenu de l'accord utilisateur, prend en charge le code Markdown et HTML",
|
||||
"在此输入隐私政策内容,支持 Markdown & HTML 代码": "Saisissez ici le contenu de la politique de confidentialité, prend en charge le code Markdown et HTML",
|
||||
"设置用户协议": "Définir l'accord utilisateur",
|
||||
"设置隐私政策": "Définir la politique de confidentialité",
|
||||
"用户协议已更新": "L'accord utilisateur a été mis à jour",
|
||||
"隐私政策已更新": "La politique de confidentialité a été mise à jour",
|
||||
"用户协议更新失败": "Échec de la mise à jour de l'accord utilisateur",
|
||||
"隐私政策更新失败": "Échec de la mise à jour de la politique de confidentialité",
|
||||
"管理员未设置用户协议内容": "L'administrateur n'a pas défini le contenu de l'accord utilisateur",
|
||||
"管理员未设置隐私政策内容": "L'administrateur n'a pas défini le contenu de la politique de confidentialité",
|
||||
"加载用户协议内容失败...": "Échec du chargement du contenu de l'accord utilisateur...",
|
||||
"加载隐私政策内容失败...": "Échec du chargement du contenu de la politique de confidentialité...",
|
||||
"我已阅读并同意": "J'ai lu et j'accepte",
|
||||
"和": " et ",
|
||||
"请先阅读并同意用户协议和隐私政策": "Veuillez d'abord lire et accepter l'accord utilisateur et la politique de confidentialité",
|
||||
"填写用户协议内容后,用户注册时将被要求勾选已阅读用户协议": "Après avoir rempli le contenu de l'accord utilisateur, les utilisateurs devront cocher qu'ils ont lu l'accord utilisateur lors de l'inscription",
|
||||
"填写隐私政策内容后,用户注册时将被要求勾选已阅读隐私政策": "Après avoir rempli le contenu de la politique de confidentialité, les utilisateurs devront cocher qu'ils ont lu la politique de confidentialité lors de l'inscription",
|
||||
"管理员设置了外部链接,点击下方按钮访问": "L'administrateur a défini un lien externe, cliquez sur le bouton ci-dessous pour accéder",
|
||||
"访问用户协议": "Accéder à l'accord utilisateur",
|
||||
"访问隐私政策": "Accéder à la politique de confidentialité"
|
||||
}
|
||||
|
||||
@@ -111,5 +111,27 @@
|
||||
"补单失败": "补单失败",
|
||||
"确认补单": "确认补单",
|
||||
"是否将该订单标记为成功并为用户入账?": "是否将该订单标记为成功并为用户入账?",
|
||||
"操作": "操作"
|
||||
"操作": "操作",
|
||||
"用户协议": "用户协议",
|
||||
"隐私政策": "隐私政策",
|
||||
"在此输入用户协议内容,支持 Markdown & HTML 代码": "在此输入用户协议内容,支持 Markdown & HTML 代码",
|
||||
"在此输入隐私政策内容,支持 Markdown & HTML 代码": "在此输入隐私政策内容,支持 Markdown & HTML 代码",
|
||||
"设置用户协议": "设置用户协议",
|
||||
"设置隐私政策": "设置隐私政策",
|
||||
"用户协议已更新": "用户协议已更新",
|
||||
"隐私政策已更新": "隐私政策已更新",
|
||||
"用户协议更新失败": "用户协议更新失败",
|
||||
"隐私政策更新失败": "隐私政策更新失败",
|
||||
"管理员未设置用户协议内容": "管理员未设置用户协议内容",
|
||||
"管理员未设置隐私政策内容": "管理员未设置隐私政策内容",
|
||||
"加载用户协议内容失败...": "加载用户协议内容失败...",
|
||||
"加载隐私政策内容失败...": "加载隐私政策内容失败...",
|
||||
"我已阅读并同意": "我已阅读并同意",
|
||||
"和": "和",
|
||||
"请先阅读并同意用户协议和隐私政策": "请先阅读并同意用户协议和隐私政策",
|
||||
"填写用户协议内容后,用户注册时将被要求勾选已阅读用户协议": "填写用户协议内容后,用户注册时将被要求勾选已阅读用户协议",
|
||||
"填写隐私政策内容后,用户注册时将被要求勾选已阅读隐私政策": "填写隐私政策内容后,用户注册时将被要求勾选已阅读隐私政策",
|
||||
"管理员设置了外部链接,点击下方按钮访问": "管理员设置了外部链接,点击下方按钮访问",
|
||||
"访问用户协议": "访问用户协议",
|
||||
"访问隐私政策": "访问隐私政策"
|
||||
}
|
||||
|
||||
37
web/src/pages/PrivacyPolicy/index.jsx
Normal file
37
web/src/pages/PrivacyPolicy/index.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import DocumentRenderer from '../../components/common/DocumentRenderer';
|
||||
|
||||
const PrivacyPolicy = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<DocumentRenderer
|
||||
apiEndpoint="/api/privacy-policy"
|
||||
title={t('隐私政策')}
|
||||
cacheKey="privacy_policy"
|
||||
emptyMessage={t('加载隐私政策内容失败...')}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PrivacyPolicy;
|
||||
37
web/src/pages/UserAgreement/index.jsx
Normal file
37
web/src/pages/UserAgreement/index.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import DocumentRenderer from '../../components/common/DocumentRenderer';
|
||||
|
||||
const UserAgreement = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<DocumentRenderer
|
||||
apiEndpoint="/api/user-agreement"
|
||||
title={t('用户协议')}
|
||||
cacheKey="user_agreement"
|
||||
emptyMessage={t('加载用户协议内容失败...')}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserAgreement;
|
||||
Reference in New Issue
Block a user