初始化导入 new-api 源码

This commit is contained in:
OpenClaw Task Bot
2026-04-09 22:31:14 +08:00
commit 75bda2e845
994 changed files with 250292 additions and 0 deletions

View File

@@ -0,0 +1,52 @@
/*
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, useRef } from 'react';
/**
* 检测容器宽度的 Hook
* @returns {[ref, width]} 容器引用和当前宽度
*/
export const useContainerWidth = () => {
const [width, setWidth] = useState(0);
const ref = useRef(null);
useEffect(() => {
const element = ref.current;
if (!element) return;
const resizeObserver = new ResizeObserver((entries) => {
for (let entry of entries) {
const { width: newWidth } = entry.contentRect;
setWidth(newWidth);
}
});
resizeObserver.observe(element);
// 初始化宽度
setWidth(element.getBoundingClientRect().width);
return () => {
resizeObserver.disconnect();
};
}, []);
return [ref, width];
};

250
web/src/hooks/common/useHeaderBar.js vendored Normal file
View File

@@ -0,0 +1,250 @@
/*
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, useContext, useCallback, useMemo } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { UserContext } from '../../context/User';
import { StatusContext } from '../../context/Status';
import { useSetTheme, useTheme, useActualTheme } from '../../context/Theme';
import { getLogo, getSystemName, API, showSuccess } from '../../helpers';
import { normalizeLanguage } from '../../i18n/language';
import { useIsMobile } from './useIsMobile';
import { useSidebarCollapsed } from './useSidebarCollapsed';
import { useMinimumLoadingTime } from './useMinimumLoadingTime';
export const useHeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
const { t, i18n } = useTranslation();
const [userState, userDispatch] = useContext(UserContext);
const [statusState] = useContext(StatusContext);
const isMobile = useIsMobile();
const [collapsed, toggleCollapsed] = useSidebarCollapsed();
const [logoLoaded, setLogoLoaded] = useState(false);
const navigate = useNavigate();
const [currentLang, setCurrentLang] = useState(normalizeLanguage(i18n.language));
const location = useLocation();
const loading = statusState?.status === undefined;
const isLoading = useMinimumLoadingTime(loading, 200);
const systemName = getSystemName();
const logo = getLogo();
const currentDate = new Date();
const isNewYear = currentDate.getMonth() === 0 && currentDate.getDate() === 1;
const isSelfUseMode = statusState?.status?.self_use_mode_enabled || false;
const docsLink = statusState?.status?.docs_link || '';
const isDemoSiteMode = statusState?.status?.demo_site_enabled || false;
// 获取顶栏模块配置
const headerNavModulesConfig = statusState?.status?.HeaderNavModules;
// 使用useMemo确保headerNavModules正确响应statusState变化
const headerNavModules = useMemo(() => {
if (headerNavModulesConfig) {
try {
const modules = JSON.parse(headerNavModulesConfig);
// 处理向后兼容性如果pricing是boolean转换为对象格式
if (typeof modules.pricing === 'boolean') {
modules.pricing = {
enabled: modules.pricing,
requireAuth: false, // 默认不需要登录鉴权
};
}
return modules;
} catch (error) {
console.error('解析顶栏模块配置失败:', error);
return null;
}
}
return null;
}, [headerNavModulesConfig]);
// 获取模型广场权限配置
const pricingRequireAuth = useMemo(() => {
if (headerNavModules?.pricing) {
return typeof headerNavModules.pricing === 'object'
? headerNavModules.pricing.requireAuth
: false; // 默认不需要登录
}
return false; // 默认不需要登录
}, [headerNavModules]);
const isConsoleRoute = location.pathname.startsWith('/console');
const theme = useTheme();
const actualTheme = useActualTheme();
const setTheme = useSetTheme();
// Logo loading effect
useEffect(() => {
setLogoLoaded(false);
if (!logo) return;
const img = new Image();
img.src = logo;
img.onload = () => setLogoLoaded(true);
}, [logo]);
// Send theme to iframe
useEffect(() => {
try {
const iframe = document.querySelector('iframe');
const cw = iframe && iframe.contentWindow;
if (cw) {
cw.postMessage({ themeMode: actualTheme }, '*');
}
} catch (e) {
// Silently ignore cross-origin or access errors
}
}, [actualTheme]);
// Language change effect
useEffect(() => {
const handleLanguageChanged = (lng) => {
const normalizedLang = normalizeLanguage(lng);
setCurrentLang(normalizedLang);
try {
const iframe = document.querySelector('iframe');
const cw = iframe && iframe.contentWindow;
if (cw) {
cw.postMessage({ lang: normalizedLang }, '*');
}
} catch (e) {
// Silently ignore cross-origin or access errors
}
};
i18n.on('languageChanged', handleLanguageChanged);
return () => {
i18n.off('languageChanged', handleLanguageChanged);
};
}, [i18n]);
// Actions
const logout = useCallback(async () => {
await API.get('/api/user/logout');
showSuccess(t('注销成功!'));
userDispatch({ type: 'logout' });
localStorage.removeItem('user');
navigate('/login');
}, [navigate, t, userDispatch]);
const handleLanguageChange = useCallback(
async (lang) => {
// Change language immediately for responsive UX
const previousLang = normalizeLanguage(i18n.language);
i18n.changeLanguage(lang);
localStorage.setItem('i18nextLng', lang);
// If user is logged in, save preference to backend
if (userState?.user?.id) {
try {
const res = await API.put('/api/user/self', {
language: lang,
});
if (res.data.success) {
// Keep user preference and local cache in sync so route changes
// don't reapply an older remembered language.
let settings = {};
if (userState?.user?.setting) {
try {
settings = JSON.parse(userState.user.setting) || {};
} catch (e) {
settings = {};
}
}
settings.language = lang;
const nextUser = {
...userState.user,
setting: JSON.stringify(settings),
};
userDispatch({
type: 'login',
payload: nextUser,
});
localStorage.setItem('user', JSON.stringify(nextUser));
}
} catch (error) {
if (previousLang) {
i18n.changeLanguage(previousLang);
localStorage.setItem('i18nextLng', previousLang);
}
console.error('Failed to save language preference:', error);
}
}
},
[i18n, userState, userDispatch],
);
const handleThemeToggle = useCallback(
(newTheme) => {
if (
!newTheme ||
(newTheme !== 'light' && newTheme !== 'dark' && newTheme !== 'auto')
) {
return;
}
setTheme(newTheme);
},
[setTheme],
);
const handleMobileMenuToggle = useCallback(() => {
if (isMobile) {
onMobileMenuToggle();
} else {
toggleCollapsed();
}
}, [isMobile, onMobileMenuToggle, toggleCollapsed]);
return {
// State
userState,
statusState,
isMobile,
collapsed,
logoLoaded,
currentLang,
location,
isLoading,
systemName,
logo,
isNewYear,
isSelfUseMode,
docsLink,
isDemoSiteMode,
isConsoleRoute,
theme,
drawerOpen,
headerNavModules,
pricingRequireAuth,
// Actions
logout,
handleLanguageChange,
handleThemeToggle,
handleMobileMenuToggle,
navigate,
t,
};
};

35
web/src/hooks/common/useIsMobile.js vendored Normal file
View File

@@ -0,0 +1,35 @@
/*
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
*/
export const MOBILE_BREAKPOINT = 768;
import { useSyncExternalStore } from 'react';
export const useIsMobile = () => {
const query = `(max-width: ${MOBILE_BREAKPOINT - 1}px)`;
return useSyncExternalStore(
(callback) => {
const mql = window.matchMedia(query);
mql.addEventListener('change', callback);
return () => mql.removeEventListener('change', callback);
},
() => window.matchMedia(query).matches,
() => false,
);
};

