feat: 支持系统设置中自定义全站名称和副标题

新增 site_name 和 site_subtitle 两个系统配置项,允许管理员在后台自定义
站点品牌名称(默认 Aether)和副标题(默认 AI Gateway)。

- 后端:DEFAULT_CONFIGS 新增配置项,公开 /api/public/site-info 端点
- 后端:邮件发送 app_name 回退链增加 site_name
- 前端:新增 useSiteInfo composable 全局缓存站点信息
- 前端:首页、后台布局、登录页、指南页面全部改为动态读取
- 前端:系统设置页新增「站点信息」配置区块
- 前端:配置导出文件名跟随站点名称
- 优化:请求失败允许重试,保存后即时生效无需刷新页面
- 优化:document.title 随站点名称动态更新

Closes #176

Co-authored-by: LewisPen <LewisPen@nyadoo.com>
This commit is contained in:
fawney19
2026-02-13 21:50:05 +08:00
parent c171d65d3f
commit f464f32e48
14 changed files with 223 additions and 34 deletions

View File

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

View File

@@ -9,11 +9,11 @@
<div class="flex flex-col items-center text-center mb-8"> <div class="flex flex-col items-center text-center mb-8">
<img <img
src="/aether_adaptive.svg" src="/aether_adaptive.svg"
alt="Aether" :alt="siteName"
class="h-16 w-16 mb-4" class="h-16 w-16 mb-4"
> >
<h2 class="text-2xl font-semibold text-foreground"> <h2 class="text-2xl font-semibold text-foreground">
登录到 Aether 登录到 {{ siteName }}
</h2> </h2>
</div> </div>
@@ -236,6 +236,7 @@ import Input from '@/components/ui/input.vue'
import Label from '@/components/ui/label.vue' import Label from '@/components/ui/label.vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
import { useSiteInfo } from '@/composables/useSiteInfo'
import { isDemoMode, DEMO_ACCOUNTS } from '@/config/demo' import { isDemoMode, DEMO_ACCOUNTS } from '@/config/demo'
import RegisterDialog from './RegisterDialog.vue' import RegisterDialog from './RegisterDialog.vue'
import { authApi } from '@/api/auth' import { authApi } from '@/api/auth'
@@ -254,6 +255,7 @@ const emit = defineEmits<{
const router = useRouter() const router = useRouter()
const authStore = useAuthStore() const authStore = useAuthStore()
const { success: showSuccess, warning: showWarning, error: showError } = useToast() const { success: showSuccess, warning: showWarning, error: showError } = useToast()
const { siteName } = useSiteInfo()
const isOpen = ref(props.modelValue) const isOpen = ref(props.modelValue)
const isDemo = computed(() => isDemoMode()) const isDemo = computed(() => isDemoMode())

View File

@@ -41,9 +41,9 @@
/> />
<div class="flex flex-col justify-center"> <div class="flex flex-col justify-center">
<h1 class="text-lg font-bold text-[#191919] dark:text-white leading-none"> <h1 class="text-lg font-bold text-[#191919] dark:text-white leading-none">
Aether {{ siteName }}
</h1> </h1>
<span class="text-[10px] text-[#91918d] dark:text-muted-foreground leading-none mt-1.5 font-medium tracking-wide">Multi Private Gateway</span> <span class="text-[10px] text-[#91918d] dark:text-muted-foreground leading-none mt-1.5 font-medium tracking-wide">{{ siteSubtitle }}</span>
</div> </div>
</RouterLink> </RouterLink>
</div> </div>
@@ -105,9 +105,9 @@
/> />
<div class="flex flex-col justify-center"> <div class="flex flex-col justify-center">
<h1 class="text-lg font-bold text-[#191919] dark:text-white leading-none"> <h1 class="text-lg font-bold text-[#191919] dark:text-white leading-none">
Aether {{ siteName }}
</h1> </h1>
<span class="text-[10px] text-[#91918d] dark:text-muted-foreground leading-none mt-1.5 font-medium tracking-wide">Multi Private Gateway</span> <span class="text-[10px] text-[#91918d] dark:text-muted-foreground leading-none mt-1.5 font-medium tracking-wide">{{ siteSubtitle }}</span>
</div> </div>
</RouterLink> </RouterLink>
@@ -333,6 +333,7 @@ import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useModuleStore } from '@/stores/modules' import { useModuleStore } from '@/stores/modules'
import { useDarkMode } from '@/composables/useDarkMode' import { useDarkMode } from '@/composables/useDarkMode'
import { useSiteInfo } from '@/composables/useSiteInfo'
import { isDemoMode } from '@/config/demo' import { isDemoMode } from '@/config/demo'
import { adminApi, type CheckUpdateResponse } from '@/api/admin' import { adminApi, type CheckUpdateResponse } from '@/api/admin'
import Button from '@/components/ui/button.vue' import Button from '@/components/ui/button.vue'
@@ -378,6 +379,7 @@ const route = useRoute()
const authStore = useAuthStore() const authStore = useAuthStore()
const moduleStore = useModuleStore() const moduleStore = useModuleStore()
const { themeMode, toggleDarkMode } = useDarkMode() const { themeMode, toggleDarkMode } = useDarkMode()
const { siteName, siteSubtitle } = useSiteInfo()
const isDemo = computed(() => isDemoMode()) const isDemo = computed(() => isDemoMode())
const showAuthError = ref(false) const showAuthError = ref(false)

