Procházet zdrojové kódy

feat: 支持 LOGO_FILE_PATH 环境变量指定本地 Logo,优化定价与语言设置

- 新增 LOGO_FILE_PATH 环境变量,优先级高于数据库配置,支持本地文件服务
- 渠道定价高级字段(缓存/图片/音频)不再回退全局默认值,未设置直接返回 0
- 缓存价格单位从表头移到具体价格值,新增渠道 ID 复制功能
- 修复默认语言在用户已有偏好时仍被覆盖的问题
- img 标签统一添加 referrerPolicy/crossOrigin 防止跨域问题
- 新增缓存倍率和最小余额阈值的详细说明文本

Co-Authored-By: Claude <noreply@anthropic.com>
master
fengsilin před 2 týdny
rodič
revize
93e331b624
19 změnil soubory, kde provedl 101 přidání a 59 odebrání
  1. +8
    -0
      common/constants.go
  2. +1
    -1
      controller/misc.go
  3. +10
    -0
      main.go
  4. +7
    -12
      model/channel_pricing.go
  5. +8
    -0
      router/web-router.go
  6. +3
    -3
      web/src/components/auth/LoginForm.jsx
  7. +1
    -1
      web/src/components/auth/PasswordResetConfirm.jsx
  8. +1
    -1
      web/src/components/auth/PasswordResetForm.jsx
  9. +3
    -3
      web/src/components/auth/RegisterForm.jsx
  10. +2
    -0
      web/src/components/layout/Footer.jsx
  11. +3
    -2
      web/src/components/layout/PageLayout.jsx
  12. +2
    -0
      web/src/components/layout/headerbar/HeaderLogo.jsx
  13. +28
    -12
      web/src/components/table/model-pricing/modal/components/ChannelPricingCard.jsx
  14. +1
    -6
      web/src/context/User/index.jsx
  15. +14
    -14
      web/src/hooks/common/useHeaderBar.js
  16. +3
    -1
      web/src/i18n/locales/en.json
  17. +3
    -1
      web/src/i18n/locales/zh-CN.json
  18. +1
    -0
      web/src/pages/Setting/Operation/SettingsRegionSync.jsx
  19. +2
    -2
      web/src/pages/Setting/Ratio/ChannelPricingView.jsx

+ 8
- 0
common/constants.go Zobrazit soubor

@@ -15,6 +15,14 @@ var Version = "v0.0.0" // this hard coding will be replaced automatic
var SystemName = "New API"
var Footer = ""
var Logo = ""
var LogoFilePath = "" // LOGO_FILE_PATH 环境变量指定的本地 Logo 文件路径

func GetEffectiveLogo() string {
if LogoFilePath != "" {
return "/logo.png"
}
return Logo
}
var TopUpLink = ""
var DefaultLanguage = "" // admin-configured default language; empty = follow browser detection



+ 1
- 1
controller/misc.go Zobrazit soubor

