release: prepare v0.1.131
This commit is contained in:
@@ -1 +1 @@
|
|||||||
0.1.130
|
0.1.131
|
||||||
|
|||||||
@@ -1134,6 +1134,14 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
|||||||
response.BadRequest(c, "Custom menu item visibility must be 'user' or 'admin'")
|
response.BadRequest(c, "Custom menu item visibility must be 'user' or 'admin'")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
switch item.Placement {
|
||||||
|
case "", "sidebar":
|
||||||
|
items[i].Placement = "sidebar"
|
||||||
|
case "home_header", "both":
|
||||||
|
default:
|
||||||
|
response.BadRequest(c, "Custom menu item placement must be 'sidebar', 'home_header' or 'both'")
|
||||||
|
return
|
||||||
|
}
|
||||||
if len(item.IconSVG) > maxMenuItemIconSVGLen {
|
if len(item.IconSVG) > maxMenuItemIconSVGLen {
|
||||||
response.BadRequest(c, "Custom menu item icon SVG is too large (max 10KB)")
|
response.BadRequest(c, "Custom menu item icon SVG is too large (max 10KB)")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type CustomMenuItem struct {
|
|||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
PageSlug string `json:"page_slug,omitempty"`
|
PageSlug string `json:"page_slug,omitempty"`
|
||||||
Visibility string `json:"visibility"` // "user" or "admin"
|
Visibility string `json:"visibility"` // "user" or "admin"
|
||||||
|
Placement string `json:"placement,omitempty"`
|
||||||
SortOrder int `json:"sort_order"`
|
SortOrder int `json:"sort_order"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,9 +368,25 @@ func ParseCustomMenuItems(raw string) []CustomMenuItem {
|
|||||||
if err := json.Unmarshal([]byte(raw), &items); err != nil {
|
if err := json.Unmarshal([]byte(raw), &items); err != nil {
|
||||||
return []CustomMenuItem{}
|
return []CustomMenuItem{}
|
||||||
}
|
}
|
||||||
|
for i := range items {
|
||||||
|
items[i].Placement = normalizeCustomMenuPlacement(items[i].Placement)
|
||||||
|
}
|
||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeCustomMenuPlacement(raw string) string {
|
||||||
|
switch strings.TrimSpace(raw) {
|
||||||
|
case "", "sidebar":
|
||||||
|
return "sidebar"
|
||||||
|
case "home_header":
|
||||||
|
return "home_header"
|
||||||
|
case "both":
|
||||||
|
return "both"
|
||||||
|
default:
|
||||||
|
return "sidebar"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ParseUserVisibleMenuItems parses custom menu items and filters out admin-only entries.
|
// ParseUserVisibleMenuItems parses custom menu items and filters out admin-only entries.
|
||||||
func ParseUserVisibleMenuItems(raw string) []CustomMenuItem {
|
func ParseUserVisibleMenuItems(raw string) []CustomMenuItem {
|
||||||
items := ParseCustomMenuItems(raw)
|
items := ParseCustomMenuItems(raw)
|
||||||
|
|||||||
@@ -91,8 +91,17 @@ func SecurityHeaders(cfg config.CSPConfig, getFrameSrcOrigins func() []string) g
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
embeddedMode := isEmbeddedUIRequest(c)
|
||||||
|
if embeddedMode {
|
||||||
|
finalPolicy = setDirective(finalPolicy, "frame-ancestors", "'self'")
|
||||||
|
}
|
||||||
|
|
||||||
c.Header("X-Content-Type-Options", "nosniff")
|
c.Header("X-Content-Type-Options", "nosniff")
|
||||||
c.Header("X-Frame-Options", "DENY")
|
if embeddedMode {
|
||||||
|
c.Header("X-Frame-Options", "SAMEORIGIN")
|
||||||
|
} else {
|
||||||
|
c.Header("X-Frame-Options", "DENY")
|
||||||
|
}
|
||||||
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
|
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||||
if isAPIRoutePath(c) {
|
if isAPIRoutePath(c) {
|
||||||
c.Next()
|
c.Next()
|
||||||
@@ -127,6 +136,13 @@ func isAPIRoutePath(c *gin.Context) bool {
|
|||||||
strings.HasPrefix(path, "/images")
|
strings.HasPrefix(path, "/images")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isEmbeddedUIRequest(c *gin.Context) bool {
|
||||||
|
if c == nil || c.Request == nil || c.Request.URL == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.EqualFold(strings.TrimSpace(c.Query("ui_mode")), "embedded")
|
||||||
|
}
|
||||||
|
|
||||||
// enhanceCSPPolicy 确保 CSP 策略包含 nonce 支持和支付 SDK 必需域名。
|
// enhanceCSPPolicy 确保 CSP 策略包含 nonce 支持和支付 SDK 必需域名。
|
||||||
// 这样旧配置文件没有及时补域名时,前端支付组件仍能正常加载。
|
// 这样旧配置文件没有及时补域名时,前端支付组件仍能正常加载。
|
||||||
func enhanceCSPPolicy(policy string) string {
|
func enhanceCSPPolicy(policy string) string {
|
||||||
@@ -195,3 +211,31 @@ func addToDirective(policy, directive, value string) string {
|
|||||||
insertPos := idx + endIdx
|
insertPos := idx + endIdx
|
||||||
return policy[:insertPos] + " " + value + policy[insertPos:]
|
return policy[:insertPos] + " " + value + policy[insertPos:]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setDirective(policy, directive, value string) string {
|
||||||
|
directives := strings.Split(policy, ";")
|
||||||
|
replaced := false
|
||||||
|
for i, rawDirective := range directives {
|
||||||
|
fields := strings.Fields(strings.TrimSpace(rawDirective))
|
||||||
|
if len(fields) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if fields[0] == directive {
|
||||||
|
directives[i] = directive + " " + value
|
||||||
|
replaced = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !replaced {
|
||||||
|
directives = append(directives, directive+" "+value)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]string, 0, len(directives))
|
||||||
|
for _, rawDirective := range directives {
|
||||||
|
trimmed := strings.TrimSpace(rawDirective)
|
||||||
|
if trimmed == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, trimmed)
|
||||||
|
}
|
||||||
|
return strings.Join(result, "; ")
|
||||||
|
}
|
||||||
|
|||||||
@@ -151,6 +151,24 @@ func TestSecurityHeaders(t *testing.T) {
|
|||||||
assert.Empty(t, GetNonceFromContext(c))
|
assert.Empty(t, GetNonceFromContext(c))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("embedded_ui_allows_same_origin_frame", func(t *testing.T) {
|
||||||
|
cfg := config.CSPConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Policy: "default-src 'self'; frame-ancestors 'none'",
|
||||||
|
}
|
||||||
|
middleware := SecurityHeaders(cfg, nil)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodGet, "/docs/api.html?ui_mode=embedded", nil)
|
||||||
|
|
||||||
|
middleware(c)
|
||||||
|
|
||||||
|
assert.Equal(t, "SAMEORIGIN", w.Header().Get("X-Frame-Options"))
|
||||||
|
assert.Contains(t, w.Header().Get("Content-Security-Policy"), "frame-ancestors 'self'")
|
||||||
|
assert.NotContains(t, w.Header().Get("Content-Security-Policy"), "frame-ancestors 'none'")
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("csp_enabled_with_nonce_placeholder", func(t *testing.T) {
|
t.Run("csp_enabled_with_nonce_placeholder", func(t *testing.T) {
|
||||||
cfg := config.CSPConfig{
|
cfg := config.CSPConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
@@ -410,6 +428,23 @@ func TestAddToDirective(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSetDirective(t *testing.T) {
|
||||||
|
t.Run("replaces_existing_directive", func(t *testing.T) {
|
||||||
|
policy := "default-src 'self'; frame-ancestors 'none'; script-src 'self'"
|
||||||
|
result := setDirective(policy, "frame-ancestors", "'self'")
|
||||||
|
|
||||||
|
assert.Contains(t, result, "frame-ancestors 'self'")
|
||||||
|
assert.NotContains(t, result, "frame-ancestors 'none'")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("adds_directive_when_missing", func(t *testing.T) {
|
||||||
|
policy := "default-src 'self'; script-src 'self'"
|
||||||
|
result := setDirective(policy, "frame-ancestors", "'self'")
|
||||||
|
|
||||||
|
assert.Contains(t, result, "frame-ancestors 'self'")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Benchmark tests
|
// Benchmark tests
|
||||||
func BenchmarkGenerateNonce(b *testing.B) {
|
func BenchmarkGenerateNonce(b *testing.B) {
|
||||||
for i := 0; i < b.N; i++ {
|
for i := 0; i < b.N; i++ {
|
||||||
|
|||||||
@@ -720,7 +720,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
|||||||
PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]),
|
PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]),
|
||||||
TableDefaultPageSize: tableDefaultPageSize,
|
TableDefaultPageSize: tableDefaultPageSize,
|
||||||
TablePageSizeOptions: tablePageSizeOptions,
|
TablePageSizeOptions: tablePageSizeOptions,
|
||||||
CustomMenuItems: settings[SettingKeyCustomMenuItems],
|
CustomMenuItems: string(normalizeCustomMenuItemsRaw(settings[SettingKeyCustomMenuItems])),
|
||||||
CustomEndpoints: settings[SettingKeyCustomEndpoints],
|
CustomEndpoints: settings[SettingKeyCustomEndpoints],
|
||||||
LinuxDoOAuthEnabled: linuxDoEnabled,
|
LinuxDoOAuthEnabled: linuxDoEnabled,
|
||||||
WeChatOAuthEnabled: weChatEnabled,
|
WeChatOAuthEnabled: weChatEnabled,
|
||||||
@@ -987,7 +987,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
|
|||||||
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
|
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
|
||||||
TableDefaultPageSize: settings.TableDefaultPageSize,
|
TableDefaultPageSize: settings.TableDefaultPageSize,
|
||||||
TablePageSizeOptions: settings.TablePageSizeOptions,
|
TablePageSizeOptions: settings.TablePageSizeOptions,
|
||||||
CustomMenuItems: filterUserVisibleMenuItems(settings.CustomMenuItems),
|
CustomMenuItems: filterUserVisibleMenuItems(string(normalizeCustomMenuItemsRaw(settings.CustomMenuItems))),
|
||||||
CustomEndpoints: safeRawJSONArray(settings.CustomEndpoints),
|
CustomEndpoints: safeRawJSONArray(settings.CustomEndpoints),
|
||||||
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
|
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
|
||||||
WeChatOAuthEnabled: settings.WeChatOAuthEnabled,
|
WeChatOAuthEnabled: settings.WeChatOAuthEnabled,
|
||||||
@@ -1200,6 +1200,32 @@ func filterUserVisibleMenuItems(raw string) json.RawMessage {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeCustomMenuItemsRaw(raw string) json.RawMessage {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" || raw == "[]" {
|
||||||
|
return json.RawMessage("[]")
|
||||||
|
}
|
||||||
|
var items []map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(raw), &items); err != nil {
|
||||||
|
return json.RawMessage("[]")
|
||||||
|
}
|
||||||
|
for _, item := range items {
|
||||||
|
placement, _ := item["placement"].(string)
|
||||||
|
switch strings.TrimSpace(placement) {
|
||||||
|
case "", "sidebar":
|
||||||
|
item["placement"] = "sidebar"
|
||||||
|
case "home_header", "both":
|
||||||
|
default:
|
||||||
|
item["placement"] = "sidebar"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
normalized, err := json.Marshal(items)
|
||||||
|
if err != nil {
|
||||||
|
return json.RawMessage("[]")
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
// safeRawJSONArray returns raw as json.RawMessage if it's valid JSON, otherwise "[]".
|
// safeRawJSONArray returns raw as json.RawMessage if it's valid JSON, otherwise "[]".
|
||||||
func safeRawJSONArray(raw string) json.RawMessage {
|
func safeRawJSONArray(raw string) json.RawMessage {
|
||||||
raw = strings.TrimSpace(raw)
|
raw = strings.TrimSpace(raw)
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ services:
|
|||||||
# Sub2API Application
|
# Sub2API Application
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
sub2api:
|
sub2api:
|
||||||
image: weishaw/sub2api:latest
|
image: ghcr.io/man209111-cpu/sub2api:latest
|
||||||
container_name: sub2api
|
container_name: sub2api
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ulimits:
|
ulimits:
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ services:
|
|||||||
# Sub2API Application
|
# Sub2API Application
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
sub2api:
|
sub2api:
|
||||||
image: weishaw/sub2api:latest
|
image: ghcr.io/man209111-cpu/sub2api:latest
|
||||||
container_name: sub2api
|
container_name: sub2api
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ulimits:
|
ulimits:
|
||||||
|
|||||||
+1068
-310
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,667 @@
|
|||||||
|
(function () {
|
||||||
|
window.DOCS_I18N = {
|
||||||
|
zh: {
|
||||||
|
lang: "zh-CN",
|
||||||
|
localeLabel: "语言",
|
||||||
|
viewDocs: "查看文档",
|
||||||
|
goLogin: "登录",
|
||||||
|
dashboard: "控制台",
|
||||||
|
themeDark: "切换到深色模式",
|
||||||
|
themeLight: "切换到浅色模式",
|
||||||
|
titleSuffix: "大模型 API 接入文档",
|
||||||
|
messages: {
|
||||||
|
copy: "复制",
|
||||||
|
copied: "已复制",
|
||||||
|
copyFailed: "复制失败,请手动复制。",
|
||||||
|
pasteApiKeyFirst: "请先粘贴 API Key。",
|
||||||
|
tryingOpen: "正在尝试拉起 CC-Switch...",
|
||||||
|
clientNotOpened:
|
||||||
|
"没有拉起客户端。请先安装 CC-Switch,或检查协议处理程序是否已注册。",
|
||||||
|
deepLinkCopied: "CC-Switch 深链已复制。",
|
||||||
|
importFailed: "导入失败。",
|
||||||
|
copyRootHost: "复制根 Host",
|
||||||
|
copyV1: "复制 /v1",
|
||||||
|
},
|
||||||
|
hero: {
|
||||||
|
priority: "CC-Switch 优先",
|
||||||
|
defaultHostLabel: "默认 Host:",
|
||||||
|
docPathLabel: "文档路径:",
|
||||||
|
title: "大模型 API 接入文档",
|
||||||
|
copyHtml:
|
||||||
|
'推荐流程:先进入 <code>/keys</code> 创建或复制 API Key,再优先点击 <code>导入到 CCS</code> 一键导入到 CC-Switch。其余客户端按对应协议接入:OpenAI SDK / HTTP 使用 <code>/v1</code>,Claude Code 使用根 Host,Gemini / OpenClaw / Hermes 按各自配置格式接入。',
|
||||||
|
actions: ["进入 API 密钥", "下载 CC-Switch", "查看 Host 列表"],
|
||||||
|
statLabels: ["默认主 Host", "站内取 Key 路径", "文档放置方式", "兼容协议"],
|
||||||
|
keyPathHtml:
|
||||||
|
"<code>/keys</code> -> 创建密钥 / 使用密钥 / 导入到 CCS",
|
||||||
|
placement:
|
||||||
|
"静态页,适合挂到文档链接或自定义菜单",
|
||||||
|
protocols:
|
||||||
|
"OpenAI / Claude / Gemini / Responses / OpenClaw / Hermes",
|
||||||
|
},
|
||||||
|
sidebar: {
|
||||||
|
title: "目录",
|
||||||
|
links: [
|
||||||
|
"快速开始",
|
||||||
|
"CC-Switch",
|
||||||
|
"支持平台概览",
|
||||||
|
"Host 列表",
|
||||||
|
"HTTP 示例",
|
||||||
|
"JavaScript SDK",
|
||||||
|
"Python SDK",
|
||||||
|
"Claude Code",
|
||||||
|
"Gemini CLI",
|
||||||
|
"Codex CLI",
|
||||||
|
"OpenCode",
|
||||||
|
"OpenClaw",
|
||||||
|
"Hermes",
|
||||||
|
"常见问题",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
quickstart: {
|
||||||
|
title: "快速开始",
|
||||||
|
descHtml:
|
||||||
|
'默认推荐走 CC-Switch。它和站内 <code>API 密钥</code> 页面已经对齐,拿到 Key 后可以直接一键导入。',
|
||||||
|
cards: [
|
||||||
|
{
|
||||||
|
title: "获取 API Key",
|
||||||
|
bodyHtml:
|
||||||
|
'登录站点后进入 <code>/keys</code>。创建密钥后,列表行内可以直接看到 <code>使用密钥</code> 和 <code>导入到 CCS</code> 两个动作。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "优先一键导入",
|
||||||
|
bodyHtml:
|
||||||
|
'点击 <code>导入到 CCS</code>。OpenAI 分组会导入为 Codex,Anthropic 分组导入为 Claude Code,Gemini 分组导入为 Gemini CLI,Antigravity 会先让你选 Claude 或 Gemini。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "按客户端使用",
|
||||||
|
bodyHtml:
|
||||||
|
"如果不使用 CC-Switch,按下方目录选择对应格式:HTTP、OpenAI SDK、Claude Code、Gemini CLI、Codex CLI、OpenClaw、Hermes。",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
noteHtml:
|
||||||
|
'后台管理员如果隐藏了 <code>导入到 CCS</code> 按钮,用户仍然可以在本页使用“手动导入 CC-Switch”工具,或按下方 CLI / SDK 示例手动配置。',
|
||||||
|
},
|
||||||
|
ccswitch: {
|
||||||
|
title: "CC-Switch",
|
||||||
|
descHtml:
|
||||||
|
'站内默认推荐使用 CC-Switch。先在站内 <code>/keys</code> 获取 API Key,再导入到对应客户端配置。',
|
||||||
|
cards: {
|
||||||
|
download: {
|
||||||
|
title: "下载安装索引",
|
||||||
|
items: [
|
||||||
|
'官方 Releases:<a href="https://github.com/farion1231/cc-switch/releases" target="_blank" rel="noopener noreferrer">GitHub Releases</a>',
|
||||||
|
'Windows:推荐 <code>CC-Switch-*-Windows.msi</code>;便携版可用 <code>Windows-Portable.zip</code>',
|
||||||
|
'macOS:推荐 <code>macOS.zip</code>;Homebrew 可用 <code>brew tap farion1231/ccswitch</code> + <code>brew install --cask cc-switch</code>',
|
||||||
|
'Linux:Debian/Ubuntu 用 <code>.deb</code>,Fedora/RHEL 用 <code>.rpm</code>,通用可用 <code>.AppImage</code>',
|
||||||
|
],
|
||||||
|
actions: ["打开下载页", "去 API 密钥"],
|
||||||
|
},
|
||||||
|
oneClick: {
|
||||||
|
title: "站内一键导入位置",
|
||||||
|
items: [
|
||||||
|
"路径:<code>/keys</code>",
|
||||||
|
'拿到 Key 后,点击同一行的 <code>导入到 CCS</code>',
|
||||||
|
'Antigravity 分组会先选择导入到 <code>Claude Code</code> 或 <code>Gemini CLI</code>',
|
||||||
|
"导入完成后,在 CC-Switch 对应应用的 Provider 列表里查看",
|
||||||
|
],
|
||||||
|
noteHtml:
|
||||||
|
'如果点击导入后没有拉起客户端,通常是 CC-Switch 未安装,或系统还没有注册 <code>ccswitch://</code> 协议。',
|
||||||
|
},
|
||||||
|
importMap: {
|
||||||
|
title: "导入映射",
|
||||||
|
headers: ["站内分组", "导入应用", "CC-Switch Endpoint"],
|
||||||
|
rows: [
|
||||||
|
["OpenAI", "Codex", "<code>HOST</code>"],
|
||||||
|
["Anthropic / Claude", "Claude Code", "<code>HOST</code>"],
|
||||||
|
["Gemini", "Gemini CLI", "<code>HOST</code>"],
|
||||||
|
[
|
||||||
|
"Antigravity",
|
||||||
|
"Claude Code / Gemini CLI",
|
||||||
|
"<code>HOST/antigravity</code>",
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
manual: {
|
||||||
|
title: "手动导入 CC-Switch",
|
||||||
|
labels: ["Host", "导入类型", "Provider 名称", "API Key"],
|
||||||
|
hostHelpHtml:
|
||||||
|
'默认优先使用 <span data-default-host-text>https://re.94xy.cn</span>。',
|
||||||
|
apiKeyPlaceholder: "粘贴从 /keys 复制出来的 API Key",
|
||||||
|
buttons: ["导入到 CC-Switch", "复制深链"],
|
||||||
|
options: [
|
||||||
|
"OpenAI / Codex",
|
||||||
|
"Claude Code",
|
||||||
|
"Gemini CLI",
|
||||||
|
"Antigravity -> Claude",
|
||||||
|
"Antigravity -> Gemini",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
overview: {
|
||||||
|
title: "支持平台概览",
|
||||||
|
descHtml:
|
||||||
|
'不同客户端读取 Host 的方式不一样。HTTP / OpenAI SDK 通常需要显式写 <code>/v1</code>;站内 CLI 配置模板大多使用根 Host。',
|
||||||
|
headers: ["平台 / 客户端", "协议", "推荐写法", "说明"],
|
||||||
|
rows: [
|
||||||
|
[
|
||||||
|
"HTTP / OpenAI SDK",
|
||||||
|
"OpenAI Chat / Responses",
|
||||||
|
'<code><span data-default-host-text>https://re.94xy.cn</span>/v1</code>',
|
||||||
|
"标准服务端与脚本最稳妥",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Claude Code",
|
||||||
|
"Anthropic 兼容",
|
||||||
|
'<code><span data-default-host-text>https://re.94xy.cn</span></code>',
|
||||||
|
"使用 <code>ANTHROPIC_BASE_URL</code>",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Gemini CLI",
|
||||||
|
"Gemini CLI 兼容",
|
||||||
|
'<code><span data-default-host-text>https://re.94xy.cn</span></code>',
|
||||||
|
"使用 <code>GOOGLE_GEMINI_BASE_URL</code>",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Codex CLI",
|
||||||
|
"OpenAI Responses",
|
||||||
|
'<code><span data-default-host-text>https://re.94xy.cn</span></code>',
|
||||||
|
"按站内生成模板写入 <code>~/.codex</code>",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"OpenCode",
|
||||||
|
"OpenAI / Anthropic / Google Provider",
|
||||||
|
'<code><span data-default-host-text>https://re.94xy.cn</span>/v1</code>',
|
||||||
|
"OpenAI 分组按 <code>baseURL</code> 配置",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"OpenClaw",
|
||||||
|
"Custom Provider",
|
||||||
|
'<code><span data-default-host-text>https://re.94xy.cn</span>/v1</code>',
|
||||||
|
'按 <code>~/.openclaw/openclaw.json</code> 写 <code>models.providers</code>',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Hermes",
|
||||||
|
"Custom Endpoint",
|
||||||
|
'<code><span data-default-host-text>https://re.94xy.cn</span>/v1</code>',
|
||||||
|
"当前版本使用 <code>~/.hermes/config.yaml</code>",
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
hosts: {
|
||||||
|
title: "API 接口地址 Host 列表",
|
||||||
|
descHtml:
|
||||||
|
'默认主 Host 为 <code>re.94xy.cn</code>。如果后台配置了公开的 <code>API Base URL</code> 或自定义端点,下方会自动带出来。',
|
||||||
|
noteHtml:
|
||||||
|
'HTTP / OpenAI SDK 通常写 <code>HOST/v1</code>;Gemini / OpenClaw / Hermes 的详细格式看各自章节。站内 <code>API 密钥</code> 页面也会展示管理员配置的可用 Host。',
|
||||||
|
},
|
||||||
|
http: {
|
||||||
|
title: "HTTP 示例",
|
||||||
|
descHtml:
|
||||||
|
'直接脚本、后端服务、第三方网关优先用 OpenAI 兼容格式。下方示例默认使用 <code><span data-default-host-text>https://re.94xy.cn</span>/v1</code>。',
|
||||||
|
},
|
||||||
|
sdkJs: {
|
||||||
|
title: "JavaScript SDK",
|
||||||
|
descHtml:
|
||||||
|
"只要 SDK 支持自定义 <code>baseURL</code>,大多数都能直接接入。",
|
||||||
|
},
|
||||||
|
sdkPython: {
|
||||||
|
title: "Python SDK",
|
||||||
|
descHtml:
|
||||||
|
'Python 接入方式和 JavaScript 一样,关键是把 <code>base_url</code> 指向网关的 <code>/v1</code>。',
|
||||||
|
},
|
||||||
|
claude: {
|
||||||
|
title: "Claude Code",
|
||||||
|
descHtml:
|
||||||
|
'Claude Code 按站内现有模板使用根 Host,不额外拼 <code>/v1</code>。',
|
||||||
|
},
|
||||||
|
gemini: {
|
||||||
|
title: "Gemini CLI",
|
||||||
|
descHtml:
|
||||||
|
"Gemini CLI 也使用根 Host。模型名按你当前可用权限调整。",
|
||||||
|
},
|
||||||
|
codex: {
|
||||||
|
title: "Codex CLI",
|
||||||
|
descHtml:
|
||||||
|
'站内 <code>使用密钥</code> 页面给 Codex CLI 生成的是 <code>~/.codex/config.toml</code> 与 <code>auth.json</code> 两个文件。以下格式与站内模板一致。',
|
||||||
|
},
|
||||||
|
opencode: {
|
||||||
|
title: "OpenCode",
|
||||||
|
descHtml:
|
||||||
|
'OpenCode 建议写入 <code>opencode.json</code>。OpenAI 分组的核心是 <code>provider.openai.options.baseURL</code> 与 <code>apiKey</code>。',
|
||||||
|
},
|
||||||
|
openclaw: {
|
||||||
|
title: "OpenClaw",
|
||||||
|
descHtml:
|
||||||
|
'OpenClaw 当前推荐按 <code>~/.openclaw/openclaw.json</code> 的 <code>models.providers</code> 写自定义 Provider。这里采用站内实际生成格式:OpenAI 分组使用 <code>api: "openai-responses"</code>。',
|
||||||
|
noteHtml:
|
||||||
|
'参考 OpenClaw 官方模型提供者文档:自定义 Provider 建议放在 <code>models.providers</code> 下,并按实际后端选择 <code>openai-responses</code> 或其他 API 类型。',
|
||||||
|
},
|
||||||
|
hermes: {
|
||||||
|
title: "Hermes",
|
||||||
|
descHtml:
|
||||||
|
'Hermes 当前版本以 <code>~/.hermes/config.yaml</code> 为准。官方文档已经明确:旧的 <code>OPENAI_BASE_URL</code> / <code>LLM_MODEL</code> 环境变量不再作为主配置来源。',
|
||||||
|
interactiveTitle: "交互式配置",
|
||||||
|
warningHtml:
|
||||||
|
'当前 Hermes 的自定义端点以 <code>config.yaml</code> 为单一事实来源。不要再依赖旧版 <code>.env</code> 里的 <code>OPENAI_BASE_URL</code>。',
|
||||||
|
},
|
||||||
|
faq: {
|
||||||
|
title: "常见问题",
|
||||||
|
headers: ["问题", "排查方向"],
|
||||||
|
rows: [
|
||||||
|
[
|
||||||
|
"CC-Switch 一键导入没有拉起",
|
||||||
|
'确认已安装 CC-Switch,且系统已注册 <code>ccswitch://</code> 协议;否则先走安装。',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"<code>401</code> / <code>invalid_api_key</code>",
|
||||||
|
'检查 Key 是否从 <code>/keys</code> 复制完整,确认没有多空格、换行或旧 Key。',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"<code>403</code>",
|
||||||
|
"密钥可能被禁用、触发 IP 限制,或当前分组不可用。",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"<code>404</code>",
|
||||||
|
'大多数是 Host 或路径写错。HTTP / SDK 常用 <code>/v1</code>;Claude / Gemini / Codex CLI 多数写根 Host。',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Hermes / OpenClaw 不工作",
|
||||||
|
'先确认配置文件路径正确,再确认 Host 是否按当前客户端格式填写。OpenClaw 看 <code>openclaw.json</code>,Hermes 看 <code>~/.hermes/config.yaml</code>。',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
footerHtml: '静态文档地址:<code>/docs/api.html</code>',
|
||||||
|
codeTitles: {
|
||||||
|
httpChat: "cURL /v1/chat/completions",
|
||||||
|
httpModels: "cURL /v1/models",
|
||||||
|
sdkJs: "openai npm",
|
||||||
|
sdkPython: "openai python",
|
||||||
|
claude: "macOS / Linux",
|
||||||
|
gemini: "macOS / Linux",
|
||||||
|
codexConfig: "~/.codex/config.toml",
|
||||||
|
codexAuth: "~/.codex/auth.json",
|
||||||
|
opencode: "opencode.json",
|
||||||
|
openclaw: "~/.openclaw/openclaw.json",
|
||||||
|
hermes: "~/.hermes/config.yaml",
|
||||||
|
hermesInteractive: "交互式配置",
|
||||||
|
},
|
||||||
|
codeSamples: {
|
||||||
|
httpChat: `curl https://re.94xy.cn/v1/chat/completions \\
|
||||||
|
-H "Authorization: Bearer YOUR_API_KEY" \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d '{
|
||||||
|
"model": "gpt-5.4",
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": "You are a helpful assistant."},
|
||||||
|
{"role": "user", "content": "请给我一份三点接入建议"}
|
||||||
|
]
|
||||||
|
}'`,
|
||||||
|
hermesInteractive: `hermes model
|
||||||
|
# 选择 "Custom endpoint (self-hosted / VLLM / etc.)"
|
||||||
|
# API base URL: https://re.94xy.cn/v1
|
||||||
|
# API key: YOUR_API_KEY
|
||||||
|
# Model name: gpt-5.4`,
|
||||||
|
},
|
||||||
|
hostCard: {
|
||||||
|
fallbackDesc: "公开可访问的网关入口",
|
||||||
|
labels: {
|
||||||
|
root: "根 Host",
|
||||||
|
openai: "OpenAI SDK / HTTP",
|
||||||
|
gemini: "Gemini / OpenClaw / Hermes",
|
||||||
|
antigravity: "Antigravity",
|
||||||
|
},
|
||||||
|
builtin: {
|
||||||
|
default: {
|
||||||
|
name: "默认主 Host",
|
||||||
|
description: "文档默认推荐值",
|
||||||
|
},
|
||||||
|
current: {
|
||||||
|
name: "当前访问地址",
|
||||||
|
description: "当前浏览器访问的站点地址",
|
||||||
|
},
|
||||||
|
apiBase: {
|
||||||
|
name: "后台 API Base URL",
|
||||||
|
description: "系统设置 -> 通用设置 中公开的 API Base URL",
|
||||||
|
},
|
||||||
|
customPrefix: "自定义端点",
|
||||||
|
customDescription: "后台公开的自定义 Host",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
lang: "en",
|
||||||
|
localeLabel: "Language",
|
||||||
|
viewDocs: "Docs",
|
||||||
|
goLogin: "Login",
|
||||||
|
dashboard: "Dashboard",
|
||||||
|
themeDark: "Switch to dark mode",
|
||||||
|
themeLight: "Switch to light mode",
|
||||||
|
titleSuffix: "API Integration Guide",
|
||||||
|
messages: {
|
||||||
|
copy: "Copy",
|
||||||
|
copied: "Copied",
|
||||||
|
copyFailed: "Copy failed. Please copy it manually.",
|
||||||
|
pasteApiKeyFirst: "Paste an API key first.",
|
||||||
|
tryingOpen: "Trying to open CC-Switch...",
|
||||||
|
clientNotOpened:
|
||||||
|
"The client was not opened. Install CC-Switch or check whether the protocol handler is registered.",
|
||||||
|
deepLinkCopied: "CC-Switch deep link copied.",
|
||||||
|
importFailed: "Import failed.",
|
||||||
|
copyRootHost: "Copy root host",
|
||||||
|
copyV1: "Copy /v1",
|
||||||
|
},
|
||||||
|
hero: {
|
||||||
|
priority: "CC-Switch First",
|
||||||
|
defaultHostLabel: "Default Host:",
|
||||||
|
docPathLabel: "Docs path:",
|
||||||
|
title: "API Integration Guide",
|
||||||
|
copyHtml:
|
||||||
|
'Recommended flow: go to <code>/keys</code> to create or copy an API key, then click <code>Import to CCS</code> to import it into CC-Switch in one step. For other clients, use the matching protocol: OpenAI SDK / HTTP uses <code>/v1</code>; Claude Code uses the root host; Gemini / OpenClaw / Hermes use their own config formats.',
|
||||||
|
actions: ["Open API Keys", "Download CC-Switch", "View Host List"],
|
||||||
|
statLabels: [
|
||||||
|
"Default host",
|
||||||
|
"Where to get keys",
|
||||||
|
"Recommended placement",
|
||||||
|
"Supported protocols",
|
||||||
|
],
|
||||||
|
keyPathHtml:
|
||||||
|
"<code>/keys</code> -> create key / use key / import to CCS",
|
||||||
|
placement:
|
||||||
|
"Static page, suitable for the Docs link or a custom menu entry",
|
||||||
|
protocols:
|
||||||
|
"OpenAI / Claude / Gemini / Responses / OpenClaw / Hermes",
|
||||||
|
},
|
||||||
|
sidebar: {
|
||||||
|
title: "Contents",
|
||||||
|
links: [
|
||||||
|
"Quick Start",
|
||||||
|
"CC-Switch",
|
||||||
|
"Platform Overview",
|
||||||
|
"Host List",
|
||||||
|
"HTTP Examples",
|
||||||
|
"JavaScript SDK",
|
||||||
|
"Python SDK",
|
||||||
|
"Claude Code",
|
||||||
|
"Gemini CLI",
|
||||||
|
"Codex CLI",
|
||||||
|
"OpenCode",
|
||||||
|
"OpenClaw",
|
||||||
|
"Hermes",
|
||||||
|
"FAQ",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
quickstart: {
|
||||||
|
title: "Quick Start",
|
||||||
|
descHtml:
|
||||||
|
'CC-Switch is the default recommendation. It already matches the <code>API Keys</code> page on this site, so once you have a key you can import it directly.',
|
||||||
|
cards: [
|
||||||
|
{
|
||||||
|
title: "Get an API key",
|
||||||
|
bodyHtml:
|
||||||
|
'After logging in, open <code>/keys</code>. Once a key is created, each row shows both <code>Use Key</code> and <code>Import to CCS</code>.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Prefer one-click import",
|
||||||
|
bodyHtml:
|
||||||
|
'Click <code>Import to CCS</code>. OpenAI groups are imported as Codex, Anthropic groups as Claude Code, Gemini groups as Gemini CLI, and Antigravity lets you choose Claude or Gemini first.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Choose by client",
|
||||||
|
bodyHtml:
|
||||||
|
"If you do not use CC-Switch, pick the matching format below: HTTP, OpenAI SDK, Claude Code, Gemini CLI, Codex CLI, OpenClaw, or Hermes.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
noteHtml:
|
||||||
|
'If the admin hides the <code>Import to CCS</code> button, users can still use the manual CC-Switch import tool on this page or configure the client manually with the CLI / SDK examples below.',
|
||||||
|
},
|
||||||
|
ccswitch: {
|
||||||
|
title: "CC-Switch",
|
||||||
|
descHtml:
|
||||||
|
'CC-Switch is the default recommendation on this site. First get an API key from <code>/keys</code>, then import it into the target client.',
|
||||||
|
cards: {
|
||||||
|
download: {
|
||||||
|
title: "Download Index",
|
||||||
|
items: [
|
||||||
|
'Official releases: <a href="https://github.com/farion1231/cc-switch/releases" target="_blank" rel="noopener noreferrer">GitHub Releases</a>',
|
||||||
|
'Windows: use <code>CC-Switch-*-Windows.msi</code>; a portable build is available as <code>Windows-Portable.zip</code>',
|
||||||
|
'macOS: use <code>macOS.zip</code>; Homebrew is also available with <code>brew tap farion1231/ccswitch</code> + <code>brew install --cask cc-switch</code>',
|
||||||
|
'Linux: use <code>.deb</code> for Debian/Ubuntu, <code>.rpm</code> for Fedora/RHEL, or <code>.AppImage</code> for a generic build',
|
||||||
|
],
|
||||||
|
actions: ["Open download page", "Open API Keys"],
|
||||||
|
},
|
||||||
|
oneClick: {
|
||||||
|
title: "Where one-click import lives",
|
||||||
|
items: [
|
||||||
|
"Path: <code>/keys</code>",
|
||||||
|
'After you get the key, click <code>Import to CCS</code> on the same row',
|
||||||
|
'For Antigravity groups, choose either <code>Claude Code</code> or <code>Gemini CLI</code> first',
|
||||||
|
"After import, check the provider list for that app inside CC-Switch",
|
||||||
|
],
|
||||||
|
noteHtml:
|
||||||
|
'If clicking import does not open the client, CC-Switch is usually not installed yet, or the <code>ccswitch://</code> protocol has not been registered on the system.',
|
||||||
|
},
|
||||||
|
importMap: {
|
||||||
|
title: "Import Mapping",
|
||||||
|
headers: ["Site group", "Imported app", "CC-Switch endpoint"],
|
||||||
|
rows: [
|
||||||
|
["OpenAI", "Codex", "<code>HOST</code>"],
|
||||||
|
["Anthropic / Claude", "Claude Code", "<code>HOST</code>"],
|
||||||
|
["Gemini", "Gemini CLI", "<code>HOST</code>"],
|
||||||
|
[
|
||||||
|
"Antigravity",
|
||||||
|
"Claude Code / Gemini CLI",
|
||||||
|
"<code>HOST/antigravity</code>",
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
manual: {
|
||||||
|
title: "Manual CC-Switch Import",
|
||||||
|
labels: ["Host", "Import type", "Provider name", "API key"],
|
||||||
|
hostHelpHtml:
|
||||||
|
'Default recommendation: <span data-default-host-text>https://re.94xy.cn</span>.',
|
||||||
|
apiKeyPlaceholder: "Paste the API key copied from /keys",
|
||||||
|
buttons: ["Import to CC-Switch", "Copy deep link"],
|
||||||
|
options: [
|
||||||
|
"OpenAI / Codex",
|
||||||
|
"Claude Code",
|
||||||
|
"Gemini CLI",
|
||||||
|
"Antigravity -> Claude",
|
||||||
|
"Antigravity -> Gemini",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
overview: {
|
||||||
|
title: "Platform Overview",
|
||||||
|
descHtml:
|
||||||
|
'Different clients read the host in different ways. HTTP / OpenAI SDK usually needs an explicit <code>/v1</code>; most CLI templates on this site use the root host.',
|
||||||
|
headers: ["Platform / Client", "Protocol", "Recommended format", "Notes"],
|
||||||
|
rows: [
|
||||||
|
[
|
||||||
|
"HTTP / OpenAI SDK",
|
||||||
|
"OpenAI Chat / Responses",
|
||||||
|
'<code><span data-default-host-text>https://re.94xy.cn</span>/v1</code>',
|
||||||
|
"Best default for servers and scripts",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Claude Code",
|
||||||
|
"Anthropic compatible",
|
||||||
|
'<code><span data-default-host-text>https://re.94xy.cn</span></code>',
|
||||||
|
"Use <code>ANTHROPIC_BASE_URL</code>",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Gemini CLI",
|
||||||
|
"Gemini CLI compatible",
|
||||||
|
'<code><span data-default-host-text>https://re.94xy.cn</span></code>',
|
||||||
|
"Use <code>GOOGLE_GEMINI_BASE_URL</code>",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Codex CLI",
|
||||||
|
"OpenAI Responses",
|
||||||
|
'<code><span data-default-host-text>https://re.94xy.cn</span></code>',
|
||||||
|
"Write the template generated by this site into <code>~/.codex</code>",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"OpenCode",
|
||||||
|
"OpenAI / Anthropic / Google Provider",
|
||||||
|
'<code><span data-default-host-text>https://re.94xy.cn</span>/v1</code>',
|
||||||
|
"OpenAI groups use <code>baseURL</code>",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"OpenClaw",
|
||||||
|
"Custom Provider",
|
||||||
|
'<code><span data-default-host-text>https://re.94xy.cn</span>/v1</code>',
|
||||||
|
'Write <code>models.providers</code> in <code>~/.openclaw/openclaw.json</code>',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Hermes",
|
||||||
|
"Custom Endpoint",
|
||||||
|
'<code><span data-default-host-text>https://re.94xy.cn</span>/v1</code>',
|
||||||
|
"Current releases use <code>~/.hermes/config.yaml</code>",
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
hosts: {
|
||||||
|
title: "API Host List",
|
||||||
|
descHtml:
|
||||||
|
'The default primary host is <code>re.94xy.cn</code>. If the admin configured a public <code>API Base URL</code> or custom endpoints, they will appear below automatically.',
|
||||||
|
noteHtml:
|
||||||
|
'HTTP / OpenAI SDK usually uses <code>HOST/v1</code>; for Gemini / OpenClaw / Hermes, see each section for the exact format. The <code>API Keys</code> page on this site also shows the available hosts configured by the admin.',
|
||||||
|
},
|
||||||
|
http: {
|
||||||
|
title: "HTTP Examples",
|
||||||
|
descHtml:
|
||||||
|
'For direct scripts, backend services, or third-party gateways, prefer the OpenAI-compatible format. The examples below use <code><span data-default-host-text>https://re.94xy.cn</span>/v1</code>.',
|
||||||
|
},
|
||||||
|
sdkJs: {
|
||||||
|
title: "JavaScript SDK",
|
||||||
|
descHtml:
|
||||||
|
"As long as the SDK supports a custom <code>baseURL</code>, most clients work directly.",
|
||||||
|
},
|
||||||
|
sdkPython: {
|
||||||
|
title: "Python SDK",
|
||||||
|
descHtml:
|
||||||
|
'Python works the same way as JavaScript: point <code>base_url</code> at the gateway <code>/v1</code>.',
|
||||||
|
},
|
||||||
|
claude: {
|
||||||
|
title: "Claude Code",
|
||||||
|
descHtml:
|
||||||
|
'Claude Code uses the root host from the current site templates and does not need an extra <code>/v1</code>.',
|
||||||
|
},
|
||||||
|
gemini: {
|
||||||
|
title: "Gemini CLI",
|
||||||
|
descHtml:
|
||||||
|
"Gemini CLI also uses the root host. Adjust the model name based on the access you currently have.",
|
||||||
|
},
|
||||||
|
codex: {
|
||||||
|
title: "Codex CLI",
|
||||||
|
descHtml:
|
||||||
|
'The <code>Use Key</code> page on this site generates two files for Codex CLI: <code>~/.codex/config.toml</code> and <code>auth.json</code>. The format below matches that template.',
|
||||||
|
},
|
||||||
|
opencode: {
|
||||||
|
title: "OpenCode",
|
||||||
|
descHtml:
|
||||||
|
'OpenCode is best configured through <code>opencode.json</code>. For OpenAI groups, the key fields are <code>provider.openai.options.baseURL</code> and <code>apiKey</code>.',
|
||||||
|
},
|
||||||
|
openclaw: {
|
||||||
|
title: "OpenClaw",
|
||||||
|
descHtml:
|
||||||
|
'For OpenClaw, the current recommendation is to define a custom provider in <code>models.providers</code> inside <code>~/.openclaw/openclaw.json</code>. This page follows the format generated by the site: OpenAI groups use <code>api: "openai-responses"</code>.',
|
||||||
|
noteHtml:
|
||||||
|
'See the official OpenClaw provider docs as well: custom providers should live under <code>models.providers</code>, and the API type should match the backend, such as <code>openai-responses</code>.',
|
||||||
|
},
|
||||||
|
hermes: {
|
||||||
|
title: "Hermes",
|
||||||
|
descHtml:
|
||||||
|
'Current Hermes releases use <code>~/.hermes/config.yaml</code>. The official docs already make it clear that the older <code>OPENAI_BASE_URL</code> / <code>LLM_MODEL</code> environment variables are no longer the primary source of truth.',
|
||||||
|
interactiveTitle: "Interactive setup",
|
||||||
|
warningHtml:
|
||||||
|
'For Hermes, the custom endpoint should now be configured from <code>config.yaml</code>. Do not rely on the legacy <code>OPENAI_BASE_URL</code> value from older <code>.env</code> setups anymore.',
|
||||||
|
},
|
||||||
|
faq: {
|
||||||
|
title: "FAQ",
|
||||||
|
headers: ["Issue", "What to check"],
|
||||||
|
rows: [
|
||||||
|
[
|
||||||
|
"CC-Switch one-click import does not open",
|
||||||
|
'Confirm that CC-Switch is installed and the <code>ccswitch://</code> protocol is registered on the system first.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"<code>401</code> / <code>invalid_api_key</code>",
|
||||||
|
'Check whether the key was copied completely from <code>/keys</code>, without extra spaces, line breaks, or an older key value.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"<code>403</code>",
|
||||||
|
"The key may have been disabled, failed an IP restriction, or the current group may not be available.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"<code>404</code>",
|
||||||
|
'Most of the time the host or path is wrong. HTTP / SDK usually uses <code>/v1</code>; Claude / Gemini / Codex CLI usually use the root host.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Hermes / OpenClaw does not work",
|
||||||
|
'Check the config file path first, then verify the host format for the current client. OpenClaw uses <code>openclaw.json</code>; Hermes uses <code>~/.hermes/config.yaml</code>.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
footerHtml: 'Static docs path: <code>/docs/api.html</code>',
|
||||||
|
codeTitles: {
|
||||||
|
httpChat: "cURL /v1/chat/completions",
|
||||||
|
httpModels: "cURL /v1/models",
|
||||||
|
sdkJs: "openai npm",
|
||||||
|
sdkPython: "openai python",
|
||||||
|
claude: "macOS / Linux",
|
||||||
|
gemini: "macOS / Linux",
|
||||||
|
codexConfig: "~/.codex/config.toml",
|
||||||
|
codexAuth: "~/.codex/auth.json",
|
||||||
|
opencode: "opencode.json",
|
||||||
|
openclaw: "~/.openclaw/openclaw.json",
|
||||||
|
hermes: "~/.hermes/config.yaml",
|
||||||
|
hermesInteractive: "Interactive setup",
|
||||||
|
},
|
||||||
|
codeSamples: {
|
||||||
|
httpChat: `curl https://re.94xy.cn/v1/chat/completions \\
|
||||||
|
-H "Authorization: Bearer YOUR_API_KEY" \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d '{
|
||||||
|
"model": "gpt-5.4",
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": "You are a helpful assistant."},
|
||||||
|
{"role": "user", "content": "Please give me three integration tips"}
|
||||||
|
]
|
||||||
|
}'`,
|
||||||
|
hermesInteractive: `hermes model
|
||||||
|
# Choose "Custom endpoint (self-hosted / VLLM / etc.)"
|
||||||
|
# API base URL: https://re.94xy.cn/v1
|
||||||
|
# API key: YOUR_API_KEY
|
||||||
|
# Model name: gpt-5.4`,
|
||||||
|
},
|
||||||
|
hostCard: {
|
||||||
|
fallbackDesc: "Publicly accessible gateway entry",
|
||||||
|
labels: {
|
||||||
|
root: "Root host",
|
||||||
|
openai: "OpenAI SDK / HTTP",
|
||||||
|
gemini: "Gemini / OpenClaw / Hermes",
|
||||||
|
antigravity: "Antigravity",
|
||||||
|
},
|
||||||
|
builtin: {
|
||||||
|
default: {
|
||||||
|
name: "Default primary host",
|
||||||
|
description: "Recommended default in this document",
|
||||||
|
},
|
||||||
|
current: {
|
||||||
|
name: "Current site origin",
|
||||||
|
description: "The address currently opened in the browser",
|
||||||
|
},
|
||||||
|
apiBase: {
|
||||||
|
name: "Public API Base URL",
|
||||||
|
description: "The public API Base URL exposed in Settings -> General",
|
||||||
|
},
|
||||||
|
customPrefix: "Custom endpoint",
|
||||||
|
customDescription: "Custom host exposed by the admin",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -0,0 +1,792 @@
|
|||||||
|
(function () {
|
||||||
|
const DEFAULT_HOST = "https://re.94xy.cn";
|
||||||
|
const DEFAULT_SITE_NAME = "Sub2API";
|
||||||
|
const DEFAULT_SITE_SUBTITLE = "AI API Gateway Platform";
|
||||||
|
const THEME_KEY = "theme";
|
||||||
|
const LOCALE_KEY = "sub2api_locale";
|
||||||
|
const AUTH_TOKEN_KEY = "auth_token";
|
||||||
|
const AUTH_USER_KEY = "auth_user";
|
||||||
|
const DOCS_I18N = window.DOCS_I18N || {};
|
||||||
|
|
||||||
|
const defaultHostEls = document.querySelectorAll("[data-default-host-text]");
|
||||||
|
const hostListEl = document.getElementById("host-list");
|
||||||
|
const ccsHostSelect = document.getElementById("ccs-host");
|
||||||
|
const providerNameInput = document.getElementById("ccs-provider-name");
|
||||||
|
const apiKeyInput = document.getElementById("ccs-api-key");
|
||||||
|
const platformSelect = document.getElementById("ccs-platform");
|
||||||
|
const importBtn = document.getElementById("ccs-import-btn");
|
||||||
|
const copyLinkBtn = document.getElementById("ccs-copy-btn");
|
||||||
|
const resultEl = document.getElementById("ccs-result");
|
||||||
|
const siteLogoEl = document.getElementById("site-logo");
|
||||||
|
const siteNameEl = document.getElementById("site-name");
|
||||||
|
const siteSubtitleEl = document.getElementById("site-subtitle");
|
||||||
|
const siteHeaderNavEl = document.getElementById("site-header-nav");
|
||||||
|
const headerLoginLinkEl = document.getElementById("header-login-link");
|
||||||
|
const localeSwitcherEl = document.getElementById("locale-switcher");
|
||||||
|
const themeToggleEl = document.getElementById("theme-toggle");
|
||||||
|
|
||||||
|
let currentLocale = resolveLocale();
|
||||||
|
let currentTheme = resolveTheme();
|
||||||
|
let publicSettings = null;
|
||||||
|
|
||||||
|
applyTheme(currentTheme);
|
||||||
|
document.documentElement.setAttribute("lang", getText().lang || currentLocale);
|
||||||
|
if (localeSwitcherEl) {
|
||||||
|
localeSwitcherEl.value = currentLocale;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getText() {
|
||||||
|
return DOCS_I18N[currentLocale] || DOCS_I18N.zh || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSearchParams() {
|
||||||
|
return new URLSearchParams(window.location.search);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLocale(value) {
|
||||||
|
if (typeof value !== "string") return "";
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
if (!normalized) return "";
|
||||||
|
if (normalized === "zh" || normalized.startsWith("zh-")) return "zh";
|
||||||
|
if (normalized === "en" || normalized.startsWith("en-")) return "en";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTheme(value) {
|
||||||
|
return value === "dark" || value === "light" ? value : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLocale() {
|
||||||
|
const queryLocale = normalizeLocale(
|
||||||
|
getSearchParams().get("lang") || getSearchParams().get("locale"),
|
||||||
|
);
|
||||||
|
if (queryLocale) {
|
||||||
|
return queryLocale;
|
||||||
|
}
|
||||||
|
|
||||||
|
const saved = normalizeLocale(localStorage.getItem(LOCALE_KEY));
|
||||||
|
if (saved) {
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
const documentLang = normalizeLocale(
|
||||||
|
document.documentElement.getAttribute("lang"),
|
||||||
|
);
|
||||||
|
if (documentLang) {
|
||||||
|
return documentLang;
|
||||||
|
}
|
||||||
|
|
||||||
|
return navigator.language.toLowerCase().startsWith("zh") ? "zh" : "en";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTheme() {
|
||||||
|
const queryTheme = normalizeTheme(getSearchParams().get("theme"));
|
||||||
|
if (queryTheme) {
|
||||||
|
return queryTheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
const saved = normalizeTheme(localStorage.getItem(THEME_KEY));
|
||||||
|
if (saved) {
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||||
|
? "dark"
|
||||||
|
: "light";
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTheme(theme) {
|
||||||
|
currentTheme = theme === "dark" ? "dark" : "light";
|
||||||
|
document.documentElement.classList.toggle("dark", currentTheme === "dark");
|
||||||
|
localStorage.setItem(THEME_KEY, currentTheme);
|
||||||
|
const text = getText();
|
||||||
|
if (themeToggleEl) {
|
||||||
|
themeToggleEl.textContent = currentTheme === "dark" ? "☀" : "☾";
|
||||||
|
themeToggleEl.title =
|
||||||
|
currentTheme === "dark" ? text.themeLight : text.themeDark;
|
||||||
|
themeToggleEl.setAttribute("aria-label", themeToggleEl.title || "Theme");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAuthUser() {
|
||||||
|
const rawUser = localStorage.getItem(AUTH_USER_KEY);
|
||||||
|
const token = localStorage.getItem(AUTH_TOKEN_KEY);
|
||||||
|
if (!rawUser || !token) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const user = JSON.parse(rawUser);
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
email: typeof user?.email === "string" ? user.email : "",
|
||||||
|
role: typeof user?.role === "string" ? user.role : "",
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueBy(items, keyFn) {
|
||||||
|
const seen = new Set();
|
||||||
|
return items.filter((item) => {
|
||||||
|
const key = keyFn(item);
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripSuffix(value) {
|
||||||
|
return value
|
||||||
|
.replace(/\/antigravity\/v1beta\/?$/i, "")
|
||||||
|
.replace(/\/antigravity\/v1\/?$/i, "")
|
||||||
|
.replace(/\/antigravity\/?$/i, "")
|
||||||
|
.replace(/\/v1beta\/?$/i, "")
|
||||||
|
.replace(/\/v1\/?$/i, "")
|
||||||
|
.replace(/\/+$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveUrl(value) {
|
||||||
|
if (!value) return "";
|
||||||
|
const trimmed = String(value).trim();
|
||||||
|
if (!trimmed) return "";
|
||||||
|
try {
|
||||||
|
if (/^https?:\/\//i.test(trimmed)) {
|
||||||
|
return stripSuffix(new URL(trimmed).toString());
|
||||||
|
}
|
||||||
|
if (trimmed.startsWith("/")) {
|
||||||
|
return stripSuffix(new URL(trimmed, window.location.origin).toString());
|
||||||
|
}
|
||||||
|
return stripSuffix(new URL(`https://${trimmed}`).toString());
|
||||||
|
} catch (error) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinUrl(root, suffix) {
|
||||||
|
return `${root.replace(/\/+$/g, "")}${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUsageScript() {
|
||||||
|
return `({
|
||||||
|
request: {
|
||||||
|
url: "{{baseUrl}}/v1/usage",
|
||||||
|
method: "GET",
|
||||||
|
headers: { "Authorization": "Bearer {{apiKey}}" }
|
||||||
|
},
|
||||||
|
extractor: function(response) {
|
||||||
|
const remaining = response?.remaining ?? response?.quota?.remaining ?? response?.balance;
|
||||||
|
const unit = response?.unit ?? response?.quota?.unit ?? "USD";
|
||||||
|
return {
|
||||||
|
isValid: response?.is_active ?? response?.isValid ?? true,
|
||||||
|
remaining,
|
||||||
|
unit
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCcSwitchConfig(platform, baseUrl) {
|
||||||
|
const root = baseUrl.replace(/\/+$/g, "");
|
||||||
|
switch (platform) {
|
||||||
|
case "openai":
|
||||||
|
return { app: "codex", endpoint: root, model: "gpt-5.4" };
|
||||||
|
case "gemini":
|
||||||
|
return { app: "gemini", endpoint: root };
|
||||||
|
case "antigravity-claude":
|
||||||
|
return { app: "claude", endpoint: `${root}/antigravity` };
|
||||||
|
case "antigravity-gemini":
|
||||||
|
return { app: "gemini", endpoint: `${root}/antigravity` };
|
||||||
|
default:
|
||||||
|
return { app: "claude", endpoint: root };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCcSwitchLink() {
|
||||||
|
const text = getText();
|
||||||
|
const baseUrl = ccsHostSelect.value || DEFAULT_HOST;
|
||||||
|
const apiKey = apiKeyInput.value.trim();
|
||||||
|
const providerName = providerNameInput.value.trim() || DEFAULT_SITE_NAME;
|
||||||
|
const platform = platformSelect.value;
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error(text.messages?.pasteApiKeyFirst || "Paste API key first.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = resolveCcSwitchConfig(platform, baseUrl);
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("resource", "provider");
|
||||||
|
params.set("app", config.app);
|
||||||
|
if (config.model) {
|
||||||
|
params.set("model", config.model);
|
||||||
|
}
|
||||||
|
params.set("name", providerName);
|
||||||
|
params.set("homepage", baseUrl);
|
||||||
|
params.set("endpoint", config.endpoint);
|
||||||
|
params.set("apiKey", apiKey);
|
||||||
|
params.set("configFormat", "json");
|
||||||
|
params.set("usageEnabled", "true");
|
||||||
|
params.set("usageScript", btoa(buildUsageScript()));
|
||||||
|
params.set("usageAutoInterval", "30");
|
||||||
|
return `ccswitch://v1/import?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePlacement(value) {
|
||||||
|
return value === "home_header" || value === "both" ? value : "sidebar";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCustomMenuHref(item) {
|
||||||
|
return `/custom/${encodeURIComponent(item.id)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hostCardTemplate(item) {
|
||||||
|
const text = getText();
|
||||||
|
const labels = text.hostCard?.labels || {};
|
||||||
|
const root = item.root;
|
||||||
|
const openaiBase = joinUrl(root, "/v1");
|
||||||
|
const geminiBase = joinUrl(root, "/v1beta");
|
||||||
|
const antigravityBase = joinUrl(root, "/antigravity");
|
||||||
|
const antigravityGeminiBase = joinUrl(root, "/antigravity/v1beta");
|
||||||
|
|
||||||
|
return `
|
||||||
|
<article class="host-card">
|
||||||
|
<h3>${item.name}</h3>
|
||||||
|
<p>${item.description || text.hostCard?.fallbackDesc || ""}</p>
|
||||||
|
<div class="host-grid">
|
||||||
|
<div class="host-item">
|
||||||
|
<strong>${labels.root || "Root host"}</strong>
|
||||||
|
<code>${root}</code>
|
||||||
|
</div>
|
||||||
|
<div class="host-item">
|
||||||
|
<strong>${labels.openai || "OpenAI SDK / HTTP"}</strong>
|
||||||
|
<code>${openaiBase}</code>
|
||||||
|
</div>
|
||||||
|
<div class="host-item">
|
||||||
|
<strong>${labels.gemini || "Gemini / OpenClaw / Hermes"}</strong>
|
||||||
|
<code>${geminiBase}</code>
|
||||||
|
</div>
|
||||||
|
<div class="host-item">
|
||||||
|
<strong>${labels.antigravity || "Antigravity"}</strong>
|
||||||
|
<code>${antigravityBase}</code><br />
|
||||||
|
<code>${antigravityGeminiBase}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="host-actions">
|
||||||
|
<button class="tiny-btn" type="button" data-copy-text="${root}">${text.messages?.copyRootHost || "Copy root host"}</button>
|
||||||
|
<button class="tiny-btn" type="button" data-copy-text="${openaiBase}">${text.messages?.copyV1 || "Copy /v1"}</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHosts(items) {
|
||||||
|
hostListEl.innerHTML = items.map(hostCardTemplate).join("");
|
||||||
|
ccsHostSelect.innerHTML = items
|
||||||
|
.map((item) => `<option value="${item.root}">${item.name} - ${item.root}</option>`)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyText(text, trigger) {
|
||||||
|
const i18n = getText();
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
if (trigger) {
|
||||||
|
const prev = trigger.textContent;
|
||||||
|
trigger.textContent = i18n.messages?.copied || "Copied";
|
||||||
|
setTimeout(() => {
|
||||||
|
trigger.textContent = prev;
|
||||||
|
}, 1200);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (resultEl) {
|
||||||
|
resultEl.textContent =
|
||||||
|
i18n.messages?.copyFailed || "Copy failed. Please copy it manually.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHeaderMenus(settings) {
|
||||||
|
if (!siteHeaderNavEl) return;
|
||||||
|
const items = Array.isArray(settings?.custom_menu_items)
|
||||||
|
? settings.custom_menu_items
|
||||||
|
: [];
|
||||||
|
const visible = items
|
||||||
|
.filter((item) => item && item.visibility === "user")
|
||||||
|
.filter((item) => {
|
||||||
|
const placement = normalizePlacement(item.placement);
|
||||||
|
return placement === "home_header" || placement === "both";
|
||||||
|
})
|
||||||
|
.sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0));
|
||||||
|
|
||||||
|
siteHeaderNavEl.innerHTML = visible
|
||||||
|
.map((item) => {
|
||||||
|
const icon = item.icon_svg
|
||||||
|
? `<span style="display:inline-flex;width:16px;height:16px;align-items:center;justify-content:center;">${item.icon_svg}</span>`
|
||||||
|
: "";
|
||||||
|
return `<a class="header-link" href="${buildCustomMenuHref(item)}">${icon}<span>${item.label || ""}</span></a>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHeader(settings) {
|
||||||
|
const text = getText();
|
||||||
|
const auth = readAuthUser();
|
||||||
|
const siteName =
|
||||||
|
typeof settings?.site_name === "string" && settings.site_name.trim()
|
||||||
|
? settings.site_name.trim()
|
||||||
|
: DEFAULT_SITE_NAME;
|
||||||
|
const siteSubtitle =
|
||||||
|
typeof settings?.site_subtitle === "string" && settings.site_subtitle.trim()
|
||||||
|
? settings.site_subtitle.trim()
|
||||||
|
: DEFAULT_SITE_SUBTITLE;
|
||||||
|
const siteLogo =
|
||||||
|
typeof settings?.site_logo === "string" && settings.site_logo.trim()
|
||||||
|
? settings.site_logo.trim()
|
||||||
|
: "/logo.png";
|
||||||
|
|
||||||
|
if (siteLogoEl) {
|
||||||
|
siteLogoEl.src = siteLogo;
|
||||||
|
}
|
||||||
|
if (siteNameEl) {
|
||||||
|
siteNameEl.textContent = siteName;
|
||||||
|
}
|
||||||
|
if (siteSubtitleEl) {
|
||||||
|
siteSubtitleEl.textContent = siteSubtitle;
|
||||||
|
}
|
||||||
|
document.title = `${siteName} - ${text.titleSuffix || ""}`;
|
||||||
|
|
||||||
|
if (headerLoginLinkEl) {
|
||||||
|
if (auth) {
|
||||||
|
headerLoginLinkEl.href =
|
||||||
|
auth.role === "admin" || auth.role === "useradmin"
|
||||||
|
? "/admin/dashboard"
|
||||||
|
: "/dashboard";
|
||||||
|
headerLoginLinkEl.querySelector("[data-i18n]").textContent =
|
||||||
|
text.dashboard || "Dashboard";
|
||||||
|
} else {
|
||||||
|
headerLoginLinkEl.href = "/login";
|
||||||
|
headerLoginLinkEl.querySelector("[data-i18n]").textContent =
|
||||||
|
text.goLogin || "Login";
|
||||||
|
}
|
||||||
|
headerLoginLinkEl.title = text.goLogin || "Login";
|
||||||
|
}
|
||||||
|
renderHeaderMenus(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceNodeContent(selector, content, mode) {
|
||||||
|
const el = document.querySelector(selector);
|
||||||
|
if (!el) return;
|
||||||
|
if (mode === "html") {
|
||||||
|
el.innerHTML = content;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.textContent = content;
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceAllText(selector, values) {
|
||||||
|
const nodes = document.querySelectorAll(selector);
|
||||||
|
nodes.forEach((node, index) => {
|
||||||
|
if (values[index] != null) {
|
||||||
|
node.textContent = values[index];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceAllHtml(selector, values) {
|
||||||
|
const nodes = document.querySelectorAll(selector);
|
||||||
|
nodes.forEach((node, index) => {
|
||||||
|
if (values[index] != null) {
|
||||||
|
node.innerHTML = values[index];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStaticContent() {
|
||||||
|
const text = getText();
|
||||||
|
document.documentElement.setAttribute("lang", text.lang || currentLocale);
|
||||||
|
if (localeSwitcherEl) {
|
||||||
|
localeSwitcherEl.setAttribute("aria-label", text.localeLabel || "Language");
|
||||||
|
}
|
||||||
|
|
||||||
|
replaceNodeContent(".eyebrow-row .pill.brand", text.hero?.priority || "", "text");
|
||||||
|
replaceNodeContent(
|
||||||
|
".eyebrow-row .pill:nth-child(2)",
|
||||||
|
`${text.hero?.defaultHostLabel || ""}<span data-default-host-text>${DEFAULT_HOST}</span>`,
|
||||||
|
"html",
|
||||||
|
);
|
||||||
|
replaceNodeContent(
|
||||||
|
".eyebrow-row .pill:nth-child(3)",
|
||||||
|
`${text.hero?.docPathLabel || ""}<code>/docs/api.html</code>`,
|
||||||
|
"html",
|
||||||
|
);
|
||||||
|
replaceNodeContent(".hero h1", text.hero?.title || "", "text");
|
||||||
|
replaceNodeContent(".hero-copy", text.hero?.copyHtml || "", "html");
|
||||||
|
replaceAllText(".hero-actions a", text.hero?.actions || []);
|
||||||
|
replaceAllText(".hero-stat .label", text.hero?.statLabels || []);
|
||||||
|
replaceNodeContent(
|
||||||
|
".hero-stat:nth-child(2) .value",
|
||||||
|
text.hero?.keyPathHtml || "",
|
||||||
|
"html",
|
||||||
|
);
|
||||||
|
replaceNodeContent(
|
||||||
|
".hero-stat:nth-child(3) .value",
|
||||||
|
text.hero?.placement || "",
|
||||||
|
"text",
|
||||||
|
);
|
||||||
|
replaceNodeContent(
|
||||||
|
".hero-stat:nth-child(4) .value",
|
||||||
|
text.hero?.protocols || "",
|
||||||
|
"text",
|
||||||
|
);
|
||||||
|
|
||||||
|
replaceNodeContent(".sidebar h2", text.sidebar?.title || "", "text");
|
||||||
|
replaceAllText(".sidebar a", text.sidebar?.links || []);
|
||||||
|
|
||||||
|
replaceNodeContent("#quickstart h2", text.quickstart?.title || "", "text");
|
||||||
|
replaceNodeContent("#quickstart .section-desc", text.quickstart?.descHtml || "", "html");
|
||||||
|
replaceAllText("#quickstart .card h3", (text.quickstart?.cards || []).map((item) => item.title));
|
||||||
|
replaceAllHtml("#quickstart .card p", (text.quickstart?.cards || []).map((item) => item.bodyHtml));
|
||||||
|
replaceNodeContent("#quickstart .note", text.quickstart?.noteHtml || "", "html");
|
||||||
|
|
||||||
|
replaceNodeContent("#ccswitch h2", text.ccswitch?.title || "", "text");
|
||||||
|
replaceNodeContent("#ccswitch > .section-desc", text.ccswitch?.descHtml || "", "html");
|
||||||
|
replaceAllText("#ccswitch .grid-2 .card h3", [
|
||||||
|
text.ccswitch?.cards?.download?.title || "",
|
||||||
|
text.ccswitch?.cards?.oneClick?.title || "",
|
||||||
|
text.ccswitch?.cards?.importMap?.title || "",
|
||||||
|
text.ccswitch?.cards?.manual?.title || "",
|
||||||
|
]);
|
||||||
|
replaceAllHtml(
|
||||||
|
"#ccswitch .grid-2:nth-of-type(1) .card:nth-child(1) .plain-list li",
|
||||||
|
text.ccswitch?.cards?.download?.items || [],
|
||||||
|
);
|
||||||
|
replaceAllText(
|
||||||
|
"#ccswitch .grid-2:nth-of-type(1) .card:nth-child(1) .meta-row a",
|
||||||
|
text.ccswitch?.cards?.download?.actions || [],
|
||||||
|
);
|
||||||
|
replaceAllHtml(
|
||||||
|
"#ccswitch .grid-2:nth-of-type(1) .card:nth-child(2) .plain-list li",
|
||||||
|
text.ccswitch?.cards?.oneClick?.items || [],
|
||||||
|
);
|
||||||
|
replaceNodeContent(
|
||||||
|
"#ccswitch .grid-2:nth-of-type(1) .card:nth-child(2) .note",
|
||||||
|
text.ccswitch?.cards?.oneClick?.noteHtml || "",
|
||||||
|
"html",
|
||||||
|
);
|
||||||
|
replaceAllText(
|
||||||
|
"#ccswitch .grid-2:nth-of-type(2) .card:nth-child(1) th",
|
||||||
|
text.ccswitch?.cards?.importMap?.headers || [],
|
||||||
|
);
|
||||||
|
const importRows = document.querySelectorAll(
|
||||||
|
"#ccswitch .grid-2:nth-of-type(2) .card:nth-child(1) tbody tr",
|
||||||
|
);
|
||||||
|
importRows.forEach((row, rowIndex) => {
|
||||||
|
const cells = row.querySelectorAll("td");
|
||||||
|
const values = text.ccswitch?.cards?.importMap?.rows?.[rowIndex] || [];
|
||||||
|
cells.forEach((cell, colIndex) => {
|
||||||
|
if (values[colIndex] != null) {
|
||||||
|
cell.innerHTML = values[colIndex];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
replaceAllText(
|
||||||
|
"#ccswitch .grid-2:nth-of-type(2) .card:nth-child(2) label",
|
||||||
|
text.ccswitch?.cards?.manual?.labels || [],
|
||||||
|
);
|
||||||
|
replaceNodeContent(
|
||||||
|
"#ccswitch .grid-2:nth-of-type(2) .card:nth-child(2) small",
|
||||||
|
text.ccswitch?.cards?.manual?.hostHelpHtml || "",
|
||||||
|
"html",
|
||||||
|
);
|
||||||
|
replaceAllText(
|
||||||
|
"#ccswitch .grid-2:nth-of-type(2) .card:nth-child(2) .host-actions button",
|
||||||
|
text.ccswitch?.cards?.manual?.buttons || [],
|
||||||
|
);
|
||||||
|
if (apiKeyInput) {
|
||||||
|
apiKeyInput.placeholder = text.ccswitch?.cards?.manual?.apiKeyPlaceholder || "";
|
||||||
|
}
|
||||||
|
if (platformSelect) {
|
||||||
|
Array.from(platformSelect.options).forEach((option, index) => {
|
||||||
|
if (text.ccswitch?.cards?.manual?.options?.[index]) {
|
||||||
|
option.textContent = text.ccswitch.cards.manual.options[index];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
replaceNodeContent("#overview h2", text.overview?.title || "", "text");
|
||||||
|
replaceNodeContent("#overview .section-desc", text.overview?.descHtml || "", "html");
|
||||||
|
replaceAllText("#overview th", text.overview?.headers || []);
|
||||||
|
const overviewRows = document.querySelectorAll("#overview tbody tr");
|
||||||
|
overviewRows.forEach((row, rowIndex) => {
|
||||||
|
const cells = row.querySelectorAll("td");
|
||||||
|
const values = text.overview?.rows?.[rowIndex] || [];
|
||||||
|
cells.forEach((cell, colIndex) => {
|
||||||
|
if (values[colIndex] != null) {
|
||||||
|
cell.innerHTML = values[colIndex];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
replaceNodeContent("#hosts h2", text.hosts?.title || "", "text");
|
||||||
|
replaceNodeContent("#hosts .section-desc", text.hosts?.descHtml || "", "html");
|
||||||
|
replaceNodeContent("#hosts .note", text.hosts?.noteHtml || "", "html");
|
||||||
|
|
||||||
|
replaceNodeContent("#http h2", text.http?.title || "", "text");
|
||||||
|
replaceNodeContent("#http .section-desc", text.http?.descHtml || "", "html");
|
||||||
|
replaceNodeContent("#sdk-js h2", text.sdkJs?.title || "", "text");
|
||||||
|
replaceNodeContent("#sdk-js .section-desc", text.sdkJs?.descHtml || "", "html");
|
||||||
|
replaceNodeContent("#sdk-python h2", text.sdkPython?.title || "", "text");
|
||||||
|
replaceNodeContent("#sdk-python .section-desc", text.sdkPython?.descHtml || "", "html");
|
||||||
|
replaceNodeContent("#claude-code h2", text.claude?.title || "", "text");
|
||||||
|
replaceNodeContent("#claude-code .section-desc", text.claude?.descHtml || "", "html");
|
||||||
|
replaceNodeContent("#gemini-cli h2", text.gemini?.title || "", "text");
|
||||||
|
replaceNodeContent("#gemini-cli .section-desc", text.gemini?.descHtml || "", "html");
|
||||||
|
replaceNodeContent("#codex-cli h2", text.codex?.title || "", "text");
|
||||||
|
replaceNodeContent("#codex-cli .section-desc", text.codex?.descHtml || "", "html");
|
||||||
|
replaceNodeContent("#opencode h2", text.opencode?.title || "", "text");
|
||||||
|
replaceNodeContent("#opencode .section-desc", text.opencode?.descHtml || "", "html");
|
||||||
|
replaceNodeContent("#openclaw h2", text.openclaw?.title || "", "text");
|
||||||
|
replaceNodeContent("#openclaw .section-desc", text.openclaw?.descHtml || "", "html");
|
||||||
|
replaceNodeContent("#openclaw .note", text.openclaw?.noteHtml || "", "html");
|
||||||
|
replaceNodeContent("#hermes h2", text.hermes?.title || "", "text");
|
||||||
|
replaceNodeContent("#hermes .section-desc", text.hermes?.descHtml || "", "html");
|
||||||
|
replaceNodeContent(
|
||||||
|
"#hermes .grid-2 .code-block:nth-child(2) .code-title",
|
||||||
|
text.hermes?.interactiveTitle || "",
|
||||||
|
"text",
|
||||||
|
);
|
||||||
|
replaceNodeContent("#hermes .note", text.hermes?.warningHtml || "", "html");
|
||||||
|
replaceNodeContent("#faq h2", text.faq?.title || "", "text");
|
||||||
|
replaceAllText("#faq th", text.faq?.headers || []);
|
||||||
|
const faqRows = document.querySelectorAll("#faq tbody tr");
|
||||||
|
faqRows.forEach((row, rowIndex) => {
|
||||||
|
const cells = row.querySelectorAll("td");
|
||||||
|
const values = text.faq?.rows?.[rowIndex] || [];
|
||||||
|
cells.forEach((cell, colIndex) => {
|
||||||
|
if (values[colIndex] != null) {
|
||||||
|
cell.innerHTML = values[colIndex];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
replaceNodeContent(".footer", text.footerHtml || "", "html");
|
||||||
|
|
||||||
|
const copyButtons = document.querySelectorAll(".copy-btn");
|
||||||
|
copyButtons.forEach((button) => {
|
||||||
|
button.textContent = text.messages?.copy || "Copy";
|
||||||
|
});
|
||||||
|
replaceAllText(".code-title", [
|
||||||
|
text.codeTitles?.httpChat || "",
|
||||||
|
text.codeTitles?.httpModels || "",
|
||||||
|
text.codeTitles?.sdkJs || "",
|
||||||
|
text.codeTitles?.sdkPython || "",
|
||||||
|
text.codeTitles?.claude || "",
|
||||||
|
text.codeTitles?.gemini || "",
|
||||||
|
text.codeTitles?.codexConfig || "",
|
||||||
|
text.codeTitles?.codexAuth || "",
|
||||||
|
text.codeTitles?.opencode || "",
|
||||||
|
text.codeTitles?.openclaw || "",
|
||||||
|
text.codeTitles?.hermes || "",
|
||||||
|
text.codeTitles?.hermesInteractive || "",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const codeHttpChat = document.getElementById("code-http-chat");
|
||||||
|
const codeHermesInteractive = document.getElementById("code-hermes-interactive");
|
||||||
|
if (codeHttpChat && text.codeSamples?.httpChat) {
|
||||||
|
codeHttpChat.textContent = text.codeSamples.httpChat;
|
||||||
|
}
|
||||||
|
if (codeHermesInteractive && text.codeSamples?.hermesInteractive) {
|
||||||
|
codeHermesInteractive.textContent = text.codeSamples.hermesInteractive;
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultHostEls.forEach((el) => {
|
||||||
|
el.textContent = DEFAULT_HOST;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncState(options) {
|
||||||
|
const previousLocale = currentLocale;
|
||||||
|
currentLocale = resolveLocale();
|
||||||
|
currentTheme = resolveTheme();
|
||||||
|
|
||||||
|
if (localeSwitcherEl) {
|
||||||
|
localeSwitcherEl.value = currentLocale;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyTheme(currentTheme);
|
||||||
|
renderStaticContent();
|
||||||
|
updateHeader(publicSettings);
|
||||||
|
|
||||||
|
if (options?.reloadHosts || previousLocale !== currentLocale) {
|
||||||
|
loadPublicSettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPublicSettings() {
|
||||||
|
const text = getText();
|
||||||
|
const builtins = text.hostCard?.builtin || {};
|
||||||
|
const seeds = [
|
||||||
|
{
|
||||||
|
name: builtins.default?.name || "Default primary host",
|
||||||
|
root: resolveUrl(DEFAULT_HOST),
|
||||||
|
description: builtins.default?.description || "",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const currentRoot = resolveUrl(window.location.origin);
|
||||||
|
if (currentRoot && currentRoot !== resolveUrl(DEFAULT_HOST)) {
|
||||||
|
seeds.push({
|
||||||
|
name: builtins.current?.name || "Current site origin",
|
||||||
|
root: currentRoot,
|
||||||
|
description: builtins.current?.description || "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/v1/settings/public", {
|
||||||
|
credentials: "same-origin",
|
||||||
|
});
|
||||||
|
const payload = await response.json();
|
||||||
|
const settings = payload && payload.data ? payload.data : payload;
|
||||||
|
publicSettings = settings;
|
||||||
|
updateHeader(settings);
|
||||||
|
|
||||||
|
if (
|
||||||
|
settings &&
|
||||||
|
typeof settings.site_name === "string" &&
|
||||||
|
settings.site_name.trim()
|
||||||
|
) {
|
||||||
|
providerNameInput.value = settings.site_name.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings && settings.api_base_url) {
|
||||||
|
const apiRoot = resolveUrl(settings.api_base_url);
|
||||||
|
if (apiRoot) {
|
||||||
|
seeds.push({
|
||||||
|
name: builtins.apiBase?.name || "Public API Base URL",
|
||||||
|
root: apiRoot,
|
||||||
|
description: builtins.apiBase?.description || "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings && Array.isArray(settings.custom_endpoints)) {
|
||||||
|
settings.custom_endpoints.forEach((item, index) => {
|
||||||
|
const root = resolveUrl(item.endpoint);
|
||||||
|
if (!root) return;
|
||||||
|
seeds.push({
|
||||||
|
name:
|
||||||
|
item.name ||
|
||||||
|
`${builtins.customPrefix || "Custom endpoint"} ${index + 1}`,
|
||||||
|
root: root,
|
||||||
|
description:
|
||||||
|
item.description || builtins.customDescription || "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
updateHeader(publicSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deduped = uniqueBy(
|
||||||
|
seeds.filter((item) => item.root),
|
||||||
|
(item) => item.root,
|
||||||
|
);
|
||||||
|
|
||||||
|
renderHosts(deduped);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initHeaderEvents() {
|
||||||
|
if (themeToggleEl) {
|
||||||
|
themeToggleEl.addEventListener("click", () => {
|
||||||
|
applyTheme(currentTheme === "dark" ? "light" : "dark");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (localeSwitcherEl) {
|
||||||
|
localeSwitcherEl.addEventListener("change", (event) => {
|
||||||
|
const next = event.target.value === "zh" ? "zh" : "en";
|
||||||
|
currentLocale = next;
|
||||||
|
localStorage.setItem(LOCALE_KEY, next);
|
||||||
|
syncState({ reloadHosts: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initCrossContextSync() {
|
||||||
|
window.addEventListener("storage", (event) => {
|
||||||
|
if (
|
||||||
|
event.key &&
|
||||||
|
event.key !== LOCALE_KEY &&
|
||||||
|
event.key !== THEME_KEY &&
|
||||||
|
event.key !== AUTH_TOKEN_KEY &&
|
||||||
|
event.key !== AUTH_USER_KEY
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncState({
|
||||||
|
reloadHosts:
|
||||||
|
event.key === null ||
|
||||||
|
event.key === LOCALE_KEY ||
|
||||||
|
event.key === AUTH_TOKEN_KEY ||
|
||||||
|
event.key === AUTH_USER_KEY,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("click", (event) => {
|
||||||
|
const target = event.target;
|
||||||
|
if (!(target instanceof HTMLElement)) return;
|
||||||
|
|
||||||
|
const copyTargetId = target.getAttribute("data-copy-target");
|
||||||
|
if (copyTargetId) {
|
||||||
|
const codeEl = document.getElementById(copyTargetId);
|
||||||
|
if (codeEl) {
|
||||||
|
copyText(codeEl.textContent || "", target);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyTextValue = target.getAttribute("data-copy-text");
|
||||||
|
if (copyTextValue) {
|
||||||
|
copyText(copyTextValue, target);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (importBtn) {
|
||||||
|
importBtn.addEventListener("click", () => {
|
||||||
|
const text = getText();
|
||||||
|
try {
|
||||||
|
const link = buildCcSwitchLink();
|
||||||
|
resultEl.textContent = text.messages?.tryingOpen || "";
|
||||||
|
window.open(link, "_self");
|
||||||
|
setTimeout(() => {
|
||||||
|
if (document.hasFocus()) {
|
||||||
|
resultEl.textContent = text.messages?.clientNotOpened || "";
|
||||||
|
}
|
||||||
|
}, 120);
|
||||||
|
} catch (error) {
|
||||||
|
resultEl.textContent = error.message || text.messages?.importFailed || "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (copyLinkBtn) {
|
||||||
|
copyLinkBtn.addEventListener("click", async () => {
|
||||||
|
const text = getText();
|
||||||
|
try {
|
||||||
|
const link = buildCcSwitchLink();
|
||||||
|
await copyText(link, copyLinkBtn);
|
||||||
|
resultEl.textContent = text.messages?.deepLinkCopied || "";
|
||||||
|
} catch (error) {
|
||||||
|
resultEl.textContent = error.message || text.messages?.copyFailed || "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
renderStaticContent();
|
||||||
|
updateHeader(publicSettings);
|
||||||
|
initHeaderEvents();
|
||||||
|
initCrossContextSync();
|
||||||
|
loadPublicSettings();
|
||||||
|
})();
|
||||||
@@ -187,6 +187,7 @@ import { useAdminSettingsStore, useAppStore, useAuthStore, useOnboardingStore }
|
|||||||
import VersionBadge from '@/components/common/VersionBadge.vue'
|
import VersionBadge from '@/components/common/VersionBadge.vue'
|
||||||
import { sanitizeSvg } from '@/utils/sanitize'
|
import { sanitizeSvg } from '@/utils/sanitize'
|
||||||
import { FeatureFlags, makeSidebarFlag } from '@/utils/featureFlags'
|
import { FeatureFlags, makeSidebarFlag } from '@/utils/featureFlags'
|
||||||
|
import { getCustomMenuRoute, isSidebarMenuPlacement, normalizeCustomMenuItems } from '@/utils/custom-menu'
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
path: string
|
path: string
|
||||||
@@ -693,7 +694,7 @@ function buildSelfNavItems(withDashboard: boolean): NavItem[] {
|
|||||||
{ path: '/affiliate', label: t('nav.affiliate'), icon: UsersIcon, hideInSimpleMode: true, featureFlag: flagAffiliate },
|
{ path: '/affiliate', label: t('nav.affiliate'), icon: UsersIcon, hideInSimpleMode: true, featureFlag: flagAffiliate },
|
||||||
{ path: '/profile', label: t('nav.profile'), icon: UserIcon },
|
{ path: '/profile', label: t('nav.profile'), icon: UserIcon },
|
||||||
...customMenuItemsForUser.value.map((item): NavItem => ({
|
...customMenuItemsForUser.value.map((item): NavItem => ({
|
||||||
path: `/custom/${item.id}`,
|
path: getCustomMenuRoute(item.id),
|
||||||
label: item.label,
|
label: item.label,
|
||||||
icon: null,
|
icon: null,
|
||||||
iconSvg: item.icon_svg,
|
iconSvg: item.icon_svg,
|
||||||
@@ -718,15 +719,15 @@ const personalNavItems = computed((): NavItem[] => finalizeNav(buildSelfNavItems
|
|||||||
|
|
||||||
// Custom menu items filtered by visibility
|
// Custom menu items filtered by visibility
|
||||||
const customMenuItemsForUser = computed(() => {
|
const customMenuItemsForUser = computed(() => {
|
||||||
const items = appStore.cachedPublicSettings?.custom_menu_items ?? []
|
const items = normalizeCustomMenuItems(appStore.cachedPublicSettings?.custom_menu_items)
|
||||||
return items
|
return items
|
||||||
.filter((item) => item.visibility === 'user')
|
.filter((item) => item.visibility === 'user' && isSidebarMenuPlacement(item))
|
||||||
.sort((a, b) => a.sort_order - b.sort_order)
|
.sort((a, b) => a.sort_order - b.sort_order)
|
||||||
})
|
})
|
||||||
|
|
||||||
const customMenuItemsForAdmin = computed(() => {
|
const customMenuItemsForAdmin = computed(() => {
|
||||||
return adminSettingsStore.customMenuItems
|
return normalizeCustomMenuItems(adminSettingsStore.customMenuItems)
|
||||||
.filter((item) => item.visibility === 'admin')
|
.filter((item) => item.visibility === 'admin' && isSidebarMenuPlacement(item))
|
||||||
.sort((a, b) => a.sort_order - b.sort_order)
|
.sort((a, b) => a.sort_order - b.sort_order)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -795,7 +796,7 @@ const adminNavItems = computed((): NavItem[] => {
|
|||||||
filtered.push({ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon })
|
filtered.push({ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon })
|
||||||
}
|
}
|
||||||
for (const cm of customMenuItemsForAdmin.value) {
|
for (const cm of customMenuItemsForAdmin.value) {
|
||||||
filtered.push({ path: `/custom/${cm.id}`, label: cm.label, icon: null, iconSvg: cm.icon_svg })
|
filtered.push({ path: getCustomMenuRoute(cm.id), label: cm.label, icon: null, iconSvg: cm.icon_svg })
|
||||||
}
|
}
|
||||||
return filtered
|
return filtered
|
||||||
}
|
}
|
||||||
@@ -804,7 +805,7 @@ const adminNavItems = computed((): NavItem[] => {
|
|||||||
visible.push({ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon })
|
visible.push({ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon })
|
||||||
}
|
}
|
||||||
for (const cm of customMenuItemsForAdmin.value) {
|
for (const cm of customMenuItemsForAdmin.value) {
|
||||||
visible.push({ path: `/custom/${cm.id}`, label: cm.label, icon: null, iconSvg: cm.icon_svg })
|
visible.push({ path: getCustomMenuRoute(cm.id), label: cm.label, icon: null, iconSvg: cm.icon_svg })
|
||||||
}
|
}
|
||||||
return visible
|
return visible
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5457,7 +5457,7 @@ export default {
|
|||||||
},
|
},
|
||||||
customMenu: {
|
customMenu: {
|
||||||
title: 'Custom Menu Pages',
|
title: 'Custom Menu Pages',
|
||||||
description: 'Add custom iframe pages to the sidebar navigation. Each page can be visible to regular users or administrators.',
|
description: 'Add custom pages to site navigation. Each page can be visible to regular users or administrators, and can be shown in the sidebar, the home header, or both.',
|
||||||
itemLabel: 'Menu Item #{n}',
|
itemLabel: 'Menu Item #{n}',
|
||||||
name: 'Menu Name',
|
name: 'Menu Name',
|
||||||
namePlaceholder: 'e.g. Help Center',
|
namePlaceholder: 'e.g. Help Center',
|
||||||
@@ -5471,6 +5471,10 @@ export default {
|
|||||||
visibility: 'Visible To',
|
visibility: 'Visible To',
|
||||||
visibilityUser: 'Regular Users',
|
visibilityUser: 'Regular Users',
|
||||||
visibilityAdmin: 'Administrators',
|
visibilityAdmin: 'Administrators',
|
||||||
|
placement: 'Placement',
|
||||||
|
placementSidebar: 'Sidebar only',
|
||||||
|
placementHomeHeader: 'Home header only',
|
||||||
|
placementBoth: 'Sidebar + Home header',
|
||||||
add: 'Add Menu Item',
|
add: 'Add Menu Item',
|
||||||
remove: 'Remove',
|
remove: 'Remove',
|
||||||
moveUp: 'Move Up',
|
moveUp: 'Move Up',
|
||||||
|
|||||||
@@ -5618,7 +5618,7 @@ export default {
|
|||||||
},
|
},
|
||||||
customMenu: {
|
customMenu: {
|
||||||
title: '自定义菜单页面',
|
title: '自定义菜单页面',
|
||||||
description: '添加自定义 iframe 页面到侧边栏导航。每个页面可以设置为普通用户或管理员可见。',
|
description: '添加自定义页面到站点导航。每个页面可以设置为普通用户或管理员可见,并指定显示在侧边栏、首页头部,或同时显示。',
|
||||||
itemLabel: '菜单项 #{n}',
|
itemLabel: '菜单项 #{n}',
|
||||||
name: '菜单名称',
|
name: '菜单名称',
|
||||||
namePlaceholder: '如:帮助中心',
|
namePlaceholder: '如:帮助中心',
|
||||||
@@ -5632,6 +5632,10 @@ export default {
|
|||||||
visibility: '可见角色',
|
visibility: '可见角色',
|
||||||
visibilityUser: '普通用户',
|
visibilityUser: '普通用户',
|
||||||
visibilityAdmin: '管理员',
|
visibilityAdmin: '管理员',
|
||||||
|
placement: '显示位置',
|
||||||
|
placementSidebar: '仅侧边栏',
|
||||||
|
placementHomeHeader: '仅首页头部',
|
||||||
|
placementBoth: '侧边栏 + 首页头部',
|
||||||
add: '添加菜单项',
|
add: '添加菜单项',
|
||||||
remove: '删除',
|
remove: '删除',
|
||||||
moveUp: '上移',
|
moveUp: '上移',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { adminAPI } from '@/api'
|
import { adminAPI } from '@/api'
|
||||||
import type { CustomMenuItem } from '@/types'
|
import type { CustomMenuItem } from '@/types'
|
||||||
|
import { normalizeCustomMenuItems } from '@/utils/custom-menu'
|
||||||
|
|
||||||
export const useAdminSettingsStore = defineStore('adminSettings', () => {
|
export const useAdminSettingsStore = defineStore('adminSettings', () => {
|
||||||
const loaded = ref(false)
|
const loaded = ref(false)
|
||||||
@@ -70,7 +71,7 @@ export const useAdminSettingsStore = defineStore('adminSettings', () => {
|
|||||||
opsQueryModeDefault.value = settings.ops_query_mode_default || 'auto'
|
opsQueryModeDefault.value = settings.ops_query_mode_default || 'auto'
|
||||||
writeCachedString('ops_query_mode_default_cached', opsQueryModeDefault.value)
|
writeCachedString('ops_query_mode_default_cached', opsQueryModeDefault.value)
|
||||||
|
|
||||||
customMenuItems.value = Array.isArray(settings.custom_menu_items) ? settings.custom_menu_items : []
|
customMenuItems.value = normalizeCustomMenuItems(settings.custom_menu_items)
|
||||||
|
|
||||||
paymentEnabled.value = paymentConfigResp.data?.enabled ?? false
|
paymentEnabled.value = paymentConfigResp.data?.enabled ?? false
|
||||||
writeCachedBool('payment_enabled_cached', paymentEnabled.value)
|
writeCachedBool('payment_enabled_cached', paymentEnabled.value)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { defineStore } from 'pinia'
|
|||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import type { Toast, ToastType, PublicSettings } from '@/types'
|
import type { Toast, ToastType, PublicSettings } from '@/types'
|
||||||
import { i18n } from '@/i18n'
|
import { i18n } from '@/i18n'
|
||||||
|
import { normalizeCustomMenuItems } from '@/utils/custom-menu'
|
||||||
import {
|
import {
|
||||||
checkUpdates as checkUpdatesAPI,
|
checkUpdates as checkUpdatesAPI,
|
||||||
type VersionInfo,
|
type VersionInfo,
|
||||||
@@ -288,16 +289,20 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
* Apply settings to store state (internal helper to avoid code duplication)
|
* Apply settings to store state (internal helper to avoid code duplication)
|
||||||
*/
|
*/
|
||||||
function applySettings(config: PublicSettings): void {
|
function applySettings(config: PublicSettings): void {
|
||||||
if (typeof window !== 'undefined') {
|
const normalizedConfig: PublicSettings = {
|
||||||
window.__APP_CONFIG__ = { ...config }
|
...config,
|
||||||
|
custom_menu_items: normalizeCustomMenuItems(config.custom_menu_items)
|
||||||
}
|
}
|
||||||
cachedPublicSettings.value = config
|
if (typeof window !== 'undefined') {
|
||||||
siteName.value = config.site_name || 'Sub2API'
|
window.__APP_CONFIG__ = { ...normalizedConfig }
|
||||||
siteLogo.value = config.site_logo || ''
|
}
|
||||||
siteVersion.value = config.version || ''
|
cachedPublicSettings.value = normalizedConfig
|
||||||
contactInfo.value = config.contact_info || ''
|
siteName.value = normalizedConfig.site_name || 'Sub2API'
|
||||||
apiBaseUrl.value = config.api_base_url || ''
|
siteLogo.value = normalizedConfig.site_logo || ''
|
||||||
docUrl.value = config.doc_url || ''
|
siteVersion.value = normalizedConfig.version || ''
|
||||||
|
contactInfo.value = normalizedConfig.contact_info || ''
|
||||||
|
apiBaseUrl.value = normalizedConfig.api_base_url || ''
|
||||||
|
docUrl.value = normalizedConfig.doc_url || ''
|
||||||
publicSettingsLoaded.value = true
|
publicSettingsLoaded.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -163,6 +163,8 @@ export interface SendVerifyCodeResponse {
|
|||||||
countdown: number
|
countdown: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CustomMenuPlacement = 'sidebar' | 'home_header' | 'both'
|
||||||
|
|
||||||
export interface CustomMenuItem {
|
export interface CustomMenuItem {
|
||||||
id: string
|
id: string
|
||||||
label: string
|
label: string
|
||||||
@@ -170,6 +172,7 @@ export interface CustomMenuItem {
|
|||||||
url: string
|
url: string
|
||||||
page_slug?: string
|
page_slug?: string
|
||||||
visibility: 'user' | 'admin'
|
visibility: 'user' | 'admin'
|
||||||
|
placement?: CustomMenuPlacement
|
||||||
sort_order: number
|
sort_order: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type { CustomMenuItem, CustomMenuPlacement } from '@/types'
|
||||||
|
|
||||||
|
export const DEFAULT_CUSTOM_MENU_PLACEMENT: CustomMenuPlacement = 'sidebar'
|
||||||
|
|
||||||
|
export function normalizeCustomMenuPlacement(
|
||||||
|
value: unknown,
|
||||||
|
): CustomMenuPlacement {
|
||||||
|
if (value === 'home_header' || value === 'both') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return DEFAULT_CUSTOM_MENU_PLACEMENT
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeCustomMenuItem(item: CustomMenuItem): CustomMenuItem {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
placement: normalizeCustomMenuPlacement(item.placement),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeCustomMenuItems(
|
||||||
|
items: CustomMenuItem[] | null | undefined,
|
||||||
|
): CustomMenuItem[] {
|
||||||
|
if (!Array.isArray(items)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return items.map((item) => normalizeCustomMenuItem(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSidebarMenuPlacement(
|
||||||
|
item: Pick<CustomMenuItem, 'placement'>,
|
||||||
|
): boolean {
|
||||||
|
const placement = normalizeCustomMenuPlacement(item.placement)
|
||||||
|
return placement === 'sidebar' || placement === 'both'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isHomeHeaderMenuPlacement(
|
||||||
|
item: Pick<CustomMenuItem, 'placement'>,
|
||||||
|
): boolean {
|
||||||
|
const placement = normalizeCustomMenuPlacement(item.placement)
|
||||||
|
return placement === 'home_header' || placement === 'both'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCustomMenuRoute(id: string): string {
|
||||||
|
return `/custom/${encodeURIComponent(id)}`
|
||||||
|
}
|
||||||
@@ -47,7 +47,26 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Nav Actions -->
|
<!-- Nav Actions -->
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-2 sm:gap-3">
|
||||||
|
<div
|
||||||
|
v-if="homeHeaderMenuItems.length > 0"
|
||||||
|
class="hidden items-center gap-1 lg:flex"
|
||||||
|
>
|
||||||
|
<router-link
|
||||||
|
v-for="item in homeHeaderMenuItems"
|
||||||
|
:key="item.id"
|
||||||
|
:to="customMenuRoute(item.id)"
|
||||||
|
class="inline-flex items-center gap-2 rounded-full border border-gray-200/70 bg-white/80 px-3 py-2 text-sm font-medium text-gray-700 transition-colors hover:border-primary-300 hover:text-primary-700 dark:border-dark-700/80 dark:bg-dark-900/70 dark:text-dark-100 dark:hover:border-primary-500/50 dark:hover:text-white"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="item.icon_svg"
|
||||||
|
class="flex h-4 w-4 items-center justify-center text-current"
|
||||||
|
v-html="sanitizeSvg(item.icon_svg)"
|
||||||
|
></span>
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Language Switcher -->
|
<!-- Language Switcher -->
|
||||||
<LocaleSwitcher />
|
<LocaleSwitcher />
|
||||||
|
|
||||||
@@ -410,6 +429,12 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import { useAuthStore, useAppStore } from '@/stores'
|
import { useAuthStore, useAppStore } from '@/stores'
|
||||||
import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue'
|
import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue'
|
||||||
import Icon from '@/components/icons/Icon.vue'
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
|
import { sanitizeSvg } from '@/utils/sanitize'
|
||||||
|
import {
|
||||||
|
getCustomMenuRoute,
|
||||||
|
isHomeHeaderMenuPlacement,
|
||||||
|
normalizeCustomMenuItems,
|
||||||
|
} from '@/utils/custom-menu'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
@@ -422,6 +447,11 @@ const siteLogo = computed(() => appStore.cachedPublicSettings?.site_logo || appS
|
|||||||
const siteSubtitle = computed(() => appStore.cachedPublicSettings?.site_subtitle || 'AI API Gateway Platform')
|
const siteSubtitle = computed(() => appStore.cachedPublicSettings?.site_subtitle || 'AI API Gateway Platform')
|
||||||
const docUrl = computed(() => appStore.cachedPublicSettings?.doc_url || appStore.docUrl || '')
|
const docUrl = computed(() => appStore.cachedPublicSettings?.doc_url || appStore.docUrl || '')
|
||||||
const homeContent = computed(() => appStore.cachedPublicSettings?.home_content || '')
|
const homeContent = computed(() => appStore.cachedPublicSettings?.home_content || '')
|
||||||
|
const homeHeaderMenuItems = computed(() =>
|
||||||
|
normalizeCustomMenuItems(appStore.cachedPublicSettings?.custom_menu_items)
|
||||||
|
.filter((item) => item.visibility === 'user' && isHomeHeaderMenuPlacement(item))
|
||||||
|
.sort((a, b) => a.sort_order - b.sort_order)
|
||||||
|
)
|
||||||
|
|
||||||
// Check if homeContent is a URL (for iframe display)
|
// Check if homeContent is a URL (for iframe display)
|
||||||
const isHomeContentUrl = computed(() => {
|
const isHomeContentUrl = computed(() => {
|
||||||
@@ -448,6 +478,10 @@ const userInitial = computed(() => {
|
|||||||
// Current year for footer
|
// Current year for footer
|
||||||
const currentYear = computed(() => new Date().getFullYear())
|
const currentYear = computed(() => new Date().getFullYear())
|
||||||
|
|
||||||
|
function customMenuRoute(id: string) {
|
||||||
|
return getCustomMenuRoute(id)
|
||||||
|
}
|
||||||
|
|
||||||
// Toggle theme
|
// Toggle theme
|
||||||
function toggleTheme() {
|
function toggleTheme() {
|
||||||
isDark.value = !isDark.value
|
isDark.value = !isDark.value
|
||||||
|
|||||||
@@ -4408,6 +4408,26 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Placement -->
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
{{ t("admin.settings.customMenu.placement") }}
|
||||||
|
</label>
|
||||||
|
<select v-model="item.placement" class="input text-sm">
|
||||||
|
<option value="sidebar">
|
||||||
|
{{ t("admin.settings.customMenu.placementSidebar") }}
|
||||||
|
</option>
|
||||||
|
<option value="home_header">
|
||||||
|
{{ t("admin.settings.customMenu.placementHomeHeader") }}
|
||||||
|
</option>
|
||||||
|
<option value="both">
|
||||||
|
{{ t("admin.settings.customMenu.placementBoth") }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- URL (full width) -->
|
<!-- URL (full width) -->
|
||||||
<div class="sm:col-span-2">
|
<div class="sm:col-span-2">
|
||||||
<label
|
<label
|
||||||
@@ -6145,6 +6165,7 @@ import type {
|
|||||||
} from "@/api/admin/settings";
|
} from "@/api/admin/settings";
|
||||||
import type {
|
import type {
|
||||||
AdminGroup,
|
AdminGroup,
|
||||||
|
CustomMenuPlacement,
|
||||||
LoginAgreementDocument,
|
LoginAgreementDocument,
|
||||||
NotifyEmailEntry,
|
NotifyEmailEntry,
|
||||||
Proxy,
|
Proxy,
|
||||||
@@ -6163,6 +6184,10 @@ import ProxySelector from "@/components/common/ProxySelector.vue";
|
|||||||
import ImageUpload from "@/components/common/ImageUpload.vue";
|
import ImageUpload from "@/components/common/ImageUpload.vue";
|
||||||
import BackupSettings from "@/views/admin/BackupView.vue";
|
import BackupSettings from "@/views/admin/BackupView.vue";
|
||||||
import { useClipboard } from "@/composables/useClipboard";
|
import { useClipboard } from "@/composables/useClipboard";
|
||||||
|
import {
|
||||||
|
DEFAULT_CUSTOM_MENU_PLACEMENT,
|
||||||
|
normalizeCustomMenuItems,
|
||||||
|
} from "@/utils/custom-menu";
|
||||||
import { affiliatesAPI, type AffiliateAdminEntry, type SimpleUser as AffiliateSimpleUser } from "@/api/admin/affiliates";
|
import { affiliatesAPI, type AffiliateAdminEntry, type SimpleUser as AffiliateSimpleUser } from "@/api/admin/affiliates";
|
||||||
import { extractApiErrorMessage, extractI18nErrorMessage } from "@/utils/apiError";
|
import { extractApiErrorMessage, extractI18nErrorMessage } from "@/utils/apiError";
|
||||||
import { useAppStore } from "@/stores";
|
import { useAppStore } from "@/stores";
|
||||||
@@ -6491,6 +6516,7 @@ const form = reactive<SettingsForm>({
|
|||||||
icon_svg: string;
|
icon_svg: string;
|
||||||
url: string;
|
url: string;
|
||||||
visibility: "user" | "admin";
|
visibility: "user" | "admin";
|
||||||
|
placement: CustomMenuPlacement;
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
}>,
|
}>,
|
||||||
custom_endpoints: [] as Array<{
|
custom_endpoints: [] as Array<{
|
||||||
@@ -7094,6 +7120,7 @@ function addMenuItem() {
|
|||||||
icon_svg: "",
|
icon_svg: "",
|
||||||
url: "",
|
url: "",
|
||||||
visibility: "user",
|
visibility: "user",
|
||||||
|
placement: DEFAULT_CUSTOM_MENU_PLACEMENT,
|
||||||
sort_order: form.custom_menu_items.length,
|
sort_order: form.custom_menu_items.length,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -7209,6 +7236,12 @@ async function loadSettings() {
|
|||||||
(form as Record<string, unknown>)[key] = value;
|
(form as Record<string, unknown>)[key] = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
form.custom_menu_items = normalizeCustomMenuItems(
|
||||||
|
settings.custom_menu_items,
|
||||||
|
).map((item) => ({
|
||||||
|
...item,
|
||||||
|
placement: item.placement || DEFAULT_CUSTOM_MENU_PLACEMENT,
|
||||||
|
}));
|
||||||
form.login_agreement_mode =
|
form.login_agreement_mode =
|
||||||
settings.login_agreement_mode === "checkbox" ? "checkbox" : "modal";
|
settings.login_agreement_mode === "checkbox" ? "checkbox" : "modal";
|
||||||
form.login_agreement_updated_at =
|
form.login_agreement_updated_at =
|
||||||
@@ -7575,7 +7608,10 @@ async function saveSettings() {
|
|||||||
hide_ccs_import_button: form.hide_ccs_import_button,
|
hide_ccs_import_button: form.hide_ccs_import_button,
|
||||||
table_default_page_size: form.table_default_page_size,
|
table_default_page_size: form.table_default_page_size,
|
||||||
table_page_size_options: form.table_page_size_options,
|
table_page_size_options: form.table_page_size_options,
|
||||||
custom_menu_items: form.custom_menu_items,
|
custom_menu_items: form.custom_menu_items.map((item) => ({
|
||||||
|
...item,
|
||||||
|
placement: item.placement || DEFAULT_CUSTOM_MENU_PLACEMENT,
|
||||||
|
})),
|
||||||
custom_endpoints: form.custom_endpoints,
|
custom_endpoints: form.custom_endpoints,
|
||||||
frontend_url: form.frontend_url,
|
frontend_url: form.frontend_url,
|
||||||
smtp_host: form.smtp_host,
|
smtp_host: form.smtp_host,
|
||||||
|
|||||||
Reference in New Issue
Block a user