merge: 合并 upstream/main 并解决冲突
解决了以下文件的冲突: - backend/internal/handler/admin/setting_handler.go - 采用 upstream 的字段对齐风格和 *Configured 字段名 - 添加 EnableIdentityPatch 和 IdentityPatchPrompt 字段 - backend/internal/handler/gateway_handler.go - 采用 upstream 的 billingErrorDetails 错误处理方式 - frontend/src/api/admin/settings.ts - 采用 upstream 的 *_configured 字段名 - 添加 enable_identity_patch 和 identity_patch_prompt 字段 - frontend/src/views/admin/SettingsView.vue - 合并 turnstile_secret_key_configured 字段 - 保留 enable_identity_patch 和 identity_patch_prompt 字段
This commit is contained in:
@@ -26,20 +26,44 @@ export interface SystemSettings {
|
||||
smtp_host: string
|
||||
smtp_port: number
|
||||
smtp_username: string
|
||||
smtp_password: string
|
||||
smtp_password_configured: boolean
|
||||
smtp_from_email: string
|
||||
smtp_from_name: string
|
||||
smtp_use_tls: boolean
|
||||
// Cloudflare Turnstile settings
|
||||
turnstile_enabled: boolean
|
||||
turnstile_site_key: string
|
||||
turnstile_secret_key: string
|
||||
|
||||
turnstile_secret_key_configured: boolean
|
||||
// Identity patch configuration (Claude -> Gemini)
|
||||
enable_identity_patch: boolean
|
||||
identity_patch_prompt: string
|
||||
}
|
||||
|
||||
export interface UpdateSettingsRequest {
|
||||
registration_enabled?: boolean
|
||||
email_verify_enabled?: boolean
|
||||
default_balance?: number
|
||||
default_concurrency?: number
|
||||
site_name?: string
|
||||
site_logo?: string
|
||||
site_subtitle?: string
|
||||
api_base_url?: string
|
||||
contact_info?: string
|
||||
doc_url?: string
|
||||
smtp_host?: string
|
||||
smtp_port?: number
|
||||
smtp_username?: string
|
||||
smtp_password?: string
|
||||
smtp_from_email?: string
|
||||
smtp_from_name?: string
|
||||
smtp_use_tls?: boolean
|
||||
turnstile_enabled?: boolean
|
||||
turnstile_site_key?: string
|
||||
turnstile_secret_key?: string
|
||||
enable_identity_patch?: boolean
|
||||
identity_patch_prompt?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all system settings
|
||||
* @returns System settings
|
||||
@@ -54,7 +78,7 @@ export async function getSettings(): Promise<SystemSettings> {
|
||||
* @param settings - Partial settings to update
|
||||
* @returns Updated settings
|
||||
*/
|
||||
export async function updateSettings(settings: Partial<SystemSettings>): Promise<SystemSettings> {
|
||||
export async function updateSettings(settings: UpdateSettingsRequest): Promise<SystemSettings> {
|
||||
const { data } = await apiClient.put<SystemSettings>('/admin/settings', settings)
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -69,8 +69,24 @@ apiClient.interceptors.response.use(
|
||||
|
||||
// 401: Unauthorized - clear token and redirect to login
|
||||
if (status === 401) {
|
||||
const hasToken = !!localStorage.getItem('auth_token')
|
||||
const url = error.config?.url || ''
|
||||
const isAuthEndpoint =
|
||||
url.includes('/auth/login') || url.includes('/auth/register') || url.includes('/auth/refresh')
|
||||
const headers = error.config?.headers as Record<string, unknown> | undefined
|
||||
const authHeader = headers?.Authorization ?? headers?.authorization
|
||||
const sentAuth =
|
||||
typeof authHeader === 'string'
|
||||
? authHeader.trim() !== ''
|
||||
: Array.isArray(authHeader)
|
||||
? authHeader.length > 0
|
||||
: !!authHeader
|
||||
|
||||
localStorage.removeItem('auth_token')
|
||||
localStorage.removeItem('auth_user')
|
||||
if ((hasToken || sentAuth) && !isAuthEndpoint) {
|
||||
sessionStorage.setItem('auth_expired', '1')
|
||||
}
|
||||
// Only redirect if not already on login page
|
||||
if (!window.location.pathname.includes('/login')) {
|
||||
window.location.href = '/login'
|
||||
|
||||
@@ -136,16 +136,16 @@
|
||||
<ol
|
||||
class="list-inside list-decimal space-y-1 text-xs text-amber-700 dark:text-amber-300"
|
||||
>
|
||||
<li v-html="t('admin.accounts.oauth.step1')"></li>
|
||||
<li v-html="t('admin.accounts.oauth.step2')"></li>
|
||||
<li v-html="t('admin.accounts.oauth.step3')"></li>
|
||||
<li v-html="t('admin.accounts.oauth.step4')"></li>
|
||||
<li v-html="t('admin.accounts.oauth.step5')"></li>
|
||||
<li v-html="t('admin.accounts.oauth.step6')"></li>
|
||||
<li>{{ t('admin.accounts.oauth.step1') }}</li>
|
||||
<li>{{ t('admin.accounts.oauth.step2') }}</li>
|
||||
<li>{{ t('admin.accounts.oauth.step3') }}</li>
|
||||
<li>{{ t('admin.accounts.oauth.step4') }}</li>
|
||||
<li>{{ t('admin.accounts.oauth.step5') }}</li>
|
||||
<li>{{ t('admin.accounts.oauth.step6') }}</li>
|
||||
</ol>
|
||||
<p
|
||||
class="mt-2 text-xs text-amber-600 dark:text-amber-400"
|
||||
v-html="t('admin.accounts.oauth.sessionKeyFormat')"
|
||||
v-text="t('admin.accounts.oauth.sessionKeyFormat')"
|
||||
></p>
|
||||
</div>
|
||||
|
||||
@@ -390,7 +390,7 @@
|
||||
>
|
||||
<p
|
||||
class="text-xs text-amber-800 dark:text-amber-300"
|
||||
v-html="oauthImportantNotice"
|
||||
v-text="oauthImportantNotice"
|
||||
></p>
|
||||
</div>
|
||||
<!-- Proxy Warning (for non-OpenAI) -->
|
||||
@@ -400,7 +400,7 @@
|
||||
>
|
||||
<p
|
||||
class="text-xs text-yellow-800 dark:text-yellow-300"
|
||||
v-html="t('admin.accounts.oauth.proxyWarning')"
|
||||
v-text="t('admin.accounts.oauth.proxyWarning')"
|
||||
></p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -423,7 +423,7 @@
|
||||
</p>
|
||||
<p
|
||||
class="mb-3 text-sm text-blue-700 dark:text-blue-300"
|
||||
v-html="oauthAuthCodeDesc"
|
||||
v-text="oauthAuthCodeDesc"
|
||||
></p>
|
||||
<div>
|
||||
<label class="input-label">
|
||||
|
||||
52
frontend/src/components/common/GroupOptionItem.vue
Normal file
52
frontend/src/components/common/GroupOptionItem.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<div class="flex min-w-0 flex-1 items-center justify-between gap-2">
|
||||
<div
|
||||
class="flex min-w-0 flex-1 flex-col items-start gap-1"
|
||||
:title="description || undefined"
|
||||
>
|
||||
<GroupBadge
|
||||
:name="name"
|
||||
:platform="platform"
|
||||
:subscription-type="subscriptionType"
|
||||
:rate-multiplier="rateMultiplier"
|
||||
/>
|
||||
<span
|
||||
v-if="description"
|
||||
class="w-full truncate text-left text-xs text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{{ description }}
|
||||
</span>
|
||||
</div>
|
||||
<svg
|
||||
v-if="showCheckmark && selected"
|
||||
class="h-4 w-4 shrink-0 text-primary-600 dark:text-primary-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import GroupBadge from './GroupBadge.vue'
|
||||
import type { SubscriptionType, GroupPlatform } from '@/types'
|
||||
|
||||
interface Props {
|
||||
name: string
|
||||
platform: GroupPlatform
|
||||
subscriptionType?: SubscriptionType
|
||||
rateMultiplier?: number
|
||||
description?: string | null
|
||||
selected?: boolean
|
||||
showCheckmark?: boolean
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
subscriptionType: 'standard',
|
||||
selected: false,
|
||||
showCheckmark: true
|
||||
})
|
||||
</script>
|
||||
@@ -107,7 +107,10 @@
|
||||
</button>
|
||||
</div>
|
||||
<!-- Code Content -->
|
||||
<pre class="p-4 text-sm font-mono text-gray-100 overflow-x-auto"><code v-html="file.highlighted"></code></pre>
|
||||
<pre class="p-4 text-sm font-mono text-gray-100 overflow-x-auto">
|
||||
<code v-if="file.highlighted" v-html="file.highlighted"></code>
|
||||
<code v-else v-text="file.content"></code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -164,8 +167,8 @@ interface TabConfig {
|
||||
interface FileConfig {
|
||||
path: string
|
||||
content: string
|
||||
highlighted: string
|
||||
hint?: string // Optional hint message for this file
|
||||
highlighted?: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
@@ -311,14 +314,23 @@ const platformNote = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
// Syntax highlighting helpers
|
||||
const keyword = (text: string) => `<span class="text-purple-400">${text}</span>`
|
||||
const variable = (text: string) => `<span class="text-cyan-400">${text}</span>`
|
||||
const string = (text: string) => `<span class="text-green-400">${text}</span>`
|
||||
const operator = (text: string) => `<span class="text-yellow-400">${text}</span>`
|
||||
const comment = (text: string) => `<span class="text-gray-500">${text}</span>`
|
||||
const key = (text: string) => `<span class="text-blue-400">${text}</span>`
|
||||
const escapeHtml = (value: string) => value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
|
||||
const wrapToken = (className: string, value: string) =>
|
||||
`<span class="${className}">${escapeHtml(value)}</span>`
|
||||
|
||||
const keyword = (value: string) => wrapToken('text-emerald-300', value)
|
||||
const variable = (value: string) => wrapToken('text-sky-200', value)
|
||||
const operator = (value: string) => wrapToken('text-slate-400', value)
|
||||
const string = (value: string) => wrapToken('text-amber-200', value)
|
||||
const comment = (value: string) => wrapToken('text-slate-500', value)
|
||||
|
||||
// Syntax highlighting helpers
|
||||
// Generate file configs based on platform and active tab
|
||||
const currentFiles = computed((): FileConfig[] => {
|
||||
const baseUrl = props.baseUrl || window.location.origin
|
||||
@@ -343,37 +355,29 @@ const currentFiles = computed((): FileConfig[] => {
|
||||
function generateAnthropicFiles(baseUrl: string, apiKey: string): FileConfig[] {
|
||||
let path: string
|
||||
let content: string
|
||||
let highlighted: string
|
||||
|
||||
switch (activeTab.value) {
|
||||
case 'unix':
|
||||
path = 'Terminal'
|
||||
content = `export ANTHROPIC_BASE_URL="${baseUrl}"
|
||||
export ANTHROPIC_AUTH_TOKEN="${apiKey}"`
|
||||
highlighted = `${keyword('export')} ${variable('ANTHROPIC_BASE_URL')}${operator('=')}${string(`"${baseUrl}"`)}
|
||||
${keyword('export')} ${variable('ANTHROPIC_AUTH_TOKEN')}${operator('=')}${string(`"${apiKey}"`)}`
|
||||
break
|
||||
case 'cmd':
|
||||
path = 'Command Prompt'
|
||||
content = `set ANTHROPIC_BASE_URL=${baseUrl}
|
||||
set ANTHROPIC_AUTH_TOKEN=${apiKey}`
|
||||
highlighted = `${keyword('set')} ${variable('ANTHROPIC_BASE_URL')}${operator('=')}${baseUrl}
|
||||
${keyword('set')} ${variable('ANTHROPIC_AUTH_TOKEN')}${operator('=')}${apiKey}`
|
||||
break
|
||||
case 'powershell':
|
||||
path = 'PowerShell'
|
||||
content = `$env:ANTHROPIC_BASE_URL="${baseUrl}"
|
||||
$env:ANTHROPIC_AUTH_TOKEN="${apiKey}"`
|
||||
highlighted = `${keyword('$env:')}${variable('ANTHROPIC_BASE_URL')}${operator('=')}${string(`"${baseUrl}"`)}
|
||||
${keyword('$env:')}${variable('ANTHROPIC_AUTH_TOKEN')}${operator('=')}${string(`"${apiKey}"`)}`
|
||||
break
|
||||
default:
|
||||
path = 'Terminal'
|
||||
content = ''
|
||||
highlighted = ''
|
||||
}
|
||||
|
||||
return [{ path, content, highlighted }]
|
||||
return [{ path, content }]
|
||||
}
|
||||
|
||||
function generateGeminiCliContent(baseUrl: string, apiKey: string): FileConfig {
|
||||
@@ -398,9 +402,9 @@ ${keyword('export')} ${variable('GEMINI_MODEL')}${operator('=')}${string(`"${mod
|
||||
content = `set GOOGLE_GEMINI_BASE_URL=${baseUrl}
|
||||
set GEMINI_API_KEY=${apiKey}
|
||||
set GEMINI_MODEL=${model}`
|
||||
highlighted = `${keyword('set')} ${variable('GOOGLE_GEMINI_BASE_URL')}${operator('=')}${baseUrl}
|
||||
${keyword('set')} ${variable('GEMINI_API_KEY')}${operator('=')}${apiKey}
|
||||
${keyword('set')} ${variable('GEMINI_MODEL')}${operator('=')}${model}
|
||||
highlighted = `${keyword('set')} ${variable('GOOGLE_GEMINI_BASE_URL')}${operator('=')}${string(baseUrl)}
|
||||
${keyword('set')} ${variable('GEMINI_API_KEY')}${operator('=')}${string(apiKey)}
|
||||
${keyword('set')} ${variable('GEMINI_MODEL')}${operator('=')}${string(model)}
|
||||
${comment(`REM ${modelComment}`)}`
|
||||
break
|
||||
case 'powershell':
|
||||
@@ -440,40 +444,20 @@ base_url = "${baseUrl}"
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = true`
|
||||
|
||||
const configHighlighted = `${key('model_provider')} ${operator('=')} ${string('"sub2api"')}
|
||||
${key('model')} ${operator('=')} ${string('"gpt-5.2-codex"')}
|
||||
${key('model_reasoning_effort')} ${operator('=')} ${string('"high"')}
|
||||
${key('network_access')} ${operator('=')} ${string('"enabled"')}
|
||||
${key('disable_response_storage')} ${operator('=')} ${keyword('true')}
|
||||
${key('windows_wsl_setup_acknowledged')} ${operator('=')} ${keyword('true')}
|
||||
${key('model_verbosity')} ${operator('=')} ${string('"high"')}
|
||||
|
||||
${comment('[model_providers.sub2api]')}
|
||||
${key('name')} ${operator('=')} ${string('"sub2api"')}
|
||||
${key('base_url')} ${operator('=')} ${string(`"${baseUrl}"`)}
|
||||
${key('wire_api')} ${operator('=')} ${string('"responses"')}
|
||||
${key('requires_openai_auth')} ${operator('=')} ${keyword('true')}`
|
||||
|
||||
// auth.json content
|
||||
const authContent = `{
|
||||
"OPENAI_API_KEY": "${apiKey}"
|
||||
}`
|
||||
|
||||
const authHighlighted = `{
|
||||
${key('"OPENAI_API_KEY"')}: ${string(`"${apiKey}"`)}
|
||||
}`
|
||||
|
||||
return [
|
||||
{
|
||||
path: `${configDir}/config.toml`,
|
||||
content: configContent,
|
||||
highlighted: configHighlighted,
|
||||
hint: t('keys.useKeyModal.openai.configTomlHint')
|
||||
},
|
||||
{
|
||||
path: `${configDir}/auth.json`,
|
||||
content: authContent,
|
||||
highlighted: authHighlighted
|
||||
content: authContent
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { getPublicSettings } from '@/api/auth'
|
||||
import { sanitizeUrl } from '@/utils/url'
|
||||
|
||||
const siteName = ref('Sub2API')
|
||||
const siteLogo = ref('')
|
||||
@@ -74,7 +75,7 @@ onMounted(async () => {
|
||||
try {
|
||||
const settings = await getPublicSettings()
|
||||
siteName.value = settings.site_name || 'Sub2API'
|
||||
siteLogo.value = settings.site_logo || ''
|
||||
siteLogo.value = sanitizeUrl(settings.site_logo || '', { allowRelative: true })
|
||||
siteSubtitle.value = settings.site_subtitle || 'Subscription to API Conversion Platform'
|
||||
} catch (error) {
|
||||
console.error('Failed to load public settings:', error)
|
||||
|
||||
@@ -217,6 +217,7 @@ export default {
|
||||
registrationFailed: 'Registration failed. Please try again.',
|
||||
loginSuccess: 'Login successful! Welcome back.',
|
||||
accountCreatedSuccess: 'Account created successfully! Welcome to {siteName}.',
|
||||
reloginRequired: 'Session expired. Please log in again.',
|
||||
turnstileExpired: 'Verification expired, please try again',
|
||||
turnstileFailed: 'Verification failed, please try again',
|
||||
completeVerification: 'Please complete the verification',
|
||||
@@ -1162,13 +1163,13 @@ export default {
|
||||
'One sessionKey per line, e.g.:\nsk-ant-sid01-xxxxx...\nsk-ant-sid01-yyyyy...',
|
||||
sessionKeyPlaceholderSingle: 'sk-ant-sid01-xxxxx...',
|
||||
howToGetSessionKey: 'How to get sessionKey',
|
||||
step1: 'Login to <strong>claude.ai</strong> in your browser',
|
||||
step2: 'Press <kbd>F12</kbd> to open Developer Tools',
|
||||
step3: 'Go to <strong>Application</strong> tab',
|
||||
step4: 'Find <strong>Cookies</strong> → <strong>https://claude.ai</strong>',
|
||||
step5: 'Find the row with key <strong>sessionKey</strong>',
|
||||
step6: 'Copy the <strong>Value</strong>',
|
||||
sessionKeyFormat: 'sessionKey usually starts with <code>sk-ant-sid01-</code>',
|
||||
step1: 'Login to claude.ai in your browser',
|
||||
step2: 'Press F12 to open Developer Tools',
|
||||
step3: 'Go to Application tab',
|
||||
step4: 'Find Cookies → https://claude.ai',
|
||||
step5: 'Find the row with key sessionKey',
|
||||
step6: 'Copy the Value',
|
||||
sessionKeyFormat: 'sessionKey usually starts with sk-ant-sid01-',
|
||||
startAutoAuth: 'Start Auto-Auth',
|
||||
authorizing: 'Authorizing...',
|
||||
followSteps: 'Follow these steps to authorize your Claude account:',
|
||||
@@ -1180,10 +1181,10 @@ export default {
|
||||
openUrlDesc:
|
||||
'Open the authorization URL in a new tab, log in to your Claude account and authorize.',
|
||||
proxyWarning:
|
||||
'<strong>Note:</strong> If you configured a proxy, make sure your browser uses the same proxy to access the authorization page.',
|
||||
'Note: If you configured a proxy, make sure your browser uses the same proxy to access the authorization page.',
|
||||
step3EnterCode: 'Enter the Authorization Code',
|
||||
authCodeDesc:
|
||||
'After authorization is complete, the page will display an <strong>Authorization Code</strong>. Copy and paste it below:',
|
||||
'After authorization is complete, the page will display an Authorization Code. Copy and paste it below:',
|
||||
authCode: 'Authorization Code',
|
||||
authCodePlaceholder: 'Paste the Authorization Code from Claude page...',
|
||||
authCodeHint: 'Paste the Authorization Code copied from the Claude page',
|
||||
@@ -1204,10 +1205,10 @@ export default {
|
||||
openUrlDesc:
|
||||
'Open the authorization URL in a new tab, log in to your OpenAI account and authorize.',
|
||||
importantNotice:
|
||||
'<strong>Important:</strong> The page may take a while to load after authorization. Please wait patiently. When the browser address bar changes to <code>http://localhost...</code>, the authorization is complete.',
|
||||
'Important: The page may take a while to load after authorization. Please wait patiently. When the browser address bar changes to http://localhost..., the authorization is complete.',
|
||||
step3EnterCode: 'Enter Authorization URL or Code',
|
||||
authCodeDesc:
|
||||
'After authorization is complete, when the page URL becomes <code>http://localhost:xxx/auth/callback?code=...</code>:',
|
||||
'After authorization is complete, when the page URL becomes http://localhost:xxx/auth/callback?code=...:',
|
||||
authCode: 'Authorization URL or Code',
|
||||
authCodePlaceholder:
|
||||
'Option 1: Copy the complete URL\n(http://localhost:xxx/auth/callback?code=...)\nOption 2: Copy only the code parameter value',
|
||||
@@ -1230,7 +1231,7 @@ export default {
|
||||
'Open the authorization URL in a new tab, log in to your Google account and authorize.',
|
||||
step3EnterCode: 'Enter Authorization URL or Code',
|
||||
authCodeDesc:
|
||||
'After authorization, copy the callback URL (recommended) or just the <code>code</code> and paste it below.',
|
||||
'After authorization, copy the callback URL (recommended) or just the code and paste it below.',
|
||||
authCode: 'Callback URL or Code',
|
||||
authCodePlaceholder:
|
||||
'Option 1 (recommended): Paste the callback URL\nOption 2: Paste only the code value',
|
||||
@@ -1272,10 +1273,10 @@ export default {
|
||||
step2OpenUrl: 'Open the URL in your browser and complete authorization',
|
||||
openUrlDesc: 'Open the authorization URL in a new tab, log in to your Google account and authorize.',
|
||||
importantNotice:
|
||||
'<strong>Important:</strong> The page may take a while to load after authorization. Please wait patiently. When the browser address bar shows <code>http://localhost...</code>, authorization is complete.',
|
||||
'Important: The page may take a while to load after authorization. Please wait patiently. When the browser address bar shows http://localhost..., authorization is complete.',
|
||||
step3EnterCode: 'Enter Authorization URL or Code',
|
||||
authCodeDesc:
|
||||
'After authorization, when the page URL becomes <code>http://localhost:xxx/auth/callback?code=...</code>:',
|
||||
'After authorization, when the page URL becomes http://localhost:xxx/auth/callback?code=...:',
|
||||
authCode: 'Authorization URL or Code',
|
||||
authCodePlaceholder:
|
||||
'Option 1: Copy the complete URL\n(http://localhost:xxx/auth/callback?code=...)\nOption 2: Copy only the code parameter value',
|
||||
@@ -1699,8 +1700,8 @@ export default {
|
||||
secretKey: 'Secret Key',
|
||||
siteKeyHint: 'Get this from your Cloudflare Dashboard',
|
||||
cloudflareDashboard: 'Cloudflare Dashboard',
|
||||
secretKeyHint: 'Server-side verification key (keep this secret)'
|
||||
},
|
||||
secretKeyHint: 'Server-side verification key (keep this secret)',
|
||||
secretKeyConfiguredHint: 'Secret key configured. Leave empty to keep the current value.' },
|
||||
defaults: {
|
||||
title: 'Default User Settings',
|
||||
description: 'Default values for new users',
|
||||
@@ -1750,6 +1751,8 @@ export default {
|
||||
password: 'SMTP Password',
|
||||
passwordPlaceholder: '********',
|
||||
passwordHint: 'Leave empty to keep existing password',
|
||||
passwordConfiguredPlaceholder: '********',
|
||||
passwordConfiguredHint: 'Password configured. Leave empty to keep the current value.',
|
||||
fromEmail: 'From Email',
|
||||
fromEmailPlaceholder: "noreply{'@'}example.com",
|
||||
fromName: 'From Name',
|
||||
|
||||
@@ -215,6 +215,7 @@ export default {
|
||||
registrationFailed: '注册失败,请重试。',
|
||||
loginSuccess: '登录成功!欢迎回来。',
|
||||
accountCreatedSuccess: '账户创建成功!欢迎使用 {siteName}。',
|
||||
reloginRequired: '会话已过期,请重新登录。',
|
||||
turnstileExpired: '验证已过期,请重试',
|
||||
turnstileFailed: '验证失败,请重试',
|
||||
completeVerification: '请完成验证',
|
||||
@@ -1296,13 +1297,13 @@ export default {
|
||||
'每行一个 sessionKey,例如:\nsk-ant-sid01-xxxxx...\nsk-ant-sid01-yyyyy...',
|
||||
sessionKeyPlaceholderSingle: 'sk-ant-sid01-xxxxx...',
|
||||
howToGetSessionKey: '如何获取 sessionKey',
|
||||
step1: '在浏览器中登录 <strong>claude.ai</strong>',
|
||||
step2: '按 <kbd>F12</kbd> 打开开发者工具',
|
||||
step3: '切换到 <strong>Application</strong> 标签',
|
||||
step4: '找到 <strong>Cookies</strong> → <strong>https://claude.ai</strong>',
|
||||
step5: '找到 <strong>sessionKey</strong> 所在行',
|
||||
step6: '复制 <strong>Value</strong> 列的值',
|
||||
sessionKeyFormat: 'sessionKey 通常以 <code>sk-ant-sid01-</code> 开头',
|
||||
step1: '在浏览器中登录 claude.ai',
|
||||
step2: '按 F12 打开开发者工具',
|
||||
step3: '切换到 Application 标签',
|
||||
step4: '找到 Cookies → https://claude.ai',
|
||||
step5: '找到 sessionKey 所在行',
|
||||
step6: '复制 Value 列的值',
|
||||
sessionKeyFormat: 'sessionKey 通常以 sk-ant-sid01- 开头',
|
||||
startAutoAuth: '开始自动授权',
|
||||
authorizing: '授权中...',
|
||||
followSteps: '按照以下步骤授权您的 Claude 账号:',
|
||||
@@ -1313,9 +1314,9 @@ export default {
|
||||
step2OpenUrl: '在浏览器中打开 URL 并完成授权',
|
||||
openUrlDesc: '在新标签页中打开授权 URL,登录您的 Claude 账号并授权。',
|
||||
proxyWarning:
|
||||
'<strong>注意:</strong>如果您配置了代理,请确保浏览器使用相同的代理访问授权页面。',
|
||||
'注意:如果您配置了代理,请确保浏览器使用相同的代理访问授权页面。',
|
||||
step3EnterCode: '输入授权码',
|
||||
authCodeDesc: '授权完成后,页面会显示一个 <strong>授权码</strong>。复制并粘贴到下方:',
|
||||
authCodeDesc: '授权完成后,页面会显示一个授权码。复制并粘贴到下方:',
|
||||
authCode: '授权码',
|
||||
authCodePlaceholder: '粘贴 Claude 页面的授权码...',
|
||||
authCodeHint: '粘贴从 Claude 页面复制的授权码',
|
||||
@@ -1335,10 +1336,10 @@ export default {
|
||||
step2OpenUrl: '在浏览器中打开链接并完成授权',
|
||||
openUrlDesc: '请在新标签页中打开授权链接,登录您的 OpenAI 账户并授权。',
|
||||
importantNotice:
|
||||
'<strong>重要提示:</strong>授权后页面可能会加载较长时间,请耐心等待。当浏览器地址栏变为 <code>http://localhost...</code> 开头时,表示授权已完成。',
|
||||
'重要提示:授权后页面可能会加载较长时间,请耐心等待。当浏览器地址栏变为 http://localhost... 开头时,表示授权已完成。',
|
||||
step3EnterCode: '输入授权链接或 Code',
|
||||
authCodeDesc:
|
||||
'授权完成后,当页面地址变为 <code>http://localhost:xxx/auth/callback?code=...</code> 时:',
|
||||
'授权完成后,当页面地址变为 http://localhost:xxx/auth/callback?code=... 时:',
|
||||
authCode: '授权链接或 Code',
|
||||
authCodePlaceholder:
|
||||
'方式1:复制完整的链接\n(http://localhost:xxx/auth/callback?code=...)\n方式2:仅复制 code 参数的值',
|
||||
@@ -1357,7 +1358,7 @@ export default {
|
||||
step2OpenUrl: '在浏览器中打开链接并完成授权',
|
||||
openUrlDesc: '请在新标签页中打开授权链接,登录您的 Google 账户并授权。',
|
||||
step3EnterCode: '输入回调链接或 Code',
|
||||
authCodeDesc: '授权完成后,复制浏览器跳转后的回调链接(推荐)或仅复制 <code>code</code>,粘贴到下方即可。',
|
||||
authCodeDesc: '授权完成后,复制浏览器跳转后的回调链接(推荐)或仅复制 code,粘贴到下方即可。',
|
||||
authCode: '回调链接或 Code',
|
||||
authCodePlaceholder: '方式1(推荐):粘贴回调链接\n方式2:仅粘贴 code 参数的值',
|
||||
authCodeHint: '系统会自动从链接中解析 code/state。',
|
||||
@@ -1393,10 +1394,10 @@ export default {
|
||||
step2OpenUrl: '在浏览器中打开链接并完成授权',
|
||||
openUrlDesc: '请在新标签页中打开授权链接,登录您的 Google 账户并授权。',
|
||||
importantNotice:
|
||||
'<strong>重要提示:</strong>授权后页面可能会加载较长时间,请耐心等待。当浏览器地址栏变为 <code>http://localhost...</code> 开头时,表示授权已完成。',
|
||||
'重要提示:授权后页面可能会加载较长时间,请耐心等待。当浏览器地址栏变为 http://localhost... 开头时,表示授权已完成。',
|
||||
step3EnterCode: '输入授权链接或 Code',
|
||||
authCodeDesc:
|
||||
'授权完成后,当页面地址变为 <code>http://localhost:xxx/auth/callback?code=...</code> 时:',
|
||||
'授权完成后,当页面地址变为 http://localhost:xxx/auth/callback?code=... 时:',
|
||||
authCode: '授权链接或 Code',
|
||||
authCodePlaceholder:
|
||||
'方式1:复制完整的链接\n(http://localhost:xxx/auth/callback?code=...)\n方式2:仅复制 code 参数的值',
|
||||
@@ -1845,8 +1846,8 @@ export default {
|
||||
secretKey: '私密密钥',
|
||||
siteKeyHint: '从 Cloudflare Dashboard 获取',
|
||||
cloudflareDashboard: 'Cloudflare Dashboard',
|
||||
secretKeyHint: '服务端验证密钥(请保密)'
|
||||
},
|
||||
secretKeyHint: '服务端验证密钥(请保密)',
|
||||
secretKeyConfiguredHint: '密钥已配置,留空以保留当前值。' },
|
||||
defaults: {
|
||||
title: '用户默认设置',
|
||||
description: '新用户的默认值',
|
||||
@@ -1895,6 +1896,8 @@ export default {
|
||||
password: 'SMTP 密码',
|
||||
passwordPlaceholder: '********',
|
||||
passwordHint: '留空以保留现有密码',
|
||||
passwordConfiguredPlaceholder: '********',
|
||||
passwordConfiguredHint: '密码已配置,留空以保留当前值。',
|
||||
fromEmail: '发件人邮箱',
|
||||
fromEmailPlaceholder: "noreply{'@'}example.com",
|
||||
fromName: '发件人名称',
|
||||
|
||||
37
frontend/src/utils/url.ts
Normal file
37
frontend/src/utils/url.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 验证并规范化 URL
|
||||
* 默认只接受绝对 URL(以 http:// 或 https:// 开头),可按需允许相对路径
|
||||
* @param value 用户输入的 URL
|
||||
* @returns 规范化后的 URL,如果无效则返回空字符串
|
||||
*/
|
||||
type SanitizeOptions = {
|
||||
allowRelative?: boolean
|
||||
}
|
||||
|
||||
export function sanitizeUrl(value: string, options: SanitizeOptions = {}): string {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (options.allowRelative && trimmed.startsWith('/') && !trimmed.startsWith('//')) {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
// 只接受绝对 URL,不使用 base URL 来避免相对路径被解析为当前域名
|
||||
// 检查是否以 http:// 或 https:// 开头
|
||||
if (!trimmed.match(/^https?:\/\//i)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(trimmed)
|
||||
const protocol = parsed.protocol.toLowerCase()
|
||||
if (protocol !== 'http:' && protocol !== 'https:') {
|
||||
return ''
|
||||
}
|
||||
return parsed.toString()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
@@ -493,6 +493,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { getPublicSettings } from '@/api/auth'
|
||||
import { useAuthStore } from '@/stores'
|
||||
import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue'
|
||||
import { sanitizeUrl } from '@/utils/url'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -549,9 +550,9 @@ onMounted(async () => {
|
||||
try {
|
||||
const settings = await getPublicSettings()
|
||||
siteName.value = settings.site_name || 'Sub2API'
|
||||
siteLogo.value = settings.site_logo || ''
|
||||
siteLogo.value = sanitizeUrl(settings.site_logo || '', { allowRelative: true })
|
||||
siteSubtitle.value = settings.site_subtitle || 'AI API Gateway Platform'
|
||||
docUrl.value = settings.doc_url || ''
|
||||
docUrl.value = sanitizeUrl(settings.doc_url || '', { allowRelative: true })
|
||||
} catch (error) {
|
||||
console.error('Failed to load public settings:', error)
|
||||
}
|
||||
|
||||
@@ -255,7 +255,11 @@
|
||||
placeholder="0x4AAAAAAA..."
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.turnstile.secretKeyHint') }}
|
||||
{{
|
||||
form.turnstile_secret_key_configured
|
||||
? t('admin.settings.turnstile.secretKeyConfiguredHint')
|
||||
: t('admin.settings.turnstile.secretKeyHint')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -577,10 +581,18 @@
|
||||
v-model="form.smtp_password"
|
||||
type="password"
|
||||
class="input"
|
||||
:placeholder="t('admin.settings.smtp.passwordPlaceholder')"
|
||||
:placeholder="
|
||||
form.smtp_password_configured
|
||||
? t('admin.settings.smtp.passwordConfiguredPlaceholder')
|
||||
: t('admin.settings.smtp.passwordPlaceholder')
|
||||
"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.smtp.passwordHint') }}
|
||||
{{
|
||||
form.smtp_password_configured
|
||||
? t('admin.settings.smtp.passwordConfiguredHint')
|
||||
: t('admin.settings.smtp.passwordHint')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
@@ -713,7 +725,7 @@
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { adminAPI } from '@/api'
|
||||
import type { SystemSettings } from '@/api/admin/settings'
|
||||
import type { SystemSettings, UpdateSettingsRequest } from '@/api/admin/settings'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import Toggle from '@/components/common/Toggle.vue'
|
||||
import { useAppStore } from '@/stores'
|
||||
@@ -735,7 +747,12 @@ const adminApiKeyMasked = ref('')
|
||||
const adminApiKeyOperating = ref(false)
|
||||
const newAdminApiKey = ref('')
|
||||
|
||||
const form = reactive<SystemSettings>({
|
||||
type SettingsForm = SystemSettings & {
|
||||
smtp_password: string
|
||||
turnstile_secret_key: string
|
||||
}
|
||||
|
||||
const form = reactive<SettingsForm>({
|
||||
registration_enabled: true,
|
||||
email_verify_enabled: false,
|
||||
default_balance: 0,
|
||||
@@ -750,6 +767,7 @@ const form = reactive<SystemSettings>({
|
||||
smtp_port: 587,
|
||||
smtp_username: '',
|
||||
smtp_password: '',
|
||||
smtp_password_configured: false,
|
||||
smtp_from_email: '',
|
||||
smtp_from_name: '',
|
||||
smtp_use_tls: true,
|
||||
@@ -757,6 +775,7 @@ const form = reactive<SystemSettings>({
|
||||
turnstile_enabled: false,
|
||||
turnstile_site_key: '',
|
||||
turnstile_secret_key: '',
|
||||
turnstile_secret_key_configured: false,
|
||||
// Identity patch (Claude -> Gemini)
|
||||
enable_identity_patch: true,
|
||||
identity_patch_prompt: ''
|
||||
@@ -805,6 +824,8 @@ async function loadSettings() {
|
||||
try {
|
||||
const settings = await adminAPI.settings.getSettings()
|
||||
Object.assign(form, settings)
|
||||
form.smtp_password = ''
|
||||
form.turnstile_secret_key = ''
|
||||
} catch (error: any) {
|
||||
appStore.showError(
|
||||
t('admin.settings.failedToLoad') + ': ' + (error.message || t('common.unknownError'))
|
||||
@@ -817,7 +838,32 @@ async function loadSettings() {
|
||||
async function saveSettings() {
|
||||
saving.value = true
|
||||
try {
|
||||
await adminAPI.settings.updateSettings(form)
|
||||
const payload: UpdateSettingsRequest = {
|
||||
registration_enabled: form.registration_enabled,
|
||||
email_verify_enabled: form.email_verify_enabled,
|
||||
default_balance: form.default_balance,
|
||||
default_concurrency: form.default_concurrency,
|
||||
site_name: form.site_name,
|
||||
site_logo: form.site_logo,
|
||||
site_subtitle: form.site_subtitle,
|
||||
api_base_url: form.api_base_url,
|
||||
contact_info: form.contact_info,
|
||||
doc_url: form.doc_url,
|
||||
smtp_host: form.smtp_host,
|
||||
smtp_port: form.smtp_port,
|
||||
smtp_username: form.smtp_username,
|
||||
smtp_password: form.smtp_password || undefined,
|
||||
smtp_from_email: form.smtp_from_email,
|
||||
smtp_from_name: form.smtp_from_name,
|
||||
smtp_use_tls: form.smtp_use_tls,
|
||||
turnstile_enabled: form.turnstile_enabled,
|
||||
turnstile_site_key: form.turnstile_site_key,
|
||||
turnstile_secret_key: form.turnstile_secret_key || undefined
|
||||
}
|
||||
const updated = await adminAPI.settings.updateSettings(payload)
|
||||
Object.assign(form, updated)
|
||||
form.smtp_password = ''
|
||||
form.turnstile_secret_key = ''
|
||||
// Refresh cached public settings so sidebar/header update immediately
|
||||
await appStore.fetchPublicSettings(true)
|
||||
appStore.showSuccess(t('admin.settings.settingsSaved'))
|
||||
|
||||
@@ -277,6 +277,14 @@ const errors = reactive({
|
||||
// ==================== Lifecycle ====================
|
||||
|
||||
onMounted(async () => {
|
||||
const expiredFlag = sessionStorage.getItem('auth_expired')
|
||||
if (expiredFlag) {
|
||||
sessionStorage.removeItem('auth_expired')
|
||||
const message = t('auth.reloginRequired')
|
||||
errorMessage.value = message
|
||||
appStore.showWarning(message)
|
||||
}
|
||||
|
||||
try {
|
||||
const settings = await getPublicSettings()
|
||||
turnstileEnabled.value = settings.turnstile_enabled
|
||||
|
||||
@@ -335,12 +335,14 @@
|
||||
/>
|
||||
<span v-else class="text-gray-400">{{ t('keys.selectGroup') }}</span>
|
||||
</template>
|
||||
<template #option="{ option }">
|
||||
<GroupBadge
|
||||
<template #option="{ option, selected }">
|
||||
<GroupOptionItem
|
||||
:name="(option as unknown as GroupOption).label"
|
||||
:platform="(option as unknown as GroupOption).platform"
|
||||
:subscription-type="(option as unknown as GroupOption).subscriptionType"
|
||||
:rate-multiplier="(option as unknown as GroupOption).rate"
|
||||
:description="(option as unknown as GroupOption).description"
|
||||
:selected="selected"
|
||||
/>
|
||||
</template>
|
||||
</Select>
|
||||
@@ -517,26 +519,19 @@
|
||||
? 'bg-primary-50 dark:bg-primary-900/20'
|
||||
: 'hover:bg-gray-100 dark:hover:bg-dark-700'
|
||||
]"
|
||||
:title="option.description || undefined"
|
||||
>
|
||||
<GroupBadge
|
||||
<GroupOptionItem
|
||||
:name="option.label"
|
||||
:platform="option.platform"
|
||||
:subscription-type="option.subscriptionType"
|
||||
:rate-multiplier="option.rate"
|
||||
/>
|
||||
<svg
|
||||
v-if="
|
||||
:description="option.description"
|
||||
:selected="
|
||||
selectedKeyForGroup?.group_id === option.value ||
|
||||
(!selectedKeyForGroup?.group_id && option.value === null)
|
||||
"
|
||||
class="h-4 w-4 shrink-0 text-primary-600 dark:text-primary-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -563,6 +558,7 @@ import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import UseKeyModal from '@/components/keys/UseKeyModal.vue'
|
||||
import GroupBadge from '@/components/common/GroupBadge.vue'
|
||||
import GroupOptionItem from '@/components/common/GroupOptionItem.vue'
|
||||
import type { ApiKey, Group, PublicSettings, SubscriptionType, GroupPlatform } from '@/types'
|
||||
import type { Column } from '@/components/common/types'
|
||||
import type { BatchApiKeyUsageStats } from '@/api/usage'
|
||||
@@ -571,6 +567,7 @@ import { formatDateTime } from '@/utils/format'
|
||||
interface GroupOption {
|
||||
value: number
|
||||
label: string
|
||||
description: string | null
|
||||
rate: number
|
||||
subscriptionType: SubscriptionType
|
||||
platform: GroupPlatform
|
||||
@@ -666,6 +663,7 @@ const groupOptions = computed(() =>
|
||||
groups.value.map((group) => ({
|
||||
value: group.id,
|
||||
label: group.name,
|
||||
description: group.description,
|
||||
rate: group.rate_multiplier,
|
||||
subscriptionType: group.subscription_type,
|
||||
platform: group.platform
|
||||
|
||||
Reference in New Issue
Block a user