View File

@@ -0,0 +1,50 @@
/*
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, useRef } from 'react';
/**
* 自定义 Hook确保骨架屏至少显示指定的时间
* @param {boolean} loading - 实际的加载状态
* @param {number} minimumTime - 最小显示时间(毫秒),默认 1000ms
* @returns {boolean} showSkeleton - 是否显示骨架屏的状态
*/
export const useMinimumLoadingTime = (loading, minimumTime = 1000) => {
const [showSkeleton, setShowSkeleton] = useState(loading);
const loadingStartRef = useRef(Date.now());
useEffect(() => {
if (loading) {
loadingStartRef.current = Date.now();
setShowSkeleton(true);
} else {
const elapsed = Date.now() - loadingStartRef.current;
const remaining = Math.max(0, minimumTime - elapsed);
if (remaining === 0) {
setShowSkeleton(false);
} else {
const timer = setTimeout(() => setShowSkeleton(false), remaining);
return () => clearTimeout(timer);
}
}
}, [loading, minimumTime]);
return showSkeleton;
};

87
web/src/hooks/common/useNavigation.js vendored Normal file
View File

@@ -0,0 +1,87 @@
/*
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 { 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 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',
},
];
// 根据配置过滤导航链接
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,
};
};

View File

@@ -0,0 +1,94 @@
/*
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';
export const useNotifications = (statusState) => {
const [noticeVisible, setNoticeVisible] = useState(false);
const [unreadCount, setUnreadCount] = useState(0);
const announcements = statusState?.status?.announcements || [];
// Helper functions
const getAnnouncementKey = (a) =>
`${a?.publishDate || ''}-${(a?.content || '').slice(0, 30)}`;
const calculateUnreadCount = () => {
if (!announcements.length) return 0;
let readKeys = [];
try {
readKeys = JSON.parse(localStorage.getItem('notice_read_keys')) || [];
} catch (_) {
readKeys = [];
}
const readSet = new Set(readKeys);
return announcements.filter((a) => !readSet.has(getAnnouncementKey(a)))
.length;
};
const getUnreadKeys = () => {
if (!announcements.length) return [];
let readKeys = [];
try {
readKeys = JSON.parse(localStorage.getItem('notice_read_keys')) || [];
} catch (_) {
readKeys = [];
}
const readSet = new Set(readKeys);
return announcements
.filter((a) => !readSet.has(getAnnouncementKey(a)))
.map(getAnnouncementKey);
};
// Effects
useEffect(() => {
setUnreadCount(calculateUnreadCount());
}, [announcements]);
// Actions
const handleNoticeOpen = () => {
setNoticeVisible(true);
};
const handleNoticeClose = () => {
setNoticeVisible(false);
if (announcements.length) {
let readKeys = [];
try {
readKeys = JSON.parse(localStorage.getItem('notice_read_keys')) || [];
} catch (_) {
readKeys = [];
}
const mergedKeys = Array.from(
new Set([...readKeys, ...announcements.map(getAnnouncementKey)]),
);
localStorage.setItem('notice_read_keys', JSON.stringify(mergedKeys));
}
setUnreadCount(0);
};
return {
noticeVisible,
unreadCount,
announcements,
handleNoticeOpen,
handleNoticeClose,
getUnreadKeys,
};
};

View File

@@ -0,0 +1,274 @@
/*
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, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { SecureVerificationService } from '../../services/secureVerification';
import { showError, showSuccess } from '../../helpers';
import { isVerificationRequiredError } from '../../helpers/secureApiCall';
/**
* 通用安全验证 Hook
* @param {Object} options - 配置选项
* @param {Function} options.onSuccess - 验证成功回调
* @param {Function} options.onError - 验证失败回调
* @param {string} options.successMessage - 成功提示消息
* @param {boolean} options.autoReset - 验证完成后是否自动重置状态,默认为 true
*/
export const useSecureVerification = ({
onSuccess,
onError,
successMessage,
autoReset = true,
} = {}) => {
const { t } = useTranslation();
// 验证方式可用性状态
const [verificationMethods, setVerificationMethods] = useState({
has2FA: false,
hasPasskey: false,
passkeySupported: false,
});
// 模态框状态
const [isModalVisible, setIsModalVisible] = useState(false);
// 当前验证状态
const [verificationState, setVerificationState] = useState({
method: null, // '2fa' | 'passkey'
loading: false,
code: '',
apiCall: null,
});
// 检查可用的验证方式
const checkVerificationMethods = useCallback(async () => {
const methods =
await SecureVerificationService.checkAvailableVerificationMethods();
setVerificationMethods(methods);
return methods;
}, []);
// 初始化时检查验证方式
useEffect(() => {
checkVerificationMethods();
}, [checkVerificationMethods]);
// 重置状态
const resetState = useCallback(() => {
setVerificationState({
method: null,
loading: false,
code: '',
apiCall: null,
});
setIsModalVisible(false);
}, []);
// 开始验证流程
const startVerification = useCallback(
async (apiCall, options = {}) => {
const { preferredMethod, title, description } = options;
// 检查验证方式
const methods = await checkVerificationMethods();
if (!methods.has2FA && !methods.hasPasskey) {
const errorMessage = t('您需要先启用两步验证或 Passkey 才能执行此操作');
showError(errorMessage);
onError?.(new Error(errorMessage));
return false;
}
// 设置默认验证方式
let defaultMethod = preferredMethod;
if (!defaultMethod) {
if (methods.hasPasskey && methods.passkeySupported) {
defaultMethod = 'passkey';
} else if (methods.has2FA) {
defaultMethod = '2fa';
}
}
setVerificationState((prev) => ({
...prev,
method: defaultMethod,
apiCall,
title,
description,
}));
setIsModalVisible(true);
return true;
},
[checkVerificationMethods, onError, t],
);
// 执行验证
const executeVerification = useCallback(
async (method, code = '') => {
if (!verificationState.apiCall) {
showError(t('验证配置错误'));
return;
}
setVerificationState((prev) => ({ ...prev, loading: true }));
try {
// 先调用验证 API成功后后端会设置 session
await SecureVerificationService.verify(method, code);
// 验证成功,调用业务 API此时中间件会通过
const result = await verificationState.apiCall();
// 显示成功消息
if (successMessage) {
showSuccess(successMessage);
}
// 调用成功回调
onSuccess?.(result, method);
// 自动重置状态
if (autoReset) {
resetState();
}
return result;
} catch (error) {
showError(error.message || t('验证失败,请重试'));
onError?.(error);
throw error;
} finally {
setVerificationState((prev) => ({ ...prev, loading: false }));
}
},
[
verificationState.apiCall,
successMessage,
onSuccess,
onError,
autoReset,
resetState,
t,
],
);
// 设置验证码
const setVerificationCode = useCallback((code) => {
setVerificationState((prev) => ({ ...prev, code }));
}, []);
// 切换验证方式
const switchVerificationMethod = useCallback((method) => {
setVerificationState((prev) => ({ ...prev, method, code: '' }));
}, []);
// 取消验证
const cancelVerification = useCallback(() => {
resetState();
}, [resetState]);
// 检查是否可以使用某种验证方式
const canUseMethod = useCallback(
(method) => {
switch (method) {
case '2fa':
return verificationMethods.has2FA;
case 'passkey':
return (
verificationMethods.hasPasskey &&
verificationMethods.passkeySupported
);
default:
return false;
}
},
[verificationMethods],
);
// 获取推荐的验证方式
const getRecommendedMethod = useCallback(() => {
if (
verificationMethods.hasPasskey &&
verificationMethods.passkeySupported
) {
return 'passkey';
}
if (verificationMethods.has2FA) {
return '2fa';
}
return null;
}, [verificationMethods]);
/**
* 包装 API 调用,自动处理验证错误
* 当 API 返回需要验证的错误时,自动弹出验证模态框
* @param {Function} apiCall - API 调用函数
* @param {Object} options - 验证选项(同 startVerification
* @returns {Promise<any>}
*/
const withVerification = useCallback(
async (apiCall, options = {}) => {
try {
// 直接尝试调用 API
return await apiCall();
} catch (error) {
// 检查是否是需要验证的错误
if (isVerificationRequiredError(error)) {
// 自动触发验证流程
await startVerification(apiCall, options);
// 不抛出错误,让验证模态框处理
return null;
}
// 其他错误继续抛出
throw error;
}
},
[startVerification],
);
return {
// 状态
isModalVisible,
verificationMethods,
verificationState,
// 方法
startVerification,
executeVerification,
cancelVerification,
resetState,
setVerificationCode,
switchVerificationMethod,
checkVerificationMethods,
// 辅助方法
canUseMethod,
getRecommendedMethod,
withVerification, // 新增:自动处理验证的包装函数
// 便捷属性
hasAnyVerificationMethod:
verificationMethods.has2FA || verificationMethods.hasPasskey,
isLoading: verificationState.loading,
currentMethod: verificationState.method,
code: verificationState.code,
};
};

