Commit 55c82713 by t0ng7u

feat(ratio-sync, ui): add built‑in “Official Ratio Preset” and harden upstream sync

Backend (controller/ratio_sync.go):
- Add built‑in official upstream to GetSyncableChannels (ID: -100, BaseURL: https://basellm.github.io)
- Support absolute endpoint URLs; otherwise join BaseURL + endpoint (defaults to /api/ratio_config)
- Harden HTTP client:
  - IPv4‑first with IPv6 fallback for github.io
  - Add ResponseHeaderTimeout
  - 3 attempts with exponential backoff (200/400/800ms)
- Validate Content-Type and limit response body to 10MB (safe decode via io.LimitReader)
- Robust parsing: support type1 ratio_config map and type2 pricing list
- Use net.SplitHostPort for host parsing
- Use float tolerance in differences comparison to avoid false mismatches
- Remove unused code (tryDirect) and improve warnings

Frontend:
- UpstreamRatioSync.jsx: auto-assign official endpoint to /llm-metadata/api/newapi/ratio_config-v1-base.json
- ChannelSelectorModal.jsx:
  - Pin the official source at the top of the list
  - Show a green “官方” tag next to the status
  - Refactor status renderer to accept the full record

Notes:
- Backward compatible; no API surface changes
- Official ratio_config reference: https://basellm.github.io/llm-metadata/api/newapi/ratio_config-v1-base.json
parent 7d972851
......@@ -4,6 +4,8 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"one-api/logger"
"strings"
......@@ -21,8 +23,26 @@ const (
defaultTimeoutSeconds = 10
defaultEndpoint = "/api/ratio_config"
maxConcurrentFetches = 8
maxRatioConfigBytes = 10 << 20 // 10MB
floatEpsilon = 1e-9
)
func nearlyEqual(a, b float64) bool {
if a > b {
return a-b < floatEpsilon
}
return b-a < floatEpsilon
}
func valuesEqual(a, b interface{}) bool {
af, aok := a.(float64)
bf, bok := b.(float64)
if aok && bok {
return nearlyEqual(af, bf)
}
return a == b
}
var ratioTypes = []string{"model_ratio", "completion_ratio", "cache_ratio", "model_price"}
type upstreamResult struct {
......@@ -87,7 +107,23 @@ func FetchUpstreamRatios(c *gin.Context) {
sem := make(chan struct{}, maxConcurrentFetches)
client := &http.Client{Transport: &http.Transport{MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second}}
dialer := &net.Dialer{Timeout: 10 * time.Second}
transport := &http.Transport{MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, ResponseHeaderTimeout: 10 * time.Second}
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
host, _, err := net.SplitHostPort(addr)
if err != nil {
host = addr
}
// 对 github.io 优先尝试 IPv4,失败则回退 IPv6
if strings.HasSuffix(host, "github.io") {
if conn, err := dialer.DialContext(ctx, "tcp4", addr); err == nil {
return conn, nil
}
return dialer.DialContext(ctx, "tcp6", addr)
}
return dialer.DialContext(ctx, network, addr)
}
client := &http.Client{Transport: transport}
for _, chn := range upstreams {
wg.Add(1)
......@@ -98,12 +134,17 @@ func FetchUpstreamRatios(c *gin.Context) {
defer func() { <-sem }()
endpoint := chItem.Endpoint
if endpoint == "" {
endpoint = defaultEndpoint
} else if !strings.HasPrefix(endpoint, "/") {
endpoint = "/" + endpoint
var fullURL string
if strings.HasPrefix(endpoint, "http://") || strings.HasPrefix(endpoint, "https://") {
fullURL = endpoint
} else {
if endpoint == "" {
endpoint = defaultEndpoint
} else if !strings.HasPrefix(endpoint, "/") {
endpoint = "/" + endpoint
}
fullURL = chItem.BaseURL + endpoint
}
fullURL := chItem.BaseURL + endpoint
uniqueName := chItem.Name
if chItem.ID != 0 {
......@@ -120,10 +161,19 @@ func FetchUpstreamRatios(c *gin.Context) {
return
}
resp, err := client.Do(httpReq)
if err != nil {
logger.LogWarn(c.Request.Context(), "http error on "+chItem.Name+": "+err.Error())
ch <- upstreamResult{Name: uniqueName, Err: err.Error()}
// 简单重试:最多 3 次,指数退避
var resp *http.Response
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
resp, lastErr = client.Do(httpReq)
if lastErr == nil {
break
}
time.Sleep(time.Duration(200*(1<<attempt)) * time.Millisecond)
}
if lastErr != nil {
logger.LogWarn(c.Request.Context(), "http error on "+chItem.Name+": "+lastErr.Error())
ch <- upstreamResult{Name: uniqueName, Err: lastErr.Error()}
return
}
defer resp.Body.Close()
......@@ -132,6 +182,12 @@ func FetchUpstreamRatios(c *gin.Context) {
ch <- upstreamResult{Name: uniqueName, Err: resp.Status}
return
}
// Content-Type 和响应体大小校验
if ct := resp.Header.Get("Content-Type"); ct != "" && !strings.Contains(strings.ToLower(ct), "application/json") {
logger.LogWarn(c.Request.Context(), "unexpected content-type from "+chItem.Name+": "+ct)
}
limited := io.LimitReader(resp.Body, maxRatioConfigBytes)
// 兼容两种上游接口格式:
// type1: /api/ratio_config -> data 为 map[string]any,包含 model_ratio/completion_ratio/cache_ratio/model_price
// type2: /api/pricing -> data 为 []Pricing 列表,需要转换为与 type1 相同的 map 格式
......@@ -141,7 +197,7 @@ func FetchUpstreamRatios(c *gin.Context) {
Message string `json:"message"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
if err := json.NewDecoder(limited).Decode(&body); err != nil {
logger.LogWarn(c.Request.Context(), "json decode failed from "+chItem.Name+": "+err.Error())
ch <- upstreamResult{Name: uniqueName, Err: err.Error()}
return
......@@ -152,6 +208,8 @@ func FetchUpstreamRatios(c *gin.Context) {
return
}
// 若 Data 为空,将继续按 type1 尝试解析(与多数静态 ratio_config 兼容)
// 尝试按 type1 解析
var type1Data map[string]any
if err := json.Unmarshal(body.Data, &type1Data); err == nil {
......@@ -357,9 +415,9 @@ func buildDifferences(localData map[string]any, successfulChannels []struct {
upstreamValue = val
hasUpstreamValue = true
if localValue != nil && localValue != val {
if localValue != nil && !valuesEqual(localValue, val) {
hasDifference = true
} else if localValue == val {
} else if valuesEqual(localValue, val) {
upstreamValue = "same"
}
}
......@@ -466,6 +524,13 @@ func GetSyncableChannels(c *gin.Context) {
}
}
syncableChannels = append(syncableChannels, dto.SyncableChannel{
ID: -100,
Name: "官方倍率预设",
BaseURL: "https://basellm.github.io",
Status: 1,
})
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
......
......@@ -279,7 +279,10 @@ function App() {
element={
pricingRequireAuth ? (
<PrivateRoute>
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
<Suspense
fallback={<Loading></Loading>}
key={location.pathname}
>
<Pricing />
</Suspense>
</PrivateRoute>
......
......@@ -21,7 +21,13 @@ import React from 'react';
import { Link } from 'react-router-dom';
import SkeletonWrapper from './SkeletonWrapper';
const Navigation = ({ mainNavLinks, isMobile, isLoading, userState, pricingRequireAuth }) => {
const Navigation = ({
mainNavLinks,
isMobile,
isLoading,
userState,
pricingRequireAuth,
}) => {
const renderNavLinks = () => {
const baseClasses =
'flex-shrink-0 flex items-center gap-1 font-semibold rounded-md transition-all duration-200 ease-in-out';
......
......@@ -50,7 +50,11 @@ const routerMap = {
const SiderBar = ({ onNavigate = () => {} }) => {
const { t } = useTranslation();
const [collapsed, toggleCollapsed] = useSidebarCollapsed();
const { isModuleVisible, hasSectionVisibleModules, loading: sidebarLoading } = useSidebar();
const {
isModuleVisible,
hasSectionVisibleModules,
loading: sidebarLoading,
} = useSidebar();
const [selectedKeys, setSelectedKeys] = useState(['home']);
const [chatItems, setChatItems] = useState([]);
......@@ -58,160 +62,148 @@ const SiderBar = ({ onNavigate = () => {} }) => {
const location = useLocation();
const [routerMapState, setRouterMapState] = useState(routerMap);
const workspaceItems = useMemo(
() => {
const items = [
{
text: t('数据看板'),
itemKey: 'detail',
to: '/detail',
className:
localStorage.getItem('enable_data_export') === 'true'
? ''
: 'tableHiddle',
},
{
text: t('令牌管理'),
itemKey: 'token',
to: '/token',
},
{
text: t('使用日志'),
itemKey: 'log',
to: '/log',
},
{
text: t('绘图日志'),
itemKey: 'midjourney',
to: '/midjourney',
className:
localStorage.getItem('enable_drawing') === 'true'
? ''
: 'tableHiddle',
},
{
text: t('任务日志'),
itemKey: 'task',
to: '/task',
className:
localStorage.getItem('enable_task') === 'true' ? '' : 'tableHiddle',
},
];
// 根据配置过滤项目
const filteredItems = items.filter(item => {
const configVisible = isModuleVisible('console', item.itemKey);
return configVisible;
});
return filteredItems;
},
[
localStorage.getItem('enable_data_export'),
localStorage.getItem('enable_drawing'),
localStorage.getItem('enable_task'),
t,
isModuleVisible,
],
);
const financeItems = useMemo(
() => {
const items = [
{
text: t('钱包管理'),
itemKey: 'topup',
to: '/topup',
},
{
text: t('个人设置'),
itemKey: 'personal',
to: '/personal',
},
];
// 根据配置过滤项目
const filteredItems = items.filter(item => {
const configVisible = isModuleVisible('personal', item.itemKey);
return configVisible;
});
return filteredItems;
},
[t, isModuleVisible],
);
const adminItems = useMemo(
() => {
const items = [
{
text: t('渠道管理'),
itemKey: 'channel',
to: '/channel',
className: isAdmin() ? '' : 'tableHiddle',
},
{
text: t('模型管理'),
itemKey: 'models',
to: '/console/models',
className: isAdmin() ? '' : 'tableHiddle',
},
{
text: t('兑换码管理'),
itemKey: 'redemption',
to: '/redemption',
className: isAdmin() ? '' : 'tableHiddle',
},
{
text: t('用户管理'),
itemKey: 'user',
to: '/user',
className: isAdmin() ? '' : 'tableHiddle',
},
{
text: t('系统设置'),
itemKey: 'setting',
to: '/setting',
className: isRoot() ? '' : 'tableHiddle',
},
];
// 根据配置过滤项目
const filteredItems = items.filter(item => {
const configVisible = isModuleVisible('admin', item.itemKey);
return configVisible;
});
return filteredItems;
},
[isAdmin(), isRoot(), t, isModuleVisible],
);
const chatMenuItems = useMemo(
() => {
const items = [
{
text: t('操练场'),
itemKey: 'playground',
to: '/playground',
},
{
text: t('聊天'),
itemKey: 'chat',
items: chatItems,
},
];
// 根据配置过滤项目
const filteredItems = items.filter(item => {
const configVisible = isModuleVisible('chat', item.itemKey);
return configVisible;
});
return filteredItems;
},
[chatItems, t, isModuleVisible],
);
const workspaceItems = useMemo(() => {
const items = [
{
text: t('数据看板'),
itemKey: 'detail',
to: '/detail',
className:
localStorage.getItem('enable_data_export') === 'true'
? ''
: 'tableHiddle',
},
{
text: t('令牌管理'),
itemKey: 'token',
to: '/token',
},
{
text: t('使用日志'),
itemKey: 'log',
to: '/log',
},
{
text: t('绘图日志'),
itemKey: 'midjourney',
to: '/midjourney',
className:
localStorage.getItem('enable_drawing') === 'true'
? ''
: 'tableHiddle',
},
{
text: t('任务日志'),
itemKey: 'task',
to: '/task',
className:
localStorage.getItem('enable_task') === 'true' ? '' : 'tableHiddle',
},
];
// 根据配置过滤项目
const filteredItems = items.filter((item) => {
const configVisible = isModuleVisible('console', item.itemKey);
return configVisible;
});
return filteredItems;
}, [
localStorage.getItem('enable_data_export'),
localStorage.getItem('enable_drawing'),
localStorage.getItem('enable_task'),
t,
isModuleVisible,
]);
const financeItems = useMemo(() => {
const items = [
{
text: t('钱包管理'),
itemKey: 'topup',
to: '/topup',
},
{
text: t('个人设置'),
itemKey: 'personal',
to: '/personal',
},
];
// 根据配置过滤项目
const filteredItems = items.filter((item) => {
const configVisible = isModuleVisible('personal', item.itemKey);
return configVisible;
});
return filteredItems;
}, [t, isModuleVisible]);
const adminItems = useMemo(() => {
const items = [
{
text: t('渠道管理'),
itemKey: 'channel',
to: '/channel',
className: isAdmin() ? '' : 'tableHiddle',
},
{
text: t('模型管理'),
itemKey: 'models',
to: '/console/models',
className: isAdmin() ? '' : 'tableHiddle',
},
{
text: t('兑换码管理'),
itemKey: 'redemption',
to: '/redemption',
className: isAdmin() ? '' : 'tableHiddle',
},
{
text: t('用户管理'),
itemKey: 'user',
to: '/user',
className: isAdmin() ? '' : 'tableHiddle',
},
{
text: t('系统设置'),
itemKey: 'setting',
to: '/setting',
className: isRoot() ? '' : 'tableHiddle',
},
];
// 根据配置过滤项目
const filteredItems = items.filter((item) => {
const configVisible = isModuleVisible('admin', item.itemKey);
return configVisible;
});
return filteredItems;
}, [isAdmin(), isRoot(), t, isModuleVisible]);
const chatMenuItems = useMemo(() => {
const items = [
{
text: t('操练场'),
itemKey: 'playground',
to: '/playground',
},
{
text: t('聊天'),
itemKey: 'chat',
items: chatItems,
},
];
// 根据配置过滤项目
const filteredItems = items.filter((item) => {
const configVisible = isModuleVisible('chat', item.itemKey);
return configVisible;
});
return filteredItems;
}, [chatItems, t, isModuleVisible]);
// 更新路由映射,添加聊天路由
const updateRouterMapWithChats = (chats) => {
......@@ -426,7 +418,9 @@ const SiderBar = ({ onNavigate = () => {} }) => {
{/* 聊天区域 */}
{hasSectionVisibleModules('chat') && (
<div className='sidebar-section'>
{!collapsed && <div className='sidebar-group-label'>{t('聊天')}</div>}
{!collapsed && (
<div className='sidebar-group-label'>{t('聊天')}</div>
)}
{chatMenuItems.map((item) => renderSubItem(item))}
</div>
)}
......
......@@ -34,7 +34,6 @@ import {
Tag,
} from '@douyinfe/semi-ui';
import { IconSearch } from '@douyinfe/semi-icons';
import { CheckCircle, XCircle, AlertCircle, HelpCircle } from 'lucide-react';
const ChannelSelectorModal = forwardRef(
(
......@@ -65,6 +64,18 @@ const ChannelSelectorModal = forwardRef(
},
}));
// 官方渠道识别
const isOfficialChannel = (record) => {
const id = record?.key ?? record?.value ?? record?._originalData?.id;
const base = record?._originalData?.base_url || '';
const name = record?.label || '';
return (
id === -100 ||
base === 'https://basellm.github.io' ||
name === '官方倍率预设'
);
};
useEffect(() => {
if (!allChannels) return;
......@@ -77,7 +88,13 @@ const ChannelSelectorModal = forwardRef(
})
: allChannels;
setFilteredData(matched);
const sorted = [...matched].sort((a, b) => {
const wa = isOfficialChannel(a) ? 0 : 1;
const wb = isOfficialChannel(b) ? 0 : 1;
return wa - wb;
});
setFilteredData(sorted);
}, [allChannels, searchText]);
const total = filteredData.length;
......@@ -143,45 +160,49 @@ const ChannelSelectorModal = forwardRef(
);
};
const renderStatusCell = (status) => {
const renderStatusCell = (record) => {
const status = record?._originalData?.status || 0;
const official = isOfficialChannel(record);
let statusTag = null;
switch (status) {
case 1:
return (
<Tag
color='green'
shape='circle'
prefixIcon={<CheckCircle size={14} />}
>
statusTag = (
<Tag color='green' shape='circle'>
{t('已启用')}
</Tag>
);
break;
case 2:
return (
<Tag color='red' shape='circle' prefixIcon={<XCircle size={14} />}>
statusTag = (
<Tag color='red' shape='circle'>
{t('已禁用')}
</Tag>
);
break;
case 3:
return (
<Tag
color='yellow'
shape='circle'
prefixIcon={<AlertCircle size={14} />}
>
statusTag = (
<Tag color='yellow' shape='circle'>
{t('自动禁用')}
</Tag>
);
break;
default:
return (
<Tag
color='grey'
shape='circle'
prefixIcon={<HelpCircle size={14} />}
>
statusTag = (
<Tag color='grey' shape='circle'>
{t('未知状态')}
</Tag>
);
}
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{statusTag}
{official && (
<Tag color='green' shape='circle' type='light'>
{t('官方')}
</Tag>
)}
</div>
);
};
const renderNameCell = (text) => (
......@@ -207,8 +228,7 @@ const ChannelSelectorModal = forwardRef(
{
title: t('状态'),
dataIndex: '_originalData.status',
render: (_, record) =>
renderStatusCell(record._originalData?.status || 0),
render: (_, record) => renderStatusCell(record),
},
{
title: t('同步接口'),
......
......@@ -38,8 +38,6 @@ const PersonalSetting = () => {
let navigate = useNavigate();
const { t } = useTranslation();
const [inputs, setInputs] = useState({
wechat_verification_code: '',
email_verification_code: '',
......@@ -335,8 +333,6 @@ const PersonalSetting = () => {
saveNotificationSettings={saveNotificationSettings}
/>
</div>
</div>
</div>
......
......@@ -231,11 +231,14 @@ export const getChannelsColumns = ({
theme='outline'
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(record.remark).then(() => {
showSuccess(t('复制成功'));
}).catch(() => {
showError(t('复制失败'));
});
navigator.clipboard
.writeText(record.remark)
.then(() => {
showSuccess(t('复制成功'));
})
.catch(() => {
showError(t('复制失败'));
});
}}
>
{t('复制')}
......
......@@ -64,7 +64,7 @@ export const useHeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
if (typeof modules.pricing === 'boolean') {
modules.pricing = {
enabled: modules.pricing,
requireAuth: false // 默认不需要登录鉴权
requireAuth: false, // 默认不需要登录鉴权
};
}
......
......@@ -20,67 +20,66 @@ For commercial licensing, please contact support@quantumnous.com
import { useMemo } from 'react';
export const useNavigation = (t, docsLink, headerNavModules) => {
const mainNavLinks = useMemo(
() => {
// 默认配置,如果没有传入配置则显示所有模块
const defaultModules = {
home: true,
console: true,
pricing: true,
docs: true,
about: true,
};
const mainNavLinks = useMemo(() => {
// 默认配置,如果没有传入配置则显示所有模块
const defaultModules = {
home: true,
console: true,
pricing: true,
docs: true,
about: true,
};
// 使用传入的配置或默认配置
const modules = headerNavModules || defaultModules;
// 使用传入的配置或默认配置
const modules = headerNavModules || defaultModules;
const allLinks = [
{
text: t('首页'),
itemKey: 'home',
to: '/',
},
{
text: t('控制台'),
itemKey: 'console',
to: '/console',
},
{
text: t('模型广场'),
itemKey: 'pricing',
to: '/pricing',
},
...(docsLink
? [
{
text: t('文档'),
itemKey: 'docs',
isExternal: true,
externalLink: docsLink,
},
]
: []),
{
text: t('关于'),
itemKey: 'about',
to: '/about',
},
];
const allLinks = [
{
text: t('首页'),
itemKey: 'home',
to: '/',
},
{
text: t('控制台'),
itemKey: 'console',
to: '/console',
},
{
text: t('模型广场'),
itemKey: 'pricing',
to: '/pricing',
},
...(docsLink
? [
{
text: t('文档'),
itemKey: 'docs',
isExternal: true,
externalLink: docsLink,
},
]
: []),
{
text: t('关于'),
itemKey: 'about',
to: '/about',
},
];
// 根据配置过滤导航链接
return allLinks.filter(link => {
if (link.itemKey === 'docs') {
return docsLink && modules.docs;
}
if (link.itemKey === 'pricing') {
// 支持新的pricing配置格式
return typeof modules.pricing === 'object' ? modules.pricing.enabled : modules.pricing;
}
return modules[link.itemKey] === true;
});
},
[t, docsLink, headerNavModules],
);
// 根据配置过滤导航链接
return allLinks.filter((link) => {
if (link.itemKey === 'docs') {
return docsLink && modules.docs;
}
if (link.itemKey === 'pricing') {
// 支持新的pricing配置格式
return typeof modules.pricing === 'object'
? modules.pricing.enabled
: modules.pricing;
}
return modules[link.itemKey] === true;
});
}, [t, docsLink, headerNavModules]);
return {
mainNavLinks,
......
......@@ -31,7 +31,7 @@ export const useSidebar = () => {
chat: {
enabled: true,
playground: true,
chat: true
chat: true,
},
console: {
enabled: true,
......@@ -39,12 +39,12 @@ export const useSidebar = () => {
token: true,
log: true,
midjourney: true,
task: true
task: true,
},
personal: {
enabled: true,
topup: true,
personal: true
personal: true,
},
admin: {
enabled: true,
......@@ -52,8 +52,8 @@ export const useSidebar = () => {
models: true,
redemption: true,
user: true,
setting: true
}
setting: true,
},
};
// 获取管理员配置
......@@ -87,12 +87,15 @@ export const useSidebar = () => {
// 当用户没有配置时,生成一个基于管理员配置的默认用户配置
// 这样可以确保权限控制正确生效
const defaultUserConfig = {};
Object.keys(adminConfig).forEach(sectionKey => {
Object.keys(adminConfig).forEach((sectionKey) => {
if (adminConfig[sectionKey]?.enabled) {
defaultUserConfig[sectionKey] = { enabled: true };
// 为每个管理员允许的模块设置默认值为true
Object.keys(adminConfig[sectionKey]).forEach(moduleKey => {
if (moduleKey !== 'enabled' && adminConfig[sectionKey][moduleKey]) {
Object.keys(adminConfig[sectionKey]).forEach((moduleKey) => {
if (
moduleKey !== 'enabled' &&
adminConfig[sectionKey][moduleKey]
) {
defaultUserConfig[sectionKey][moduleKey] = true;
}
});
......@@ -103,10 +106,10 @@ export const useSidebar = () => {
} catch (error) {
// 出错时也生成默认配置,而不是设置为空对象
const defaultUserConfig = {};
Object.keys(adminConfig).forEach(sectionKey => {
Object.keys(adminConfig).forEach((sectionKey) => {
if (adminConfig[sectionKey]?.enabled) {
defaultUserConfig[sectionKey] = { enabled: true };
Object.keys(adminConfig[sectionKey]).forEach(moduleKey => {
Object.keys(adminConfig[sectionKey]).forEach((moduleKey) => {
if (moduleKey !== 'enabled' && adminConfig[sectionKey][moduleKey]) {
defaultUserConfig[sectionKey][moduleKey] = true;
}
......@@ -149,7 +152,7 @@ export const useSidebar = () => {
}
// 遍历所有区域
Object.keys(adminConfig).forEach(sectionKey => {
Object.keys(adminConfig).forEach((sectionKey) => {
const adminSection = adminConfig[sectionKey];
const userSection = userConfig[sectionKey];
......@@ -161,18 +164,21 @@ export const useSidebar = () => {
// 区域级别:用户可以选择隐藏管理员允许的区域
// 当userSection存在时检查enabled状态,否则默认为true
const sectionEnabled = userSection ? (userSection.enabled !== false) : true;
const sectionEnabled = userSection ? userSection.enabled !== false : true;
result[sectionKey] = { enabled: sectionEnabled };
// 功能级别:只有管理员和用户都允许的功能才显示
Object.keys(adminSection).forEach(moduleKey => {
Object.keys(adminSection).forEach((moduleKey) => {
if (moduleKey === 'enabled') return;
const adminAllowed = adminSection[moduleKey];
// 当userSection存在时检查模块状态,否则默认为true
const userAllowed = userSection ? (userSection[moduleKey] !== false) : true;
const userAllowed = userSection
? userSection[moduleKey] !== false
: true;
result[sectionKey][moduleKey] = adminAllowed && userAllowed && sectionEnabled;
result[sectionKey][moduleKey] =
adminAllowed && userAllowed && sectionEnabled;
});
});
......@@ -192,9 +198,9 @@ export const useSidebar = () => {
const hasSectionVisibleModules = (sectionKey) => {
const section = finalConfig[sectionKey];
if (!section?.enabled) return false;
return Object.keys(section).some(key =>
key !== 'enabled' && section[key] === true
return Object.keys(section).some(
(key) => key !== 'enabled' && section[key] === true,
);
};
......@@ -202,9 +208,10 @@ export const useSidebar = () => {
const getVisibleModules = (sectionKey) => {
const section = finalConfig[sectionKey];
if (!section?.enabled) return [];
return Object.keys(section)
.filter(key => key !== 'enabled' && section[key] === true);
return Object.keys(section).filter(
(key) => key !== 'enabled' && section[key] === true,
);
};
return {
......@@ -215,6 +222,6 @@ export const useSidebar = () => {
isModuleVisible,
hasSectionVisibleModules,
getVisibleModules,
refreshUserConfig
refreshUserConfig,
};
};
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useState, useEffect } from 'react';
import { API } from '../../helpers';
......@@ -52,22 +70,22 @@ export const useUserPermissions = () => {
const isSidebarModuleAllowed = (sectionKey, moduleKey) => {
if (!permissions?.sidebar_modules) return true;
const sectionPerms = permissions.sidebar_modules[sectionKey];
// 如果整个区域被禁用
if (sectionPerms === false) return false;
// 如果区域存在但模块被禁用
if (sectionPerms && sectionPerms[moduleKey] === false) return false;
return true;
};
// 获取允许的边栏区域列表
const getAllowedSidebarSections = () => {
if (!permissions?.sidebar_modules) return [];
return Object.keys(permissions.sidebar_modules).filter(sectionKey =>
isSidebarSectionAllowed(sectionKey)
return Object.keys(permissions.sidebar_modules).filter((sectionKey) =>
isSidebarSectionAllowed(sectionKey),
);
};
......@@ -75,12 +93,13 @@ export const useUserPermissions = () => {
const getAllowedSidebarModules = (sectionKey) => {
if (!permissions?.sidebar_modules) return [];
const sectionPerms = permissions.sidebar_modules[sectionKey];
if (sectionPerms === false) return [];
if (!sectionPerms || typeof sectionPerms !== 'object') return [];
return Object.keys(sectionPerms).filter(moduleKey =>
moduleKey !== 'enabled' && sectionPerms[moduleKey] === true
return Object.keys(sectionPerms).filter(
(moduleKey) =>
moduleKey !== 'enabled' && sectionPerms[moduleKey] === true,
);
};
......
......@@ -130,9 +130,7 @@ export default function ModelRatioNotSetEditor(props) {
// 在 return 语句之前,先处理过滤和分页逻辑
const filteredModels = models.filter((model) =>
searchText
? model.name.includes(searchText)
: true,
searchText ? model.name.includes(searchText) : true,
);
// 然后基于过滤后的数据计算分页数据
......
......@@ -99,9 +99,7 @@ export default function ModelSettingsVisualEditor(props) {
// 在 return 语句之前,先处理过滤和分页逻辑
const filteredModels = models.filter((model) => {
const keywordMatch = searchText
? model.name.includes(searchText)
: true;
const keywordMatch = searchText ? model.name.includes(searchText) : true;
const conflictMatch = conflictOnly ? model.hasConflict : true;
return keywordMatch && conflictMatch;
});
......
......@@ -151,8 +151,17 @@ export default function UpstreamRatioSync(props) {
setChannelEndpoints((prev) => {
const merged = { ...prev };
transferData.forEach((channel) => {
if (!merged[channel.key]) {
merged[channel.key] = DEFAULT_ENDPOINT;
const id = channel.key;
const base = channel._originalData?.base_url || '';
const name = channel.label || '';
const isOfficial =
id === -100 ||
base === 'https://basellm.github.io' ||
name === '官方倍率预设';
if (!merged[id]) {
merged[id] = isOfficial
? '/llm-metadata/api/newapi/ratio_config-v1-base.json'
: DEFAULT_ENDPOINT;
}
});
return merged;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment