(), {
showMobileRefreshTokenOption: false,
showSessionTokenOption: false,
showAccessTokenOption: false,
+ showCodexSessionImportOption: false,
platform: 'anthropic',
showProjectId: true
})
@@ -591,6 +683,7 @@ const emit = defineEmits<{
'validate-mobile-refresh-token': [refreshToken: string]
'validate-session-token': [sessionToken: string]
'import-access-token': [accessToken: string]
+ 'import-codex-session': [content: string]
'update:inputMethod': [method: AuthInputMethod]
}>()
@@ -630,12 +723,13 @@ const authCodeInput = ref('')
const sessionKeyInput = ref('')
const refreshTokenInput = ref('')
const sessionTokenInput = ref('')
+const codexSessionInput = ref('')
const showHelpDialog = ref(false)
const oauthState = ref('')
const projectId = ref('')
// Computed: show method selection when either cookie or refresh token option is enabled
-const showMethodSelection = computed(() => props.showCookieOption || props.showRefreshTokenOption || props.showMobileRefreshTokenOption || props.showSessionTokenOption || props.showAccessTokenOption)
+const showMethodSelection = computed(() => props.showCookieOption || props.showRefreshTokenOption || props.showMobileRefreshTokenOption || props.showSessionTokenOption || props.showAccessTokenOption || props.showCodexSessionImportOption)
// Clipboard
const { copied, copyToClipboard } = useClipboard()
@@ -656,6 +750,16 @@ const parsedRefreshTokenCount = computed(() => {
.filter((rt) => rt).length
})
+const parsedCodexSessionCount = computed(() => {
+ const trimmed = codexSessionInput.value.trim()
+ if (!trimmed) return 0
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) return 1
+ return trimmed
+ .split('\n')
+ .map((item) => item.trim())
+ .filter((item) => item).length
+})
+
// Watchers
watch(inputMethod, (newVal) => {
emit('update:inputMethod', newVal)
@@ -727,6 +831,12 @@ const handleValidateRefreshToken = () => {
}
}
+const handleImportCodexSession = () => {
+ if (codexSessionInput.value.trim()) {
+ emit('import-codex-session', codexSessionInput.value.trim())
+ }
+}
+
// Expose methods and state
defineExpose({
authCode: authCodeInput,
@@ -735,6 +845,7 @@ defineExpose({
sessionKey: sessionKeyInput,
refreshToken: refreshTokenInput,
sessionToken: sessionTokenInput,
+ codexSession: codexSessionInput,
inputMethod,
reset: () => {
authCodeInput.value = ''
@@ -743,6 +854,7 @@ defineExpose({
sessionKeyInput.value = ''
refreshTokenInput.value = ''
sessionTokenInput.value = ''
+ codexSessionInput.value = ''
inputMethod.value = 'manual'
showHelpDialog.value = false
}
diff --git a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts
index c4e2a9bc..04486154 100644
--- a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts
+++ b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts
@@ -216,4 +216,25 @@ describe('EditAccountModal', () => {
'gpt-5.4': 'gpt-5.4-openai-compact'
})
})
+
+ it('submits account-level Codex image generation bridge override', async () => {
+ const account = buildAccount()
+ account.extra = {
+ codex_image_generation_bridge: false,
+ codex_image_generation_bridge_enabled: true
+ }
+ updateAccountMock.mockReset()
+ checkMixedChannelRiskMock.mockReset()
+ checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
+ updateAccountMock.mockResolvedValue(account)
+
+ const wrapper = mountModal(account)
+
+ await wrapper.get('button[data-testid="codex-image-bridge-enabled"]').trigger('click')
+ await wrapper.get('form#edit-account-form').trigger('submit.prevent')
+
+ expect(updateAccountMock).toHaveBeenCalledTimes(1)
+ expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.codex_image_generation_bridge).toBe(true)
+ expect(updateAccountMock.mock.calls[0]?.[1]?.extra).not.toHaveProperty('codex_image_generation_bridge_enabled')
+ })
})
diff --git a/frontend/src/components/admin/account/AccountTableActions.vue b/frontend/src/components/admin/account/AccountTableActions.vue
index ee521f83..6874625b 100644
--- a/frontend/src/components/admin/account/AccountTableActions.vue
+++ b/frontend/src/components/admin/account/AccountTableActions.vue
@@ -5,7 +5,6 @@
-
{{ t('admin.accounts.syncFromCrs') }}
{{ t('admin.accounts.createAccount') }}
@@ -17,7 +16,7 @@ import { useI18n } from 'vue-i18n'
import Icon from '@/components/icons/Icon.vue'
defineProps(['loading'])
-defineEmits(['refresh', 'sync', 'create'])
+defineEmits(['refresh', 'create'])
const { t } = useI18n()
diff --git a/frontend/src/components/auth/EmailOAuthButtons.vue b/frontend/src/components/auth/EmailOAuthButtons.vue
new file mode 100644
index 00000000..b5d874a5
--- /dev/null
+++ b/frontend/src/components/auth/EmailOAuthButtons.vue
@@ -0,0 +1,87 @@
+
+
+
+
+
+ {{ t('auth.oauthOrContinue') }}
+
+
+
+
+
+
+
+
+ {{ providerLabel(provider) }}
+
+
+
+
+
+
diff --git a/frontend/src/components/auth/GitHubMark.vue b/frontend/src/components/auth/GitHubMark.vue
new file mode 100644
index 00000000..a790e622
--- /dev/null
+++ b/frontend/src/components/auth/GitHubMark.vue
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/frontend/src/components/auth/GoogleMark.vue b/frontend/src/components/auth/GoogleMark.vue
new file mode 100644
index 00000000..a848a811
--- /dev/null
+++ b/frontend/src/components/auth/GoogleMark.vue
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/auth/LoginAgreementPrompt.vue b/frontend/src/components/auth/LoginAgreementPrompt.vue
new file mode 100644
index 00000000..dd71cbdc
--- /dev/null
+++ b/frontend/src/components/auth/LoginAgreementPrompt.vue
@@ -0,0 +1,221 @@
+
+
+
+
+
+
+
+ 我已阅读并同意
+
+
+
+ {{ doc.title }}
+
+ 、
+
+
+
+
+
+
+
+
+
+
+
继续登录前需要先同意最新条款。
+
+ 未同意前,账号密码输入和快捷登录会保持禁用。
+
+
+
+ 查看条款
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 条款更新通知
+
+
+ {{ updatedAt }}
+
+
+
+ 我们的服务条款已于 {{ updatedAt || '近期' }} 更新。在继续使用服务之前,请仔细阅读并同意以下条款。
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ doc.title }}
+
+
+
+
+
+
+
+
+
+
+
+ 拒绝
+
+
+ 同意并继续
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/auth/__tests__/EmailOAuthButtons.spec.ts b/frontend/src/components/auth/__tests__/EmailOAuthButtons.spec.ts
new file mode 100644
index 00000000..d8517808
--- /dev/null
+++ b/frontend/src/components/auth/__tests__/EmailOAuthButtons.spec.ts
@@ -0,0 +1,103 @@
+import { mount } from '@vue/test-utils'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import EmailOAuthButtons from '@/components/auth/EmailOAuthButtons.vue'
+
+const routeState = vi.hoisted(() => ({
+ query: {} as Record
,
+}))
+
+const locationState = vi.hoisted(() => ({
+ current: { href: 'http://localhost/register?aff=AFF123' } as { href: string },
+}))
+
+vi.mock('vue-router', () => ({
+ useRoute: () => routeState,
+}))
+
+vi.mock('vue-i18n', () => ({
+ useI18n: () => ({
+ t: (key: string, params?: Record) => {
+ if (key === 'auth.emailOAuth.signIn') {
+ return `使用 ${params?.providerName ?? ''} 登录`
+ }
+ return key
+ },
+ }),
+}))
+
+describe('EmailOAuthButtons', () => {
+ beforeEach(() => {
+ routeState.query = { redirect: '/billing?plan=pro', aff: 'AFF123' }
+ locationState.current = { href: 'http://localhost/register?aff=AFF123' }
+ Object.defineProperty(window, 'location', {
+ configurable: true,
+ value: locationState.current,
+ })
+ window.localStorage.clear()
+ window.sessionStorage.clear()
+ })
+
+ it('passes the affiliate code to the email oauth start URL', async () => {
+ const wrapper = mount(EmailOAuthButtons, {
+ props: {
+ githubEnabled: true,
+ googleEnabled: false,
+ },
+ global: {
+ stubs: {
+ GitHubMark: true,
+ GoogleMark: true,
+ },
+ },
+ })
+
+ await wrapper.get('button').trigger('click')
+
+ expect(locationState.current.href).toBe(
+ '/api/v1/auth/oauth/github/start?redirect=%2Fbilling%3Fplan%3Dpro&aff_code=AFF123'
+ )
+ expect(window.sessionStorage.getItem('oauth_aff_code')).toBe('AFF123')
+ expect(window.sessionStorage.getItem('email_oauth_pending_provider')).toBe('github')
+ })
+
+ it('uses a full-width descriptive button when only GitHub is enabled', () => {
+ const wrapper = mount(EmailOAuthButtons, {
+ props: {
+ githubEnabled: true,
+ googleEnabled: false,
+ },
+ global: {
+ stubs: {
+ GitHubMark: true,
+ GoogleMark: true,
+ },
+ },
+ })
+
+ expect(wrapper.find('.grid').classes()).not.toContain('sm:grid-cols-2')
+ expect(wrapper.get('button').text()).toContain('使用 GitHub 登录')
+ })
+
+ it('uses compact labels and two columns when GitHub and Google are both enabled', () => {
+ const wrapper = mount(EmailOAuthButtons, {
+ props: {
+ githubEnabled: true,
+ googleEnabled: true,
+ },
+ global: {
+ stubs: {
+ GitHubMark: true,
+ GoogleMark: true,
+ },
+ },
+ })
+
+ expect(wrapper.find('.grid').classes()).toContain('sm:grid-cols-2')
+ const buttons = wrapper.findAll('button')
+ expect(buttons).toHaveLength(2)
+ expect(buttons[0].text()).toContain('GitHub')
+ expect(buttons[0].text()).not.toContain('使用 GitHub 登录')
+ expect(buttons[1].text()).toContain('Google')
+ expect(buttons[1].text()).not.toContain('使用 Google 登录')
+ })
+})
diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue
index 47d8a25f..bede24e9 100644
--- a/frontend/src/components/layout/AppSidebar.vue
+++ b/frontend/src/components/layout/AppSidebar.vue
@@ -593,6 +593,21 @@ const SignalIcon = {
)
}
+const ShieldIcon = {
+ render: () =>
+ h(
+ 'svg',
+ { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
+ [
+ h('path', {
+ 'stroke-linecap': 'round',
+ 'stroke-linejoin': 'round',
+ d: 'M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z'
+ })
+ ]
+ )
+}
+
const PriceTagIcon = {
render: () =>
h(
@@ -635,6 +650,7 @@ const flagChannelMonitor = makeSidebarFlag(FeatureFlags.channelMonitor)
const flagPayment = makeSidebarFlag(FeatureFlags.payment)
const flagAvailableChannels = makeSidebarFlag(FeatureFlags.availableChannels)
const flagAffiliate = makeSidebarFlag(FeatureFlags.affiliate)
+const flagRiskControl = makeSidebarFlag(FeatureFlags.riskControl)
const flagOpsMonitoring = () => adminSettingsStore.opsMonitoringEnabled
const flagAdminPayment = () => adminSettingsStore.paymentEnabled
@@ -720,6 +736,7 @@ const adminNavItems = computed((): NavItem[] => {
{ path: '/admin/accounts', label: t('nav.accounts'), icon: GlobeIcon },
{ path: '/admin/announcements', label: t('nav.announcements'), icon: BellIcon },
{ path: '/admin/proxies', label: t('nav.proxies'), icon: ServerIcon },
+ { path: '/admin/risk-control', label: t('nav.riskControl'), icon: ShieldIcon, hideInSimpleMode: true, featureFlag: flagRiskControl },
{ path: '/admin/redeem', label: t('nav.redeemCodes'), icon: TicketIcon, hideInSimpleMode: true },
{ path: '/admin/promo-codes', label: t('nav.promoCodes'), icon: GiftIcon, hideInSimpleMode: true },
{
diff --git a/frontend/src/components/user/profile/ProfileInfoCard.vue b/frontend/src/components/user/profile/ProfileInfoCard.vue
index 37ee8a55..2c190715 100644
--- a/frontend/src/components/user/profile/ProfileInfoCard.vue
+++ b/frontend/src/components/user/profile/ProfileInfoCard.vue
@@ -263,7 +263,9 @@ const providerLabels = computed>(() => ({
email: t('profile.authBindings.providers.email'),
linuxdo: t('profile.authBindings.providers.linuxdo'),
oidc: t('profile.authBindings.providers.oidc', { providerName: props.oidcProviderName }),
- wechat: t('profile.authBindings.providers.wechat')
+ wechat: t('profile.authBindings.providers.wechat'),
+ github: 'GitHub',
+ google: 'Google'
}))
function formatCurrency(value: number): string {
@@ -272,7 +274,13 @@ function formatCurrency(value: number): string {
function normalizeProvider(value: string): UserAuthProvider | null {
const normalized = value.trim().toLowerCase()
- if (normalized === 'email' || normalized === 'linuxdo' || normalized === 'wechat') {
+ if (
+ normalized === 'email' ||
+ normalized === 'linuxdo' ||
+ normalized === 'wechat' ||
+ normalized === 'github' ||
+ normalized === 'google'
+ ) {
return normalized
}
if (normalized === 'oidc' || normalized.startsWith('oidc:') || normalized.startsWith('oidc/')) {
diff --git a/frontend/src/composables/__tests__/useModelWhitelist.spec.ts b/frontend/src/composables/__tests__/useModelWhitelist.spec.ts
index d35e3b12..29ec513e 100644
--- a/frontend/src/composables/__tests__/useModelWhitelist.spec.ts
+++ b/frontend/src/composables/__tests__/useModelWhitelist.spec.ts
@@ -13,6 +13,7 @@ describe('useModelWhitelist', () => {
expect(models).toContain('gpt-5.4')
expect(models).toContain('gpt-5.4-mini')
expect(models).toContain('gpt-5.4-2026-03-05')
+ expect(models).toContain('codex-auto-review')
})
it('openai 模型列表不再暴露已下线的 ChatGPT 登录 Codex 模型', () => {
diff --git a/frontend/src/composables/useAccountOAuth.ts b/frontend/src/composables/useAccountOAuth.ts
index 564e7d95..ab4c640a 100644
--- a/frontend/src/composables/useAccountOAuth.ts
+++ b/frontend/src/composables/useAccountOAuth.ts
@@ -3,7 +3,7 @@ import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
export type AddMethod = 'oauth' | 'setup-token'
-export type AuthInputMethod = 'manual' | 'cookie' | 'refresh_token' | 'mobile_refresh_token' | 'session_token' | 'access_token'
+export type AuthInputMethod = 'manual' | 'cookie' | 'refresh_token' | 'mobile_refresh_token' | 'session_token' | 'access_token' | 'codex_session'
export interface OAuthState {
authUrl: string
diff --git a/frontend/src/composables/useModelWhitelist.ts b/frontend/src/composables/useModelWhitelist.ts
index 07c98ed0..7b474a4e 100644
--- a/frontend/src/composables/useModelWhitelist.ts
+++ b/frontend/src/composables/useModelWhitelist.ts
@@ -11,8 +11,8 @@ const openaiModels = [
'gpt-5.5',
// GPT-5.4 系列
'gpt-5.4', 'gpt-5.4-mini', 'gpt-5.4-2026-03-05',
- // GPT-5.3 系列
- 'gpt-5.3-codex', 'gpt-5.3-codex-spark',
+ // GPT-5.3 / Codex 系列
+ 'gpt-5.3-codex', 'gpt-5.3-codex-spark', 'codex-auto-review',
'gpt-4o-audio-preview', 'gpt-4o-realtime-preview',
// GPT Image 系列
'gpt-image-1', 'gpt-image-1.5', 'gpt-image-2'
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
index 2db497f2..02a4974f 100644
--- a/frontend/src/i18n/locales/en.ts
+++ b/frontend/src/i18n/locales/en.ts
@@ -384,6 +384,7 @@ export default {
channelPricing: 'Channel Pricing',
channelMonitor: 'Channel Monitor',
channelStatus: 'Channel Status',
+ riskControl: 'Risk Control',
},
// Auth
@@ -412,6 +413,9 @@ export default {
passwordRequired: 'Password is required',
passwordMinLength: 'Password must be at least 6 characters',
loginFailed: 'Login failed. Please check your credentials and try again.',
+ errors: {
+ USER_NOT_ACTIVE: 'Account has been disabled.',
+ },
registrationFailed: 'Registration failed. Please try again.',
emailSuffixNotAllowed: 'This email domain is not allowed for registration.',
emailSuffixNotAllowedWithAllowed:
@@ -474,6 +478,9 @@ export default {
completing: 'Completing registration…',
completeRegistrationFailed: 'Registration failed. Please check your invitation code and try again.'
},
+ emailOAuth: {
+ signIn: 'Continue with {providerName}'
+ },
oidc: {
signIn: 'Continue with {providerName}',
callbackTitle: 'Signing you in with {providerName}',
@@ -533,6 +540,8 @@ export default {
oauth: {
callbackTitle: 'OAuth Callback',
callbackHint: 'Copy the code and state back to the admin authorization flow when needed.',
+ invalidCallbackTitle: 'Invalid sign-in callback',
+ invalidCallbackHint: 'This page does not contain a valid authorization result. Return to the login page and start quick sign-in again.',
code: 'Code',
state: 'State',
fullUrl: 'Full URL'
@@ -2286,6 +2295,8 @@ export default {
webSearchEmulation: 'Web Search Emulation',
webSearchEmulationHint: '⚠️ When enabled, all accounts in this channel\'s Anthropic groups will intercept web_search requests. Use with caution.',
webSearchEmulationGlobalDisabled: 'Please enable the global switch first in Settings → Gateway → Web Search Emulation',
+ codexImageGenerationBridge: 'Codex Image Generation Bridge',
+ codexImageGenerationBridgeHint: 'When enabled, Codex /responses text requests in OpenAI groups may be automatically given the image_generation tool. Keep off unless the routed accounts support image generation.',
basicSettings: 'Basic Settings',
addPlatform: 'Add Platform',
noPlatforms: 'Click "Add Platform" to start configuring the channel',
@@ -2308,6 +2319,216 @@ export default {
}
},
+ riskControl: {
+ title: 'Risk Control',
+ description: 'Configure content moderation and review audit records',
+ loadFailed: 'Failed to load risk control',
+ saveFailed: 'Failed to save content moderation config',
+ logsFailed: 'Failed to load audit records',
+ saved: 'Content moderation config saved',
+ refresh: 'Refresh',
+ config: 'Content Moderation Config',
+ configHint: 'Use OpenAI Moderations to score request content and handle threshold hits by mode.',
+ openSettings: 'Moderation Settings',
+ settingsTitle: 'Content Moderation Settings',
+ refreshStatus: 'Refresh Status',
+ records: 'Audit Records',
+ recordsHint: 'Shows hits, blocks, errors, and sampled records.',
+ saveConfig: 'Save Moderation Config',
+ statusFailed: 'Failed to load runtime status',
+ enabled: 'Enable Content Moderation',
+ enabledHint: 'When off, gateway requests are not moderated even if the menu is enabled.',
+ mode: 'Global Mode',
+ modePreBlock: 'Pre-Block',
+ modePreBlockDesc: 'Synchronously reviews the latest user input before every request and rejects hits immediately.',
+ modeObserve: 'Observe Only',
+ modeObserveDesc: 'Requests pass through while the latest user input is queued for async review; hits are recorded, notified, and counted.',
+ modeOff: 'Off',
+ modeOffDesc: 'Content moderation is disabled and no audit records are written.',
+ baseUrl: 'OpenAI Base URL',
+ model: 'Model',
+ apiKey: 'OpenAI API Key',
+ apiKeys: 'OpenAI API Keys',
+ apiKeyCount: '{count} keys',
+ apiKeyPlaceholder: 'Enter API Key',
+ apiKeysPlaceholder: 'Add API Keys, one per line. They will be appended on save.',
+ apiKeysPlaceholderReplace: 'Replace API Keys, one per line. Stored keys will be replaced on save.',
+ apiKeysPlaceholderKeep: 'Add API Keys, one per line. They will be appended on save.',
+ apiKeysHint: '{count} keys are currently stored. This input only adds keys; save appends and de-duplicates them.',
+ apiKeysWriteMode: 'Write mode',
+ apiKeysModeAppend: 'Add',
+ apiKeysModeReplace: 'Replace',
+ apiKeysModeAppendHint: 'Default: save appends input keys and keeps stored keys.',
+ apiKeysModeReplaceHint: 'Replace mode: save replaces all stored keys with input keys.',
+ apiKeysReplaceWarning: 'Replace mode',
+ apiKeysReplaceNoInput: 'Replace mode requires at least 1 API Key',
+ apiKeyPlaceholderKeep: 'Leave empty to keep current key',
+ apiKeyWillClear: 'Configured key will be cleared on save',
+ apiKeyConfigured: 'Configured',
+ apiKeyTemporary: 'Pending',
+ apiKeyPendingDelete: 'Pending delete',
+ apiKeyPendingDeleteCount: '{count} keys pending deletion',
+ deleteApiKey: 'Delete this key',
+ undoDeleteApiKey: 'Undo delete',
+ inputApiKeyCount: '{count} keys in input',
+ storedApiKeyCount: '{count} stored keys',
+ testInputApiKeys: 'Test input keys',
+ testStoredApiKeys: 'Test stored keys',
+ testContentWithStoredApiKey: 'Test content with stored key',
+ testingApiKeys: 'Testing',
+ apiKeyTestNoInput: 'Enter OpenAI API Keys to test first',
+ apiKeyTestDone: 'Key test completed for {count} keys',
+ apiKeyTestFailed: 'Failed to test OpenAI API Keys',
+ apiKeyHealth: 'Key Availability',
+ apiKeyFreezeRule: '400 does not freeze; 401/403 freeze for 10 minutes; 429/529 freeze for 1 minute; other HTTP errors freeze for 10 seconds.',
+ apiKeyRows: '{count} keys',
+ apiKeyRowsCollapsed: '{count} keys hidden',
+ apiKeyRowsExpanded: 'Showing all {count} keys',
+ expandApiKeyRows: 'Expand',
+ collapseApiKeyRows: 'Collapse',
+ apiKeyHealthEmpty: 'No key status yet',
+ apiKeyHealthEmptyHint: 'Save keys or test input keys to see availability.',
+ apiKeyStatusOk: 'Available',
+ apiKeyStatusError: 'Error',
+ apiKeyStatusFrozen: 'Frozen',
+ apiKeyStatusUnknown: 'Untested',
+ apiKeyFailureCount: '{count} failures',
+ apiKeyLatency: '{ms} ms',
+ apiKeyHTTPStatus: 'HTTP {status}',
+ apiKeyFrozenUntil: 'Frozen until {time}',
+ apiKeyLastChecked: 'Checked at {time}',
+ apiKeyNotTested: 'Not tested',
+ auditTestInput: 'Audit Test Input',
+ auditTestInputHint: 'Enter a prompt and upload or paste images; images are sent as base64 and are not stored.',
+ auditTestPromptPlaceholder: 'Enter a user prompt to test; leave empty to only test key availability.',
+ auditTestImages: 'Test Images',
+ auditTestImagesHint: 'Upload, drag, or paste images. Up to 1 image, 8MB each.',
+ addAuditTestImage: 'Add image',
+ clearAuditTest: 'Clear test',
+ auditTestImageLimit: 'You can add up to {count} test images',
+ auditTestImageTooLarge: 'Each test image must be 8MB or smaller',
+ auditTestImageReadFailed: 'Failed to read test image',
+ auditTestResult: 'Audit Test Result',
+ auditTestHighest: 'Top category {category}, score {score}',
+ auditTestComposite: 'Composite score',
+ auditTestFlagged: 'Threshold hit',
+ auditTestPassed: 'Pass',
+ notConfigured: 'Not configured',
+ clearApiKey: 'Clear stored key',
+ keepApiKey: 'Keep stored key',
+ timeoutMs: 'HTTP Timeout (ms)',
+ retryCount: 'Retry Count',
+ sampleRate: 'Sample Rate',
+ recordNonHits: 'Record Non-Hits',
+ recordNonHitsHint: 'When enabled, sampled non-hit request summaries are redacted before storage.',
+ preHashCheck: 'Enable Pre-Hash Check',
+ preHashCheckHint: 'Hashes from async hits are blocked before moderation; this does not send email or increment ban counters.',
+ flaggedHashCount: 'Current hash collection size: {count}',
+ flaggedHashHint: 'Hashes are stored permanently in Redis; paste a full 64-character hash to remove a false block, or clear all stored hashes.',
+ flaggedHashPlaceholder: 'Paste full 64-character input hash',
+ deleteFlaggedHash: 'Delete hash',
+ clearFlaggedHashes: 'Clear all',
+ clearFlaggedHashesConfirm: 'Clear all risk input hashes? This does not delete audit records, but removes all historical hash blocks.',
+ flaggedHashDeleted: 'Risk hash deleted',
+ flaggedHashNotFound: 'Risk hash not found',
+ flaggedHashDeleteFailed: 'Failed to delete risk hash',
+ flaggedHashesCleared: 'Cleared {count} risk hashes',
+ flaggedHashesClearFailed: 'Failed to clear risk hashes',
+ workerCount: 'Worker Count',
+ queueSize: 'Async Queue Size',
+ blockStatus: 'Block HTTP Status',
+ blockMessage: 'Custom Block Message',
+ emailOnHit: 'Email on Hit',
+ emailOnHitHint: 'When enabled, send a risk-control email on every hit; auto-ban notices are always sent.',
+ autoBan: 'Auto Ban User',
+ autoBanHint: 'Disable the user, invalidate auth cache, and send a ban notice after the hit threshold is reached.',
+ banThreshold: 'Ban Threshold',
+ violationWindowHours: 'Count Window (hours)',
+ hitRetentionDays: 'Hit Record Retention (days)',
+ nonHitRetentionDays: 'Non-Hit Record Retention (days, max 3)',
+ violationCount: '{count} hits',
+ emailSent: 'Email sent',
+ emailNotSent: 'No email',
+ autoBanned: 'Banned',
+ unbanUser: 'Unban',
+ unbanSuccess: 'User has been unbanned',
+ unbanFailed: 'Failed to unban user',
+ inputDetailTitle: 'Input Summary Detail',
+ inputDetailContent: 'Full Content',
+ queueDelay: 'Queued {ms} ms',
+ allGroups: 'All Groups',
+ allGroupsHint: 'Auditing all groups',
+ selectedGroupsHint: 'Auditing selected groups',
+ groupScope: 'Audit Groups',
+ groupScopeHint: 'Switch on for all groups, or turn off to choose specific groups.',
+ selectedGroups: 'Selected Groups',
+ searchGroups: 'Search group name or platform',
+ noGroups: 'No groups available',
+ emptyLogs: 'No audit records',
+ workerStatus: 'Worker Runtime',
+ workerStatusHint: 'Queue and worker pool status for asynchronous observation tasks.',
+ workerPool: 'Worker Pool',
+ workerPoolMeta: '{active} processing, {idle} idle and ready, {total} total',
+ queueUsage: 'Queue Usage',
+ activeWorkers: 'Processing',
+ idleWorkers: 'Idle Ready',
+ workerActive: 'Processing an asynchronous audit task',
+ workerIdle: 'Started, idle and ready',
+ workerDisabled: 'Risk control or content audit is disabled',
+ processed: 'Processed',
+ droppedErrors: 'Dropped / Errors',
+ autoRefresh: 'Auto refresh every 15s',
+ lastCleanup: 'Last cleanup: {time}',
+ cleanupStats: 'Last cleanup deleted {hit} hits and {nonHit} non-hits',
+ riskSwitchOff: 'System switch off',
+ tabs: {
+ basic: 'Basic',
+ scope: 'Scope',
+ runtime: 'Runtime',
+ response: 'Hit Notice',
+ retention: 'Retention',
+ },
+ overview: {
+ status: 'Status',
+ enabled: 'Enabled',
+ disabled: 'Disabled',
+ apiKey: 'API Key',
+ groupScope: 'Scope',
+ logs: 'Audit Records',
+ currentFilter: 'Current filter',
+ },
+ filters: {
+ search: 'Search user/key/summary',
+ from: 'From',
+ to: 'To',
+ allGroups: 'All Groups',
+ allEndpoints: 'All Endpoints',
+ },
+ table: {
+ time: 'Time',
+ group: 'Group',
+ user: 'User',
+ apiKey: 'API Key',
+ endpoint: 'Endpoint',
+ result: 'Result',
+ highest: 'Highest',
+ actionMeta: 'Action',
+ latency: 'Latency',
+ input: 'Input Summary',
+ },
+ result: {
+ all: 'All Results',
+ hit: 'Hit',
+ blocked: 'Blocked',
+ pass: 'Pass',
+ error: 'Error',
+ },
+ action: {
+ block: 'Blocked',
+ error: 'Error',
+ },
+ },
+
// Channel Monitor
channelMonitor: {
title: 'Channel Monitor',
@@ -2559,6 +2780,11 @@ export default {
dataExportSelected: 'Export Selected',
dataExportIncludeProxies: 'Include proxies linked to the exported accounts',
dataImport: 'Import',
+ moreActions: 'More Actions',
+ dataActions: 'Data',
+ toolActions: 'Tools',
+ viewColumns: 'Columns',
+ selectedCount: '{count} selected',
dataExportConfirmMessage: 'The exported data contains sensitive account and proxy information. Store it securely.',
dataExportConfirm: 'Confirm Export',
dataExported: 'Data exported successfully',
@@ -2944,6 +3170,18 @@ export default {
codexCLIOnly: 'Codex official clients only',
codexCLIOnlyDesc:
'Only applies to OpenAI OAuth. When enabled, only Codex official client families are allowed; when disabled, the gateway bypasses this restriction and keeps existing behavior.',
+ codexImageGenerationBridge: 'Codex image-generation bridge',
+ codexImageGenerationBridgeDesc:
+ 'Account policy takes precedence over channel and global settings. Only controls whether Codex requests through the /responses text endpoint receive the image_generation tool; standalone image-generation endpoints are unaffected.',
+ codexImageGenerationBridgeInherit: 'Follow channel',
+ codexImageGenerationBridgeInheritDesc: 'Do not write an account override; use the channel or global policy.',
+ codexImageGenerationBridgeEnabled: 'Force on',
+ codexImageGenerationBridgeEnabledDesc: 'Allow image tool injection for Codex /responses requests.',
+ codexImageGenerationBridgeDisabled: 'Force off',
+ codexImageGenerationBridgeDisabledDesc: 'Block image tool injection for Codex /responses requests.',
+ codexImageGenerationBridgeBadgeInherit: 'Channel policy',
+ codexImageGenerationBridgeBadgeEnabled: 'Account on',
+ codexImageGenerationBridgeBadgeDisabled: 'Account off',
compactMode: 'Compact mode',
compactModeDesc:
'Controls how this account participates in /responses/compact routing. Auto follows probe results, Force On always allows, Force Off always excludes.',
@@ -2955,7 +3193,8 @@ export default {
'Only applies to /responses/compact. Use this when the upstream compact endpoint requires a special compact model.',
compactSupported: 'Compact supported',
compactUnsupported: 'Compact unsupported',
- compactUnknown: 'Compact unknown',
+ compactAuto: 'Compact Auto',
+ compactUnknown: 'Compact Auto',
compactLastChecked: 'Last compact probe',
testMode: 'Test mode',
testModeDefault: 'Default request',
@@ -2989,7 +3228,7 @@ export default {
targetNoWildcard: 'Target model cannot contain wildcard *',
searchModels: 'Search models...',
noMatchingModels: 'No matching models',
- fillRelatedModels: 'Fill related models',
+ fillRelatedModels: 'Sync latest supported models',
clearAllModels: 'Clear all models',
customModelName: 'Custom model name',
enterCustomModelName: 'Enter custom model name',
@@ -3254,6 +3493,16 @@ export default {
refreshTokenAuth: 'Manual RT Input',
refreshTokenDesc: 'Enter your existing OpenAI Refresh Token(s). Supports batch input (one per line). The system will automatically validate and create accounts.',
refreshTokenPlaceholder: 'Paste your OpenAI Refresh Token...\nSupports multiple, one per line',
+ codexSessionAuth: 'Codex JSON / AT Batch Input',
+ codexSessionDesc: 'Paste Codex JSON or an accessToken. Accounts use the step 1 settings.',
+ codexSessionInputLabel: 'Codex JSON or accessToken',
+ codexSessionPlaceholder: 'Multiple lines supported, one token or JSON per line',
+ codexSessionHint: 'sessionToken will not be saved as refresh_token. Without refresh_token, the account expires with the accessToken expiry; import is rejected if the expiry cannot be parsed and step 1 has no expiration.',
+ codexSessionImportAndCreate: 'Import & Create Account',
+ codexSessionEmpty: 'Please enter Codex JSON or accessToken',
+ codexSessionImportFailed: 'Failed to import Codex account',
+ codexSessionImportSuccess: 'Import completed: created {created}, updated {updated}, skipped {skipped}',
+ codexSessionImportPartial: 'Partial success: created {created}, updated {updated}, skipped {skipped}, failed {failed}',
sessionTokenAuth: 'Manual ST Input',
sessionTokenDesc: 'Enter your existing Session Token(s). Supports batch input (one per line). The system will automatically validate and create accounts.',
sessionTokenPlaceholder: 'Paste your Session Token...\nSupports multiple, one per line',
@@ -4903,6 +5152,7 @@ export default {
description: 'Manage registration, email verification, default values, and SMTP settings',
tabs: {
general: 'General',
+ agreement: 'Agreement',
features: 'Feature Switches',
security: 'Security',
users: 'Users',
@@ -4928,6 +5178,13 @@ export default {
enabled: 'Enable Available Channels',
enabledHint: 'When off, the sidebar entry is hidden and the endpoint returns an empty list.',
},
+ riskControl: {
+ title: 'Risk Control',
+ description: 'Enable the content moderation menu and gateway audit entry point. Disabled by default.',
+ configureLink: 'Configure content moderation in Risk Control',
+ enabled: 'Enable Risk Control',
+ enabledHint: 'When off, the admin sidebar entry is hidden and gateway moderation is skipped.',
+ },
affiliate: {
title: 'Affiliate (Invite Rebate)',
description: 'Existing users invite new ones; the inviter earns a percentage rebate on the invitee’s recharges. Disabled by default.',
diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts
index 8e622a39..d486d872 100644
--- a/frontend/src/i18n/locales/zh.ts
+++ b/frontend/src/i18n/locales/zh.ts
@@ -384,6 +384,7 @@ export default {
channelPricing: '渠道定价',
channelMonitor: '渠道监控',
channelStatus: '渠道状态',
+ riskControl: '风控中心',
},
// Auth
@@ -412,6 +413,9 @@ export default {
passwordRequired: '请输入密码',
passwordMinLength: '密码至少需要 6 个字符',
loginFailed: '登录失败,请检查您的凭据后重试。',
+ errors: {
+ USER_NOT_ACTIVE: '账号已被禁用',
+ },
registrationFailed: '注册失败,请重试。',
emailSuffixNotAllowed: '该邮箱域名不在允许注册范围内。',
emailSuffixNotAllowedWithAllowed: '该邮箱域名不被允许。可用域名:{suffixes}',
@@ -473,6 +477,9 @@ export default {
completing: '正在完成注册...',
completeRegistrationFailed: '注册失败,请检查邀请码后重试。'
},
+ emailOAuth: {
+ signIn: '使用 {providerName} 登录'
+ },
oidc: {
signIn: '使用 {providerName} 登录',
callbackTitle: '正在完成 {providerName} 登录',
@@ -531,6 +538,8 @@ export default {
oauth: {
callbackTitle: 'OAuth 回调',
callbackHint: '按需将授权码和状态值复制回后台授权流程。',
+ invalidCallbackTitle: '无效的登录回调',
+ invalidCallbackHint: '当前页面缺少有效的授权结果,请返回登录页重新发起快捷登录。',
code: '授权码',
state: '状态',
fullUrl: '完整URL'
@@ -2363,6 +2372,8 @@ export default {
webSearchEmulation: 'Web Search 模拟',
webSearchEmulationHint: '⚠️ 开启后该渠道下所有 Anthropic 分组的账号将自动拦截 web_search 请求,请谨慎操作',
webSearchEmulationGlobalDisabled: '请先在系统设置 → 网关 → Web Search 模拟中启用全局开关',
+ codexImageGenerationBridge: 'Codex 图片生成桥接',
+ codexImageGenerationBridgeHint: '开启后,OpenAI 分组的 Codex /responses 文本请求可能会被自动注入 image_generation 工具。仅在路由账号支持图片生成时开启。',
basicSettings: '基础设置',
addPlatform: '添加平台',
noPlatforms: '点击"添加平台"开始配置渠道',
@@ -2385,6 +2396,216 @@ export default {
}
},
+ riskControl: {
+ title: '风控中心',
+ description: '配置内容审计策略并查看审核记录',
+ loadFailed: '加载风控中心失败',
+ saveFailed: '保存内容审计配置失败',
+ logsFailed: '加载审核记录失败',
+ saved: '内容审计配置已保存',
+ refresh: '刷新',
+ config: '内容审计配置',
+ configHint: '调用 OpenAI Moderations 进行请求内容评分,命中阈值后按模式处理。',
+ openSettings: '内容审计设置',
+ settingsTitle: '内容审计设置',
+ refreshStatus: '刷新状态',
+ records: '审核记录',
+ recordsHint: '展示命中、拦截、异常和已采样记录。',
+ saveConfig: '保存内容审计配置',
+ statusFailed: '加载运行状态失败',
+ enabled: '开启内容审计',
+ enabledHint: '关闭后即使风控中心菜单启用,也不会审核网关请求。',
+ mode: '全局模式',
+ modePreBlock: '前置拦截',
+ modePreBlockDesc: '每次请求先同步审核最新用户输入,命中后立即拒绝请求。',
+ modeObserve: '仅观察',
+ modeObserveDesc: '请求直接放行,最新用户输入进入异步审核队列;命中后只记录、通知和按规则累计。',
+ modeOff: '关闭',
+ modeOffDesc: '不执行内容审计,也不会写入审核记录。',
+ baseUrl: 'OpenAI Base URL',
+ model: '模型名',
+ apiKey: 'OpenAI API Key',
+ apiKeys: 'OpenAI API Keys',
+ apiKeyCount: '{count} 个 Key',
+ apiKeyPlaceholder: '请输入 API Key',
+ apiKeysPlaceholder: '新增 API Key,每行一个;保存后会追加到已保存 Key',
+ apiKeysPlaceholderReplace: '覆盖保存 API Key,每行一个;保存后会替换全部已保存 Key',
+ apiKeysPlaceholderKeep: '新增 API Key,每行一个;保存后会追加到已保存 Key',
+ apiKeysHint: '当前已保存 {count} 个 Key;输入区只用于新增,保存时会增量追加并自动去重。',
+ apiKeysWriteMode: '写入方式',
+ apiKeysModeAppend: '增量添加',
+ apiKeysModeReplace: '覆盖保存',
+ apiKeysModeAppendHint: '默认模式:保存时追加输入区 Key,并保留已保存 Key。',
+ apiKeysModeReplaceHint: '覆盖模式:保存时用输入区 Key 替换全部已保存 Key。',
+ apiKeysReplaceWarning: '覆盖模式',
+ apiKeysReplaceNoInput: '覆盖保存至少需要输入 1 个 API Key',
+ apiKeyPlaceholderKeep: '留空保持不变',
+ apiKeyWillClear: '保存后清除已配置 Key',
+ apiKeyConfigured: '已配置',
+ apiKeyTemporary: '待保存',
+ apiKeyPendingDelete: '待删除',
+ apiKeyPendingDeleteCount: '待删除 {count} 个 Key',
+ deleteApiKey: '删除这个 Key',
+ undoDeleteApiKey: '撤销删除',
+ inputApiKeyCount: '输入区 {count} 个 Key',
+ storedApiKeyCount: '已保存 {count} 个 Key',
+ testInputApiKeys: '测试输入区 Key',
+ testStoredApiKeys: '测试已保存 Key',
+ testContentWithStoredApiKey: '用已保存 Key 试跑内容',
+ testingApiKeys: '测试中',
+ apiKeyTestNoInput: '请先输入需要测试的 OpenAI API Key',
+ apiKeyTestDone: 'Key 测试完成,共 {count} 个',
+ apiKeyTestFailed: '测试 OpenAI API Key 失败',
+ apiKeyHealth: 'Key 可用状态',
+ apiKeyFreezeRule: '400 不冻结;401/403 冻结 10 分钟;429/529 冻结 1 分钟;其他 HTTP 错误冻结 10 秒。',
+ apiKeyRows: '{count} 个 Key',
+ apiKeyRowsCollapsed: '已隐藏 {count} 个 Key',
+ apiKeyRowsExpanded: '正在显示全部 {count} 个 Key',
+ expandApiKeyRows: '展开',
+ collapseApiKeyRows: '收起',
+ apiKeyHealthEmpty: '暂无 Key 状态',
+ apiKeyHealthEmptyHint: '保存 Key 或测试输入区 Key 后会显示可用性。',
+ apiKeyStatusOk: '可用',
+ apiKeyStatusError: '异常',
+ apiKeyStatusFrozen: '冻结',
+ apiKeyStatusUnknown: '未测试',
+ apiKeyFailureCount: '失败 {count} 次',
+ apiKeyLatency: '{ms} ms',
+ apiKeyHTTPStatus: 'HTTP {status}',
+ apiKeyFrozenUntil: '冻结至 {time}',
+ apiKeyLastChecked: '检查于 {time}',
+ apiKeyNotTested: '尚未测试',
+ auditTestInput: '审计试跑输入',
+ auditTestInputHint: '可填写提示词并上传或粘贴图片;图片以 base64 发送,不会保存文件。',
+ auditTestPromptPlaceholder: '输入要测试的用户提示词;留空时仅测试 Key 可用性。',
+ auditTestImages: '测试图片',
+ auditTestImagesHint: '支持上传、拖拽或粘贴图片,最多 1 张,每张不超过 8MB。',
+ addAuditTestImage: '添加图片',
+ clearAuditTest: '清空试跑',
+ auditTestImageLimit: '最多只能添加 {count} 张测试图片',
+ auditTestImageTooLarge: '单张测试图片不能超过 8MB',
+ auditTestImageReadFailed: '读取测试图片失败',
+ auditTestResult: '审计试跑结果',
+ auditTestHighest: '最高分类 {category},分数 {score}',
+ auditTestComposite: '综合评分',
+ auditTestFlagged: '命中阈值',
+ auditTestPassed: '未命中',
+ notConfigured: '未配置',
+ clearApiKey: '清除已保存 Key',
+ keepApiKey: '保留已保存 Key',
+ timeoutMs: 'HTTP 超时 (ms)',
+ retryCount: '失败重试次数',
+ sampleRate: '采样率',
+ recordNonHits: '记录未命中输入',
+ recordNonHitsHint: '开启后会记录抽样但未命中的请求摘要,摘要会先脱敏再入库。',
+ preHashCheck: '启用前置哈希比对',
+ preHashCheckHint: '异步审核命中过的输入哈希会被前置拦截;该拦截不发送邮件,也不累计封禁次数。',
+ flaggedHashCount: '当前哈希集合数量:{count} 个',
+ flaggedHashHint: '哈希永久保存在 Redis 集合中;可粘贴完整 64 位哈希删除误拦截项,或一键清空全部风险哈希。',
+ flaggedHashPlaceholder: '粘贴完整 64 位输入哈希',
+ deleteFlaggedHash: '删除指定哈希',
+ clearFlaggedHashes: '一键清空',
+ clearFlaggedHashesConfirm: '确定要清空全部风险输入哈希吗?此操作不会删除审核记录,但会取消所有历史哈希拦截。',
+ flaggedHashDeleted: '风险哈希已删除',
+ flaggedHashNotFound: '该风险哈希不存在',
+ flaggedHashDeleteFailed: '删除风险哈希失败',
+ flaggedHashesCleared: '已清空 {count} 个风险哈希',
+ flaggedHashesClearFailed: '清空风险哈希失败',
+ workerCount: 'Worker 数',
+ queueSize: '异步队列大小',
+ blockStatus: '拦截 HTTP 状态码',
+ blockMessage: '自定义拦截提示',
+ emailOnHit: '命中后发送邮件',
+ emailOnHitHint: '开启后每次达到阈值都会向用户发送风控提醒邮件;自动封禁通知始终发送。',
+ autoBan: '自动封禁用户',
+ autoBanHint: '命中次数达到阈值后将禁用用户账号、刷新认证缓存并发送封禁通知邮件。',
+ banThreshold: '封禁触发次数',
+ violationWindowHours: '累计窗口(小时)',
+ hitRetentionDays: '命中记录保留(天)',
+ nonHitRetentionDays: '未命中记录保留(天,最多 3 天)',
+ violationCount: '{count} 次',
+ emailSent: '已发邮件',
+ emailNotSent: '未发邮件',
+ autoBanned: '已封禁',
+ unbanUser: '解封',
+ unbanSuccess: '用户已解封',
+ unbanFailed: '解封用户失败',
+ inputDetailTitle: '输入摘要详情',
+ inputDetailContent: '完整内容',
+ queueDelay: '排队 {ms} ms',
+ allGroups: '全部分组',
+ allGroupsHint: '当前审计全部分组',
+ selectedGroupsHint: '当前审计指定分组',
+ groupScope: '审计分组',
+ groupScopeHint: '开启右侧开关表示全部分组,关闭后选择指定分组。',
+ selectedGroups: '指定分组',
+ searchGroups: '搜索分组名称或平台',
+ noGroups: '暂无可用分组',
+ emptyLogs: '暂无审核记录',
+ workerStatus: 'Worker 运行状态',
+ workerStatusHint: '异步观察任务的队列和 worker 池状态。',
+ workerPool: 'Worker 池',
+ workerPoolMeta: '{active} 个处理中,{idle} 个空闲可用,共 {total} 个',
+ queueUsage: '队列占用',
+ activeWorkers: '处理中',
+ idleWorkers: '空闲可用',
+ workerActive: '正在处理异步审计任务',
+ workerIdle: '已启动,当前空闲可用',
+ workerDisabled: '风控或内容审计未启用',
+ processed: '已处理',
+ droppedErrors: '丢弃/异常',
+ autoRefresh: '每 15 秒自动刷新',
+ lastCleanup: '上次清理:{time}',
+ cleanupStats: '上次清理删除命中 {hit} 条,未命中 {nonHit} 条',
+ riskSwitchOff: '系统开关关闭',
+ tabs: {
+ basic: '基础',
+ scope: '审计范围',
+ runtime: '运行队列',
+ response: '命中通知',
+ retention: '日志保留',
+ },
+ overview: {
+ status: '运行状态',
+ enabled: '已启用',
+ disabled: '未启用',
+ apiKey: 'API Key',
+ groupScope: '审计范围',
+ logs: '审核记录',
+ currentFilter: '当前筛选结果',
+ },
+ filters: {
+ search: '按用户/Key/摘要搜索',
+ from: '开始时间',
+ to: '结束时间',
+ allGroups: '全部分组',
+ allEndpoints: '全部端点',
+ },
+ table: {
+ time: '时间',
+ group: '分组',
+ user: '用户',
+ apiKey: 'API Key',
+ endpoint: '端点',
+ result: '结果',
+ highest: '最高分',
+ actionMeta: '处置',
+ latency: '上游耗时',
+ input: '输入摘要',
+ },
+ result: {
+ all: '全部结果',
+ hit: '命中',
+ blocked: '已拦截',
+ pass: '未命中',
+ error: '异常',
+ },
+ action: {
+ block: '拦截',
+ error: '异常',
+ },
+ },
+
// Channel Monitor
channelMonitor: {
title: '渠道监控',
@@ -2635,6 +2856,11 @@ export default {
dataExportSelected: '导出选中',
dataExportIncludeProxies: '导出代理(导出账号关联的代理)',
dataImport: '导入',
+ moreActions: '更多操作',
+ dataActions: '数据操作',
+ toolActions: '工具',
+ viewColumns: '列显示',
+ selectedCount: '已选 {count}',
dataExportConfirmMessage: '导出的数据包含账号与代理的敏感信息,请妥善保存。',
dataExportConfirm: '确认导出',
dataExported: '数据导出成功',
@@ -3089,6 +3315,18 @@ export default {
responsesWebsocketsV2PassthroughHint: '当前已开启自动透传:仅影响 HTTP 透传链路,不影响 WS mode。',
codexCLIOnly: '仅允许 Codex 官方客户端',
codexCLIOnlyDesc: '仅对 OpenAI OAuth 生效。开启后仅允许 Codex 官方客户端家族访问;关闭后完全绕过并保持原逻辑。',
+ codexImageGenerationBridge: 'Codex 图片生成桥接',
+ codexImageGenerationBridgeDesc:
+ '账号级策略优先于渠道和全局配置。仅控制 Codex 走 /responses 文本端点时是否注入 image_generation 工具;不影响独立图片生成接口。',
+ codexImageGenerationBridgeInherit: '跟随渠道',
+ codexImageGenerationBridgeInheritDesc: '不写入账号覆盖,继续使用渠道或全局策略。',
+ codexImageGenerationBridgeEnabled: '强制开启',
+ codexImageGenerationBridgeEnabledDesc: '允许 Codex /responses 请求获得图片工具注入。',
+ codexImageGenerationBridgeDisabled: '强制关闭',
+ codexImageGenerationBridgeDisabledDesc: '阻断 Codex /responses 的图片工具注入。',
+ codexImageGenerationBridgeBadgeInherit: '渠道策略',
+ codexImageGenerationBridgeBadgeEnabled: '账号开启',
+ codexImageGenerationBridgeBadgeDisabled: '账号关闭',
compactMode: 'Compact 模式',
compactModeDesc:
'控制本账号在 /responses/compact 调度中的参与方式。Auto 跟随探测结果,Force On 强制允许,Force Off 强制排除。',
@@ -3100,7 +3338,8 @@ export default {
'仅在 /responses/compact 请求中生效。当上游 compact 端点需要特殊 compact 模型时使用。',
compactSupported: '支持 Compact',
compactUnsupported: '不支持 Compact',
- compactUnknown: 'Compact 未知',
+ compactAuto: 'Compact Auto',
+ compactUnknown: 'Compact Auto',
compactLastChecked: '最近探测',
testMode: '测试模式',
testModeDefault: '常规请求',
@@ -3133,7 +3372,7 @@ export default {
targetNoWildcard: '目标模型不能包含通配符 *',
searchModels: '搜索模型...',
noMatchingModels: '没有匹配的模型',
- fillRelatedModels: '填入相关模型',
+ fillRelatedModels: '同步最新支持模型',
clearAllModels: '清除所有模型',
customModelName: '自定义模型名称',
enterCustomModelName: '输入自定义模型名称',
@@ -3389,6 +3628,16 @@ export default {
refreshTokenAuth: '手动输入 RT',
refreshTokenDesc: '输入您已有的 OpenAI Refresh Token,支持批量输入(每行一个),系统将自动验证并创建账号。',
refreshTokenPlaceholder: '粘贴您的 OpenAI Refresh Token...\n支持多个,每行一个',
+ codexSessionAuth: 'Codex JSON / AT 批量输入',
+ codexSessionDesc: '粘贴 Codex JSON 或 accessToken,按第一步配置创建账号。',
+ codexSessionInputLabel: 'Codex JSON 或 accessToken',
+ codexSessionPlaceholder: '支持多行,每行一个 token 或 JSON',
+ codexSessionHint: 'sessionToken 不会作为 refresh_token 保存;未包含 refresh_token 时会按 accessToken 过期时间设置账号过期,无法解析且第一步未设置过期时间时会拒绝导入。',
+ codexSessionImportAndCreate: '导入并创建账号',
+ codexSessionEmpty: '请输入 Codex JSON 或 accessToken',
+ codexSessionImportFailed: 'Codex 账号导入失败',
+ codexSessionImportSuccess: '导入完成:新增 {created},更新 {updated},跳过 {skipped}',
+ codexSessionImportPartial: '部分成功:新增 {created},更新 {updated},跳过 {skipped},失败 {failed}',
sessionTokenAuth: '手动输入 ST',
sessionTokenDesc: '输入您已有的 Session Token,支持批量输入(每行一个),系统将自动验证并创建账号。',
sessionTokenPlaceholder: '粘贴您的 Session Token...\n支持多个,每行一个',
@@ -5066,6 +5315,7 @@ export default {
description: '管理注册、邮箱验证、默认值和 SMTP 设置',
tabs: {
general: '通用设置',
+ agreement: '登录条款',
features: '功能开关',
security: '安全与认证',
users: '用户默认值',
@@ -5091,6 +5341,13 @@ export default {
enabled: '启用可用渠道',
enabledHint: '关闭后用户端侧边栏入口隐藏,接口返回空数组。',
},
+ riskControl: {
+ title: '风控中心',
+ description: '启用内容审计菜单和全端点请求审核入口。默认关闭。',
+ configureLink: '前往 风控中心 配置内容审计',
+ enabled: '启用风控中心',
+ enabledHint: '关闭后管理员侧边栏入口隐藏,网关内容审计不会执行。',
+ },
affiliate: {
title: '邀请返利',
description: '老用户邀请新用户注册,新用户充值后老用户按比例获得返利额度。默认关闭。',
diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts
index 3947d13f..68b934db 100644
--- a/frontend/src/router/index.ts
+++ b/frontend/src/router/index.ts
@@ -68,6 +68,7 @@ const routes: RouteRecordRaw[] = [
{
path: '/auth/callback',
name: 'OAuthCallback',
+ alias: '/auth/oauth/callback',
component: () => import('@/views/auth/OAuthCallbackView.vue'),
meta: {
requiresAuth: false,
@@ -143,6 +144,15 @@ const routes: RouteRecordRaw[] = [
title: 'Key Usage',
}
},
+ {
+ path: '/legal/:documentId',
+ name: 'LegalDocument',
+ component: () => import('@/views/public/LegalDocumentView.vue'),
+ meta: {
+ requiresAuth: false,
+ title: 'Legal Document'
+ }
+ },
// ==================== User Routes ====================
{
@@ -529,6 +539,19 @@ const routes: RouteRecordRaw[] = [
descriptionKey: 'admin.settings.description'
}
},
+ {
+ path: '/admin/risk-control',
+ name: 'AdminRiskControl',
+ component: () => import('@/views/admin/RiskControlView.vue'),
+ meta: {
+ requiresAuth: true,
+ requiresAdmin: true,
+ title: 'Risk Control',
+ titleKey: 'admin.riskControl.title',
+ descriptionKey: 'admin.riskControl.description',
+ requiresRiskControl: true
+ }
+ },
{
path: '/admin/usage',
name: 'AdminUsage',
@@ -657,7 +680,7 @@ let authInitialized = false
const navigationLoading = useNavigationLoadingState()
// 延迟初始化预加载,传入 router 实例
let routePrefetch: ReturnType | null = null
-const BACKEND_MODE_ALLOWED_PATHS = ['/login', '/key-usage', '/setup', '/payment/result']
+const BACKEND_MODE_ALLOWED_PATHS = ['/login', '/key-usage', '/setup', '/payment/result', '/legal']
const BACKEND_MODE_CALLBACK_PATHS = [
'/auth/callback',
'/auth/linuxdo/callback',
@@ -771,6 +794,14 @@ router.beforeEach((to, _from, next) => {
}
}
+ if (to.meta.requiresRiskControl) {
+ const riskControlEnabled = appStore.cachedPublicSettings?.risk_control_enabled === true
+ if (!riskControlEnabled) {
+ next(authStore.isAdmin ? '/admin/settings' : '/dashboard')
+ return
+ }
+ }
+
// 简易模式下限制访问某些页面
if (authStore.isSimpleMode) {
const restrictedPaths = [
diff --git a/frontend/src/router/meta.d.ts b/frontend/src/router/meta.d.ts
index 7b2777c2..5c468016 100644
--- a/frontend/src/router/meta.d.ts
+++ b/frontend/src/router/meta.d.ts
@@ -49,6 +49,12 @@ declare module 'vue-router' {
*/
requiresPayment?: boolean
+ /**
+ * 是否要求风控中心功能开关已启用
+ * @default false
+ */
+ requiresRiskControl?: boolean
+
/**
* i18n key for the page title
*/
diff --git a/frontend/src/stores/app.ts b/frontend/src/stores/app.ts
index 876ab5c0..4d701b2e 100644
--- a/frontend/src/stores/app.ts
+++ b/frontend/src/stores/app.ts
@@ -347,6 +347,8 @@ export const useAppStore = defineStore('app', () => {
wechat_oauth_mobile_enabled: false,
oidc_oauth_enabled: false,
oidc_oauth_provider_name: 'OIDC',
+ github_oauth_enabled: false,
+ google_oauth_enabled: false,
backend_mode_enabled: false,
version: siteVersion.value,
balance_low_notify_enabled: false,
@@ -355,6 +357,7 @@ export const useAppStore = defineStore('app', () => {
channel_monitor_enabled: true,
channel_monitor_default_interval_seconds: 60,
available_channels_enabled: false,
+ risk_control_enabled: false,
affiliate_enabled: false,
}
}
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index eb3364c0..17bf4f71 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -34,7 +34,7 @@ export interface NotifyEmailEntry {
// ==================== User & Auth Types ====================
-export type UserAuthProvider = 'email' | 'linuxdo' | 'oidc' | 'wechat'
+export type UserAuthProvider = 'email' | 'linuxdo' | 'oidc' | 'wechat' | 'github' | 'google'
export interface UserAuthBindingStatus {
bound?: boolean
@@ -168,6 +168,7 @@ export interface CustomMenuItem {
label: string
icon_svg: string
url: string
+ page_slug?: string
visibility: 'user' | 'admin'
sort_order: number
}
@@ -178,6 +179,12 @@ export interface CustomEndpoint {
description: string
}
+export interface LoginAgreementDocument {
+ id: string
+ title: string
+ content_md: string
+}
+
export interface PublicSettings {
registration_enabled: boolean
email_verify_enabled: boolean
@@ -186,6 +193,11 @@ export interface PublicSettings {
promo_code_enabled: boolean
password_reset_enabled: boolean
invitation_code_enabled: boolean
+ login_agreement_enabled?: boolean
+ login_agreement_mode?: 'modal' | 'checkbox' | string
+ login_agreement_updated_at?: string
+ login_agreement_revision?: string
+ login_agreement_documents?: LoginAgreementDocument[]
turnstile_enabled: boolean
turnstile_site_key: string
site_name: string
@@ -197,6 +209,7 @@ export interface PublicSettings {
home_content: string
hide_ccs_import_button: boolean
payment_enabled: boolean
+ risk_control_enabled: boolean
table_default_page_size: number
table_page_size_options: number[]
custom_menu_items: CustomMenuItem[]
@@ -208,6 +221,8 @@ export interface PublicSettings {
wechat_oauth_mobile_enabled?: boolean
oidc_oauth_enabled: boolean
oidc_oauth_provider_name: string
+ github_oauth_enabled: boolean
+ google_oauth_enabled: boolean
backend_mode_enabled: boolean
version: string
balance_low_notify_enabled: boolean
@@ -1278,6 +1293,51 @@ export interface AdminDataImportResult {
errors?: AdminDataImportError[]
}
+export interface CodexSessionImportRequest {
+ content?: string
+ contents?: string[]
+ name?: string
+ notes?: string | null
+ group_ids?: number[]
+ proxy_id?: number | null
+ concurrency?: number
+ priority?: number
+ rate_multiplier?: number
+ load_factor?: number | null
+ expires_at?: number | null
+ auto_pause_on_expired?: boolean
+ credential_extras?: Record
+ extra?: Record
+ update_existing?: boolean
+ skip_default_group_bind?: boolean
+ confirm_mixed_channel_risk?: boolean
+}
+
+export interface CodexSessionImportMessage {
+ index: number
+ name?: string
+ message: string
+}
+
+export interface CodexSessionImportItem {
+ index: number
+ name?: string
+ action: 'created' | 'updated' | 'skipped' | 'failed'
+ account_id?: number
+ message?: string
+}
+
+export interface CodexSessionImportResult {
+ total: number
+ created: number
+ updated: number
+ skipped: number
+ failed: number
+ items?: CodexSessionImportItem[]
+ warnings?: CodexSessionImportMessage[]
+ errors?: CodexSessionImportMessage[]
+}
+
// ==================== Usage & Redeem Types ====================
export type RedeemCodeType = 'balance' | 'concurrency' | 'subscription' | 'invitation'
diff --git a/frontend/src/utils/featureFlags.ts b/frontend/src/utils/featureFlags.ts
index e0668694..403e7cdc 100644
--- a/frontend/src/utils/featureFlags.ts
+++ b/frontend/src/utils/featureFlags.ts
@@ -109,6 +109,11 @@ export const FeatureFlags = {
mode: 'opt-out',
label: 'Payment',
}),
+ riskControl: defineFlag({
+ key: 'risk_control_enabled',
+ mode: 'opt-in',
+ label: 'Risk Control',
+ }),
affiliate: defineFlag({
key: 'affiliate_enabled',
mode: 'opt-in',
diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue
index 79d1800e..deb399c9 100644
--- a/frontend/src/views/admin/AccountsView.vue
+++ b/frontend/src/views/admin/AccountsView.vue
@@ -14,7 +14,6 @@
@@ -43,7 +42,7 @@
-
-
-
- {{ t('admin.errorPassthrough.title') }}
-
-
-
-
-
- {{ t('admin.tlsFingerprintProfiles.title') }}
-
-
-
-
+
+
-
-
-
- {{ t('admin.users.columnSettings') }}
+
+ {{ t('admin.accounts.moreActions') }}
+
-
-
-
- {{ col.label }}
-
+
+
+
+ {{ t('admin.accounts.dataActions') }}
+
+
+
+
+
+
+
+
+
+ {{ t('admin.accounts.toolActions') }}
+
+
+
+
+
+
+
+
+
+ {{ t('admin.accounts.viewColumns') }}
+
+
+
+
+
+
+ {{ col.label }}
+
+
+
@@ -208,27 +247,33 @@
-
-
-
-
+
+
+
+ {{ getAntigravityTierLabel(row) }}
+
+
+ {{ getWindsurfTierLabel(row) }}
+
+
+
- {{ getOpenAICompactLabel(row) }}
-
-
- {{ getAntigravityTierLabel(row) }}
-
-
- {{ getWindsurfTierLabel(row) }}
-
+
+ {{ getOpenAICompactMeta(row)?.label }}
+
@@ -472,9 +517,9 @@ const togglingSchedulable = ref(null)
const menu = reactive<{show:boolean, acc:Account|null, pos:{top:number, left:number}|null}>({ show: false, acc: null, pos: null })
const exportingData = ref(false)
-// Column settings
-const showColumnDropdown = ref(false)
-const columnDropdownRef = ref(null)
+// Account tools dropdown
+const showAccountToolsDropdown = ref(false)
+const accountToolsDropdownRef = ref(null)
const hiddenColumns = reactive>(new Set())
const DEFAULT_HIDDEN_COLUMNS = ['today_stats', 'proxy', 'notes', 'priority', 'rate_multiplier']
const HIDDEN_COLUMNS_KEY = 'account-hidden-columns'
@@ -836,7 +881,8 @@ const isAnyModalOpen = computed(() => {
showTest.value ||
showStats.value ||
showSchedulePanel.value ||
- showErrorPassthrough.value
+ showErrorPassthrough.value ||
+ showTLSFingerprintProfiles.value
)
})
@@ -947,6 +993,35 @@ const handleManualRefresh = async () => {
usageManualRefreshToken.value += 1
}
+const closeAccountToolsDropdown = () => {
+ showAccountToolsDropdown.value = false
+}
+
+const openSyncFromCrs = () => {
+ closeAccountToolsDropdown()
+ showSync.value = true
+}
+
+const openImportData = () => {
+ closeAccountToolsDropdown()
+ showImportData.value = true
+}
+
+const openExportDataDialogFromMenu = () => {
+ closeAccountToolsDropdown()
+ openExportDataDialog()
+}
+
+const openErrorPassthrough = () => {
+ closeAccountToolsDropdown()
+ showErrorPassthrough.value = true
+}
+
+const openTLSFingerprintProfiles = () => {
+ closeAccountToolsDropdown()
+ showTLSFingerprintProfiles.value = true
+}
+
const syncPendingListChanges = async () => {
hasPendingListSync.value = false
await load()
@@ -960,7 +1035,7 @@ const { pause: pauseAutoRefresh, resume: resumeAutoRefresh } = useIntervalFn(
if (document.hidden) return
if (loading.value || autoRefreshFetching.value) return
if (isAnyModalOpen.value) return
- if (menu.show) return
+ if (menu.show || showAccountToolsDropdown.value || showAutoRefreshDropdown.value) return
if (inAutoRefreshSilentWindow()) {
autoRefreshCountdown.value = Math.max(
0,
@@ -1005,41 +1080,51 @@ function getAntigravityTierLabel(row: any): string | null {
}
}
-function getOpenAICompactState(row: any): 'supported' | 'unsupported' | 'unknown' | null {
+type OpenAICompactBadgeState = 'active' | 'blocked' | 'auto'
+
+function getOpenAICompactState(row: any): OpenAICompactBadgeState | null {
if (row.platform !== 'openai' || (row.type !== 'oauth' && row.type !== 'apikey')) return null
const extra = row.extra as Record | undefined
const mode = typeof extra?.openai_compact_mode === 'string' ? extra.openai_compact_mode : 'auto'
- if (mode === 'force_on') return 'supported'
- if (mode === 'force_off') return 'unsupported'
+ if (mode === 'force_on') return 'active'
+ if (mode === 'force_off') return 'blocked'
if (typeof extra?.openai_compact_supported === 'boolean') {
- return extra.openai_compact_supported ? 'supported' : 'unsupported'
+ return extra.openai_compact_supported ? 'active' : 'blocked'
}
- return 'unknown'
+ return 'auto'
}
-function getOpenAICompactLabel(row: any): string | null {
- switch (getOpenAICompactState(row)) {
- case 'supported': return t('admin.accounts.openai.compactSupported')
- case 'unsupported': return t('admin.accounts.openai.compactUnsupported')
- case 'unknown': return t('admin.accounts.openai.compactUnknown')
- default: return null
- }
-}
-
-function getOpenAICompactClass(row: any): string {
- switch (getOpenAICompactState(row)) {
- case 'supported': return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300'
- case 'unsupported': return 'bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300'
- case 'unknown': return 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'
- default: return ''
+function getOpenAICompactMeta(row: any): { label: string; className: string; dotClass: string } | null {
+ const state = getOpenAICompactState(row)
+ if (!state) return null
+ switch (state) {
+ case 'active':
+ return {
+ label: t('admin.accounts.openai.compactSupported'),
+ className: 'text-emerald-600 dark:text-emerald-300',
+ dotClass: 'bg-emerald-500 shadow-[0_0_0_2px_rgba(16,185,129,0.14)]'
+ }
+ case 'blocked':
+ return {
+ label: t('admin.accounts.openai.compactUnsupported'),
+ className: 'text-rose-600 dark:text-rose-300',
+ dotClass: 'bg-rose-500 shadow-[0_0_0_2px_rgba(244,63,94,0.14)]'
+ }
+ case 'auto':
+ return {
+ label: t('admin.accounts.openai.compactAuto'),
+ className: 'text-slate-500 dark:text-slate-400',
+ dotClass: 'bg-slate-300 dark:bg-slate-500'
+ }
}
}
function getOpenAICompactTitle(row: any): string {
const extra = row.extra as Record | undefined
const checkedAt = typeof extra?.openai_compact_checked_at === 'string' ? extra.openai_compact_checked_at : ''
- if (!checkedAt) return getOpenAICompactLabel(row) || ''
- return `${getOpenAICompactLabel(row)} | ${t('admin.accounts.openai.compactLastChecked')}: ${formatDateTime(new Date(checkedAt))}`
+ const label = getOpenAICompactMeta(row)?.label || ''
+ if (!checkedAt) return label
+ return `${label} | ${t('admin.accounts.openai.compactLastChecked')}: ${formatDateTime(new Date(checkedAt))}`
}
function getAntigravityTierClass(row: any): string {
@@ -1608,11 +1693,11 @@ const handleScroll = () => {
menu.show = false
}
-// 点击外部关闭列设置下拉菜单
+// 点击外部关闭顶部下拉菜单
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement
- if (columnDropdownRef.value && !columnDropdownRef.value.contains(target)) {
- showColumnDropdown.value = false
+ if (accountToolsDropdownRef.value && !accountToolsDropdownRef.value.contains(target)) {
+ showAccountToolsDropdown.value = false
}
if (autoRefreshDropdownRef.value && !autoRefreshDropdownRef.value.contains(target)) {
showAutoRefreshDropdown.value = false
@@ -1644,3 +1729,13 @@ onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
+
+
diff --git a/frontend/src/views/admin/ChannelsView.vue b/frontend/src/views/admin/ChannelsView.vue
index 216e23d5..cd1626ca 100644
--- a/frontend/src/views/admin/ChannelsView.vue
+++ b/frontend/src/views/admin/ChannelsView.vue
@@ -339,6 +339,21 @@
+
+
+
+
+
+ {{ t('admin.channels.form.codexImageGenerationBridge') }}
+
+
+ {{ t('admin.channels.form.codexImageGenerationBridgeHint') }}
+
+
+
+
+
+
@@ -643,6 +658,7 @@ interface PlatformSection {
model_mapping: Record
model_pricing: PricingFormEntry[]
web_search_emulation: boolean
+ codex_image_generation_bridge: boolean
account_stats_pricing_rules: FormPricingRule[]
}
@@ -738,6 +754,7 @@ function addPlatformSection(platform: GroupPlatform) {
model_mapping: {},
model_pricing: [],
web_search_emulation: false,
+ codex_image_generation_bridge: false,
account_stats_pricing_rules: [],
})
}
@@ -1047,6 +1064,19 @@ function formToAPI(): { group_ids: number[], model_pricing: ChannelModelPricing[
delete featuresConfig.web_search_emulation
}
+ const codexImageGenerationBridge: Record = {}
+ for (const section of form.platforms) {
+ if (!section.enabled) continue
+ if (section.platform === 'openai') {
+ codexImageGenerationBridge[section.platform] = !!section.codex_image_generation_bridge
+ }
+ }
+ if (Object.keys(codexImageGenerationBridge).length > 0) {
+ featuresConfig.codex_image_generation_bridge = codexImageGenerationBridge
+ } else {
+ delete featuresConfig.codex_image_generation_bridge
+ }
+
return { group_ids, model_pricing, model_mapping, features_config: featuresConfig }
}
@@ -1095,6 +1125,8 @@ function apiToForm(channel: Channel): PlatformSection[] {
const fc = channel.features_config
const wsEmulation = fc?.web_search_emulation as Record | undefined
const webSearchEnabled = wsEmulation?.[platform] === true
+ const codexImageGenerationBridge = fc?.codex_image_generation_bridge as Record | undefined
+ const codexImageGenerationBridgeEnabled = codexImageGenerationBridge?.[platform] === true
sections.push({
platform,
@@ -1104,6 +1136,7 @@ function apiToForm(channel: Channel): PlatformSection[] {
model_mapping: { ...mapping },
model_pricing: pricing,
web_search_emulation: webSearchEnabled,
+ codex_image_generation_bridge: codexImageGenerationBridgeEnabled,
account_stats_pricing_rules: [],
})
}
diff --git a/frontend/src/views/admin/RiskControlView.vue b/frontend/src/views/admin/RiskControlView.vue
new file mode 100644
index 00000000..74db4772
--- /dev/null
+++ b/frontend/src/views/admin/RiskControlView.vue
@@ -0,0 +1,1703 @@
+
+
+
+
+
+
+
+
+
{{ t('admin.riskControl.title') }}
+
{{ t('admin.riskControl.description') }}
+
+
+
+
+ {{ t('admin.riskControl.refreshStatus') }}
+
+
+
+ {{ t('admin.riskControl.openSettings') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ item.label }}
+
+ {{ item.badge }}
+
+
+
+
{{ item.value }}
+
{{ item.meta }}
+
+
+
+
+
+
+
+
+
+
{{ t('admin.riskControl.workerStatus') }}
+
{{ t('admin.riskControl.workerStatusHint') }}
+
+
+ {{ t('admin.riskControl.autoRefresh') }}
+
+ {{ t('admin.riskControl.lastCleanup', { time: formatDateTime(status.last_cleanup_at) }) }}
+
+
+
+
+
+
+
+
+
+
{{ t('admin.riskControl.queueUsage') }}
+
+ {{ formatNumber(status?.queue_length ?? 0) }} / {{ formatNumber(status?.queue_size ?? configForm.queue_size) }}
+
+
+
{{ queueUsagePercent }}
+
+
+
+
+
+
+
{{ t('admin.riskControl.activeWorkers') }}
+
{{ status?.active_workers ?? 0 }}
+
+
+
{{ t('admin.riskControl.idleWorkers') }}
+
{{ status?.idle_workers ?? configForm.worker_count }}
+
+
+
{{ t('admin.riskControl.processed') }}
+
{{ formatNumber(status?.processed ?? 0) }}
+
+
+
{{ t('admin.riskControl.droppedErrors') }}
+
{{ formatNumber((status?.dropped ?? 0) + (status?.errors ?? 0)) }}
+
+
+
+
+
+
+
+
{{ t('admin.riskControl.workerPool') }}
+
+ {{ t('admin.riskControl.workerPoolMeta', { active: status?.active_workers ?? 0, idle: status?.idle_workers ?? configForm.worker_count, total: status?.worker_count ?? configForm.worker_count }) }}
+
+
+
+ {{ modeLabel(status?.mode ?? configForm.mode) }}
+
+
+
+
+ #{{ worker.id }}
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('admin.riskControl.records') }}
+
{{ t('admin.riskControl.recordsHint') }}
+
+
+
+ {{ t('admin.riskControl.refresh') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('admin.riskControl.table.time') }}
+ {{ t('admin.riskControl.table.group') }}
+ {{ t('admin.riskControl.table.user') }}
+ {{ t('admin.riskControl.table.apiKey') }}
+ {{ t('admin.riskControl.table.endpoint') }}
+ {{ t('admin.riskControl.table.result') }}
+ {{ t('admin.riskControl.table.highest') }}
+ {{ t('admin.riskControl.table.actionMeta') }}
+ {{ t('admin.riskControl.table.latency') }}
+ {{ t('admin.riskControl.table.input') }}
+
+
+
+
+ {{ t('common.loading') }}
+
+
+ {{ t('admin.riskControl.emptyLogs') }}
+
+
+
+ {{ formatDateTime(row.created_at) }}
+ {{ row.group_name || '-' }}
+
+ {{ row.user_email || '-' }}
+ UID {{ row.user_id }}
+
+ {{ row.api_key_name || '-' }}
+
+ {{ row.endpoint || '-' }}
+ {{ row.provider || '-' }} / {{ row.model || '-' }}
+
+
+
+ {{ resultLabel(row) }}
+
+
+
+ {{ row.highest_category || '-' }}
+ {{ percent(row.highest_score) }}
+
+
+ {{ violationCountText(row) }}
+
+ {{ row.email_sent ? t('admin.riskControl.emailSent') : t('admin.riskControl.emailNotSent') }}
+ / {{ t('admin.riskControl.autoBanned') }}
+
+
+
+ {{ unbanningUserID === row.user_id ? t('common.processing') : t('admin.riskControl.unbanUser') }}
+
+
+
+ {{ latencyText(row.upstream_latency_ms) }}
+
+ {{ t('admin.riskControl.queueDelay', { ms: row.queue_delay_ms }) }}
+
+
+
+
+ {{ inputSummaryText(row) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ tab.label }}
+
+
+
+
+
+
+
+
{{ t('admin.riskControl.enabled') }}
+
{{ t('admin.riskControl.enabledHint') }}
+
+
+
+
+
{{ t('admin.riskControl.mode') }}
+
+
{{ modeDescription(configForm.mode) }}
+
+
+ {{ t('admin.riskControl.baseUrl') }}
+
+
+
+ {{ t('admin.riskControl.model') }}
+
+
+
+ {{ t('admin.riskControl.timeoutMs') }}
+
+
+
+ {{ t('admin.riskControl.retryCount') }}
+
+
+
+
{{ t('admin.riskControl.sampleRate') }}
+
+
+ %
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('admin.riskControl.apiKeys') }}
+
+ {{ t('admin.riskControl.apiKeysHint', { count: configForm.api_key_count }) }}
+
+
+
+
+
+
+ {{ apiKeyTesting ? t('admin.riskControl.testingApiKeys') : t('admin.riskControl.testInputApiKeys') }}
+
+
+
+ {{ storedApiKeyTestButtonText }}
+
+
+
+ {{ configForm.clear_api_key ? t('admin.riskControl.keepApiKey') : t('admin.riskControl.clearApiKey') }}
+
+
+
+
+
+
+
+
+ {{ t('admin.riskControl.apiKeysWriteMode') }}
+ {{ apiKeysModeHint }}
+
+
+
+ {{ t('admin.riskControl.apiKeysModeAppend') }}
+
+
+ {{ t('admin.riskControl.apiKeysModeReplace') }}
+
+
+
+
+
+
+ {{ t('admin.riskControl.inputApiKeyCount', { count: inputApiKeyCount }) }}
+
+
+ {{ t('admin.riskControl.storedApiKeyCount', { count: configForm.api_key_count }) }}
+
+
+ {{ t('admin.riskControl.apiKeyWillClear') }}
+
+
+ {{ t('admin.riskControl.apiKeyPendingDeleteCount', { count: pendingDeletedApiKeyCount }) }}
+
+
+ {{ t('admin.riskControl.apiKeysReplaceWarning') }}
+
+
+
+
+
+
+
{{ t('admin.riskControl.auditTestInput') }}
+
{{ t('admin.riskControl.auditTestInputHint') }}
+
+
+
+ {{ t('admin.riskControl.clearAuditTest') }}
+
+
+
+
+
+
+
+
+
{{ t('admin.riskControl.auditTestImages') }}
+
{{ t('admin.riskControl.auditTestImagesHint') }}
+
+
+
+
+ {{ t('admin.riskControl.addAuditTestImage') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('admin.riskControl.apiKeyHealth') }}
+
{{ t('admin.riskControl.apiKeyFreezeRule') }}
+
+
+ {{ t('admin.riskControl.apiKeyRows', { count: apiKeyRows.length }) }}
+
+
+
+
+
+
{{ t('admin.riskControl.apiKeyHealthEmpty') }}
+
{{ t('admin.riskControl.apiKeyHealthEmptyHint') }}
+
+
+
+
+
+
+
+ {{ row.masked || '-' }}
+
+ {{ isStoredApiKeyPendingDelete(row) ? t('admin.riskControl.apiKeyPendingDelete') : row.configured ? t('admin.riskControl.apiKeyConfigured') : t('admin.riskControl.apiKeyTemporary') }}
+
+
+
{{ apiKeyStatusMeta(row) }}
+
+
+
+
+ {{ apiKeyStatusLabel(row.status) }}
+
+
+
+
+
+
+
+ {{ row.last_error }}
+
+
+
+
+
+
+ {{ apiKeyRowsExpanded ? t('admin.riskControl.apiKeyRowsExpanded', { count: apiKeyRows.length }) : t('admin.riskControl.apiKeyRowsCollapsed', { count: hiddenApiKeyRowCount }) }}
+
+
+
+ {{ apiKeyRowsExpanded ? t('admin.riskControl.collapseApiKeyRows') : t('admin.riskControl.expandApiKeyRows') }}
+
+
+
+
+
+
+
+
{{ t('admin.riskControl.auditTestResult') }}
+
+ {{ t('admin.riskControl.auditTestHighest', { category: moderationTestResult.highest_category || '-', score: percent(moderationTestResult.highest_score) }) }}
+
+
+
+ {{ moderationTestResult.flagged ? t('admin.riskControl.auditTestFlagged') : t('admin.riskControl.auditTestPassed') }}
+
+
+
+
+ {{ t('admin.riskControl.auditTestComposite') }}
+ {{ percent(moderationTestResult.composite_score) }}
+
+
+
+
+
+
+ {{ score.category }}
+ {{ percent(score.score) }} / {{ percent(score.threshold) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('admin.riskControl.groupScope') }}
+
{{ t('admin.riskControl.groupScopeHint') }}
+
+
+
+ {{ t('admin.riskControl.allGroups') }}
+
+
+ {{ t('admin.riskControl.selectedGroups') }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ group.name }}
+ {{ group.platform }}
+
+
+
+
+
+
{{ t('admin.riskControl.noGroups') }}
+
+
+
+
+
+
+ {{ t('admin.riskControl.workerCount') }}
+
+
+
+ {{ t('admin.riskControl.queueSize') }}
+
+
+
+
+
{{ t('admin.riskControl.recordNonHits') }}
+
{{ t('admin.riskControl.recordNonHitsHint') }}
+
+
+
+
+
+
+
{{ t('admin.riskControl.preHashCheck') }}
+
{{ t('admin.riskControl.preHashCheckHint') }}
+
+
+
+
+
+
+
+ {{ t('admin.riskControl.flaggedHashCount', { count: formatNumber(status?.flagged_hash_count ?? 0) }) }}
+
+
{{ t('admin.riskControl.flaggedHashHint') }}
+
+
+
+ {{ t('admin.riskControl.clearFlaggedHashes') }}
+
+
+
+
+
+
+ {{ t('admin.riskControl.deleteFlaggedHash') }}
+
+
+
+
+
+
+
+
+
+ {{ t('admin.riskControl.blockStatus') }}
+
+
+
+ {{ t('admin.riskControl.blockMessage') }}
+
+
+
+
+
{{ t('admin.riskControl.emailOnHit') }}
+
{{ t('admin.riskControl.emailOnHitHint') }}
+
+
+
+
+
+
{{ t('admin.riskControl.autoBan') }}
+
{{ t('admin.riskControl.autoBanHint') }}
+
+
+
+
+ {{ t('admin.riskControl.banThreshold') }}
+
+
+
+ {{ t('admin.riskControl.violationWindowHours') }}
+
+
+
+
+
+
+
+ {{ t('admin.riskControl.hitRetentionDays') }}
+
+
+
+ {{ t('admin.riskControl.nonHitRetentionDays') }}
+
+
+
+
+
+ {{ t('admin.riskControl.cleanupStats', { hit: status?.last_cleanup_deleted_hit ?? 0, nonHit: status?.last_cleanup_deleted_non_hit ?? 0 }) }}
+
+
+
+
+
+
+
+ {{ t('common.cancel') }}
+
+
+
+ {{ saving ? t('common.saving') : t('admin.riskControl.saveConfig') }}
+
+
+
+
+
+
+
+
+
+
{{ t('admin.riskControl.table.time') }}
+
{{ formatDateTime(inputDetailRow.created_at) }}
+
+
+
{{ t('admin.riskControl.table.user') }}
+
{{ inputDetailRow.user_email || '-' }}
+
+
+
{{ t('admin.riskControl.table.result') }}
+
+ {{ resultLabel(inputDetailRow) }}
+
+
+
+
{{ t('admin.riskControl.table.highest') }}
+
+ {{ inputDetailRow.highest_category || '-' }} / {{ percent(inputDetailRow.highest_score) }}
+
+
+
+
+
+
+
+
{{ t('admin.riskControl.inputDetailContent') }}
+
+ {{ inputDetailRow.endpoint || '-' }} · {{ inputDetailRow.provider || '-' }} / {{ inputDetailRow.model || '-' }}
+
+
+
+ {{ inputDetailRow.group_name }}
+
+
+
{{ inputDetailText }}
+
+
+
+
+
+ {{ t('common.close') }}
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue
index c8548f40..3d42a55b 100644
--- a/frontend/src/views/admin/SettingsView.vue
+++ b/frontend/src/views/admin/SettingsView.vue
@@ -1752,6 +1752,232 @@
+
+
+
+
+ {{ localText("邮箱快捷登录", "Email OAuth Sign-in") }}
+
+
+ {{
+ localText(
+ "开启 GitHub 或 Google 邮箱授权登录后,系统会读取已验证邮箱,存在则直接登录,不存在则自动注册。",
+ "After GitHub or Google email OAuth is enabled, the system reads a verified email, signs in matching users, and auto-registers missing users.",
+ )
+ }}
+
+
+
+
+
+
+
+
+ GitHub
+
+
+ {{
+ localText(
+ "GitHub OAuth App 需要 read:user user:email 权限,回调地址填写下方后端地址。",
+ "GitHub OAuth App needs read:user user:email scopes. Use the backend callback URL below.",
+ )
+ }}
+
+
+
+
+
+
+
+
+ 开通引导:GitHub Settings → Developer settings →
+ OAuth Apps
+ → New OAuth App;Homepage URL 填站点域名,Authorization callback URL 填下面的后端回调地址。
+
+
+ Setup guide: GitHub Settings → Developer settings →
+ OAuth Apps
+ → New OAuth App. Use your site origin as Homepage URL and the backend callback URL below as Authorization callback URL.
+
+
+
+
+
+
+
+ {{ localText("后端回调地址", "Backend Callback URL") }}
+
+
+
+
+ {{ localText("生成并复制", "Generate and copy") }}
+
+
+ {{ githubOAuthRedirectUrlSuggestion }}
+
+
+
+
+
+
+ {{ localText("前端回跳地址", "Frontend Callback URL") }}
+
+
+
+
+
+
+
+
+
+
+ Google
+
+
+ {{
+ localText(
+ "Google OAuth 客户端需要 openid email profile 范围,并在凭据里登记后端回调地址。",
+ "Google OAuth client needs openid email profile scopes and the backend callback URL registered in credentials.",
+ )
+ }}
+
+
+
+
+
+
+
+ {{
+ localText(
+ "开通引导:Google Cloud Console → APIs & Services → OAuth consent screen 完成同意屏幕;Credentials → Create Credentials → OAuth client ID,类型选择 Web application,并把下面地址加入 Authorized redirect URIs。",
+ "Setup guide: Google Cloud Console → APIs & Services → OAuth consent screen, then Credentials → Create Credentials → OAuth client ID, choose Web application, and add the URL below to Authorized redirect URIs.",
+ )
+ }}
+
+
+
+
+
+
+ {{ localText("后端回调地址", "Backend Callback URL") }}
+
+
+
+
+ {{ localText("生成并复制", "Generate and copy") }}
+
+
+ {{ googleOAuthRedirectUrlSuggestion }}
+
+
+
+
+
+
+ {{ localText("前端回跳地址", "Frontend Callback URL") }}
+
+
+
+
+
+
+
+
+
{{ t("admin.settings.site.backendModeDescription") }}
-
-
-
+
+
@@ -5616,7 +6077,12 @@ import type {
WebSearchProviderConfig,
WebSearchTestResult,
} from "@/api/admin/settings";
-import type { AdminGroup, Proxy, NotifyEmailEntry } from "@/types";
+import type {
+ AdminGroup,
+ LoginAgreementDocument,
+ NotifyEmailEntry,
+ Proxy,
+} from "@/types";
import type { ProviderInstance } from "@/types/payment";
import AppLayout from "@/components/layout/AppLayout.vue";
import Icon from "@/components/icons/Icon.vue";
@@ -5646,9 +6112,10 @@ import {
const { t, locale } = useI18n();
const appStore = useAppStore();
const adminSettingsStore = useAdminSettingsStore();
+const isZhLocale = computed(() => locale.value.startsWith("zh"));
function localText(zh: string, en: string): string {
- return locale.value.startsWith("zh") ? zh : en;
+ return isZhLocale.value ? zh : en;
}
const paymentGuideHref = computed(() =>
@@ -5665,6 +6132,7 @@ const paymentMethodsHref = computed(() =>
type SettingsTab =
| "general"
+ | "agreement"
| "features"
| "security"
| "users"
@@ -5675,6 +6143,7 @@ type SettingsTab =
const activeTab = ref("general");
const settingsTabs = [
{ key: "general" as SettingsTab, icon: "home" as const },
+ { key: "agreement" as SettingsTab, icon: "document" as const },
{ key: "features" as SettingsTab, icon: "bolt" as const },
{ key: "security" as SettingsTab, icon: "shield" as const },
{ key: "users" as SettingsTab, icon: "user" as const },
@@ -5769,6 +6238,49 @@ const tablePageSizeMin = 5;
const tablePageSizeMax = 1000;
const tablePageSizeDefault = 20;
+function defaultLoginAgreementDocuments(): LoginAgreementDocument[] {
+ return [
+ {
+ id: "terms",
+ title: "服务条款",
+ content_md: "",
+ },
+ {
+ id: "usage-policy",
+ title: "使用政策",
+ content_md: "",
+ },
+ {
+ id: "supported-regions",
+ title: "支持的国家和地区",
+ content_md: "",
+ },
+ {
+ id: "service-specific-terms",
+ title: "服务特定条款",
+ content_md: "",
+ },
+ ];
+}
+
+function normalizeLoginAgreementDocumentId(raw: string): string {
+ return raw
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9_-]+/g, "-")
+ .replace(/[-_]{2,}/g, "-")
+ .replace(/^[-_]+|[-_]+$/g, "");
+}
+
+function loginAgreementRoutePath(
+ doc: LoginAgreementDocument,
+ index: number,
+): string {
+ const id =
+ normalizeLoginAgreementDocumentId(doc.id || doc.title) || `doc-${index + 1}`;
+ return `/legal/${id}`;
+}
+
interface DefaultSubscriptionGroupOption {
value: number;
label: string;
@@ -5796,6 +6308,8 @@ type SettingsForm = Omit<
wechat_connect_mp_enabled: boolean;
wechat_connect_mobile_enabled: boolean;
oidc_connect_client_secret: string;
+ github_oauth_client_secret: string;
+ google_oauth_client_secret: string;
force_email_on_third_party_signup: boolean;
openai_advanced_scheduler_enabled: boolean;
};
@@ -5809,6 +6323,10 @@ const form = reactive({
password_reset_enabled: false,
totp_enabled: false,
totp_encryption_key_configured: false,
+ login_agreement_enabled: false,
+ login_agreement_mode: "modal",
+ login_agreement_updated_at: "2026-03-31",
+ login_agreement_documents: defaultLoginAgreementDocuments(),
default_balance: 0,
affiliate_rebate_rate: 20,
affiliate_rebate_freeze_hours: 0,
@@ -5828,6 +6346,7 @@ const form = reactive({
backend_mode_enabled: false,
hide_ccs_import_button: false,
payment_enabled: false,
+ risk_control_enabled: false,
payment_min_amount: 1,
payment_max_amount: 10000,
payment_daily_limit: 50000,
@@ -5926,6 +6445,19 @@ const form = reactive({
oidc_connect_userinfo_email_path: "",
oidc_connect_userinfo_id_path: "",
oidc_connect_userinfo_username_path: "",
+ // GitHub / Google 邮箱快捷登录
+ github_oauth_enabled: false,
+ github_oauth_client_id: "",
+ github_oauth_client_secret: "",
+ github_oauth_client_secret_configured: false,
+ github_oauth_redirect_url: "",
+ github_oauth_frontend_redirect_url: "/auth/oauth/callback",
+ google_oauth_enabled: false,
+ google_oauth_client_id: "",
+ google_oauth_client_secret: "",
+ google_oauth_client_secret_configured: false,
+ google_oauth_redirect_url: "",
+ google_oauth_frontend_redirect_url: "/auth/oauth/callback",
// Model fallback
enable_model_fallback: false,
fallback_model_anthropic: "claude-3-5-sonnet-20241022",
@@ -5992,6 +6524,22 @@ const authSourceDefaultsMeta = computed(() => [
title: t("admin.settings.authSourceDefaults.sources.wechat.title"),
description: t("admin.settings.authSourceDefaults.sources.wechat.description"),
},
+ {
+ source: "github" as AuthSourceType,
+ title: "GitHub",
+ description: localText(
+ "通过 GitHub 已验证邮箱首次注册或首次绑定时应用。",
+ "Applied on first signup or first bind through a verified GitHub email.",
+ ),
+ },
+ {
+ source: "google" as AuthSourceType,
+ title: "Google",
+ description: localText(
+ "通过 Google 已验证邮箱首次注册或首次绑定时应用。",
+ "Applied on first signup or first bind through a verified Google email.",
+ ),
+ },
]);
// Proxies for web search emulation ProxySelector
@@ -6299,6 +6847,42 @@ async function setAndCopyLinuxdoRedirectUrl() {
);
}
+type EmailOAuthProvider = "github" | "google";
+
+const githubOAuthRedirectUrlSuggestion = computed(() => {
+ if (typeof window === "undefined") return "";
+ const origin =
+ window.location.origin ||
+ `${window.location.protocol}//${window.location.host}`;
+ return `${origin}/api/v1/auth/oauth/github/callback`;
+});
+
+const googleOAuthRedirectUrlSuggestion = computed(() => {
+ if (typeof window === "undefined") return "";
+ const origin =
+ window.location.origin ||
+ `${window.location.protocol}//${window.location.host}`;
+ return `${origin}/api/v1/auth/oauth/google/callback`;
+});
+
+async function setAndCopyEmailOAuthRedirectUrl(provider: EmailOAuthProvider) {
+ const url =
+ provider === "github"
+ ? githubOAuthRedirectUrlSuggestion.value
+ : googleOAuthRedirectUrlSuggestion.value;
+ if (!url) return;
+
+ if (provider === "github") {
+ form.github_oauth_redirect_url = url;
+ } else {
+ form.google_oauth_redirect_url = url;
+ }
+ await copyToClipboard(
+ url,
+ localText("回调地址已写入并复制。", "Callback URL set and copied."),
+ );
+}
+
const wechatRedirectUrlSuggestion = computed(() => {
if (typeof window === "undefined") return "";
const origin =
@@ -6426,6 +7010,43 @@ function removeEndpoint(index: number) {
form.custom_endpoints.splice(index, 1);
}
+function addLoginAgreementDocument() {
+ form.login_agreement_documents.push({
+ id: `custom-${Date.now().toString(36)}`,
+ title: "",
+ content_md: "",
+ });
+}
+
+function removeLoginAgreementDocument(index: number) {
+ form.login_agreement_documents.splice(index, 1);
+}
+
+function normalizeLoginAgreementDocumentsForSave(): LoginAgreementDocument[] {
+ return form.login_agreement_documents
+ .map((doc, index) => ({
+ id:
+ normalizeLoginAgreementDocumentId(doc.id || doc.title) ||
+ `doc-${index + 1}`,
+ title: doc.title.trim(),
+ content_md: doc.content_md.trim(),
+ }))
+ .filter((doc) => doc.title || doc.content_md);
+}
+
+function findDuplicateLoginAgreementDocumentId(
+ documents: LoginAgreementDocument[],
+): string | null {
+ const seen = new Set();
+ for (const doc of documents) {
+ if (seen.has(doc.id)) {
+ return doc.id;
+ }
+ seen.add(doc.id);
+ }
+ return null;
+}
+
function formatTablePageSizeOptions(options: number[]): string {
return options.join(", ");
}
@@ -6470,6 +7091,19 @@ async function loadSettings() {
(form as Record)[key] = value;
}
}
+ form.login_agreement_mode =
+ settings.login_agreement_mode === "checkbox" ? "checkbox" : "modal";
+ form.login_agreement_updated_at =
+ settings.login_agreement_updated_at || "2026-03-31";
+ form.login_agreement_documents =
+ Array.isArray(settings.login_agreement_documents) &&
+ settings.login_agreement_documents.length > 0
+ ? settings.login_agreement_documents.map((doc) => ({
+ id: doc.id || "",
+ title: doc.title || "",
+ content_md: doc.content_md || "",
+ }))
+ : defaultLoginAgreementDocuments();
Object.assign(authSourceDefaults, buildAuthSourceDefaultsState(settings));
form.backend_mode_enabled = settings.backend_mode_enabled;
form.default_subscriptions = normalizeDefaultSubscriptionSettings(
@@ -6489,6 +7123,8 @@ async function loadSettings() {
smtpPasswordManuallyEdited.value = false;
form.turnstile_secret_key = "";
form.linuxdo_connect_client_secret = "";
+ form.github_oauth_client_secret = "";
+ form.google_oauth_client_secret = "";
form.wechat_connect_app_secret = "";
form.wechat_connect_open_app_secret = "";
form.wechat_connect_mp_app_secret = "";
@@ -6679,6 +7315,44 @@ async function saveSettings() {
form.table_default_page_size = normalizedTableDefaultPageSize;
form.table_page_size_options = normalizedTablePageSizeOptions;
+ const normalizedLoginAgreementDocuments =
+ normalizeLoginAgreementDocumentsForSave();
+ if (form.login_agreement_enabled && normalizedLoginAgreementDocuments.length === 0) {
+ appStore.showError(
+ localText(
+ "启用登录条款确认时,至少需要保留一份文档。",
+ "At least one document is required when login agreement is enabled.",
+ ),
+ );
+ return;
+ }
+ const emptyTitleDocument = normalizedLoginAgreementDocuments.find(
+ (doc) => !doc.title,
+ );
+ if (emptyTitleDocument) {
+ appStore.showError(
+ localText(
+ "登录条款文档名称不能为空。",
+ "Login agreement document title cannot be empty.",
+ ),
+ );
+ return;
+ }
+ const duplicateLoginAgreementDocumentId =
+ findDuplicateLoginAgreementDocumentId(normalizedLoginAgreementDocuments);
+ if (duplicateLoginAgreementDocumentId) {
+ appStore.showError(
+ localText(
+ `登录条款文档路由不能重复:/legal/${duplicateLoginAgreementDocumentId}`,
+ `Login agreement document routes cannot be duplicated: /legal/${duplicateLoginAgreementDocumentId}`,
+ ),
+ );
+ return;
+ }
+ form.login_agreement_mode =
+ form.login_agreement_mode === "checkbox" ? "checkbox" : "modal";
+ form.login_agreement_documents = normalizedLoginAgreementDocuments;
+
const normalizedDefaultSubscriptions = normalizeDefaultSubscriptionSettings(
form.default_subscriptions,
);
@@ -6756,6 +7430,10 @@ async function saveSettings() {
invitation_code_enabled: form.invitation_code_enabled,
password_reset_enabled: form.password_reset_enabled,
totp_enabled: form.totp_enabled,
+ login_agreement_enabled: form.login_agreement_enabled,
+ login_agreement_mode: form.login_agreement_mode,
+ login_agreement_updated_at: form.login_agreement_updated_at,
+ login_agreement_documents: form.login_agreement_documents,
default_balance: form.default_balance,
affiliate_rebate_rate: Math.min(
100,
@@ -6847,6 +7525,20 @@ async function saveSettings() {
oidc_connect_userinfo_id_path: form.oidc_connect_userinfo_id_path,
oidc_connect_userinfo_username_path:
form.oidc_connect_userinfo_username_path,
+ github_oauth_enabled: form.github_oauth_enabled,
+ github_oauth_client_id: form.github_oauth_client_id,
+ github_oauth_client_secret:
+ form.github_oauth_client_secret || undefined,
+ github_oauth_redirect_url: form.github_oauth_redirect_url,
+ github_oauth_frontend_redirect_url:
+ form.github_oauth_frontend_redirect_url,
+ google_oauth_enabled: form.google_oauth_enabled,
+ google_oauth_client_id: form.google_oauth_client_id,
+ google_oauth_client_secret:
+ form.google_oauth_client_secret || undefined,
+ google_oauth_redirect_url: form.google_oauth_redirect_url,
+ google_oauth_frontend_redirect_url:
+ form.google_oauth_frontend_redirect_url,
enable_model_fallback: form.enable_model_fallback,
fallback_model_anthropic: form.fallback_model_anthropic,
fallback_model_openai: form.fallback_model_openai,
@@ -6865,6 +7557,7 @@ async function saveSettings() {
form.enable_anthropic_cache_ttl_1h_injection,
// Payment configuration
payment_enabled: form.payment_enabled,
+ risk_control_enabled: form.risk_control_enabled,
payment_min_amount: Number(form.payment_min_amount) || 0,
payment_max_amount: Number(form.payment_max_amount) || 0,
payment_daily_limit: Number(form.payment_daily_limit) || 0,
@@ -6962,6 +7655,8 @@ async function saveSettings() {
smtpPasswordManuallyEdited.value = false;
form.turnstile_secret_key = "";
form.linuxdo_connect_client_secret = "";
+ form.github_oauth_client_secret = "";
+ form.google_oauth_client_secret = "";
form.wechat_connect_app_secret = "";
form.wechat_connect_open_app_secret = "";
form.wechat_connect_mp_app_secret = "";
diff --git a/frontend/src/views/admin/__tests__/SettingsView.spec.ts b/frontend/src/views/admin/__tests__/SettingsView.spec.ts
index bfd1861f..915d9425 100644
--- a/frontend/src/views/admin/__tests__/SettingsView.spec.ts
+++ b/frontend/src/views/admin/__tests__/SettingsView.spec.ts
@@ -817,6 +817,24 @@ describe("admin SettingsView wechat connect controls", () => {
).toBe("/auth/wechat/callback");
});
+ it("links GitHub OAuth Apps guide to GitHub developer settings", async () => {
+ getSettings.mockResolvedValueOnce({
+ ...baseSettingsResponse,
+ github_oauth_enabled: true,
+ });
+
+ const wrapper = mountView();
+
+ await flushPromises();
+ await openSecurityTab(wrapper);
+
+ const link = wrapper.get('[data-testid="github-oauth-apps-guide-link"]');
+ expect(link.text()).toContain("OAuth Apps");
+ expect(link.attributes("href")).toBe("https://github.com/settings/developers");
+ expect(link.attributes("target")).toBe("_blank");
+ expect(link.attributes("rel")).toContain("noopener");
+ });
+
it("saves WeChat Connect fields using the backend contract and clears the secret after save", async () => {
const wrapper = mountView();
diff --git a/frontend/src/views/auth/LoginView.vue b/frontend/src/views/auth/LoginView.vue
index 78ba4b9d..3e89b079 100644
--- a/frontend/src/views/auth/LoginView.vue
+++ b/frontend/src/views/auth/LoginView.vue
@@ -10,33 +10,6 @@
{{ t('auth.signInToAccount') }}
-
-
-
-
-
-
-
-
- {{ t('auth.oauthOrContinue') }}
-
-
-
-
-
@@ -180,15 +200,19 @@ import { AuthLayout } from '@/components/layout'
import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue'
import OidcOAuthSection from '@/components/auth/OidcOAuthSection.vue'
import WechatOAuthSection from '@/components/auth/WechatOAuthSection.vue'
+import EmailOAuthButtons from '@/components/auth/EmailOAuthButtons.vue'
+import LoginAgreementPrompt from '@/components/auth/LoginAgreementPrompt.vue'
import TotpLoginModal from '@/components/auth/TotpLoginModal.vue'
import Icon from '@/components/icons/Icon.vue'
import TurnstileWidget from '@/components/TurnstileWidget.vue'
import { useAuthStore, useAppStore } from '@/stores'
import { getPublicSettings, isTotp2FARequired, isWeChatWebOAuthEnabled } from '@/api/auth'
-import type { TotpLoginResponse } from '@/types'
+import type { LoginAgreementDocument, TotpLoginResponse } from '@/types'
+import { extractI18nErrorMessage } from '@/utils/apiError'
import { clearAllAffiliateReferralCodes } from '@/utils/oauthAffiliate'
const { t } = useI18n()
+const LOGIN_AGREEMENT_STORAGE_KEY = 'sub2api_login_agreement_consent'
// ==================== Router & Stores ====================
@@ -201,6 +225,7 @@ const appStore = useAppStore()
const isLoading = ref