Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,126 changes: 589 additions & 537 deletions web/bun.lock

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
"private": true,
"type": "module",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@douyinfe/semi-icons": "^2.63.1",
"@douyinfe/semi-ui": "^2.69.1",
"@lobehub/icons": "^2.0.0",
Expand Down
16 changes: 11 additions & 5 deletions web/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,19 +72,25 @@ function App() {
try {
const modules = JSON.parse(headerNavModulesConfig);

// 处理向后兼容性:如果pricing是boolean,默认不需要登录
// New format: {items: [...]}
if (Array.isArray(modules.items)) {
const pricing = modules.items.find((it) => it.key === 'pricing');
return pricing?.requireAuth === true;
}

// Old format: {pricing: boolean} — default no auth
if (typeof modules.pricing === 'boolean') {
return false; // 默认不需要登录鉴权
return false;
}

// 如果是对象格式,使用requireAuth配置
// Old format: {pricing: {requireAuth: true}}
return modules.pricing?.requireAuth === true;
} catch (error) {
console.error('解析顶栏模块配置失败:', error);
return false; // 默认不需要登录
return false;
}
}
return false; // 默认不需要登录
return false;
}, [statusState?.status?.HeaderNavModules]);

return (
Expand Down
6 changes: 4 additions & 2 deletions web/src/components/layout/headerbar/Navigation.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,14 @@ const Navigation = ({
const linkContent = <span>{link.text}</span>;

if (link.isExternal) {
const target =
link.openInNewTab !== false ? '_blank' : '_self';
return (
<a
key={link.itemKey}
href={link.externalLink}
target='_blank'
rel='noopener noreferrer'
target={target}
rel={target === '_blank' ? 'noopener noreferrer' : undefined}
className={commonLinkClasses}
>
{linkContent}
Expand Down
71 changes: 71 additions & 0 deletions web/src/helpers/navMigration.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/*
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
*/

const BUILT_IN_ORDER = ['home', 'console', 'pricing', 'docs', 'about'];

export function migrateOldFormatToItems(modules) {
const items = BUILT_IN_ORDER.map((key) => {
if (key === 'pricing') {
const val = modules[key];
if (typeof val === 'object') {
return {
key,
enabled: val.enabled !== false,
requireAuth: val.requireAuth || false,
};
}
return { key, enabled: val !== false, requireAuth: false };
}
return { key, enabled: modules[key] !== false };
});

const customItems = Array.isArray(modules.customItems)
? modules.customItems
: [];
const sorted = [...customItems].sort(
(a, b) => (a.position ?? 99) - (b.position ?? 99),
);

const cursors = new Map();
for (const ci of sorted) {
const pos = ci.position ?? 99;
const { position: _, ...rest } = ci;
const customItem = { ...rest };

Comment on lines +23 to +50

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Harden migration against malformed persisted config.

At Line 26 and Line 48, null/non-object values can throw (pricing: null, customItems: [null]) and break header rendering. Please guard object assumptions before property access/destructuring.

🔧 Proposed defensive fix
 export function migrateOldFormatToItems(modules) {
+  const safeModules =
+    modules && typeof modules === 'object' && !Array.isArray(modules)
+      ? modules
+      : {};
+
   const items = BUILT_IN_ORDER.map((key) => {
     if (key === 'pricing') {
-      const val = modules[key];
-      if (typeof val === 'object') {
+      const val = safeModules[key];
+      if (val && typeof val === 'object' && !Array.isArray(val)) {
         return {
           key,
           enabled: val.enabled !== false,
-          requireAuth: val.requireAuth || false,
+          requireAuth: val.requireAuth === true,
         };
       }
       return { key, enabled: val !== false, requireAuth: false };
     }
-    return { key, enabled: modules[key] !== false };
+    return { key, enabled: safeModules[key] !== false };
   });
 
-  const customItems = Array.isArray(modules.customItems)
-    ? modules.customItems
+  const customItems = Array.isArray(safeModules.customItems)
+    ? safeModules.customItems.filter(
+        (ci) => ci && typeof ci === 'object' && !Array.isArray(ci),
+      )
     : [];
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/helpers/navMigration.js` around lines 23 - 50, The migration assumes
objects for pricing and customItems and can crash on null/non-object values;
update the BUILT_IN_ORDER mapping to check modules[key] != null && typeof
modules[key] === 'object' before accessing properties like enabled/requireAuth
(use a safe fallback when not an object), and when building customItems ensure
modules.customItems is an array of objects (e.g., const customItems =
Array.isArray(modules.customItems) ? modules.customItems.filter(ci => ci &&
typeof ci === 'object') : [];), then safely read ci.position with a fallback
(ci.position ?? 99) and destructure only from the filtered object (const {
position: _, ...rest } = ci) so null/primitive entries are skipped and won't
throw in the sorting/iteration using sorted, cursors, and customItem creation.

if (pos === 0) {
const cursor = cursors.get('start') ?? 0;
items.splice(cursor, 0, customItem);
cursors.set('start', cursor + 1);
} else if (pos === 99) {
items.push(customItem);
} else {
const anchorKey = BUILT_IN_ORDER[pos - 1];
const anchorIdx = items.findIndex((it) => it.key === anchorKey);
if (anchorIdx >= 0) {
const cursor = cursors.get(anchorKey) ?? anchorIdx + 1;
items.splice(cursor, 0, customItem);
cursors.set(anchorKey, cursor + 1);
} else {
items.push(customItem);
}
}
}

return items;
}
12 changes: 9 additions & 3 deletions web/src/hooks/common/useHeaderBar.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,18 @@ export const useHeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {

// 获取模型广场权限配置
const pricingRequireAuth = useMemo(() => {
// New format: {items: [...]}
if (Array.isArray(headerNavModules?.items)) {
const pricing = headerNavModules.items.find((it) => it.key === 'pricing');
return pricing?.requireAuth === true;
}
// Old format: {pricing: {enabled, requireAuth}}
if (headerNavModules?.pricing) {
return typeof headerNavModules.pricing === 'object'
? headerNavModules.pricing.requireAuth
: false; // 默认不需要登录
? headerNavModules.pricing.requireAuth === true
: false;
}
return false; // 默认不需要登录
return false;
}, [headerNavModules]);

const isConsoleRoute = location.pathname.startsWith('/console');
Expand Down
118 changes: 63 additions & 55 deletions web/src/hooks/common/useNavigation.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,67 +18,75 @@ For commercial licensing, please contact support@quantumnous.com
*/

import { useMemo } from 'react';
import { migrateOldFormatToItems } from '../../helpers/navMigration';

const BUILT_IN_KEYS = ['home', 'console', 'pricing', 'docs', 'about'];

function resolveItems(headerNavModules) {
if (!headerNavModules) return null;
if (Array.isArray(headerNavModules.items)) {
return headerNavModules.items;
}
return migrateOldFormatToItems(headerNavModules);
}

export const useNavigation = (t, docsLink, headerNavModules) => {
const mainNavLinks = useMemo(() => {
// 默认配置,如果没有传入配置则显示所有模块
const defaultModules = {
home: true,
console: true,
pricing: true,
docs: true,
about: true,
};

// 使用传入的配置或默认配置
const modules = headerNavModules || defaultModules;
const items = resolveItems(headerNavModules);
if (!items) {
// fallback: show all built-in items
const links = [
{ text: t('首页'), itemKey: 'home', to: '/' },
{ text: t('控制台'), itemKey: 'console', to: '/console' },
{ text: t('模型广场'), itemKey: 'pricing', to: '/pricing' },
];
if (docsLink) {
links.push({
text: t('文档'),
itemKey: 'docs',
isExternal: true,
externalLink: docsLink,
});
}
links.push({ text: t('关于'), itemKey: 'about', to: '/about' });
return links;
}

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 builtInLinkMap = {
home: { text: t('首页'), itemKey: 'home', to: '/' },
console: { text: t('控制台'), itemKey: 'console', to: '/console' },
pricing: { text: t('模型广场'), itemKey: 'pricing', to: '/pricing' },
docs: docsLink
? {
text: t('文档'),
itemKey: 'docs',
isExternal: true,
externalLink: docsLink,
}
: null,
about: { text: t('关于'), itemKey: 'about', to: '/about' },
};

// 根据配置过滤导航链接
return allLinks.filter((link) => {
if (link.itemKey === 'docs') {
return docsLink && modules.docs;
const links = [];
for (const item of items) {
if (item.key && BUILT_IN_KEYS.includes(item.key)) {
if (item.enabled === false) continue;
if (item.key === 'docs' && !docsLink) continue;
const link = builtInLinkMap[item.key];
if (link) links.push(link);
} else if (item.id) {
links.push({
text: item.label,
itemKey: item.id,
to: item.isExternal ? undefined : item.url,
isExternal: item.isExternal,
externalLink: item.isExternal ? item.url : undefined,
openInNewTab: item.openInNewTab,
});
}
if (link.itemKey === 'pricing') {
// 支持新的pricing配置格式
return typeof modules.pricing === 'object'
? modules.pricing.enabled
: modules.pricing;
}
return modules[link.itemKey] === true;
});
}

return links;
}, [t, docsLink, headerNavModules]);

return {
Expand Down
12 changes: 11 additions & 1 deletion web/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -3376,6 +3376,16 @@
"从剪贴板粘贴配置": "Paste Config",
"剪贴板中未检测到连接信息": "No connection info found in clipboard",
"连接信息已填入": "Connection info applied",
"无法读取剪贴板": "Cannot read clipboard"
"无法读取剪贴板": "Cannot read clipboard",
"在新标签页打开": "Open in New Tab",
"添加导航项": "Add Navigation Item",
"编辑导航项": "Edit Navigation Item",
"链接地址": "Link URL",
"内部路径": "Internal Path",
"外部链接": "External Link",
"最多添加 {{max}} 个自定义导航项": "Maximum {{max}} custom navigation items",
"请填写完整信息": "Please fill in complete information",
"链接示例: https://example.com 或 /path": "e.g. https://example.com or /path",
"拖拽可调整顺序": "Drag to reorder"
}
}
12 changes: 11 additions & 1 deletion web/src/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -3332,6 +3332,16 @@
"从剪贴板粘贴配置": "Coller la config",
"剪贴板中未检测到连接信息": "Aucune info de connexion trouvée dans le presse-papiers",
"连接信息已填入": "Informations de connexion appliquées",
"无法读取剪贴板": "Impossible de lire le presse-papiers"
"无法读取剪贴板": "Impossible de lire le presse-papiers",
"在新标签页打开": "Ouvrir dans un nouvel onglet",
"添加导航项": "Ajouter un élément de navigation",
"编辑导航项": "Modifier l'élément de navigation",
"链接地址": "Adresse du lien",
"内部路径": "Chemin interne",
"外部链接": "Lien externe",
"最多添加 {{max}} 个自定义导航项": "Maximum {{max}} éléments de navigation personnalisés",
"请填写完整信息": "Veuillez remplir toutes les informations",
"链接示例: https://example.com 或 /path": "ex. https://example.com ou /path",
"拖拽可调整顺序": "Glisser pour réorganiser"
}
}
12 changes: 11 additions & 1 deletion web/src/i18n/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -3313,6 +3313,16 @@
"从剪贴板粘贴配置": "クリップボードから貼り付け",
"剪贴板中未检测到连接信息": "クリップボードに接続情報が見つかりません",
"连接信息已填入": "接続情報を入力しました",
"无法读取剪贴板": "クリップボードを読み取れません"
"无法读取剪贴板": "クリップボードを読み取れません",
"在新标签页打开": "新しいタブで開く",
"添加导航项": "ナビゲーション項目を追加",
"编辑导航项": "ナビゲーション項目を編集",
"链接地址": "リンクアドレス",
"内部路径": "内部パス",
"外部链接": "外部リンク",
"最多添加 {{max}} 个自定义导航项": "カスタムナビゲーション項目は最大{{max}}個まで",
"请填写完整信息": "情報を完全に入力してください",
"链接示例: https://example.com 或 /path": "例: https://example.com または /path",
"拖拽可调整顺序": "ドラッグして並べ替え"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add the new settings helper-text key to this locale.

web/src/pages/Setting/Operation/SettingsHeaderNavModules.jsx, Line 450 now uses t('控制顶栏导航项的显示和排序,全局生效'), but this locale update never adds that Chinese source key. Japanese users will still see raw Chinese for that description until it is added here and synced across locales.

As per coding guidelines web/src/i18n/**/*.json: “Use CLI tools: bun run i18n:extract, bun run i18n:sync, bun run i18n:lint.”

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/i18n/locales/ja.json` around lines 3316 - 3326, Add the missing
source key "控制顶栏导航项的显示和排序,全局生效" to the Japanese locale by adding a Japanese
translation entry in web/src/i18n/locales/ja.json matching that exact Chinese
key; locate where SettingsHeaderNavModules.jsx calls t('控制顶栏导航项的显示和排序,全局生效') and
add the corresponding Japanese string (e.g., a concise translation) under that
key, then run the i18n CLI steps (bun run i18n:extract, bun run i18n:sync, bun
run i18n:lint) to propagate and validate the change across locales.

}
}
12 changes: 11 additions & 1 deletion web/src/i18n/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -3346,6 +3346,16 @@
"从剪贴板粘贴配置": "Вставить конфигурацию",
"剪贴板中未检测到连接信息": "Данные подключения не найдены в буфере обмена",
"连接信息已填入": "Данные подключения применены",
"无法读取剪贴板": "Не удалось прочитать буфер обмена"
"无法读取剪贴板": "Не удалось прочитать буфер обмена",
"在新标签页打开": "Открыть в новой вкладке",
"添加导航项": "Добавить элемент навигации",
"编辑导航项": "Редактировать элемент навигации",
"链接地址": "Адрес ссылки",
"内部路径": "Внутренний путь",
"外部链接": "Внешняя ссылка",
"最多添加 {{max}} 个自定义导航项": "Максимум {{max}} пользовательских элементов навигации",
"请填写完整信息": "Пожалуйста, заполните всю информацию",
"链接示例: https://example.com 或 /path": "напр. https://example.com или /path",
"拖拽可调整顺序": "Перетащите для изменения порядка"
}
}
11 changes: 10 additions & 1 deletion web/src/i18n/locales/vi.json
Original file line number Diff line number Diff line change
Expand Up @@ -3882,6 +3882,15 @@
"从剪贴板粘贴配置": "Dán cấu hình",
"剪贴板中未检测到连接信息": "Không tìm thấy thông tin kết nối trong bộ nhớ tạm",
"连接信息已填入": "Đã áp dụng thông tin kết nối",
"无法读取剪贴板": "Không thể đọc bộ nhớ tạm"
"无法读取剪贴板": "Không thể đọc bộ nhớ tạm",
"在新标签页打开": "Mở trong tab mới",
"添加导航项": "Thêm mục điều hướng",
"编辑导航项": "Chỉnh sửa mục điều hướng",
"内部路径": "Đường dẫn nội bộ",
"外部链接": "Liên kết ngoài",
"最多添加 {{max}} 个自定义导航项": "Tối đa {{max}} mục điều hướng tùy chỉnh",
"请填写完整信息": "Vui lòng điền đầy đủ thông tin",
"链接示例: https://example.com 或 /path": "VD: https://example.com hoặc /path",
"拖拽可调整顺序": "Kéo để sắp xếp lại"
}
}
13 changes: 12 additions & 1 deletion web/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -2980,6 +2980,17 @@
"从剪贴板粘贴配置": "从剪贴板粘贴配置",
"剪贴板中未检测到连接信息": "剪贴板中未检测到连接信息",
"连接信息已填入": "连接信息已填入",
"无法读取剪贴板": "无法读取剪贴板"
"无法读取剪贴板": "无法读取剪贴板",
"在新标签页打开": "在新标签页打开",
"添加导航项": "添加导航项",
"编辑导航项": "编辑导航项",
"链接地址": "链接地址",
"内部路径": "内部路径",
"外部链接": "外部链接",
"最多添加 {{max}} 个自定义导航项": "最多添加 {{max}} 个自定义导航项",
"请填写完整信息": "请填写完整信息",
"链接示例: https://example.com 或 /path": "链接示例: https://example.com 或 /path",
"拖拽可调整顺序": "拖拽可调整顺序",
"内置": "内置"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add the new section help text to the locale bundle.

web/src/pages/Setting/Operation/SettingsHeaderNavModules.jsx, Line 448 now calls t('控制顶栏导航项的显示和排序,全局生效'), but that source string was not added alongside the rest of the navigation-management keys here. Until it is synced, localized builds will fall back to the raw Chinese key.

➕ Suggested locale addition
     "拖拽可调整顺序": "拖拽可调整顺序",
-    "内置": "内置"
+    "内置": "内置",
+    "控制顶栏导航项的显示和排序,全局生效": "控制顶栏导航项的显示和排序,全局生效"
As per coding guidelines, "Frontend translation files must use `i18next` + `react-i18next` + `i18next-browser-languagedetector` libraries with translation files at `web/src/i18n/locales/{lang}.json`" and "Use CLI tools: `bun run i18n:extract`, `bun run i18n:sync`, `bun run i18n:lint`."
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/i18n/locales/zh-CN.json` around lines 2983 - 2994, Add the missing
Chinese translation key used in SettingsHeaderNavModules.jsx by inserting an
entry for the exact source string "控制顶栏导航项的显示和排序,全局生效" into the zh-CN locale
JSON alongside the other navigation keys (e.g., near "添加导航项"/"编辑导航项"), then run
the i18n workflow (bun run i18n:extract, bun run i18n:sync, bun run i18n:lint)
to sync and validate; update the file web/src/i18n/locales/zh-CN.json so
t('控制顶栏导航项的显示和排序,全局生效') resolves to a proper localized value instead of falling
back to the raw key.

}
}
Loading