301
web/src/hooks/common/useSidebar.js vendored Normal file
View File

@@ -0,0 +1,301 @@
/*
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, useMemo, useContext, useRef } from 'react';
import { StatusContext } from '../../context/Status';
import { API } from '../../helpers';
// 创建一个全局事件系统来同步所有useSidebar实例
const sidebarEventTarget = new EventTarget();
const SIDEBAR_REFRESH_EVENT = 'sidebar-refresh';
export const DEFAULT_ADMIN_CONFIG = {
chat: {
enabled: true,
playground: true,
chat: true,
},
console: {
enabled: true,
detail: true,
token: true,
log: true,
midjourney: true,
task: true,
},
personal: {
enabled: true,
topup: true,
personal: true,
},
admin: {
enabled: true,
channel: true,
models: true,
deployment: true,
redemption: true,
user: true,
subscription: true,
setting: true,
},
};
const deepClone = (value) => JSON.parse(JSON.stringify(value));
export const mergeAdminConfig = (savedConfig) => {
const merged = deepClone(DEFAULT_ADMIN_CONFIG);
if (!savedConfig || typeof savedConfig !== 'object') return merged;
for (const [sectionKey, sectionConfig] of Object.entries(savedConfig)) {
if (!sectionConfig || typeof sectionConfig !== 'object') continue;
if (!merged[sectionKey]) {
merged[sectionKey] = { ...sectionConfig };
continue;
}
merged[sectionKey] = { ...merged[sectionKey], ...sectionConfig };
}
return merged;
};
export const useSidebar = () => {
const [statusState] = useContext(StatusContext);
const [userConfig, setUserConfig] = useState(null);
const [loading, setLoading] = useState(true);
const instanceIdRef = useRef(null);
const hasLoadedOnceRef = useRef(false);
if (!instanceIdRef.current) {
const randomPart = Math.random().toString(16).slice(2);
instanceIdRef.current = `sidebar-${Date.now()}-${randomPart}`;
}
// 获取管理员配置
const adminConfig = useMemo(() => {
if (statusState?.status?.SidebarModulesAdmin) {
try {
const config = JSON.parse(statusState.status.SidebarModulesAdmin);
return mergeAdminConfig(config);
} catch (error) {
return mergeAdminConfig(null);
}
}
return mergeAdminConfig(null);
}, [statusState?.status?.SidebarModulesAdmin]);
// 加载用户配置的通用方法
const loadUserConfig = async ({ withLoading } = {}) => {
const shouldShowLoader =
typeof withLoading === 'boolean'
? withLoading
: !hasLoadedOnceRef.current;
try {
if (shouldShowLoader) {
setLoading(true);
}
const res = await API.get('/api/user/self');
if (res.data.success && res.data.data.sidebar_modules) {
let config;
// 检查sidebar_modules是字符串还是对象
if (typeof res.data.data.sidebar_modules === 'string') {
config = JSON.parse(res.data.data.sidebar_modules);
} else {
config = res.data.data.sidebar_modules;
}
setUserConfig(config);
} else {
// 当用户没有配置时,生成一个基于管理员配置的默认用户配置
// 这样可以确保权限控制正确生效
const defaultUserConfig = {};
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]
) {
defaultUserConfig[sectionKey][moduleKey] = true;
}
});
}
});
setUserConfig(defaultUserConfig);
}
} catch (error) {
// 出错时也生成默认配置,而不是设置为空对象
const defaultUserConfig = {};
Object.keys(adminConfig).forEach((sectionKey) => {
if (adminConfig[sectionKey]?.enabled) {
defaultUserConfig[sectionKey] = { enabled: true };
Object.keys(adminConfig[sectionKey]).forEach((moduleKey) => {
if (moduleKey !== 'enabled' && adminConfig[sectionKey][moduleKey]) {
defaultUserConfig[sectionKey][moduleKey] = true;
}
});
}
});
setUserConfig(defaultUserConfig);
} finally {
if (shouldShowLoader) {
setLoading(false);
}
hasLoadedOnceRef.current = true;
}
};
// 刷新用户配置的方法(供外部调用)
const refreshUserConfig = async () => {
if (Object.keys(adminConfig).length > 0) {
await loadUserConfig({ withLoading: false });
}
// 触发全局刷新事件通知所有useSidebar实例更新
sidebarEventTarget.dispatchEvent(
new CustomEvent(SIDEBAR_REFRESH_EVENT, {
detail: { sourceId: instanceIdRef.current, skipLoader: true },
}),
);
};
// 加载用户配置
useEffect(() => {
// 只有当管理员配置加载完成后才加载用户配置
if (Object.keys(adminConfig).length > 0) {
loadUserConfig();
}
}, [adminConfig]);
// 监听全局刷新事件
useEffect(() => {
const handleRefresh = (event) => {
if (event?.detail?.sourceId === instanceIdRef.current) {
return;
}
if (Object.keys(adminConfig).length > 0) {
loadUserConfig({
withLoading: event?.detail?.skipLoader ? false : undefined,
});
}
};
sidebarEventTarget.addEventListener(SIDEBAR_REFRESH_EVENT, handleRefresh);
return () => {
sidebarEventTarget.removeEventListener(
SIDEBAR_REFRESH_EVENT,
handleRefresh,
);
};
}, [adminConfig]);
// 计算最终的显示配置
const finalConfig = useMemo(() => {
const result = {};
// 确保adminConfig已加载
if (!adminConfig || Object.keys(adminConfig).length === 0) {
return result;
}
// 如果userConfig未加载等待加载完成
if (!userConfig) {
return result;
}
// 遍历所有区域
Object.keys(adminConfig).forEach((sectionKey) => {
const adminSection = adminConfig[sectionKey];
const userSection = userConfig[sectionKey];
// 如果管理员禁用了整个区域,则该区域不显示
if (!adminSection?.enabled) {
result[sectionKey] = { enabled: false };
return;
}
// 区域级别:用户可以选择隐藏管理员允许的区域
// 当userSection存在时检查enabled状态否则默认为true
const sectionEnabled = userSection ? userSection.enabled !== false : true;
result[sectionKey] = { enabled: sectionEnabled };
// 功能级别:只有管理员和用户都允许的功能才显示
Object.keys(adminSection).forEach((moduleKey) => {
if (moduleKey === 'enabled') return;
const adminAllowed = adminSection[moduleKey];
// 当userSection存在时检查模块状态否则默认为true
const userAllowed = userSection
? userSection[moduleKey] !== false
: true;
result[sectionKey][moduleKey] =
adminAllowed && userAllowed && sectionEnabled;
});
});
return result;
}, [adminConfig, userConfig]);
// 检查特定功能是否应该显示
const isModuleVisible = (sectionKey, moduleKey = null) => {
if (moduleKey) {
return finalConfig[sectionKey]?.[moduleKey] === true;
} else {
return finalConfig[sectionKey]?.enabled === true;
}
};
// 检查区域是否有任何可见的功能
const hasSectionVisibleModules = (sectionKey) => {
const section = finalConfig[sectionKey];
if (!section?.enabled) return false;
return Object.keys(section).some(
(key) => key !== 'enabled' && section[key] === true,
);
};
// 获取区域的可见功能列表
const getVisibleModules = (sectionKey) => {
const section = finalConfig[sectionKey];
if (!section?.enabled) return [];
return Object.keys(section).filter(
(key) => key !== 'enabled' && section[key] === true,
);
};
return {
loading,
adminConfig,
userConfig,
finalConfig,
isModuleVisible,
hasSectionVisibleModules,
getVisibleModules,
refreshUserConfig,
};
};

View File

@@ -0,0 +1,43 @@
/*
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, useCallback } from 'react';
const KEY = 'default_collapse_sidebar';
export const useSidebarCollapsed = () => {
const [collapsed, setCollapsed] = useState(
() => localStorage.getItem(KEY) === 'true',
);
const toggle = useCallback(() => {
setCollapsed((prev) => {
const next = !prev;
localStorage.setItem(KEY, next.toString());
return next;
});
}, []);
const set = useCallback((value) => {
setCollapsed(value);
localStorage.setItem(KEY, value.toString());
}, []);
return [collapsed, toggle, set];
};

View File

@@ -0,0 +1,58 @@
/*
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, useCallback } from 'react';
import { getTableCompactMode, setTableCompactMode } from '../../helpers';
import { TABLE_COMPACT_MODES_KEY } from '../../constants';
/**
* 自定义 Hook管理表格紧凑/自适应模式
* 返回 [compactMode, setCompactMode]。
* 内部使用 localStorage 保存状态,并监听 storage 事件保持多标签页同步。
*/
export function useTableCompactMode(tableKey = 'global') {
const [compactMode, setCompactModeState] = useState(() =>
getTableCompactMode(tableKey),
);
const setCompactMode = useCallback(
(value) => {
setCompactModeState(value);
setTableCompactMode(value, tableKey);
},
[tableKey],
);
useEffect(() => {
const handleStorage = (e) => {
if (e.key === TABLE_COMPACT_MODES_KEY) {
try {
const modes = JSON.parse(e.newValue || '{}');
setCompactModeState(!!modes[tableKey]);
} catch {
// ignore parse error
}
}
};
window.addEventListener('storage', handleStorage);
return () => window.removeEventListener('storage', handleStorage);
}, [tableKey]);
return [compactMode, setCompactMode];
}