@@ -61,7 +61,7 @@ func GetStatus(c *gin.Context) {
"telegram_oauth": common.TelegramOAuthEnabled,
"telegram_bot_name": common.TelegramBotName,
"system_name": common.SystemName,
"logo": common.Logo,
"logo": common.GetEffectiveLogo(),
"footer_html": common.Footer,
"wechat_qrcode": common.WeChatAccountQRCodeImageURL,
"wechat_login": common.WeChatAuthEnabled,


+ 10
- 0
main.go Zobrazit soubor

@@ -137,6 +137,16 @@ func main() {
model.InitBatchUpdater()
}

logoFilePath := os.Getenv("LOGO_FILE_PATH")
if logoFilePath != "" {
if _, err := os.Stat(logoFilePath); err != nil {
common.SysLog("LOGO_FILE_PATH file not found: " + logoFilePath + ", falling back to default")
} else {
common.LogoFilePath = logoFilePath
common.SysLog("custom logo file: " + common.LogoFilePath)
}
}

if os.Getenv("ENABLE_PPROF") == "true" {
gopool.Go(func() {
log.Println(http.ListenAndServe("0.0.0.0:8005", nil))


+ 7
- 12
model/channel_pricing.go Zobrazit soubor

@@ -274,13 +274,8 @@ func GetChannelPricingByModelWithChannelInfo(modelName string) ([]*ChannelPricin
if !hasPrice {
globalModelPrice = 0
}
// 获取全局扩展比率(用于 CASE WHEN 回退)
globalCacheRatio, _ := ratio_setting.GetCacheRatio(modelName)
globalCacheCreationRatio, _ := ratio_setting.GetCreateCacheRatio(modelName)
globalImageRatio, _ := ratio_setting.GetImageRatio(modelName)
globalAudioRatio := ratio_setting.GetAudioRatio(modelName)
globalAudioCompletionRatio := ratio_setting.GetAudioCompletionRatio(modelName)
// 查询所有支持该模型的渠道,左连接渠道定价表
// 高级字段(cache/image/audio)不回退全局值,直接返回 0
err := DB.Table("abilities").
Select(`abilities.channel_id, channels.name as channel_name, channels.type as channel_type,
COALESCE(channel_pricings.quota_type, ?) as quota_type,
@@ -289,13 +284,13 @@ func GetChannelPricingByModelWithChannelInfo(modelName string) ([]*ChannelPricin
COALESCE(channel_pricings.model_price, ?) as model_price,
channel_pricings.id as id,
channel_pricings.tag_ids as tag_ids,
CASE WHEN channel_pricings.cache_ratio > 0 THEN channel_pricings.cache_ratio ELSE ? END as cache_ratio,
CASE WHEN channel_pricings.cache_creation_ratio > 0 THEN channel_pricings.cache_creation_ratio ELSE ? END as cache_creation_ratio,
CASE WHEN channel_pricings.image_ratio > 0 THEN channel_pricings.image_ratio ELSE ? END as image_ratio,
CASE WHEN channel_pricings.audio_ratio > 0 THEN channel_pricings.audio_ratio ELSE ? END as audio_ratio,
CASE WHEN channel_pricings.audio_completion_ratio > 0 THEN channel_pricings.audio_completion_ratio ELSE ? END as audio_completion_ratio,
COALESCE(channel_pricings.cache_ratio, 0) as cache_ratio,
COALESCE(channel_pricings.cache_creation_ratio, 0) as cache_creation_ratio,
COALESCE(channel_pricings.image_ratio, 0) as image_ratio,
COALESCE(channel_pricings.audio_ratio, 0) as audio_ratio,
COALESCE(channel_pricings.audio_completion_ratio, 0) as audio_completion_ratio,
(channel_pricings.id IS NOT NULL) as has_custom_pricing`,
defaultQuotaType, globalModelRatio, globalCompletionRatio, globalModelPrice, globalCacheRatio, globalCacheCreationRatio, globalImageRatio, globalAudioRatio, globalAudioCompletionRatio).
defaultQuotaType, globalModelRatio, globalCompletionRatio, globalModelPrice).
Joins("LEFT JOIN channels ON abilities.channel_id = channels.id").
Joins("LEFT JOIN channel_pricings ON abilities.channel_id = channel_pricings.channel_id AND channel_pricings.model_name = ? AND channel_pricings.deleted_at IS NULL", modelName).
Where("abilities.model = ?", modelName).


+ 8
- 0
router/web-router.go Zobrazit soubor

@@ -14,6 +14,14 @@ import (
)

func SetWebRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) {
// LOGO_FILE_PATH 优先级最高:注册 /logo.png 路由提供磁盘文件
// 必须在 static.Serve("/") 之前注册,否则会被 embed 静态文件拦截
if common.LogoFilePath != "" {
router.GET("/logo.png", func(c *gin.Context) {
c.File(common.LogoFilePath)
})
}

router.Use(gzip.Gzip(gzip.DefaultCompression))
router.Use(middleware.GlobalWebRateLimit())
router.Use(middleware.Cache())


+ 3
- 3
web/src/components/auth/LoginForm.jsx Zobrazit soubor

@@ -505,7 +505,7 @@ const LoginForm = () => {
<div className='flex flex-col items-center'>
<div className='w-full max-w-md'>
<div className='flex items-center justify-center mb-6 gap-2'>
<img src={logo} alt='Logo' className='h-10 rounded-full' />
<img src={logo} alt='Logo' referrerPolicy='no-referrer' crossOrigin='anonymous' className='h-10 rounded-full' />
<Title heading={3} className='!text-gray-800'>
{systemName}
</Title>
@@ -721,7 +721,7 @@ const LoginForm = () => {
<div className='flex flex-col items-center'>
<div className='w-full max-w-md'>
<div className='flex items-center justify-center mb-6 gap-2'>
<img src={logo} alt='Logo' className='h-10 rounded-full' />
<img src={logo} alt='Logo' referrerPolicy='no-referrer' crossOrigin='anonymous' className='h-10 rounded-full' />
<Title heading={3}>{systemName}</Title>
</div>

@@ -885,7 +885,7 @@ const LoginForm = () => {
}}
>
<div className='flex flex-col items-center'>
<img src={status.wechat_qrcode} alt={t('微信二维码')} className='mb-4' />
<img src={status.wechat_qrcode} alt={t('微信二维码')} referrerPolicy='no-referrer' crossOrigin='anonymous' className='mb-4' />
</div>

<div className='text-center mb-4'>


+ 1
- 1
web/src/components/auth/PasswordResetConfirm.jsx Zobrazit soubor

@@ -118,7 +118,7 @@ const PasswordResetConfirm = () => {
<div className='flex flex-col items-center'>
<div className='w-full max-w-md'>
<div className='flex items-center justify-center mb-6 gap-2'>
<img src={logo} alt='Logo' className='h-10 rounded-full' />
<img src={logo} alt='Logo' referrerPolicy='no-referrer' crossOrigin='anonymous' className='h-10 rounded-full' />
<Title heading={3} className='!text-gray-800'>
{systemName}
</Title>


+ 1
- 1
web/src/components/auth/PasswordResetForm.jsx Zobrazit soubor

@@ -118,7 +118,7 @@ const PasswordResetForm = () => {
<div className='flex flex-col items-center'>
<div className='w-full max-w-md'>
<div className='flex items-center justify-center mb-6 gap-2'>
<img src={logo} alt='Logo' className='h-10 rounded-full' />
<img src={logo} alt='Logo' referrerPolicy='no-referrer' crossOrigin='anonymous' className='h-10 rounded-full' />
<Title heading={3} className='!text-gray-800'>
{systemName}
</Title>


+ 3
- 3
web/src/components/auth/RegisterForm.jsx Zobrazit soubor

@@ -396,7 +396,7 @@ const RegisterForm = () => {
<div className='flex flex-col items-center'>
<div className='w-full max-w-md'>
<div className='flex items-center justify-center mb-6 gap-2'>
<img src={logo} alt='Logo' className='h-10 rounded-full' />
<img src={logo} alt='Logo' referrerPolicy='no-referrer' crossOrigin='anonymous' className='h-10 rounded-full' />
<Title heading={3} className='!text-gray-800'>
{systemName}
</Title>
@@ -559,7 +559,7 @@ const RegisterForm = () => {
<div className='flex flex-col items-center'>
<div className='w-full max-w-md'>
<div className='flex items-center justify-center mb-6 gap-2'>
<img src={logo} alt='Logo' className='h-10 rounded-full' />
<img src={logo} alt='Logo' referrerPolicy='no-referrer' crossOrigin='anonymous' className='h-10 rounded-full' />
<Title heading={3} className='!text-gray-800'>
{systemName}
</Title>
@@ -745,7 +745,7 @@ const RegisterForm = () => {
}}
>
<div className='flex flex-col items-center'>
<img src={status.wechat_qrcode} alt={t('微信二维码')} className='mb-4' />
<img src={status.wechat_qrcode} alt={t('微信二维码')} referrerPolicy='no-referrer' crossOrigin='anonymous' className='mb-4' />
</div>

<div className='text-center mb-4'>


+ 2
- 0
web/src/components/layout/Footer.jsx Zobrazit soubor

@@ -52,6 +52,8 @@ const FooterBar = () => {
<img
src={logo}
alt={systemName}
referrerPolicy='no-referrer'
crossOrigin='anonymous'
className='w-16 h-16 rounded-full bg-gray-800 p-1.5 object-contain'
/>
</div>


+ 3
- 2
web/src/components/layout/PageLayout.jsx Zobrazit soubor

@@ -91,8 +91,9 @@ const PageLayout = () => {
if (success) {
statusDispatch({ type: 'set', payload: data });
setStatusData(data);
// Apply admin-configured default language
if (data.default_language) {
// Apply admin-configured default language only if user has no preference
const savedLang = localStorage.getItem('i18nextLng');
if (data.default_language && !savedLang) {
i18n.changeLanguage(data.default_language);
}
} else {


+ 2
- 0
web/src/components/layout/headerbar/HeaderLogo.jsx Zobrazit soubor

@@ -44,6 +44,8 @@ const HeaderLogo = ({
<img
src={logo}
alt='logo'
referrerPolicy='no-referrer'
crossOrigin='anonymous'
className={`absolute inset-0 w-full h-full transition-all duration-200 group-hover:scale-110 rounded-full ${!isLoading && logoLoaded ? 'opacity-100' : 'opacity-0'}`}
/>
</div>


+ 28
- 12
web/src/components/table/model-pricing/modal/components/ChannelPricingCard.jsx Zobrazit soubor

@@ -18,9 +18,9 @@ For commercial licensing, please contact support@quantumnous.com
*/

import React, { useState, useEffect, useMemo } from 'react';
import { Card, Avatar, Typography, Table, Tag, Spin, Banner } from '@douyinfe/semi-ui';
import { IconServer } from '@douyinfe/semi-icons';
import { API } from '../../../../../helpers';
import { Card, Avatar, Typography, Table, Tag, Spin, Banner, Tooltip } from '@douyinfe/semi-ui';
import { IconServer, IconCopy } from '@douyinfe/semi-icons';
import { API, copy, showSuccess } from '../../../../../helpers';

const { Text } = Typography;

@@ -128,9 +128,20 @@ const ChannelPricingCard = ({
dataIndex: 'channelName',
render: (text, record) => (
<div className='flex items-center gap-2 flex-wrap'>
<Tag color='grey' size='small' shape='circle'>
ID: {record.channelId}
</Tag>
<Tooltip content={t('点击复制渠道 ID')}>
<Tag
color='grey'
size='small'
shape='circle'
style={{ cursor: 'pointer' }}
onClick={async () => {
const ok = await copy(String(record.channelId));
if (ok) showSuccess(t('已复制到剪切板'));
}}
>
ID: {record.channelId} <IconCopy size='tiny' style={{ marginLeft: 2, verticalAlign: 'middle' }} />
</Tag>
</Tooltip>
<Tag color='cyan' size='small' shape='circle'>
{text}
</Tag>
@@ -213,23 +224,28 @@ const ChannelPricingCard = ({
);

const renderCachePrice = (v, record) => {
if (record.quotaType !== 0 || v <= 0) return '-';
if (record.quotaType !== 0) return '-';
return (
<div className='font-semibold text-orange-600'>
{formatPrice(record.modelRatio * v * 2)}
</div>
<>
<div className='font-semibold text-orange-600'>
{formatPrice(record.modelRatio * v * 2)}
</div>
<div className='text-xs text-gray-500'>
/ {tokenUnit === 'K' ? '1K' : '1M'} tokens
</div>
</>
);
};

const advancedColumns = hasAdvancedPricing
? [
{
title: t('缓存读取') + ` / ${tokenUnit === 'K' ? '1K' : '1M'} tokens`,
title: t('缓存读取'),
dataIndex: 'cacheRatio',
render: renderCachePrice,
},
{
title: t('缓存创建') + ` / ${tokenUnit === 'K' ? '1K' : '1M'} tokens`,
title: t('缓存创建'),
dataIndex: 'cacheCreationRatio',
render: renderCachePrice,
},


+ 1
- 6
web/src/context/User/index.jsx Zobrazit soubor

@@ -20,7 +20,6 @@ For commercial licensing, please contact support@quantumnous.com
import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { reducer, initialState } from './reducer';
import { StatusContext } from '../Status';

export const UserContext = React.createContext({
state: initialState,
@@ -30,7 +29,6 @@ export const UserContext = React.createContext({
export const UserProvider = ({ children }) => {
const [state, dispatch] = React.useReducer(reducer, initialState);
const { i18n } = useTranslation();
const [statusState] = React.useContext(StatusContext);

// Sync language preference when user data is loaded
useEffect(() => {
@@ -39,15 +37,12 @@ export const UserProvider = ({ children }) => {
const settings = JSON.parse(state.user.setting);
if (settings.language && settings.language !== i18n.language) {
i18n.changeLanguage(settings.language);
} else if (!settings.language && statusState.status?.default_language) {
// No personal preference — fall back to admin default
i18n.changeLanguage(statusState.status.default_language);
}
} catch (e) {
// Ignore parse errors
}
}
}, [state.user?.setting, statusState.status?.default_language, i18n]);
}, [state.user?.setting, i18n]);

return (
<UserContext.Provider value={[state, dispatch]}>


+ 14
- 14
web/src/hooks/common/useHeaderBar.js Zobrazit soubor

@@ -158,20 +158,20 @@ export const useHeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
});
if (res.data.success) {
// Update user context with new setting
if (userState?.user?.setting) {
try {
const settings = JSON.parse(userState.user.setting);
settings.language = lang;
userDispatch({
type: 'login',
payload: {
...userState.user,
setting: JSON.stringify(settings),
},
});
} catch (e) {
// Ignore parse errors
}
try {
const settings = userState?.user?.setting
? JSON.parse(userState.user.setting)
: {};
settings.language = lang;
userDispatch({
type: 'login',
payload: {
...userState.user,
setting: JSON.stringify(settings),
},
});
} catch (e) {
// Ignore parse errors
}
}
} catch (error) {


+ 3
- 1
web/src/i18n/locales/en.json Zobrazit soubor

@@ -345,7 +345,7 @@
"令牌的额度仅用于限制令牌本身的最大额度使用量,实际的使用受到账户的剩余额度限制": "The quota of the token is only used to limit the maximum quota usage of the token itself, and the actual usage is limited by the remaining quota of the account",
"令牌端点": "Token Endpoint",
"令牌管理": "Token Management",
"以一套域名、密钥与风控策略连接全球大模型资源,保障可观测、可拓展、可控。": "Connect to global LLM resources with a single domain, key, and policy. Observable, scalable, and controllable.",
"以一套域名、密钥与风控策略连接顶级大模型资源,保障可观测、可拓展、可控。": "Connect to top-tier LLM resources with a single domain, key, and policy. Observable, scalable, and controllable.",
"以下上游数据可能不可信:": "The following upstream data may not be reliable: ",
"以下文件解析失败,已忽略:{{list}}": "The following files failed to parse and have been ignored: {{list}}",
"以单一域名和密钥连接全部大模型供应商,智能容灾切换确保业务不中断。": "Connect all LLM providers with a single domain and key. Smart failover ensures uninterrupted service.",
@@ -2229,6 +2229,8 @@
"统一入口,极速连通": "Unified Entry, Instant Connect",
"统一的": "The Unified",
"统一的大模型接口网关": "Unified LLM API Gateway",
"链接顶级 AI 能力": "Connect Top-Tier AI",
"点击复制渠道 ID": "Click to copy channel ID",
"统一监控": "Unified Monitoring",
"统计Tokens": "Statistical Tokens",
"统计已重置": "Statistics reset",


+ 3
- 1
web/src/i18n/locales/zh-CN.json Zobrazit soubor

@@ -337,7 +337,7 @@
"令牌的额度仅用于限制令牌本身的最大额度使用量,实际的使用受到账户的剩余额度限制": "令牌的额度仅用于限制令牌本身的最大额度使用量,实际的使用受到账户的剩余额度限制",
"令牌端点": "令牌端点",
"令牌管理": "令牌管理",
"以一套域名、密钥与风控策略连接全球大模型资源,保障可观测、可拓展、可控。": "以一套域名、密钥与风控策略连接全球大模型资源,保障可观测、可拓展、可控。",
"以一套域名、密钥与风控策略连接顶级大模型资源,保障可观测、可拓展、可控。": "以一套域名、密钥与风控策略连接顶级大模型资源,保障可观测、可拓展、可控。",
"以下上游数据可能不可信:": "以下上游数据可能不可信:",
"以下文件解析失败,已忽略:{{list}}": "以下文件解析失败,已忽略:{{list}}",
"以单一域名和密钥连接全部大模型供应商,智能容灾切换确保业务不中断。": "以单一域名和密钥连接全部大模型供应商,智能容灾切换确保业务不中断。",
@@ -2211,6 +2211,8 @@
"统一入口,极速连通": "统一入口,极速连通",
"统一的": "统一的",
"统一的大模型接口网关": "统一的大模型接口网关",
"链接顶级 AI 能力": "链接顶级 AI 能力",
"点击复制渠道 ID": "点击复制渠道 ID",
"统一监控": "统一监控",
"统计Tokens": "统计Tokens",
"统计已重置": "统计已重置",


+ 1
- 0
web/src/pages/Setting/Operation/SettingsRegionSync.jsx Zobrazit soubor

@@ -323,6 +323,7 @@ export default function SettingsRegionSync(props) {
'region_sync.min_balance_threshold'
)}
min={0}
extraText={t('额度单位换算:500,000 额度 = $1 USD。例如设置为 100,000 即 $0.2')}
/>
</Col>
<Col xs={24} sm={12} md={6} lg={6} xl={6}>


+ 2
- 2
web/src/pages/Setting/Ratio/ChannelPricingView.jsx Zobrazit soubor

@@ -582,8 +582,8 @@ const ChannelPricingView = ({ channels, channelPricings, tags, onRefresh }) => {
</div>

<Form.Section text={t('高级比例(留空使用全局默认值)')}>
<Form.InputNumber field="cache_ratio" label={t('缓存读取倍率')} min={0} step={0.01} placeholder={t('全局默认值')} />
<Form.InputNumber field="cache_creation_ratio" label={t('缓存创建倍率(5分钟)')} min={0} step={0.01} placeholder={t('全局默认值(1小时自动按 1.6x 计算)')} />
<Form.InputNumber field="cache_ratio" label={t('缓存读取倍率')} min={0} step={0.01} placeholder={t('全局默认值(1.0)')} extraText={t('缓存命中时输入价格的倍率。0.5 = 缓存命中的 token 按输入价格的一半计费,留空使用全局默认值 1.0')} />
<Form.InputNumber field="cache_creation_ratio" label={t('缓存创建倍率(5分钟)')} min={0} step={0.01} placeholder={t('全局默认值(1.25)')} extraText={t('写入缓存的 token 按此倍率 × 输入价格计费。默认 1.25(5分钟 TTL);超过 1 小时自动按 1.6x 计费,留空使用全局默认值')} />
<Form.InputNumber field="image_ratio" label={t('图片倍率')} min={0} step={0.01} placeholder={t('全局默认值')} />
<Form.InputNumber field="audio_ratio" label={t('音频输入倍率')} min={0} step={0.01} placeholder={t('全局默认值')} />
<Form.InputNumber field="audio_completion_ratio" label={t('音频输出倍率')} min={0} step={0.01} placeholder={t('全局默认值')} />


Načítá se…
Zrušit
Uložit