View File

@@ -6,6 +6,60 @@
/> />
<div class="mt-6 space-y-6"> <div class="mt-6 space-y-6">
<!-- 站点信息 -->
<CardSection
title="站点信息"
description="自定义站点名称和副标题,影响导航栏、登录页、指南页面和邮件等全站显示"
>
<template #actions>
<Button
size="sm"
:disabled="siteInfoLoading || !hasSiteInfoChanges"
@click="saveSiteInfo"
>
{{ siteInfoLoading ? '保存中...' : '保存' }}
</Button>
</template>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<Label
for="site-name"
class="block text-sm font-medium"
>
站点名称
</Label>
<Input
id="site-name"
v-model="systemConfig.site_name"
type="text"
placeholder="Aether"
class="mt-1"
/>
<p class="mt-1 text-xs text-muted-foreground">
显示在导航栏登录页标题和邮件中
</p>
</div>
<div>
<Label
for="site-subtitle"
class="block text-sm font-medium"
>
站点副标题
</Label>
<Input
id="site-subtitle"
v-model="systemConfig.site_subtitle"
type="text"
placeholder="AI Gateway"
class="mt-1"
/>
<p class="mt-1 text-xs text-muted-foreground">
显示在导航栏品牌名称下方
</p>
</div>
</div>
</CardSection>
<!-- 配置导出/导入 --> <!-- 配置导出/导入 -->
<CardSection <CardSection
title="配置管理" title="配置管理"
@@ -1055,11 +1109,16 @@ import { useToast } from '@/composables/useToast'
import { adminApi, type ConfigExportData, type ConfigImportResponse, type UsersExportData, type UsersImportResponse } from '@/api/admin' import { adminApi, type ConfigExportData, type ConfigImportResponse, type UsersExportData, type UsersImportResponse } from '@/api/admin'
import { log } from '@/utils/logger' import { log } from '@/utils/logger'
import { useProxyNodesStore } from '@/stores/proxy-nodes' import { useProxyNodesStore } from '@/stores/proxy-nodes'
import { useSiteInfo } from '@/composables/useSiteInfo'
const { success, error } = useToast() const { success, error } = useToast()
const { refreshSiteInfo } = useSiteInfo()
const proxyNodesStore = useProxyNodesStore() const proxyNodesStore = useProxyNodesStore()
interface SystemConfig { interface SystemConfig {
// 站点信息
site_name: string
site_subtitle: string
// 网络代理 // 网络代理
system_proxy_node_id: string | null system_proxy_node_id: string | null
// 基础配置 // 基础配置
@@ -1092,6 +1151,7 @@ interface SystemConfig {
enable_oauth_token_refresh: boolean enable_oauth_token_refresh: boolean
} }
const siteInfoLoading = ref(false)
const proxyConfigLoading = ref(false) const proxyConfigLoading = ref(false)
const basicConfigLoading = ref(false) const basicConfigLoading = ref(false)
const logConfigLoading = ref(false) const logConfigLoading = ref(false)
@@ -1123,6 +1183,9 @@ const usersMergeModeSelectOpen = ref(false)
const systemVersion = ref<string>('') const systemVersion = ref<string>('')
const systemConfig = ref<SystemConfig>({ const systemConfig = ref<SystemConfig>({
// 站点信息
site_name: 'Aether',
site_subtitle: 'AI Gateway',
// 网络代理 // 网络代理
system_proxy_node_id: null, system_proxy_node_id: null,
// 基础配置 // 基础配置
@@ -1159,6 +1222,14 @@ const systemConfig = ref<SystemConfig>({
const originalConfig = ref<SystemConfig | null>(null) const originalConfig = ref<SystemConfig | null>(null)
// 检测各模块是否有变动 // 检测各模块是否有变动
const hasSiteInfoChanges = computed(() => {
if (!originalConfig.value) return false
return (
systemConfig.value.site_name !== originalConfig.value.site_name ||
systemConfig.value.site_subtitle !== originalConfig.value.site_subtitle
)
})
const hasBasicConfigChanges = computed(() => { const hasBasicConfigChanges = computed(() => {
if (!originalConfig.value) return false if (!originalConfig.value) return false
return ( return (
@@ -1238,6 +1309,9 @@ async function loadSystemVersion() {
async function loadSystemConfig() { async function loadSystemConfig() {
try { try {
const configs = [ const configs = [
// 站点信息
'site_name',
'site_subtitle',
// 网络代理 // 网络代理
'system_proxy_node_id', 'system_proxy_node_id',
// 基础配置 // 基础配置
@@ -1298,6 +1372,32 @@ const hasProxyConfigChanges = computed(() => {
return systemConfig.value.system_proxy_node_id !== originalConfig.value.system_proxy_node_id return systemConfig.value.system_proxy_node_id !== originalConfig.value.system_proxy_node_id
}) })
async function saveSiteInfo() {
siteInfoLoading.value = true
try {
const configItems = [
{ key: 'site_name', value: systemConfig.value.site_name, description: '站点名称' },
{ key: 'site_subtitle', value: systemConfig.value.site_subtitle, description: '站点副标题' },
]
await Promise.all(
configItems.map(item =>
adminApi.updateSystemConfig(item.key, item.value, item.description)
)
)
if (originalConfig.value) {
originalConfig.value.site_name = systemConfig.value.site_name
originalConfig.value.site_subtitle = systemConfig.value.site_subtitle
}
await refreshSiteInfo()
success('站点信息已保存')
} catch (err) {
error('保存站点信息失败')
log.error('保存站点信息失败:', err)
} finally {
siteInfoLoading.value = false
}
}
async function saveProxyConfig() { async function saveProxyConfig() {
proxyConfigLoading.value = true proxyConfigLoading.value = true
try { try {
@@ -1755,7 +1855,7 @@ async function handleExportConfig() {
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
const a = document.createElement('a') const a = document.createElement('a')
a.href = url a.href = url
a.download = `aether-config-${new Date().toISOString().slice(0, 10)}.json` a.download = `${systemConfig.value.site_name.toLowerCase()}-config-${new Date().toISOString().slice(0, 10)}.json`
document.body.appendChild(a) document.body.appendChild(a)
a.click() a.click()
document.body.removeChild(a) document.body.removeChild(a)
@@ -1847,7 +1947,7 @@ async function handleExportUsers() {
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
const a = document.createElement('a') const a = document.createElement('a')
a.href = url a.href = url
a.download = `aether-users-${new Date().toISOString().slice(0, 10)}.json` a.download = `${systemConfig.value.site_name.toLowerCase()}-users-${new Date().toISOString().slice(0, 10)}.json`
document.body.appendChild(a) document.body.appendChild(a)
a.click() a.click()
document.body.removeChild(a) document.body.removeChild(a)

View File

@@ -34,9 +34,9 @@
/> />
<div class="flex flex-col justify-center"> <div class="flex flex-col justify-center">
<h1 class="text-base sm:text-lg font-bold text-[#191919] dark:text-white leading-none"> <h1 class="text-base sm:text-lg font-bold text-[#191919] dark:text-white leading-none">
Aether {{ siteName }}
</h1> </h1>
<span class="text-[9px] sm:text-[10px] text-[#91918d] dark:text-muted-foreground leading-none mt-1 sm:mt-1.5 font-medium tracking-wide">AI Gateway</span> <span class="text-[9px] sm:text-[10px] text-[#91918d] dark:text-muted-foreground leading-none mt-1 sm:mt-1.5 font-medium tracking-wide">{{ siteSubtitle }}</span>
</div> </div>
</div> </div>
@@ -104,9 +104,9 @@
/> />
<div class="flex flex-col justify-center"> <div class="flex flex-col justify-center">
<h1 class="text-lg font-bold text-[#191919] dark:text-white leading-none"> <h1 class="text-lg font-bold text-[#191919] dark:text-white leading-none">
Aether {{ siteName }}
</h1> </h1>
<span class="text-[10px] text-[#91918d] dark:text-muted-foreground leading-none mt-1.5 font-medium tracking-wide">AI Gateway</span> <span class="text-[10px] text-[#91918d] dark:text-muted-foreground leading-none mt-1.5 font-medium tracking-wide">{{ siteSubtitle }}</span>
</div> </div>
</div> </div>
@@ -458,6 +458,7 @@ import GithubIcon from '@/components/icons/GithubIcon.vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useDarkMode } from '@/composables/useDarkMode' import { useDarkMode } from '@/composables/useDarkMode'
import { useClipboard } from '@/composables/useClipboard' import { useClipboard } from '@/composables/useClipboard'
import { useSiteInfo } from '@/composables/useSiteInfo'
import LoginDialog from '@/features/auth/components/LoginDialog.vue' import LoginDialog from '@/features/auth/components/LoginDialog.vue'
import RippleLogo from '@/components/RippleLogo.vue' import RippleLogo from '@/components/RippleLogo.vue'
import HeaderLogo from '@/components/HeaderLogo.vue' import HeaderLogo from '@/components/HeaderLogo.vue'
@@ -483,6 +484,7 @@ import {
const authStore = useAuthStore() const authStore = useAuthStore()
const { isDark, themeMode, toggleDarkMode } = useDarkMode() const { isDark, themeMode, toggleDarkMode } = useDarkMode()
const { copyToClipboard } = useClipboard() const { copyToClipboard } = useClipboard()
const { siteName, siteSubtitle } = useSiteInfo()
const dashboardPath = computed(() => const dashboardPath = computed(() =>
authStore.user?.role === 'admin' ? '/admin/dashboard' : '/dashboard' authStore.user?.role === 'admin' ? '/admin/dashboard' : '/dashboard'
@@ -566,10 +568,10 @@ const { claudeConfig, codexConfig, codexAuthConfig, geminiEnvConfig, geminiSetti
// Dialog state // Dialog state
const showLoginDialog = ref(false) const showLoginDialog = ref(false)
// Typewriter effect for "Aether" // Typewriter effect for site name
const aetherText = ref('') const aetherText = ref('')
const showCursor = ref(true) const showCursor = ref(true)
const typewriterFullText = 'Aether' const typewriterFullText = computed(() => siteName.value)
let typewriterTimer: ReturnType<typeof setTimeout> | null = null let typewriterTimer: ReturnType<typeof setTimeout> | null = null
const hasTypewriterStarted = ref(false) const hasTypewriterStarted = ref(false)
@@ -578,19 +580,20 @@ const startTypewriter = () => {
hasTypewriterStarted.value = true hasTypewriterStarted.value = true
aetherText.value = '' aetherText.value = ''
showCursor.value = true showCursor.value = true
const typeSpeed = 200 const typeSpeed = 200
const deleteSpeed = 120 const deleteSpeed = 120
const pauseAfterType = 3500 const pauseAfterType = 3500
const pauseAfterDelete = 1000 const pauseAfterDelete = 1000
const typeLoop = () => { const typeLoop = () => {
let index = 0 let index = 0
const fullText = typewriterFullText.value
// Type phase // Type phase
const typeNextChar = () => { const typeNextChar = () => {
if (index < typewriterFullText.length) { if (index < fullText.length) {
aetherText.value = typewriterFullText.slice(0, index + 1) aetherText.value = fullText.slice(0, index + 1)
index++ index++
typewriterTimer = setTimeout(typeNextChar, typeSpeed) typewriterTimer = setTimeout(typeNextChar, typeSpeed)
} else { } else {
@@ -598,7 +601,7 @@ const startTypewriter = () => {
typewriterTimer = setTimeout(deleteChars, pauseAfterType) typewriterTimer = setTimeout(deleteChars, pauseAfterType)
} }
} }
// Delete phase // Delete phase
const deleteChars = () => { const deleteChars = () => {
if (aetherText.value.length > 0) { if (aetherText.value.length > 0) {
@@ -609,7 +612,7 @@ const startTypewriter = () => {
typewriterTimer = setTimeout(typeLoop, pauseAfterDelete) typewriterTimer = setTimeout(typeLoop, pauseAfterDelete)
} }
} }
typeNextChar() typeNextChar()
} }

View File

@@ -2,6 +2,9 @@
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import { ArrowRight, Shuffle, FileCode, Globe, Shield, Check, Info, AlertTriangle, Settings } from 'lucide-vue-next' import { ArrowRight, Shuffle, FileCode, Globe, Shield, Check, Info, AlertTriangle, Settings } from 'lucide-vue-next'
import { panelClasses } from './guide-config' import { panelClasses } from './guide-config'
import { useSiteInfo } from '@/composables/useSiteInfo'
const { siteName } = useSiteInfo()
withDefaults( withDefaults(
defineProps<{ defineProps<{
@@ -162,7 +165,7 @@ const systemSettings = [
高级功能 高级功能
</h1> </h1>
<p class="text-lg text-[#666663] dark:text-[#a3a094]"> <p class="text-lg text-[#666663] dark:text-[#a3a094]">
Aether 提供了一些高级功能帮助你实现更灵活的 API 管理 {{ siteName }} 提供了一些高级功能帮助你实现更灵活的 API 管理
</p> </p>
</div> </div>

View File

@@ -2,6 +2,9 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { Search, ChevronDown, ExternalLink, HelpCircle } from 'lucide-vue-next' import { Search, ChevronDown, ExternalLink, HelpCircle } from 'lucide-vue-next'
import { faqItems, panelClasses } from './guide-config' import { faqItems, panelClasses } from './guide-config'
import { useSiteInfo } from '@/composables/useSiteInfo'
const { siteName } = useSiteInfo()
withDefaults( withDefaults(
defineProps<{ defineProps<{
@@ -70,7 +73,7 @@ function toggleAll() {
常见问题 常见问题
</h1> </h1>
<p class="text-lg text-[#666663] dark:text-[#a3a094]"> <p class="text-lg text-[#666663] dark:text-[#a3a094]">
关于 Aether 使用和配置的常见问题解答 关于 {{ siteName }} 使用和配置的常见问题解答
</p> </p>
</div> </div>

View File

@@ -14,9 +14,9 @@
/> />
<div class="flex flex-col justify-center"> <div class="flex flex-col justify-center">
<h1 class="text-base sm:text-lg font-bold text-[#191919] dark:text-white leading-none"> <h1 class="text-base sm:text-lg font-bold text-[#191919] dark:text-white leading-none">
Aether {{ siteName }}
</h1> </h1>
<span class="text-[9px] sm:text-[10px] text-[#91918d] dark:text-muted-foreground leading-none mt-1 sm:mt-1.5 font-medium tracking-wide">AI Gateway</span> <span class="text-[9px] sm:text-[10px] text-[#91918d] dark:text-muted-foreground leading-none mt-1 sm:mt-1.5 font-medium tracking-wide">{{ siteSubtitle }}</span>
</div> </div>
</RouterLink> </RouterLink>
@@ -157,7 +157,7 @@
<!-- Base URL Input --> <!-- Base URL Input -->
<div class="p-4 border-t border-[#e5e4df] dark:border-[rgba(227,224,211,0.12)]"> <div class="p-4 border-t border-[#e5e4df] dark:border-[rgba(227,224,211,0.12)]">
<label class="block text-xs font-medium text-[#666663] dark:text-muted-foreground mb-2"> <label class="block text-xs font-medium text-[#666663] dark:text-muted-foreground mb-2">
Aether Base URL {{ siteName }} Base URL
</label> </label>
<input <input
v-model="baseUrl" v-model="baseUrl"
@@ -200,10 +200,12 @@ import {
import GithubIcon from '@/components/icons/GithubIcon.vue' import GithubIcon from '@/components/icons/GithubIcon.vue'
import HeaderLogo from '@/components/HeaderLogo.vue' import HeaderLogo from '@/components/HeaderLogo.vue'
import { useDarkMode } from '@/composables/useDarkMode' import { useDarkMode } from '@/composables/useDarkMode'
import { useSiteInfo } from '@/composables/useSiteInfo'
import { guideNavItems } from './guide-config' import { guideNavItems } from './guide-config'
const route = useRoute() const route = useRoute()
const { themeMode, toggleDarkMode } = useDarkMode() const { themeMode, toggleDarkMode } = useDarkMode()
const { siteName, siteSubtitle } = useSiteInfo()
const showMobileNav = ref(false) const showMobileNav = ref(false)
const baseUrl = ref(typeof window !== 'undefined' ? window.location.origin : 'https://your-aether.com') const baseUrl = ref(typeof window !== 'undefined' ? window.location.origin : 'https://your-aether.com')

View File

@@ -2,6 +2,9 @@
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import { ArrowRight, Server, Layers, Key, Box, ChevronRight } from 'lucide-vue-next' import { ArrowRight, Server, Layers, Key, Box, ChevronRight } from 'lucide-vue-next'
import { coreConcepts, apiFormats, configSteps, panelClasses } from './guide-config' import { coreConcepts, apiFormats, configSteps, panelClasses } from './guide-config'
import { useSiteInfo } from '@/composables/useSiteInfo'
const { siteName } = useSiteInfo()
withDefaults( withDefaults(
defineProps<{ defineProps<{
@@ -41,10 +44,10 @@ const conceptIconColors = {
<!-- 标题区域 --> <!-- 标题区域 -->
<div class="space-y-4"> <div class="space-y-4">
<h1 class="text-3xl font-bold text-[#262624] dark:text-[#f1ead8]"> <h1 class="text-3xl font-bold text-[#262624] dark:text-[#f1ead8]">
欢迎使用 Aether 欢迎使用 {{ siteName }}
</h1> </h1>
<p class="text-lg text-[#666663] dark:text-[#a3a094]"> <p class="text-lg text-[#666663] dark:text-[#a3a094]">
Aether 是一个 AI API 网关帮助你统一管理多个 AI 服务供应商实现负载均衡访问控制和用量统计 {{ siteName }} 是一个 AI API 网关帮助你统一管理多个 AI 服务供应商实现负载均衡访问控制和用量统计
</p> </p>
</div> </div>
@@ -54,7 +57,7 @@ const conceptIconColors = {
核心概念 核心概念
</h2> </h2>
<p class="text-[#666663] dark:text-[#a3a094]"> <p class="text-[#666663] dark:text-[#a3a094]">
理解这四个核心概念是配置 Aether 的基础 理解这四个核心概念是配置 {{ siteName }} 的基础
</p> </p>
<div class="grid gap-4 sm:grid-cols-2"> <div class="grid gap-4 sm:grid-cols-2">
@@ -161,7 +164,7 @@ const conceptIconColors = {
支持的 API 格式 支持的 API 格式
</h2> </h2>
<p class="text-[#666663] dark:text-[#a3a094]"> <p class="text-[#666663] dark:text-[#a3a094]">
Aether 支持多种 API 格式可以作为不同客户端的统一入口 {{ siteName }} 支持多种 API 格式可以作为不同客户端的统一入口
</p> </p>
<div <div

View File

@@ -2,6 +2,9 @@
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import { ArrowRight, Server, Settings, Check, AlertTriangle, Info } from 'lucide-vue-next' import { ArrowRight, Server, Settings, Check, AlertTriangle, Info } from 'lucide-vue-next'
import { apiFormats, panelClasses } from './guide-config' import { apiFormats, panelClasses } from './guide-config'
import { useSiteInfo } from '@/composables/useSiteInfo'
const { siteName } = useSiteInfo()
withDefaults( withDefaults(
defineProps<{ defineProps<{
@@ -73,7 +76,7 @@ const providerExamples = [
供应商管理 供应商管理
</h1> </h1>
<p class="text-lg text-[#666663] dark:text-[#a3a094]"> <p class="text-lg text-[#666663] dark:text-[#a3a094]">
供应商和端点是 Aether 的基础配置决定了系统可以调用哪些 AI 服务 供应商和端点是 {{ siteName }} 的基础配置决定了系统可以调用哪些 AI 服务
</p> </p>
</div> </div>

View File

@@ -2,6 +2,9 @@
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import { ArrowRight, Users, Key, Shield, Check, Info, AlertTriangle, Clock } from 'lucide-vue-next' import { ArrowRight, Users, Key, Shield, Check, Info, AlertTriangle, Clock } from 'lucide-vue-next'
import { panelClasses } from './guide-config' import { panelClasses } from './guide-config'
import { useSiteInfo } from '@/composables/useSiteInfo'
const { siteName } = useSiteInfo()
withDefaults( withDefaults(
defineProps<{ defineProps<{
@@ -46,7 +49,7 @@ const roleComparison = [
用户与密钥 用户与密钥
</h1> </h1>
<p class="text-lg text-[#666663] dark:text-[#a3a094]"> <p class="text-lg text-[#666663] dark:text-[#a3a094]">
用户和 API Key Aether 的访问控制核心通过用户管理分配角色权限通过 Key 管理控制 API 访问 用户和 API Key {{ siteName }} 的访问控制核心通过用户管理分配角色权限通过 Key 管理控制 API 访问
</p> </p>
</div> </div>
@@ -192,7 +195,7 @@ const roleComparison = [
<ul class="space-y-3 text-sm text-[#666663] dark:text-[#a3a094]"> <ul class="space-y-3 text-sm text-[#666663] dark:text-[#a3a094]">
<li class="flex items-start gap-2"> <li class="flex items-start gap-2">
<Check class="h-4 w-4 text-green-500 mt-0.5 flex-shrink-0" /> <Check class="h-4 w-4 text-green-500 mt-0.5 flex-shrink-0" />
<span>API Key 是用户调用 Aether API 的凭证</span> <span>API Key 是用户调用 {{ siteName }} API 的凭证</span>
</li> </li>
<li class="flex items-start gap-2"> <li class="flex items-start gap-2">
<Check class="h-4 w-4 text-green-500 mt-0.5 flex-shrink-0" /> <Check class="h-4 w-4 text-green-500 mt-0.5 flex-shrink-0" />

View File

@@ -39,11 +39,21 @@ from src.models.endpoint_models import (
PublicHealthEvent, PublicHealthEvent,
) )
from src.services.health.endpoint import EndpointHealthService from src.services.health.endpoint import EndpointHealthService
from src.services.system.config import SystemConfigService
router = APIRouter(prefix="/api/public", tags=["System Catalog"]) router = APIRouter(prefix="/api/public", tags=["System Catalog"])
pipeline = ApiRequestPipeline() pipeline = ApiRequestPipeline()
@router.get("/site-info")
def get_site_info(db: Session = Depends(get_db)) -> dict[str, str]:
"""获取站点基本信息(公开接口,无需认证)"""
return {
"site_name": SystemConfigService.get_config(db, "site_name", default="Aether"),
"site_subtitle": SystemConfigService.get_config(db, "site_subtitle", default="AI Gateway"),
}
@router.get("/providers", response_model=list[PublicProviderResponse]) @router.get("/providers", response_model=list[PublicProviderResponse])
async def get_public_providers( async def get_public_providers(
request: Request, request: Request,

View File

@@ -130,8 +130,10 @@ class EmailSenderService:
return False, error return False, error
# 生成邮件内容 # 生成邮件内容
# 优先使用 email_app_name否则回退到 smtp_from_name # 优先使用 email_app_name否则回退到 site_name最后回退到 smtp_from_name
app_name = SystemConfigService.get_config(db, "email_app_name", default=None) app_name = SystemConfigService.get_config(db, "email_app_name", default=None)
if not app_name:
app_name = SystemConfigService.get_config(db, "site_name", default=None)
if not app_name: if not app_name:
app_name = SystemConfigService.get_config(db, "smtp_from_name", default="Aether") app_name = SystemConfigService.get_config(db, "smtp_from_name", default="Aether")

View File

@@ -85,6 +85,15 @@ class SystemConfigService:
# 默认配置 # 默认配置
DEFAULT_CONFIGS = { DEFAULT_CONFIGS = {
# 站点信息
"site_name": {
"value": "Aether",
"description": "站点名称,显示在页面标题、导航栏、登录页面和邮件中",
},
"site_subtitle": {
"value": "AI Gateway",
"description": "站点副标题,显示在导航栏品牌名称下方",
},
REQUEST_RECORD_LEVEL_KEY: { REQUEST_RECORD_LEVEL_KEY: {
"value": RequestRecordLevel.BASIC.value, "value": RequestRecordLevel.BASIC.value,
"description": "请求记录级别basic(基本信息), headers(含请求/响应头), full(完整请求/响应)", "description": "请求记录级别basic(基本信息), headers(含请求/响应头), full(完整请求/响应)",