View File

@@ -0,0 +1,119 @@
/*
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';
/**
* 用户权限钩子 - 从后端获取用户权限,替代前端角色判断
* 确保权限控制的安全性,防止前端绕过
*/
export const useUserPermissions = () => {
const [permissions, setPermissions] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// 加载用户权限(从用户信息接口获取)
const loadPermissions = async () => {
try {
setLoading(true);
setError(null);
const res = await API.get('/api/user/self');
if (res.data.success) {
const userPermissions = res.data.data.permissions;
setPermissions(userPermissions);
console.log('用户权限加载成功:', userPermissions);
} else {
setError(res.data.message || '获取权限失败');
console.error('获取权限失败:', res.data.message);
}
} catch (error) {
setError('网络错误,请重试');
console.error('加载用户权限异常:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadPermissions();
}, []);
// 检查是否有边栏设置权限
const hasSidebarSettingsPermission = () => {
return permissions?.sidebar_settings === true;
};
// 检查是否允许访问特定的边栏区域
const isSidebarSectionAllowed = (sectionKey) => {
if (!permissions?.sidebar_modules) return true;
const sectionPerms = permissions.sidebar_modules[sectionKey];
return sectionPerms !== false;
};
// 检查是否允许访问特定的边栏模块
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),
);
};
// 获取特定区域允许的模块列表
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 {
permissions,
loading,
error,
loadPermissions,
hasSidebarSettingsPermission,
isSidebarSectionAllowed,
isSidebarModuleAllowed,
getAllowedSidebarSections,
getAllowedSidebarModules,
};
};
export default useUserPermissions;