mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
fix(public): 修复用户可见性、额度、验证与 Codex 探测
This commit is contained in:
@@ -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"
|
||||
/>
|
||||
|
||||
<!-- 配置导出/导入 -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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('站点信息已保存')
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
169
frontend/src/views/shared/__tests__/Dashboard.spec.ts
Normal file
169
frontend/src/views/shared/__tests__/Dashboard.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
69
frontend/src/views/user/__tests__/ModelCatalog.spec.ts
Normal file
69
frontend/src/views/user/__tests__/ModelCatalog.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user