fix(public): 修复用户可见性、额度、验证与 Codex 探测

This commit is contained in:
Entropy.Xu
2026-05-16 00:51:44 +08:00
parent 8eb4c029b2
commit bbd3c30b0e
50 changed files with 2321 additions and 347 deletions

View File

@@ -33,6 +33,7 @@ export interface UserStats {
export interface SendVerificationCodeRequest {
email: string
turnstile_token?: string
}
export interface SendVerificationCodeResponse {
@@ -67,6 +68,7 @@ export interface RegisterRequest {
email?: string
username: string
password: string
turnstile_token?: string
}
export interface RegisterResponse {
@@ -81,6 +83,8 @@ export interface RegistrationSettingsResponse {
require_email_verification: boolean
email_configured: boolean
password_policy_level: string
turnstile_enabled?: boolean
turnstile_site_key?: string | null
}
export interface AuthSettingsResponse {
@@ -153,10 +157,17 @@ export const authApi = {
return response.data
},
async sendVerificationCode(email: string): Promise<SendVerificationCodeResponse> {
async sendVerificationCode(
email: string,
turnstileToken?: string
): Promise<SendVerificationCodeResponse> {
const payload: SendVerificationCodeRequest = { email }
if (turnstileToken) {
payload.turnstile_token = turnstileToken
}
const response = await apiClient.post<SendVerificationCodeResponse>(
'/api/auth/send-verification-code',
{ email }
payload
)
return response.data
},

View File

@@ -30,7 +30,7 @@ export interface UserPreferences {
avatar_url?: string
bio?: string
default_provider_id?: string // UUID
default_provider?: Record<string, unknown>
default_provider?: Record<string, unknown> | string | null // 仅管理员可见
theme: string
language: string
timezone?: string
@@ -52,7 +52,7 @@ export interface ProviderConfig {
// 使用记录接口
export interface UsageRecordDetail {
id: string
provider: string
provider?: string // 仅管理员可见
model: string
input_tokens: number
effective_input_tokens?: number

View File

@@ -0,0 +1,34 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const apiClientMocks = vi.hoisted(() => ({
get: vi.fn(),
}))
vi.mock('@/api/client', () => ({
default: apiClientMocks,
}))
describe('useSiteInfo', () => {
beforeEach(() => {
vi.resetModules()
apiClientMocks.get.mockReset()
})
it('loads github link display setting from public site info', async () => {
apiClientMocks.get.mockResolvedValue({
data: {
site_name: 'Custom Aether',
site_subtitle: 'Gateway',
show_github_link: false,
},
})
const { useSiteInfo } = await import('../useSiteInfo')
const { siteName, siteSubtitle, showGithubLink, refreshSiteInfo } = useSiteInfo()
await refreshSiteInfo()
expect(siteName.value).toBe('Custom Aether')
expect(siteSubtitle.value).toBe('Gateway')
expect(showGithubLink.value).toBe(false)
})
})

View File

@@ -4,11 +4,13 @@ import apiClient from '@/api/client'
interface SiteInfo {
site_name: string
site_subtitle: string
show_github_link?: boolean
}
// 模块级缓存,所有组件共享同一份数据
const siteName = ref('Aether')
const siteSubtitle = ref('AI Gateway')
const showGithubLink = ref(true)
const loaded = ref(false)
let fetchPromise: Promise<void> | null = null
@@ -17,6 +19,7 @@ async function fetchSiteInfo() {
const response = await apiClient.get<SiteInfo>('/api/public/site-info')
siteName.value = response.data.site_name
siteSubtitle.value = response.data.site_subtitle
showGithubLink.value = response.data.show_github_link !== false
loaded.value = true
} catch {
// 加载失败时保持默认值,允许后续重试
@@ -35,7 +38,7 @@ export function useSiteInfo() {
if (!loaded.value && !fetchPromise) {
fetchPromise = fetchSiteInfo()
}
return { siteName, siteSubtitle, refreshSiteInfo }
return { siteName, siteSubtitle, showGithubLink, refreshSiteInfo }
}
// 站点名称变化时同步更新 document.title

View File

@@ -227,6 +227,8 @@
:require-email-verification="requireEmailVerification"
:email-configured="emailConfigured"
:password-policy-level="passwordPolicyLevel"
:turnstile-enabled="turnstileEnabled"
:turnstile-site-key="turnstileSiteKey"
@success="handleRegisterSuccess"
@switch-to-login="handleSwitchToLogin"
/>
@@ -270,6 +272,8 @@ const showRegisterDialog = ref(false)
const requireEmailVerification = ref(false)
const emailConfigured = ref(true) // 邮箱服务是否已配置
const passwordPolicyLevel = ref<PasswordPolicyLevel>('weak')
const turnstileEnabled = ref(false)
const turnstileSiteKey = ref<string | null>(null)
const allowRegistration = ref(false) // 由系统配置控制,默认关闭
// LDAP authentication settings
@@ -388,6 +392,8 @@ onMounted(async () => {
requireEmailVerification.value = !!regSettings.require_email_verification
emailConfigured.value = !!regSettings.email_configured
passwordPolicyLevel.value = normalizePasswordPolicyLevel(regSettings.password_policy_level)
turnstileEnabled.value = !!regSettings.turnstile_enabled
turnstileSiteKey.value = regSettings.turnstile_site_key || null
localEnabled.value = authSettings.local_enabled
ldapEnabled.value = authSettings.ldap_enabled
@@ -413,6 +419,8 @@ onMounted(async () => {
requireEmailVerification.value = false
emailConfigured.value = false
passwordPolicyLevel.value = 'weak'
turnstileEnabled.value = false
turnstileSiteKey.value = null
localEnabled.value = true
ldapEnabled.value = false
ldapExclusive.value = false

View File

@@ -55,6 +55,20 @@
/>
</div>
<div
v-if="turnstileRequired && (!requireEmailVerification || !emailVerified)"
class="space-y-2"
>
<Label>人机验证 <span class="text-destructive">*</span></Label>
<TurnstileWidget
ref="turnstileWidgetRef"
v-model="turnstileToken"
:site-key="turnstileSiteKey"
:disabled="isLoading || isSendingCode"
@error="handleTurnstileError"
/>
</div>
<!-- Verification Code Section (仅当需要邮箱验证时显示) -->
<div
v-if="emailConfigured && requireEmailVerification"
@@ -232,7 +246,7 @@
<script setup lang="ts">
import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
import { authApi } from '@/api/auth'
import { authApi, type RegisterRequest } from '@/api/auth'
import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
import {
@@ -245,12 +259,15 @@ import { Dialog } from '@/components/ui'
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
import Label from '@/components/ui/label.vue'
import TurnstileWidget from './TurnstileWidget.vue'
interface Props {
open?: boolean
requireEmailVerification?: boolean
emailConfigured?: boolean
passwordPolicyLevel?: PasswordPolicyLevel
turnstileEnabled?: boolean
turnstileSiteKey?: string | null
}
interface Emits {
@@ -263,7 +280,9 @@ const props = withDefaults(defineProps<Props>(), {
open: false,
requireEmailVerification: false,
emailConfigured: true,
passwordPolicyLevel: 'weak'
passwordPolicyLevel: 'weak',
turnstileEnabled: false,
turnstileSiteKey: null
})
const emit = defineEmits<Emits>()
@@ -379,11 +398,26 @@ const codeSentAt = ref<number | null>(null)
const cooldownSeconds = ref(0)
const expireMinutes = ref(5)
const cooldownTimer = ref<number | null>(null)
const turnstileToken = ref('')
const turnstileWidgetRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
const turnstileSiteKey = computed(() => props.turnstileSiteKey || '')
const turnstileRequired = computed(() => !!props.turnstileEnabled && !!turnstileSiteKey.value)
const resetTurnstile = () => {
turnstileToken.value = ''
turnstileWidgetRef.value?.reset()
}
const handleTurnstileError = (message: string) => {
showError(message, '人机验证失败')
}
// Send code cooldown timer
const canSendCode = computed(() => {
if (!formData.value.email) return false
if (cooldownSeconds.value > 0) return false
if (turnstileRequired.value && !turnstileToken.value) return false
return true
})
@@ -391,6 +425,7 @@ const sendCodeButtonText = computed(() => {
if (isSendingCode.value) return '发送中...'
if (emailVerified.value) return '验证成功'
if (cooldownSeconds.value > 0) return `${cooldownSeconds.value}秒后重试`
if (turnstileRequired.value && !turnstileToken.value) return '请先完成人机验证'
if (codeSentAt.value) return '重新发送验证码'
return '发送验证码'
})
@@ -428,6 +463,8 @@ const canSubmit = computed(() => {
if (!formData.value.email || !emailVerified.value) {
return false
}
} else if (turnstileRequired.value && !turnstileToken.value) {
return false
}
// Check password match
@@ -484,6 +521,7 @@ watch(
cooldownTimer.value = null
}
codeDigits.value = ['', '', '', '', '', '']
resetTurnstile()
}
// 清除之前的定时器
@@ -551,6 +589,7 @@ const resetForm = () => {
isSendingCode.value = false
codeSentAt.value = null
cooldownSeconds.value = 0
resetTurnstile()
// Reset password field nonce
formNonce.value = createFormNonce()
@@ -581,9 +620,13 @@ const handleSendCode = async () => {
isSendingCode.value = true
try {
const response = await authApi.sendVerificationCode(formData.value.email)
const response = await authApi.sendVerificationCode(
formData.value.email,
turnstileRequired.value ? turnstileToken.value : undefined
)
if (response.success) {
resetTurnstile()
codeSentAt.value = Date.now()
if (response.expire_minutes) {
expireMinutes.value = response.expire_minutes
@@ -599,9 +642,11 @@ const handleSendCode = async () => {
codeInputRefs.value[0]?.focus()
})
} else {
resetTurnstile()
showError(response.message || '请稍后重试', '发送失败')
}
} catch (error: unknown) {
resetTurnstile()
showError(parseApiError(error, '网络错误,请重试'), '发送失败')
} finally {
isSendingCode.value = false
@@ -657,13 +702,17 @@ const handleSubmit = async () => {
showError('请先完成邮箱验证')
return
}
if (!props.requireEmailVerification && turnstileRequired.value && !turnstileToken.value) {
showError('请先完成人机验证')
return
}
isLoading.value = true
loadingText.value = '注册中...'
try {
// 构建请求数据:邮箱可选
const registerData: { email?: string; username: string; password: string } = {
const registerData: RegisterRequest = {
username: formData.value.username,
password: formData.value.password
}
@@ -671,6 +720,9 @@ const handleSubmit = async () => {
if (formData.value.email && formData.value.email.trim()) {
registerData.email = formData.value.email
}
if (!props.requireEmailVerification && turnstileRequired.value) {
registerData.turnstile_token = turnstileToken.value
}
const response = await authApi.register(registerData)
@@ -679,6 +731,7 @@ const handleSubmit = async () => {
emit('success')
isOpen.value = false
} catch (error: unknown) {
resetTurnstile()
showError(parseApiError(error, '注册失败,请重试'), '注册失败')
} finally {
isLoading.value = false

View File

@@ -0,0 +1,157 @@
<template>
<div class="space-y-2">
<div
ref="containerRef"
class="min-h-[65px]"
:class="disabled ? 'pointer-events-none opacity-60' : ''"
/>
<p
v-if="errorMessage"
class="text-xs text-destructive"
>
{{ errorMessage }}
</p>
</div>
</template>
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
const TURNSTILE_SCRIPT_URL = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'
let loadTurnstilePromise: Promise<void> | null = null
interface TurnstileApi {
render: (container: HTMLElement, options: Record<string, unknown>) => string
reset: (widgetId: string) => void
remove: (widgetId: string) => void
}
declare global {
interface Window {
turnstile?: TurnstileApi
}
}
const props = withDefaults(defineProps<{
modelValue?: string
siteKey: string
disabled?: boolean
}>(), {
modelValue: '',
disabled: false,
})
const emit = defineEmits<{
'update:modelValue': [value: string]
error: [message: string]
}>()
const containerRef = ref<HTMLElement | null>(null)
const widgetId = ref<string | null>(null)
const errorMessage = ref('')
function loadTurnstileScript(): Promise<void> {
if (window.turnstile) {
return Promise.resolve()
}
if (loadTurnstilePromise) {
return loadTurnstilePromise
}
loadTurnstilePromise = new Promise((resolve, reject) => {
const existing = document.querySelector<HTMLScriptElement>(
`script[src="${TURNSTILE_SCRIPT_URL}"]`
)
if (existing) {
existing.addEventListener('load', () => resolve(), { once: true })
existing.addEventListener('error', () => {
existing.remove()
loadTurnstilePromise = null
reject(new Error('turnstile script failed'))
}, { once: true })
return
}
const script = document.createElement('script')
script.src = TURNSTILE_SCRIPT_URL
script.async = true
script.defer = true
script.onload = () => resolve()
script.onerror = () => {
script.remove()
loadTurnstilePromise = null
reject(new Error('turnstile script failed'))
}
document.head.appendChild(script)
})
return loadTurnstilePromise
}
function clearWidget() {
if (widgetId.value && window.turnstile) {
window.turnstile.remove(widgetId.value)
}
widgetId.value = null
emit('update:modelValue', '')
}
async function renderWidget() {
if (!props.siteKey || !containerRef.value) return
clearWidget()
errorMessage.value = ''
try {
await loadTurnstileScript()
await nextTick()
if (!window.turnstile || !containerRef.value) return
widgetId.value = window.turnstile.render(containerRef.value, {
sitekey: props.siteKey,
callback: (token: string) => {
errorMessage.value = ''
emit('update:modelValue', token)
},
'expired-callback': () => {
emit('update:modelValue', '')
},
'error-callback': () => {
const message = '人机验证加载失败,请重试'
errorMessage.value = message
emit('update:modelValue', '')
emit('error', message)
},
})
} catch {
const message = '人机验证加载失败,请重试'
errorMessage.value = message
emit('update:modelValue', '')
emit('error', message)
}
}
function reset() {
emit('update:modelValue', '')
errorMessage.value = ''
if (widgetId.value && window.turnstile) {
window.turnstile.reset(widgetId.value)
return
}
void renderWidget()
}
onMounted(() => {
void renderWidget()
})
onBeforeUnmount(() => {
if (widgetId.value && window.turnstile) {
window.turnstile.remove(widgetId.value)
}
})
watch(() => props.siteKey, () => {
void renderWidget()
})
defineExpose({ reset })
</script>

View File

@@ -0,0 +1,205 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, nextTick, type App } from 'vue'
import RegisterDialog from '../RegisterDialog.vue'
const authApiMocks = vi.hoisted(() => ({
sendVerificationCode: vi.fn(),
getVerificationStatus: vi.fn(),
verifyEmail: vi.fn(),
register: vi.fn(),
}))
vi.mock('@/api/auth', () => ({
authApi: authApiMocks,
}))
vi.mock('@/composables/useToast', () => ({
useToast: () => ({
success: vi.fn(),
error: vi.fn(),
}),
}))
vi.mock('@/utils/errorParser', () => ({
parseApiError: (_error: unknown, fallback: string) => fallback,
}))
vi.mock('../TurnstileWidget.vue', () => ({
default: defineComponent({
name: 'TurnstileWidgetStub',
props: {
modelValue: { type: String, default: '' },
siteKey: { type: String, required: true },
},
emits: ['update:modelValue'],
setup(_props, { emit, expose }) {
expose({ reset: vi.fn() })
return () =>
h('button', {
type: 'button',
'data-testid': 'turnstile-widget',
onClick: () => emit('update:modelValue', 'turnstile-token-123'),
}, 'Turnstile')
},
}),
}))
vi.mock('@/components/ui', async () => {
const { defineComponent, h } = await import('vue')
return {
Dialog: defineComponent({
name: 'DialogStub',
props: { open: { type: Boolean, default: false } },
emits: ['update:open'],
setup(props, { slots }) {
return () => props.open
? h('div', [slots.default?.(), slots.footer?.()])
: null
},
}),
}
})
vi.mock('@/components/ui/button.vue', async () => {
const { defineComponent, h } = await import('vue')
return {
default: defineComponent({
name: 'ButtonStub',
props: {
disabled: { type: Boolean, default: false },
type: { type: String, default: 'button' },
},
emits: ['click'],
setup(props, { attrs, emit, slots }) {
return () => h('button', {
...attrs,
type: props.type,
disabled: props.disabled,
onClick: (event: MouseEvent) => emit('click', event),
}, slots.default?.())
},
}),
}
})
vi.mock('@/components/ui/input.vue', async () => {
const { defineComponent, h } = await import('vue')
return {
default: defineComponent({
name: 'InputStub',
props: {
modelValue: { type: [String, Number], default: '' },
disabled: { type: Boolean, default: false },
type: { type: String, default: 'text' },
id: { type: String, default: undefined },
},
emits: ['update:modelValue'],
setup(props, { attrs, emit }) {
return () => h('input', {
...attrs,
id: props.id,
type: props.type,
disabled: props.disabled,
value: props.modelValue,
onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).value),
})
},
}),
}
})
vi.mock('@/components/ui/label.vue', async () => {
const { defineComponent, h } = await import('vue')
return {
default: defineComponent({
name: 'LabelStub',
setup(_props, { attrs, slots }) {
return () => h('label', attrs, slots.default?.())
},
}),
}
})
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
function mountRegisterDialog() {
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(RegisterDialog, {
open: true,
requireEmailVerification: true,
emailConfigured: true,
turnstileEnabled: true,
turnstileSiteKey: 'site-key-123',
'onUpdate:open': vi.fn(),
})
app.mount(root)
mountedApps.push({ app, root })
return root
}
async function settle() {
for (let index = 0; index < 4; index += 1) {
await Promise.resolve()
await nextTick()
}
}
beforeEach(() => {
authApiMocks.sendVerificationCode.mockReset()
authApiMocks.getVerificationStatus.mockReset()
authApiMocks.verifyEmail.mockReset()
authApiMocks.register.mockReset()
authApiMocks.getVerificationStatus.mockResolvedValue({
has_pending_code: false,
is_verified: false,
cooldown_remaining: null,
code_expires_in: null,
})
authApiMocks.sendVerificationCode.mockResolvedValue({
success: true,
message: 'ok',
expire_minutes: 5,
})
})
afterEach(() => {
for (const { app, root } of mountedApps.splice(0)) {
app.unmount()
root.remove()
}
document.body.innerHTML = ''
})
describe('RegisterDialog Turnstile verification flow', () => {
it('shows Turnstile before sending code and includes the token in the request', async () => {
const root = mountRegisterDialog()
await settle()
expect(root.textContent).toContain('Turnstile')
const emailInput = root.querySelector('#reg-email') as HTMLInputElement
emailInput.value = 'alice@example.com'
emailInput.dispatchEvent(new Event('input'))
await settle()
const sendButtonBeforeToken = Array.from(root.querySelectorAll('button'))
.find((button) => button.textContent?.includes('请先完成人机验证')) as HTMLButtonElement
expect(sendButtonBeforeToken.disabled).toBe(true)
const turnstileButton = root.querySelector('[data-testid="turnstile-widget"]') as HTMLButtonElement
turnstileButton.click()
await settle()
const sendButton = Array.from(root.querySelectorAll('button'))
.find((button) => button.textContent?.includes('发送验证码')) as HTMLButtonElement
expect(sendButton.disabled).toBe(false)
sendButton.click()
await settle()
expect(authApiMocks.sendVerificationCode).toHaveBeenCalledWith(
'alice@example.com',
'turnstile-token-123'
)
})
})

View File

@@ -325,6 +325,7 @@
</button>
<!-- GitHub Link -->
<a
v-if="showGithubLink"
href="https://github.com/fawney19/Aether"
target="_blank"
rel="noopener noreferrer"
@@ -412,7 +413,7 @@ const route = useRoute()
const authStore = useAuthStore()
const moduleStore = useModuleStore()
const { themeMode, toggleDarkMode } = useDarkMode()
const { siteName, siteSubtitle } = useSiteInfo()
const { siteName, siteSubtitle, showGithubLink } = useSiteInfo()
const isDemo = computed(() => isDemoMode())
const isAdmin = computed(() => authStore.user?.role === 'admin')

View File

@@ -14,11 +14,13 @@
id="section-site-info"
:site-name="systemConfig.site_name"
:site-subtitle="systemConfig.site_subtitle"
:show-github-link="systemConfig.show_github_link"
:loading="siteInfoLoading"
:has-changes="hasSiteInfoChanges"
@save="saveSiteInfo"
@update:site-name="systemConfig.site_name = $event"
@update:site-subtitle="systemConfig.site_subtitle = $event"
@update:show-github-link="systemConfig.show_github_link = $event"
/>
<!-- 配置导出/导入 -->

View File

@@ -51,6 +51,21 @@
显示在导航栏品牌名称下方
</p>
</div>
<div class="md:col-span-2 flex items-center justify-between gap-4 rounded-lg border border-border/60 bg-muted/20 p-4">
<div>
<Label class="block text-sm font-medium">
GitHub 仓库入口
</Label>
<p class="mt-1 text-xs text-muted-foreground">
控制首页指南页和控制台顶部的 GitHub 链接是否展示
</p>
</div>
<Switch
:model-value="showGithubLink"
:disabled="loading"
@update:model-value="$emit('update:showGithubLink', $event)"
/>
</div>
</div>
</CardSection>
</template>
@@ -59,11 +74,13 @@
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
import Label from '@/components/ui/label.vue'
import Switch from '@/components/ui/switch.vue'
import { CardSection } from '@/components/layout'
defineProps<{
siteName: string
siteSubtitle: string
showGithubLink: boolean
loading: boolean
hasChanges: boolean
}>()
@@ -72,5 +89,6 @@ defineEmits<{
save: []
'update:siteName': [value: string]
'update:siteSubtitle': [value: string]
'update:showGithubLink': [value: boolean]
}>()
</script>

View File

@@ -0,0 +1,77 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, nextTick, type App } from 'vue'
import SiteInfoSection from '../SiteInfoSection.vue'
vi.mock('@/components/layout', async () => {
const { defineComponent, h } = await import('vue')
return {
CardSection: defineComponent({
name: 'CardSectionStub',
props: {
title: String,
description: String,
},
setup(props, { slots }) {
return () => h('section', [
h('h2', props.title),
h('p', props.description),
slots.actions?.(),
slots.default?.(),
])
},
}),
}
})
vi.mock('@/components/ui/button.vue', () => ({
default: defineComponent({
name: 'ButtonStub',
setup(_, { slots }) {
return () => h('button', slots.default?.())
},
}),
}))
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
function mountSection(onUpdateShowGithubLink = vi.fn()) {
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(SiteInfoSection, {
siteName: 'Aether',
siteSubtitle: 'AI Gateway',
showGithubLink: false,
loading: false,
hasChanges: true,
onSave: vi.fn(),
'onUpdate:siteName': vi.fn(),
'onUpdate:siteSubtitle': vi.fn(),
'onUpdate:showGithubLink': onUpdateShowGithubLink,
})
app.mount(root)
mountedApps.push({ app, root })
return { root, onUpdateShowGithubLink }
}
afterEach(() => {
for (const { app, root } of mountedApps.splice(0)) {
app.unmount()
root.remove()
}
document.body.innerHTML = ''
})
describe('SiteInfoSection', () => {
it('renders and emits the github link display switch', async () => {
const { root, onUpdateShowGithubLink } = mountSection()
await nextTick()
expect(root.textContent).toContain('GitHub 仓库入口')
const switchButton = root.querySelector('[role="switch"]') as HTMLButtonElement | null
expect(switchButton?.getAttribute('aria-checked')).toBe('false')
switchButton?.click()
expect(onUpdateShowGithubLink).toHaveBeenCalledWith(true)
})
})

View File

@@ -8,6 +8,7 @@ export interface SystemConfig {
// 站点信息
site_name: string
site_subtitle: string
show_github_link: boolean
// 网络代理
system_proxy_node_id: string | null
// 基础配置
@@ -49,6 +50,7 @@ const CONFIG_KEYS = [
// 站点信息
'site_name',
'site_subtitle',
'show_github_link',
// 网络代理
'system_proxy_node_id',
// 基础配置
@@ -91,6 +93,7 @@ function createDefaultConfig(): SystemConfig {
// 站点信息
site_name: 'Aether',
site_subtitle: 'AI Gateway',
show_github_link: true,
// 网络代理
system_proxy_node_id: null,
// 基础配置
@@ -149,7 +152,8 @@ export function useSystemConfig() {
if (!originalConfig.value) return false
return (
systemConfig.value.site_name !== originalConfig.value.site_name ||
systemConfig.value.site_subtitle !== originalConfig.value.site_subtitle
systemConfig.value.site_subtitle !== originalConfig.value.site_subtitle ||
systemConfig.value.show_github_link !== originalConfig.value.show_github_link
)
})
@@ -273,6 +277,11 @@ export function useSystemConfig() {
value: systemConfig.value.site_subtitle,
description: '站点副标题',
},
{
key: 'show_github_link',
value: systemConfig.value.show_github_link,
description: '是否显示 GitHub 仓库入口',
},
]
await Promise.all(
configItems.map((item) =>
@@ -282,6 +291,7 @@ export function useSystemConfig() {
if (originalConfig.value) {
originalConfig.value.site_name = systemConfig.value.site_name
originalConfig.value.site_subtitle = systemConfig.value.site_subtitle
originalConfig.value.show_github_link = systemConfig.value.show_github_link
}
await refreshSiteInfo()
success('站点信息已保存')

View File

@@ -75,6 +75,7 @@
/>
</button>
<a
v-if="showGithubLink"
href="https://github.com/fawney19/Aether"
target="_blank"
rel="noopener noreferrer"
@@ -89,7 +90,10 @@
<!-- Desktop layout (>= md): Centered nav with balanced spacing -->
<div class="h-16 hidden md:flex items-center justify-between px-8">
<!-- Left spacer for balance (matches right icons width) -->
<div class="w-[76px] shrink-0" />
<div
class="shrink-0"
:class="showGithubLink ? 'w-[76px]' : 'w-9'"
/>
<!-- Center: Logo + Nav + Login Button -->
<div class="flex items-center">
@@ -186,6 +190,7 @@
/>
</button>
<a
v-if="showGithubLink"
href="https://github.com/fawney19/Aether"
target="_blank"
rel="noopener noreferrer"
@@ -495,7 +500,7 @@ import {
const authStore = useAuthStore()
const { isDark, themeMode, toggleDarkMode } = useDarkMode()
const { copyToClipboard } = useClipboard()
const { siteName, siteSubtitle } = useSiteInfo()
const { siteName, siteSubtitle, showGithubLink } = useSiteInfo()
const dashboardPath = computed(() =>
authStore.canAccessAdmin ? '/admin/dashboard' : '/dashboard'

View File

@@ -261,6 +261,7 @@
/>
</button>
<a
v-if="showGithubLink"
href="https://github.com/fawney19/Aether"
target="_blank"
rel="noopener noreferrer"
@@ -340,7 +341,7 @@ import { guideNavItems } from './guide-config'
const route = useRoute()
const { themeMode, toggleDarkMode } = useDarkMode()
const { siteName, siteSubtitle } = useSiteInfo()
const { siteName, siteSubtitle, showGithubLink } = useSiteInfo()
const mobileMenuOpen = ref(false)
const baseUrl = ref(typeof window !== 'undefined' ? window.location.origin : 'https://your-aether.com')

View File

@@ -12,10 +12,12 @@ import {
Zap,
} from 'lucide-vue-next'
import { panelClasses } from './guide-config'
import { useSiteInfo } from '@/composables/useSiteInfo'
// 部署步骤数据
const activeDeployTab = ref(0)
const copiedStep = ref<string | null>(null)
const { showGithubLink } = useSiteInfo()
const productionSteps = [
{
@@ -397,6 +399,7 @@ function copyStep(stepId: string, code: string) {
<h3>1. Aether-Proxy</h3>
<p>Rust实现, 超小资源占有, 适合性能低的VPS直接使用。</p>
<a
v-if="showGithubLink"
href="https://github.com/fawney19/Aether/tree/main/aether-proxy"
target="_blank"
rel="noopener noreferrer"

View File

@@ -789,7 +789,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted, computed, onBeforeUnmount, nextTick, watch } from 'vue'
import { ref, onMounted, computed, onBeforeUnmount, nextTick, watch, markRaw } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { dashboardApi, type DashboardStat, type DailyStat, type ProviderSummary } from '@/api/dashboard'
import { getDateRangeFromPeriod } from '@/features/usage/composables'
@@ -1328,7 +1328,7 @@ async function loadDashboardData() {
})
stats.value = statsData.stats.map(stat => ({
...stat,
icon: iconMap[stat.icon] || Activity
icon: markRaw(iconMap[stat.icon] || Activity)
}))
if (statsData.today) todayStats.value = statsData.today
if (isAdmin.value) {

View File

@@ -0,0 +1,169 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, nextTick, type App } from 'vue'
import Dashboard from '../Dashboard.vue'
const dashboardApiMocks = vi.hoisted(() => ({
getStats: vi.fn(),
getDailyStats: vi.fn(),
}))
vi.mock('@/stores/auth', () => ({
useAuthStore: () => ({
canAccessAdmin: false,
isAdmin: false,
isAuditAdmin: false,
}),
}))
vi.mock('@/api/dashboard', () => ({
dashboardApi: dashboardApiMocks,
}))
vi.mock('@/api/announcements', () => ({
announcementApi: {
getAnnouncements: vi.fn().mockResolvedValue({ items: [] }),
markAsRead: vi.fn().mockResolvedValue({}),
},
}))
vi.mock('@/components/charts/BarChart.vue', async () => {
const { defineComponent, h } = await import('vue')
return { default: defineComponent({ name: 'BarChartStub', setup: () => () => h('div') }) }
})
vi.mock('@/components/charts/DoughnutChart.vue', async () => {
const { defineComponent, h } = await import('vue')
return { default: defineComponent({ name: 'DoughnutChartStub', setup: () => () => h('div') }) }
})
vi.mock('@/components/charts/LineChart.vue', async () => {
const { defineComponent, h } = await import('vue')
return { default: defineComponent({ name: 'LineChartStub', setup: () => () => h('div') }) }
})
vi.mock('@/components/common', async () => {
const { defineComponent, h } = await import('vue')
return {
TimeRangePicker: defineComponent({
name: 'TimeRangePickerStub',
setup() {
return () => h('div')
},
}),
}
})
vi.mock('@/components/ui', async () => {
const { defineComponent, h } = await import('vue')
const passthrough = (name: string, tag = 'div') => defineComponent({
name,
setup(_, { slots }) {
return () => h(tag, slots.default?.())
},
})
return {
Card: passthrough('CardStub', 'section'),
Badge: passthrough('BadgeStub', 'span'),
Button: passthrough('ButtonStub', 'button'),
Skeleton: defineComponent({ name: 'SkeletonStub', setup: () => () => h('div') }),
Dialog: passthrough('DialogStub'),
Table: passthrough('TableStub', 'table'),
TableHeader: passthrough('TableHeaderStub', 'thead'),
TableBody: passthrough('TableBodyStub', 'tbody'),
TableRow: passthrough('TableRowStub', 'tr'),
TableHead: passthrough('TableHeadStub', 'th'),
TableCell: passthrough('TableCellStub', 'td'),
}
})
vi.mock('lucide-vue-next', async () => {
const Icon = defineComponent({
name: 'IconStub',
setup() {
return () => h('span')
},
})
return {
Users: Icon,
Activity: Icon,
TrendingUp: Icon,
DollarSign: Icon,
Key: Icon,
Hash: Icon,
Zap: Icon,
Bell: Icon,
AlertCircle: Icon,
AlertTriangle: Icon,
Info: Icon,
Wrench: Icon,
Loader2: Icon,
Clock: Icon,
Database: Icon,
Shuffle: Icon,
}
})
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
function mountDashboard() {
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(Dashboard)
app.mount(root)
mountedApps.push({ app, root })
return root
}
async function settle() {
for (let index = 0; index < 8; index += 1) {
await Promise.resolve()
await nextTick()
}
}
beforeEach(() => {
dashboardApiMocks.getStats.mockReset()
dashboardApiMocks.getDailyStats.mockReset()
dashboardApiMocks.getDailyStats.mockResolvedValue({
daily_stats: [],
model_summary: [],
period: { start_date: '2026-05-01', end_date: '2026-05-15', days: 15 },
})
})
afterEach(() => {
for (const { app, root } of mountedApps.splice(0)) {
app.unmount()
root.remove()
}
document.body.innerHTML = ''
})
describe('Dashboard ordinary user wallet card', () => {
it('renders package and wallet balance split from mocked stats', async () => {
dashboardApiMocks.getStats.mockResolvedValue({
stats: [
{ name: 'API 密钥', value: '0', subValue: '活跃 0', icon: 'Activity' },
{ name: '本月请求', value: '0', subValue: '今日 0', icon: 'Users' },
{
name: '钱包余额',
value: '$110.00',
subValue: '套餐额度 $100.00 · 钱包余额 $10.00',
icon: 'DollarSign',
},
{ name: '本月 Token', value: '0', subValue: '输入 0 / 输出 0', icon: 'Zap' },
],
today: { requests: 0, tokens: 0, cost: 0 },
cache_stats: { cache_creation_tokens: 0, cache_read_tokens: 0, total_cache_tokens: 0 },
token_breakdown: { input: 0, output: 0, cache_creation: 0, cache_read: 0 },
monthly_cost: 0,
})
const root = mountDashboard()
await settle()
expect(root.textContent).toContain('$110.00')
expect(root.textContent).toContain('套餐额度 $100.00 · 钱包余额 $10.00')
})
})

View File

@@ -0,0 +1,69 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createApp, nextTick, type App } from 'vue'
import type { PublicGlobalModel } from '@/api/public-models'
import UserModelDetailDrawer from '../components/UserModelDetailDrawer.vue'
vi.mock('@/composables/useClipboard', () => ({
useClipboard: () => ({
copyToClipboard: vi.fn(),
}),
}))
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
function model(overrides: Partial<PublicGlobalModel> = {}): PublicGlobalModel {
return {
id: 'gm-test',
name: 'gpt-5',
display_name: 'GPT 5',
is_active: true,
default_tiered_pricing: null,
default_price_per_request: null,
supported_capabilities: ['chat'],
config: null,
usage_count: 0,
...overrides,
}
}
function mountDrawer(selectedModel: PublicGlobalModel) {
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(UserModelDetailDrawer, {
open: true,
model: selectedModel,
'onUpdate:open': vi.fn(),
})
app.mount(root)
mountedApps.push({ app, root })
return root
}
afterEach(() => {
for (const { app, root } of mountedApps.splice(0)) {
app.unmount()
root.remove()
}
document.body.innerHTML = ''
})
describe('user model catalog detail drawer', () => {
it('does not render model mapping fields for ordinary users', async () => {
mountDrawer(model({
config: {
description: 'User visible description',
model_mappings: ['gpt-5-upstream'],
provider_model_mappings: [{ name: 'provider-gpt-5' }],
},
}))
await nextTick()
const text = document.body.textContent || ''
expect(text).toContain('GPT 5')
expect(text).toContain('User visible description')
expect(text).not.toContain('模型映射')
expect(text).not.toContain('gpt-5-upstream')
expect(text).not.toContain('provider-gpt-5')
})
})