初始化导入 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,56 @@
/*
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 normalizeModelList = (models = []) =>
Array.from(
new Set(
(models || []).map((model) => String(model || '').trim()).filter(Boolean),
),
);
export const parseUpstreamUpdateMeta = (settings) => {
let parsed = null;
if (settings && typeof settings === 'object') {
parsed = settings;
} else if (typeof settings === 'string') {
try {
parsed = JSON.parse(settings);
} catch (error) {
parsed = null;
}
}
if (!parsed || typeof parsed !== 'object') {
return {
enabled: false,
pendingAddModels: [],
pendingRemoveModels: [],
};
}
return {
enabled: parsed.upstream_model_update_check_enabled === true,
pendingAddModels: normalizeModelList(
parsed.upstream_model_update_last_detected_models,
),
pendingRemoveModels: normalizeModelList(
parsed.upstream_model_update_last_removed_models,
),
};
};

View File

@@ -0,0 +1,309 @@
/*
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 { useRef, useState } from 'react';
import { API, showError, showInfo, showSuccess } from '../../helpers';
import { normalizeModelList } from './upstreamUpdateUtils';
const getManualIgnoredModelCountFromSettings = (settings) => {
let parsed = null;
if (settings && typeof settings === 'object') {
parsed = settings;
} else if (typeof settings === 'string') {
try {
parsed = JSON.parse(settings);
} catch (error) {
parsed = null;
}
}
if (!parsed || typeof parsed !== 'object') {
return 0;
}
return normalizeModelList(parsed.upstream_model_update_ignored_models).length;
};
export const useChannelUpstreamUpdates = ({ t, refresh }) => {
const [showUpstreamUpdateModal, setShowUpstreamUpdateModal] = useState(false);
const [upstreamUpdateChannel, setUpstreamUpdateChannel] = useState(null);
const [upstreamUpdateAddModels, setUpstreamUpdateAddModels] = useState([]);
const [upstreamUpdateRemoveModels, setUpstreamUpdateRemoveModels] = useState(
[],
);
const [upstreamUpdatePreferredTab, setUpstreamUpdatePreferredTab] =
useState('add');
const [upstreamApplyLoading, setUpstreamApplyLoading] = useState(false);
const [detectAllUpstreamUpdatesLoading, setDetectAllUpstreamUpdatesLoading] =
useState(false);
const [applyAllUpstreamUpdatesLoading, setApplyAllUpstreamUpdatesLoading] =
useState(false);
const applyUpstreamUpdatesInFlightRef = useRef(false);
const detectChannelUpstreamUpdatesInFlightRef = useRef(false);
const detectAllUpstreamUpdatesInFlightRef = useRef(false);
const applyAllUpstreamUpdatesInFlightRef = useRef(false);
const openUpstreamUpdateModal = (
record,
pendingAddModels = [],
pendingRemoveModels = [],
preferredTab = 'add',
) => {
const normalizedAddModels = normalizeModelList(pendingAddModels);
const normalizedRemoveModels = normalizeModelList(pendingRemoveModels);
if (
!record?.id ||
(normalizedAddModels.length === 0 && normalizedRemoveModels.length === 0)
) {
showInfo(t('该渠道暂无可处理的上游模型更新'));
return;
}
setUpstreamUpdateChannel(record);
setUpstreamUpdateAddModels(normalizedAddModels);
setUpstreamUpdateRemoveModels(normalizedRemoveModels);
const normalizedPreferredTab = preferredTab === 'remove' ? 'remove' : 'add';
setUpstreamUpdatePreferredTab(normalizedPreferredTab);
setShowUpstreamUpdateModal(true);
};
const closeUpstreamUpdateModal = () => {
setShowUpstreamUpdateModal(false);
setUpstreamUpdateChannel(null);
setUpstreamUpdateAddModels([]);
setUpstreamUpdateRemoveModels([]);
setUpstreamUpdatePreferredTab('add');
};
const applyUpstreamUpdates = async ({
addModels: selectedAddModels = [],
removeModels: selectedRemoveModels = [],
} = {}) => {
if (applyUpstreamUpdatesInFlightRef.current) {
showInfo(t('正在处理,请稍候'));
return;
}
if (!upstreamUpdateChannel?.id) {
closeUpstreamUpdateModal();
return;
}
applyUpstreamUpdatesInFlightRef.current = true;
setUpstreamApplyLoading(true);
try {
const normalizedSelectedAddModels = normalizeModelList(selectedAddModels);
const normalizedSelectedRemoveModels =
normalizeModelList(selectedRemoveModels);
const selectedAddSet = new Set(normalizedSelectedAddModels);
const ignoreModels = upstreamUpdateAddModels.filter(
(model) => !selectedAddSet.has(model),
);
const res = await API.post(
'/api/channel/upstream_updates/apply',
{
id: upstreamUpdateChannel.id,
add_models: normalizedSelectedAddModels,
ignore_models: ignoreModels,
remove_models: normalizedSelectedRemoveModels,
},
{ skipErrorHandler: true },
);
const { success, message, data } = res.data || {};
if (!success) {
showError(message || t('操作失败'));
return;
}
const addedCount = data?.added_models?.length || 0;
const removedCount = data?.removed_models?.length || 0;
const totalIgnoredCount = getManualIgnoredModelCountFromSettings(
data?.settings,
);
const ignoredCount = normalizeModelList(ignoreModels).length;
showSuccess(
t(
'已处理上游模型更新:加入 {{added}} 个,删除 {{removed}} 个,本次忽略 {{ignored}} 个,当前已忽略模型 {{totalIgnored}} 个',
{
added: addedCount,
removed: removedCount,
ignored: ignoredCount,
totalIgnored: totalIgnoredCount,
},
),
);
closeUpstreamUpdateModal();
await refresh();
} catch (error) {
showError(
error?.response?.data?.message || error?.message || t('操作失败'),
);
} finally {
applyUpstreamUpdatesInFlightRef.current = false;
setUpstreamApplyLoading(false);
}
};
const applyAllUpstreamUpdates = async () => {
if (applyAllUpstreamUpdatesInFlightRef.current) {
showInfo(t('正在批量处理,请稍候'));
return;
}
applyAllUpstreamUpdatesInFlightRef.current = true;
setApplyAllUpstreamUpdatesLoading(true);
try {
const res = await API.post(
'/api/channel/upstream_updates/apply_all',
{},
{ skipErrorHandler: true },
);
const { success, message, data } = res.data || {};
if (!success) {
showError(message || t('批量处理失败'));
return;
}
const channelCount = data?.processed_channels || 0;
const addedCount = data?.added_models || 0;
const removedCount = data?.removed_models || 0;
const failedCount = (data?.failed_channel_ids || []).length;
showSuccess(
t(
'已批量处理上游模型更新:渠道 {{channels}} 个,加入 {{added}} 个,删除 {{removed}} 个,失败 {{fails}} 个',
{
channels: channelCount,
added: addedCount,
removed: removedCount,
fails: failedCount,
},
),
);
await refresh();
} catch (error) {
showError(
error?.response?.data?.message || error?.message || t('批量处理失败'),
);
} finally {
applyAllUpstreamUpdatesInFlightRef.current = false;
setApplyAllUpstreamUpdatesLoading(false);
}
};
const detectChannelUpstreamUpdates = async (channel) => {
if (detectChannelUpstreamUpdatesInFlightRef.current) {
showInfo(t('正在检测,请稍候'));
return;
}
if (!channel?.id) {
return;
}
detectChannelUpstreamUpdatesInFlightRef.current = true;
try {
const res = await API.post(
'/api/channel/upstream_updates/detect',
{
id: channel.id,
},
{ skipErrorHandler: true },
);
const { success, message, data } = res.data || {};
if (!success) {
showError(message || t('检测失败'));
return;
}
const addCount = data?.add_models?.length || 0;
const removeCount = data?.remove_models?.length || 0;
showSuccess(
t('检测完成:新增 {{add}} 个,删除 {{remove}} 个', {
add: addCount,
remove: removeCount,
}),
);
await refresh();
} catch (error) {
showError(
error?.response?.data?.message || error?.message || t('检测失败'),
);
} finally {
detectChannelUpstreamUpdatesInFlightRef.current = false;
}
};
const detectAllUpstreamUpdates = async () => {
if (detectAllUpstreamUpdatesInFlightRef.current) {
showInfo(t('正在批量检测,请稍候'));
return;
}
detectAllUpstreamUpdatesInFlightRef.current = true;
setDetectAllUpstreamUpdatesLoading(true);
try {
const res = await API.post(
'/api/channel/upstream_updates/detect_all',
{},
{ skipErrorHandler: true },
);
const { success, message, data } = res.data || {};
if (!success) {
showError(message || t('批量检测失败'));
return;
}
const channelCount = data?.processed_channels || 0;
const addCount = data?.detected_add_models || 0;
const removeCount = data?.detected_remove_models || 0;
const failedCount = (data?.failed_channel_ids || []).length;
showSuccess(
t(
'批量检测完成:渠道 {{channels}} 个,新增 {{add}} 个,删除 {{remove}} 个,失败 {{fails}} 个',
{
channels: channelCount,
add: addCount,
remove: removeCount,
fails: failedCount,
},
),
);
await refresh();
} catch (error) {
showError(
error?.response?.data?.message || error?.message || t('批量检测失败'),
);
} finally {
detectAllUpstreamUpdatesInFlightRef.current = false;
setDetectAllUpstreamUpdatesLoading(false);
}
};
return {
showUpstreamUpdateModal,
setShowUpstreamUpdateModal,
upstreamUpdateChannel,
upstreamUpdateAddModels,
upstreamUpdateRemoveModels,
upstreamUpdatePreferredTab,
upstreamApplyLoading,
detectAllUpstreamUpdatesLoading,
applyAllUpstreamUpdatesLoading,
openUpstreamUpdateModal,
closeUpstreamUpdateModal,
applyUpstreamUpdates,
applyAllUpstreamUpdates,
detectChannelUpstreamUpdates,
detectAllUpstreamUpdates,
};
};

1255
web/src/hooks/channels/useChannelsData.jsx vendored Normal file

File diff suppressed because it is too large Load Diff

49
web/src/hooks/chat/useTokenKeys.js vendored Normal file
View File

@@ -0,0 +1,49 @@
/*
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 { useEffect, useState } from 'react';
import { fetchTokenKeys, getServerAddress } from '../../helpers/token';
import { showError } from '../../helpers';
export function useTokenKeys(id) {
const [keys, setKeys] = useState([]);
const [serverAddress, setServerAddress] = useState('');
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const loadAllData = async () => {
const fetchedKeys = await fetchTokenKeys();
if (fetchedKeys.length === 0) {
showError('当前没有可用的启用令牌,请确认是否有令牌处于启用状态!');
setTimeout(() => {
window.location.href = '/console/token';
}, 1500); // 延迟 1.5 秒后跳转
}
setKeys(fetchedKeys);
setIsLoading(false);
const address = getServerAddress();
setServerAddress(address);
};
loadAllData();
}, []);
return { keys, serverAddress, isLoading };
}

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;

View File

@@ -0,0 +1,447 @@
/*
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, useEffect } from 'react';
import { initVChartSemiTheme } from '@visactor/vchart-semi-theme';
import {
modelColorMap,
renderNumber,
renderQuota,
modelToColor,
getQuotaWithUnit,
} from '../../helpers';
import {
processRawData,
calculateTrendData,
aggregateDataByTimeAndModel,
generateChartTimePoints,
updateChartSpec,
updateMapValue,
initializeMaps,
} from '../../helpers/dashboard';
export const useDashboardCharts = (
dataExportDefaultTime,
setTrendData,
setConsumeQuota,
setTimes,
setConsumeTokens,
setPieData,
setLineData,
setModelColors,
t,
) => {
// ========== 图表规格状态 ==========
const [spec_pie, setSpecPie] = useState({
type: 'pie',
data: [
{
id: 'id0',
values: [{ type: 'null', value: '0' }],
},
],
outerRadius: 0.8,
innerRadius: 0.5,
padAngle: 0.6,
valueField: 'value',
categoryField: 'type',
pie: {
style: {
cornerRadius: 10,
},
state: {
hover: {
outerRadius: 0.85,
stroke: '#000',
lineWidth: 1,
},
selected: {
outerRadius: 0.85,
stroke: '#000',
lineWidth: 1,
},
},
},
title: {
visible: true,
text: t('模型调用次数占比'),
subtext: `${t('总计')}${renderNumber(0)}`,
},
legends: {
visible: true,
orient: 'left',
},
label: {
visible: true,
},
tooltip: {
mark: {
content: [
{
key: (datum) => datum['type'],
value: (datum) => renderNumber(datum['value']),
},
],
},
},
color: {
specified: modelColorMap,
},
});
const [spec_line, setSpecLine] = useState({
type: 'bar',
data: [
{
id: 'barData',
values: [],
},
],
xField: 'Time',
yField: 'Usage',
seriesField: 'Model',
stack: true,
legends: {
visible: true,
selectMode: 'single',
},
title: {
visible: true,
text: t('模型消耗分布'),
subtext: `${t('总计')}${renderQuota(0, 2)}`,
},
bar: {
state: {
hover: {
stroke: '#000',
lineWidth: 1,
},
},
},
tooltip: {
mark: {
content: [
{
key: (datum) => datum['Model'],
value: (datum) => renderQuota(datum['rawQuota'] || 0, 4),
},
],
},
dimension: {
content: [
{
key: (datum) => datum['Model'],
value: (datum) => datum['rawQuota'] || 0,
},
],
updateContent: (array) => {
array.sort((a, b) => b.value - a.value);
let sum = 0;
for (let i = 0; i < array.length; i++) {
if (array[i].key == '其他') {
continue;
}
let value = parseFloat(array[i].value);
if (isNaN(value)) {
value = 0;
}
if (array[i].datum && array[i].datum.TimeSum) {
sum = array[i].datum.TimeSum;
}
array[i].value = renderQuota(value, 4);
}
array.unshift({
key: t('总计'),
value: renderQuota(sum, 4),
});
return array;
},
},
},
color: {
specified: modelColorMap,
},
});
// 模型消耗趋势折线图
const [spec_model_line, setSpecModelLine] = useState({
type: 'line',
data: [
{
id: 'lineData',
values: [],
},
],
xField: 'Time',
yField: 'Count',
seriesField: 'Model',
legends: {
visible: true,
selectMode: 'single',
},
title: {
visible: true,
text: t('模型消耗趋势'),
subtext: '',
},
tooltip: {
mark: {
content: [
{
key: (datum) => datum['Model'],
value: (datum) => renderNumber(datum['Count']),
},
],
},
},
color: {
specified: modelColorMap,
},
});
// 模型调用次数排行柱状图
const [spec_rank_bar, setSpecRankBar] = useState({
type: 'bar',
data: [
{
id: 'rankData',
values: [],
},
],
xField: 'Model',
yField: 'Count',
seriesField: 'Model',
legends: {
visible: true,
selectMode: 'single',
},
title: {
visible: true,
text: t('模型调用次数排行'),
subtext: '',
},
bar: {
state: {
hover: {
stroke: '#000',
lineWidth: 1,
},
},
},
tooltip: {
mark: {
content: [
{
key: (datum) => datum['Model'],
value: (datum) => renderNumber(datum['Count']),
},
],
},
},
color: {
specified: modelColorMap,
},
});
// ========== 数据处理函数 ==========
const generateModelColors = useCallback((uniqueModels, modelColors) => {
const newModelColors = {};
Array.from(uniqueModels).forEach((modelName) => {
newModelColors[modelName] =
modelColorMap[modelName] ||
modelColors[modelName] ||
modelToColor(modelName);
});
return newModelColors;
}, []);
const updateChartData = useCallback(
(data) => {
const processedData = processRawData(
data,
dataExportDefaultTime,
initializeMaps,
updateMapValue,
);
const {
totalQuota,
totalTimes,
totalTokens,
uniqueModels,
timePoints,
timeQuotaMap,
timeTokensMap,
timeCountMap,
} = processedData;
const trendDataResult = calculateTrendData(
timePoints,
timeQuotaMap,
timeTokensMap,
timeCountMap,
dataExportDefaultTime,
);
setTrendData(trendDataResult);
const newModelColors = generateModelColors(uniqueModels, {});
setModelColors(newModelColors);
const aggregatedData = aggregateDataByTimeAndModel(
data,
dataExportDefaultTime,
);
const modelTotals = new Map();
for (let [_, value] of aggregatedData) {
updateMapValue(modelTotals, value.model, value.count);
}
const newPieData = Array.from(modelTotals)
.map(([model, count]) => ({
type: model,
value: count,
}))
.sort((a, b) => b.value - a.value);
const chartTimePoints = generateChartTimePoints(
aggregatedData,
data,
dataExportDefaultTime,
);
let newLineData = [];
chartTimePoints.forEach((time) => {
let timeData = Array.from(uniqueModels).map((model) => {
const key = `${time}-${model}`;
const aggregated = aggregatedData.get(key);
return {
Time: time,
Model: model,
rawQuota: aggregated?.quota || 0,
Usage: aggregated?.quota
? getQuotaWithUnit(aggregated.quota, 4)
: 0,
};
});
const timeSum = timeData.reduce((sum, item) => sum + item.rawQuota, 0);
timeData.sort((a, b) => b.rawQuota - a.rawQuota);
timeData = timeData.map((item) => ({ ...item, TimeSum: timeSum }));
newLineData.push(...timeData);
});
newLineData.sort((a, b) => a.Time.localeCompare(b.Time));
updateChartSpec(
setSpecPie,
newPieData,
`${t('总计')}${renderNumber(totalTimes)}`,
newModelColors,
'id0',
);
updateChartSpec(
setSpecLine,
newLineData,
`${t('总计')}${renderQuota(totalQuota, 2)}`,
newModelColors,
'barData',
);
// ===== 模型调用次数折线图 =====
let modelLineData = [];
chartTimePoints.forEach((time) => {
const timeData = Array.from(uniqueModels).map((model) => {
const key = `${time}-${model}`;
const aggregated = aggregatedData.get(key);
return {
Time: time,
Model: model,
Count: aggregated?.count || 0,
};
});
modelLineData.push(...timeData);
});
modelLineData.sort((a, b) => a.Time.localeCompare(b.Time));
// ===== 模型调用次数排行柱状图 =====
const rankData = Array.from(modelTotals)
.map(([model, count]) => ({
Model: model,
Count: count,
}))
.sort((a, b) => b.Count - a.Count);
updateChartSpec(
setSpecModelLine,
modelLineData,
`${t('总计')}${renderNumber(totalTimes)}`,
newModelColors,
'lineData',
);
updateChartSpec(
setSpecRankBar,
rankData,
`${t('总计')}${renderNumber(totalTimes)}`,
newModelColors,
'rankData',
);
setPieData(newPieData);
setLineData(newLineData);
setConsumeQuota(totalQuota);
setTimes(totalTimes);
setConsumeTokens(totalTokens);
},
[
dataExportDefaultTime,
setTrendData,
generateModelColors,
setModelColors,
setPieData,
setLineData,
setConsumeQuota,
setTimes,
setConsumeTokens,
t,
],
);
// ========== 初始化图表主题 ==========
useEffect(() => {
initVChartSemiTheme({
isWatchingThemeSwitch: true,
});
}, []);
return {
// 图表规格
spec_pie,
spec_line,
spec_model_line,
spec_rank_bar,
// 函数
updateChartData,
generateModelColors,
};
};

View File

@@ -0,0 +1,346 @@
/*
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, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { API, isAdmin, showError, timestamp2string } from '../../helpers';
import { getDefaultTime, getInitialTimestamp } from '../../helpers/dashboard';
import { TIME_OPTIONS } from '../../constants/dashboard.constants';
import { useIsMobile } from '../common/useIsMobile';
import { useMinimumLoadingTime } from '../common/useMinimumLoadingTime';
export const useDashboardData = (userState, userDispatch, statusState) => {
const { t } = useTranslation();
const navigate = useNavigate();
const isMobile = useIsMobile();
const initialized = useRef(false);
// ========== 基础状态 ==========
const [loading, setLoading] = useState(false);
const [greetingVisible, setGreetingVisible] = useState(false);
const [searchModalVisible, setSearchModalVisible] = useState(false);
const showLoading = useMinimumLoadingTime(loading);
// ========== 输入状态 ==========
const [inputs, setInputs] = useState({
username: '',
token_name: '',
model_name: '',
start_timestamp: getInitialTimestamp(),
end_timestamp: timestamp2string(new Date().getTime() / 1000 + 3600),
channel: '',
data_export_default_time: '',
});
const [dataExportDefaultTime, setDataExportDefaultTime] =
useState(getDefaultTime());
// ========== 数据状态 ==========
const [quotaData, setQuotaData] = useState([]);
const [consumeQuota, setConsumeQuota] = useState(0);
const [consumeTokens, setConsumeTokens] = useState(0);
const [times, setTimes] = useState(0);
const [pieData, setPieData] = useState([{ type: 'null', value: '0' }]);
const [lineData, setLineData] = useState([]);
const [modelColors, setModelColors] = useState({});
// ========== 图表状态 ==========
const [activeChartTab, setActiveChartTab] = useState('1');
// ========== 趋势数据 ==========
const [cacheMetrics, setCacheMetrics] = useState({ overall: {}, today: {} });
const [trendData, setTrendData] = useState({
balance: [],
usedQuota: [],
requestCount: [],
times: [],
consumeQuota: [],
tokens: [],
rpm: [],
tpm: [],
});
// ========== Uptime 数据 ==========
const [uptimeData, setUptimeData] = useState([]);
const [uptimeLoading, setUptimeLoading] = useState(false);
const [activeUptimeTab, setActiveUptimeTab] = useState('');
// ========== 常量 ==========
const now = new Date();
const isAdminUser = isAdmin();
// ========== Panel enable flags ==========
const apiInfoEnabled = statusState?.status?.api_info_enabled ?? true;
const announcementsEnabled =
statusState?.status?.announcements_enabled ?? true;
const faqEnabled = statusState?.status?.faq_enabled ?? true;
const uptimeEnabled = statusState?.status?.uptime_kuma_enabled ?? true;
const hasApiInfoPanel = apiInfoEnabled;
const hasInfoPanels = announcementsEnabled || faqEnabled || uptimeEnabled;
// ========== Memoized Values ==========
const timeOptions = useMemo(
() =>
TIME_OPTIONS.map((option) => ({
...option,
label: t(option.label),
})),
[t],
);
const performanceMetrics = useMemo(() => {
const { start_timestamp, end_timestamp } = inputs;
const timeDiff =
(Date.parse(end_timestamp) - Date.parse(start_timestamp)) / 60000;
const avgRPM = isNaN(times / timeDiff)
? '0'
: (times / timeDiff).toFixed(3);
const avgTPM = isNaN(consumeTokens / timeDiff)
? '0'
: (consumeTokens / timeDiff).toFixed(3);
return { avgRPM, avgTPM, timeDiff };
}, [times, consumeTokens, inputs.start_timestamp, inputs.end_timestamp]);
const getGreeting = useMemo(() => {
const hours = new Date().getHours();
let greeting = '';
if (hours >= 5 && hours < 12) {
greeting = t('早上好');
} else if (hours >= 12 && hours < 14) {
greeting = t('中午好');
} else if (hours >= 14 && hours < 18) {
greeting = t('下午好');
} else {
greeting = t('晚上好');
}
const username = userState?.user?.username || '';
return `👋${greeting}${username}`;
}, [t, userState?.user?.username]);
// ========== 回调函数 ==========
const handleInputChange = useCallback((value, name) => {
if (name === 'data_export_default_time') {
setDataExportDefaultTime(value);
localStorage.setItem('data_export_default_time', value);
return;
}
setInputs((inputs) => ({ ...inputs, [name]: value }));
}, []);
const showSearchModal = useCallback(() => {
setSearchModalVisible(true);
}, []);
const handleCloseModal = useCallback(() => {
setSearchModalVisible(false);
}, []);
// ========== API 调用函数 ==========
const loadQuotaData = useCallback(async () => {
setLoading(true);
try {
let url = '';
const { start_timestamp, end_timestamp, username } = inputs;
let localStartTimestamp = Date.parse(start_timestamp) / 1000;
let localEndTimestamp = Date.parse(end_timestamp) / 1000;
const cacheBust = `_=${Date.now()}`;
if (isAdminUser) {
url = `/api/data/?username=${username}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&default_time=${dataExportDefaultTime}&${cacheBust}`;
} else {
url = `/api/data/self/?start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&default_time=${dataExportDefaultTime}&${cacheBust}`;
}
const res = await API.get(url, { disableDuplicate: true });
const { success, message, data } = res.data;
if (success) {
const normalizedData = Array.isArray(data) ? [...data] : [];
setQuotaData(normalizedData);
if (normalizedData.length === 0) {
normalizedData.push({
count: 0,
model_name: '无数据',
quota: 0,
token_used: 0,
created_at: now.getTime() / 1000,
});
}
normalizedData.sort((a, b) => a.created_at - b.created_at);
return normalizedData;
} else {
showError(message);
return [];
}
} finally {
setLoading(false);
}
}, [inputs, dataExportDefaultTime, isAdminUser, now]);
const loadCacheStats = useCallback(async () => {
try {
const res = await API.get(`/api/log/self/stat?type=2&_=${Date.now()}`, {
disableDuplicate: true,
});
if (res.data?.success) {
setCacheMetrics({
overall: res.data?.data?.cache_overall || {},
today: res.data?.data?.cache_today || {},
});
}
} catch (e) {}
}, []);
const loadUptimeData = useCallback(async () => {
setUptimeLoading(true);
try {
const res = await API.get('/api/uptime/status');
const { success, message, data } = res.data;
if (success) {
setUptimeData(data || []);
if (data && data.length > 0 && !activeUptimeTab) {
setActiveUptimeTab(data[0].categoryName);
}
} else {
showError(message);
}
} catch (err) {
console.error(err);
} finally {
setUptimeLoading(false);
}
}, [activeUptimeTab]);
const getUserData = useCallback(async () => {
let res = await API.get(`/api/user/self`);
const { success, message, data } = res.data;
if (success) {
userDispatch({ type: 'login', payload: data });
} else {
showError(message);
}
}, [userDispatch]);
const refresh = useCallback(async () => {
const data = await loadQuotaData();
await loadUptimeData();
await loadCacheStats();
return data;
}, [loadQuotaData, loadUptimeData, loadCacheStats]);
const handleSearchConfirm = useCallback(
async (updateChartDataCallback) => {
const data = await refresh();
if (data && data.length > 0 && updateChartDataCallback) {
updateChartDataCallback(data);
}
setSearchModalVisible(false);
},
[refresh],
);
// ========== Effects ==========
useEffect(() => {
const timer = setTimeout(() => {
setGreetingVisible(true);
}, 100);
return () => clearTimeout(timer);
}, []);
useEffect(() => {
if (!initialized.current) {
getUserData();
initialized.current = true;
}
}, [getUserData]);
return {
// 基础状态
loading: showLoading,
greetingVisible,
searchModalVisible,
// 输入状态
inputs,
dataExportDefaultTime,
// 数据状态
quotaData,
consumeQuota,
setConsumeQuota,
consumeTokens,
setConsumeTokens,
times,
setTimes,
pieData,
setPieData,
lineData,
setLineData,
modelColors,
setModelColors,
// 图表状态
activeChartTab,
setActiveChartTab,
// 趋势数据
trendData,
setTrendData,
cacheMetrics,
// Uptime 数据
uptimeData,
uptimeLoading,
activeUptimeTab,
setActiveUptimeTab,
// 计算值
timeOptions,
performanceMetrics,
getGreeting,
isAdminUser,
hasApiInfoPanel,
hasInfoPanels,
apiInfoEnabled,
announcementsEnabled,
faqEnabled,
uptimeEnabled,
// 函数
handleInputChange,
showSearchModal,
handleCloseModal,
loadQuotaData,
loadUptimeData,
loadCacheStats,
getUserData,
refresh,
handleSearchConfirm,
// 导航和翻译
navigate,
t,
isMobile,
};
};

View File

@@ -0,0 +1,171 @@
/*
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';
import { Wallet, Activity, Zap, Gauge } from 'lucide-react';
import {
IconMoneyExchangeStroked,
IconHistogram,
IconCoinMoneyStroked,
IconTextStroked,
IconPulse,
IconStopwatchStroked,
IconTypograph,
IconSend,
} from '@douyinfe/semi-icons';
import { renderQuota } from '../../helpers';
import { createSectionTitle } from '../../helpers/dashboard';
export const useDashboardStats = (
userState,
consumeQuota,
consumeTokens,
times,
trendData,
performanceMetrics,
cacheMetrics,
navigate,
t,
) => {
const groupedStatsData = useMemo(
() => [
{
title: createSectionTitle(Wallet, t('账户数据')),
color: 'bg-blue-50',
items: [
{
title: t('当前余额'),
value: renderQuota(userState?.user?.quota),
icon: <IconMoneyExchangeStroked />,
avatarColor: 'blue',
trendData: [],
trendColor: '#3b82f6',
},
{
title: t('历史消耗'),
value: renderQuota(userState?.user?.used_quota),
icon: <IconHistogram />,
avatarColor: 'purple',
trendData: [],
trendColor: '#8b5cf6',
},
],
},
{
title: createSectionTitle(Activity, t('使用统计')),
color: 'bg-green-50',
items: [
{
title: t('请求次数'),
value: userState.user?.request_count,
icon: <IconSend />,
avatarColor: 'green',
trendData: [],
trendColor: '#10b981',
},
{
title: t('统计次数'),
value: times,
icon: <IconPulse />,
avatarColor: 'cyan',
trendData: trendData.times,
trendColor: '#06b6d4',
},
],
},
{
title: createSectionTitle(Zap, t('资源消耗')),
color: 'bg-yellow-50',
items: [
{
title: t('统计额度'),
value: renderQuota(consumeQuota),
icon: <IconCoinMoneyStroked />,
avatarColor: 'yellow',
trendData: trendData.consumeQuota,
trendColor: '#f59e0b',
},
{
title: t('统计Tokens'),
value: isNaN(consumeTokens) ? 0 : consumeTokens.toLocaleString(),
icon: <IconTextStroked />,
avatarColor: 'pink',
trendData: trendData.tokens,
trendColor: '#ec4899',
},
],
},
{
title: createSectionTitle(Gauge, t('缓存指标')),
color: 'bg-indigo-50',
items: [
{
title: t('缓存命中率(总体)'),
value: `${(((cacheMetrics?.overall?.cache_hit_rate || 0) * 100) || 0).toFixed(2)}%`,
icon: <IconStopwatchStroked />,
avatarColor: 'indigo',
trendData: trendData.rpm,
trendColor: '#6366f1',
},
{
title: t('缓存命中率(今日)'),
value: `${(((cacheMetrics?.today?.cache_hit_rate || 0) * 100) || 0).toFixed(2)}%`,
icon: <IconStopwatchStroked />,
avatarColor: 'violet',
trendData: trendData.rpm,
trendColor: '#8b5cf6',
},
{
title: t('缓存Token总体'),
value: ((cacheMetrics?.overall?.cache_tokens || 0)).toLocaleString(),
icon: <IconTypograph />,
avatarColor: 'blue',
trendData: trendData.tpm,
trendColor: '#3b82f6',
},
{
title: t('缓存Token今日'),
value: ((cacheMetrics?.today?.cache_tokens || 0)).toLocaleString(),
icon: <IconTypograph />,
avatarColor: 'orange',
trendData: trendData.tpm,
trendColor: '#f97316',
},
],
},
],
[
userState?.user?.quota,
userState?.user?.used_quota,
userState?.user?.request_count,
times,
consumeQuota,
consumeTokens,
trendData,
performanceMetrics,
cacheMetrics,
navigate,
t,
],
);
return {
groupedStatsData,
};
};

338
web/src/hooks/mj-logs/useMjLogsData.js vendored Normal file
View File

@@ -0,0 +1,338 @@
/*
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 { useTranslation } from 'react-i18next';
import { Modal } from '@douyinfe/semi-ui';
import {
API,
copy,
isAdmin,
showError,
showSuccess,
timestamp2string,
} from '../../helpers';
import { ITEMS_PER_PAGE } from '../../constants';
import { useTableCompactMode } from '../common/useTableCompactMode';
export const useMjLogsData = () => {
const { t } = useTranslation();
// Define column keys for selection
const COLUMN_KEYS = {
SUBMIT_TIME: 'submit_time',
DURATION: 'duration',
CHANNEL: 'channel',
TYPE: 'type',
TASK_ID: 'task_id',
SUBMIT_RESULT: 'submit_result',
TASK_STATUS: 'task_status',
PROGRESS: 'progress',
IMAGE: 'image',
PROMPT: 'prompt',
PROMPT_EN: 'prompt_en',
FAIL_REASON: 'fail_reason',
};
// Basic state
const [logs, setLogs] = useState([]);
const [loading, setLoading] = useState(true);
const [activePage, setActivePage] = useState(1);
const [logCount, setLogCount] = useState(0);
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
const [showBanner, setShowBanner] = useState(false);
// User and admin
const isAdminUser = isAdmin();
// Role-specific storage key to prevent different roles from overwriting each other
const STORAGE_KEY = isAdminUser
? 'mj-logs-table-columns-admin'
: 'mj-logs-table-columns-user';
// Modal states
const [isModalOpen, setIsModalOpen] = useState(false);
const [modalContent, setModalContent] = useState('');
const [isModalOpenurl, setIsModalOpenurl] = useState(false);
const [modalImageUrl, setModalImageUrl] = useState('');
// Form state
const [formApi, setFormApi] = useState(null);
let now = new Date();
const formInitValues = {
channel_id: '',
mj_id: '',
dateRange: [
timestamp2string(now.getTime() / 1000 - 2592000),
timestamp2string(now.getTime() / 1000 + 3600),
],
};
// Column visibility state
const [visibleColumns, setVisibleColumns] = useState({});
const [showColumnSelector, setShowColumnSelector] = useState(false);
// Compact mode
const [compactMode, setCompactMode] = useTableCompactMode('mjLogs');
// Load saved column preferences from localStorage
useEffect(() => {
const savedColumns = localStorage.getItem(STORAGE_KEY);
if (savedColumns) {
try {
const parsed = JSON.parse(savedColumns);
const defaults = getDefaultColumnVisibility();
const merged = { ...defaults, ...parsed };
// For non-admin users, force-hide admin-only columns (does not touch admin settings)
if (!isAdminUser) {
merged[COLUMN_KEYS.CHANNEL] = false;
merged[COLUMN_KEYS.SUBMIT_RESULT] = false;
}
setVisibleColumns(merged);
} catch (e) {
console.error('Failed to parse saved column preferences', e);
initDefaultColumns();
}
} else {
initDefaultColumns();
}
}, []);
// Check banner notification
useEffect(() => {
const mjNotifyEnabled = localStorage.getItem('mj_notify_enabled');
if (mjNotifyEnabled !== 'true') {
setShowBanner(true);
}
}, []);
// Get default column visibility based on user role
const getDefaultColumnVisibility = () => {
return {
[COLUMN_KEYS.SUBMIT_TIME]: true,
[COLUMN_KEYS.DURATION]: true,
[COLUMN_KEYS.CHANNEL]: isAdminUser,
[COLUMN_KEYS.TYPE]: true,
[COLUMN_KEYS.TASK_ID]: true,
[COLUMN_KEYS.SUBMIT_RESULT]: isAdminUser,
[COLUMN_KEYS.TASK_STATUS]: true,
[COLUMN_KEYS.PROGRESS]: true,
[COLUMN_KEYS.IMAGE]: true,
[COLUMN_KEYS.PROMPT]: true,
[COLUMN_KEYS.PROMPT_EN]: true,
[COLUMN_KEYS.FAIL_REASON]: true,
};
};
// Initialize default column visibility
const initDefaultColumns = () => {
const defaults = getDefaultColumnVisibility();
setVisibleColumns(defaults);
localStorage.setItem(STORAGE_KEY, JSON.stringify(defaults));
};
// Handle column visibility change
const handleColumnVisibilityChange = (columnKey, checked) => {
const updatedColumns = { ...visibleColumns, [columnKey]: checked };
setVisibleColumns(updatedColumns);
};
// Handle "Select All" checkbox
const handleSelectAll = (checked) => {
const allKeys = Object.keys(COLUMN_KEYS).map((key) => COLUMN_KEYS[key]);
const updatedColumns = {};
allKeys.forEach((key) => {
if (
(key === COLUMN_KEYS.CHANNEL || key === COLUMN_KEYS.SUBMIT_RESULT) &&
!isAdminUser
) {
updatedColumns[key] = false;
} else {
updatedColumns[key] = checked;
}
});
setVisibleColumns(updatedColumns);
};
// Persist column settings to the role-specific STORAGE_KEY
useEffect(() => {
if (Object.keys(visibleColumns).length > 0) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(visibleColumns));
}
}, [visibleColumns]);
// Get form values helper function
const getFormValues = () => {
const formValues = formApi ? formApi.getValues() : {};
let start_timestamp = timestamp2string(now.getTime() / 1000 - 2592000);
let end_timestamp = timestamp2string(now.getTime() / 1000 + 3600);
if (
formValues.dateRange &&
Array.isArray(formValues.dateRange) &&
formValues.dateRange.length === 2
) {
start_timestamp = formValues.dateRange[0];
end_timestamp = formValues.dateRange[1];
}
return {
channel_id: formValues.channel_id || '',
mj_id: formValues.mj_id || '',
start_timestamp,
end_timestamp,
};
};
// Enrich logs data
const enrichLogs = (items) => {
return items.map((log) => ({
...log,
timestamp2string: timestamp2string(log.created_at),
key: '' + log.id,
}));
};
// Sync page data
const syncPageData = (payload) => {
const items = enrichLogs(payload.items || []);
setLogs(items);
setLogCount(payload.total || 0);
setActivePage(payload.page || 1);
setPageSize(payload.page_size || pageSize);
};
// Load logs function
const loadLogs = async (page = 1, size = pageSize) => {
setLoading(true);
const { channel_id, mj_id, start_timestamp, end_timestamp } =
getFormValues();
let localStartTimestamp = Date.parse(start_timestamp);
let localEndTimestamp = Date.parse(end_timestamp);
const url = isAdminUser
? `/api/mj/?p=${page}&page_size=${size}&channel_id=${channel_id}&mj_id=${mj_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`
: `/api/mj/self/?p=${page}&page_size=${size}&mj_id=${mj_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
const res = await API.get(url);
const { success, message, data } = res.data;
if (success) {
syncPageData(data);
} else {
showError(message);
}
setLoading(false);
};
// Page handlers
const handlePageChange = (page) => {
loadLogs(page, pageSize).then();
};
const handlePageSizeChange = async (size) => {
localStorage.setItem('mj-page-size', size + '');
await loadLogs(1, size);
};
// Refresh function
const refresh = async () => {
await loadLogs(1, pageSize);
};
// Copy text function
const copyText = async (text) => {
if (await copy(text)) {
showSuccess(t('已复制:') + text);
} else {
Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text });
}
};
// Modal handlers
const openContentModal = (content) => {
setModalContent(content);
setIsModalOpen(true);
};
const openImageModal = (imageUrl) => {
setModalImageUrl(imageUrl);
setIsModalOpenurl(true);
};
// Initialize data
useEffect(() => {
const localPageSize =
parseInt(localStorage.getItem('mj-page-size')) || ITEMS_PER_PAGE;
setPageSize(localPageSize);
loadLogs(1, localPageSize).then();
}, []);
return {
// Basic state
logs,
loading,
activePage,
logCount,
pageSize,
showBanner,
isAdminUser,
// Modal state
isModalOpen,
setIsModalOpen,
modalContent,
isModalOpenurl,
setIsModalOpenurl,
modalImageUrl,
// Form state
formApi,
setFormApi,
formInitValues,
getFormValues,
// Column visibility
visibleColumns,
showColumnSelector,
setShowColumnSelector,
handleColumnVisibilityChange,
handleSelectAll,
initDefaultColumns,
COLUMN_KEYS,
// Compact mode
compactMode,
setCompactMode,
// Functions
loadLogs,
handlePageChange,
handlePageSizeChange,
refresh,
copyText,
openContentModal,
openImageModal,
enrichLogs,
syncPageData,
// Translation
t,
};
};

View File

@@ -0,0 +1,522 @@
/*
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, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { API, showError, showSuccess } from '../../helpers';
import { ITEMS_PER_PAGE } from '../../constants';
import { useTableCompactMode } from '../common/useTableCompactMode';
export const useDeploymentsData = () => {
const { t } = useTranslation();
const [compactMode, setCompactMode] = useTableCompactMode('deployments');
const requestSeq = useRef(0);
// State management
const [deployments, setDeployments] = useState([]);
const [loading, setLoading] = useState(true);
const [activePage, setActivePage] = useState(1);
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
const [searching, setSearching] = useState(false);
const [deploymentCount, setDeploymentCount] = useState(0);
const [query, setQuery] = useState({ keyword: '', status: '' });
// Modal states
const [showEdit, setShowEdit] = useState(false);
const [editingDeployment, setEditingDeployment] = useState({
id: undefined,
});
// Row selection
const [selectedKeys, setSelectedKeys] = useState([]);
const rowSelection = {
getCheckboxProps: (record) => ({
name: record.deployment_name,
}),
selectedRowKeys: selectedKeys.map((deployment) => deployment.id),
onChange: (selectedRowKeys, selectedRows) => {
setSelectedKeys(selectedRows);
},
};
// Form initial values
const formInitValues = {
searchKeyword: '',
searchStatus: '',
};
// ---------- helpers ----------
// Safely extract array items from API payload
const extractItems = (payload) => {
const items = payload?.items || payload || [];
return Array.isArray(items) ? items : [];
};
// Form API reference
const [formApi, setFormApi] = useState(null);
// Get form values helper function
const getFormValues = () => formApi?.getValues() || formInitValues;
// Close edit modal
const closeEdit = () => {
setShowEdit(false);
setTimeout(() => {
setEditingDeployment({ id: undefined });
}, 500);
};
const normalizeQuery = (terms) => {
const keyword = (terms?.searchKeyword ?? '').trim();
const status = (terms?.searchStatus ?? '').trim();
return { keyword, status };
};
// Column visibility
const COLUMN_KEYS = useMemo(
() => ({
id: 'id',
status: 'status',
provider: 'provider',
container_name: 'container_name',
time_remaining: 'time_remaining',
hardware_info: 'hardware_info',
created_at: 'created_at',
actions: 'actions',
// Legacy keys for compatibility
deployment_name: 'deployment_name',
model_name: 'model_name',
instance_count: 'instance_count',
resource_config: 'resource_config',
updated_at: 'updated_at',
}),
[],
);
const ensureRequiredColumns = (columns = {}) => {
const normalized = {
...columns,
[COLUMN_KEYS.container_name]: true,
[COLUMN_KEYS.actions]: true,
};
if (normalized[COLUMN_KEYS.provider] === undefined) {
normalized[COLUMN_KEYS.provider] = true;
}
return normalized;
};
const [visibleColumns, setVisibleColumnsState] = useState(() => {
const saved = localStorage.getItem('deployments_visible_columns');
if (saved) {
try {
const parsed = JSON.parse(saved);
return ensureRequiredColumns(parsed);
} catch (e) {
console.error('Failed to parse saved column visibility:', e);
}
}
return ensureRequiredColumns({
[COLUMN_KEYS.container_name]: true,
[COLUMN_KEYS.status]: true,
[COLUMN_KEYS.provider]: true,
[COLUMN_KEYS.time_remaining]: true,
[COLUMN_KEYS.hardware_info]: true,
[COLUMN_KEYS.created_at]: true,
[COLUMN_KEYS.actions]: true,
// Legacy columns (hidden by default)
[COLUMN_KEYS.deployment_name]: false,
[COLUMN_KEYS.model_name]: false,
[COLUMN_KEYS.instance_count]: false,
[COLUMN_KEYS.resource_config]: false,
[COLUMN_KEYS.updated_at]: false,
});
});
// Column selector modal
const [showColumnSelector, setShowColumnSelector] = useState(false);
// Save column visibility to localStorage
const saveColumnVisibility = (newVisibleColumns) => {
const normalized = ensureRequiredColumns(newVisibleColumns);
localStorage.setItem(
'deployments_visible_columns',
JSON.stringify(normalized),
);
setVisibleColumnsState(normalized);
};
const applyDeploymentsData = ({ data, page }) => {
const items = extractItems(data);
setActivePage(data?.page ?? page);
setDeploymentCount(data?.total ?? items.length);
setSelectedKeys([]);
setDeployments(
items.map((deployment) => ({ ...deployment, key: deployment.id })),
);
};
const fetchDeployments = async ({ page, size, keyword, status }) => {
const seq = ++requestSeq.current;
const isSearchMode = Boolean(keyword) || Boolean(status);
if (isSearchMode) {
setSearching(true);
} else {
setLoading(true);
}
try {
let url;
if (isSearchMode) {
const params = new URLSearchParams({
p: String(page),
page_size: String(size),
});
if (keyword) params.append('keyword', keyword);
if (status) params.append('status', status);
url = `/api/deployments/search?${params.toString()}`;
} else {
url = `/api/deployments/?p=${page}&page_size=${size}`;
}
const res = await API.get(url);
if (seq !== requestSeq.current) return;
const { success, message, data } = res.data;
if (!success) {
showError(message);
setDeployments([]);
setDeploymentCount(0);
return;
}
applyDeploymentsData({ data, page });
} catch (error) {
if (seq !== requestSeq.current) return;
console.error(error);
showError(isSearchMode ? t('搜索失败') : t('获取部署列表失败'));
setDeployments([]);
setDeploymentCount(0);
} finally {
if (seq !== requestSeq.current) return;
setLoading(false);
setSearching(false);
}
};
// Refresh data
const refresh = async (page = activePage) => {
await fetchDeployments({
page,
size: pageSize,
keyword: query.keyword,
status: query.status,
});
};
// Handle page change
const handlePageChange = (page) => {
setActivePage(page);
fetchDeployments({
page,
size: pageSize,
keyword: query.keyword,
status: query.status,
});
};
// Handle page size change
const handlePageSizeChange = (size) => {
setPageSize(size);
setActivePage(1);
fetchDeployments({
page: 1,
size,
keyword: query.keyword,
status: query.status,
});
};
const loadDeployments = async (page = 1, size = pageSize) => {
await fetchDeployments({
page,
size,
keyword: query.keyword,
status: query.status,
});
};
// Search deployments (also supports pagination)
const searchDeployments = async (searchTerms) => {
const nextQuery = normalizeQuery(searchTerms);
setQuery(nextQuery);
setActivePage(1);
await fetchDeployments({
page: 1,
size: pageSize,
keyword: nextQuery.keyword,
status: nextQuery.status,
});
};
// Deployment operations
const startDeployment = async (deploymentId) => {
try {
const res = await API.post(`/api/deployments/${deploymentId}/start`);
if (res.data.success) {
showSuccess(t('部署启动成功'));
await refresh();
} else {
showError(res.data.message);
}
} catch (error) {
console.error(error);
showError(t('启动部署失败'));
}
};
const restartDeployment = async (deploymentId) => {
try {
const res = await API.post(`/api/deployments/${deploymentId}/restart`);
if (res.data.success) {
showSuccess(t('部署重启成功'));
await refresh();
} else {
showError(res.data.message);
}
} catch (error) {
console.error(error);
showError(t('重启部署失败'));
}
};
const deleteDeployment = async (deploymentId) => {
try {
const res = await API.delete(`/api/deployments/${deploymentId}`);
if (res.data.success) {
showSuccess(t('部署删除成功'));
await refresh();
} else {
showError(res.data.message);
}
} catch (error) {
console.error(error);
showError(t('删除部署失败'));
}
};
const syncDeploymentToChannel = async (deployment) => {
if (!deployment?.id) {
showError(t('同步渠道失败:缺少部署信息'));
return;
}
try {
const containersResp = await API.get(
`/api/deployments/${deployment.id}/containers`,
);
if (!containersResp.data?.success) {
showError(containersResp.data?.message || t('获取容器信息失败'));
return;
}
const containers = containersResp.data?.data?.containers || [];
const activeContainer = containers.find((ctr) => ctr?.public_url);
if (!activeContainer?.public_url) {
showError(t('未找到可用的容器访问地址'));
return;
}
const rawUrl = String(activeContainer.public_url).trim();
const baseUrl = rawUrl.replace(/\/+$/, '');
if (!baseUrl) {
showError(t('容器访问地址无效'));
return;
}
const baseName =
deployment.container_name ||
deployment.deployment_name ||
deployment.name ||
deployment.id;
const safeName = String(baseName || 'ionet').slice(0, 60);
const channelName = `[IO.NET] ${safeName}`;
let randomKey;
try {
randomKey =
typeof crypto !== 'undefined' && crypto.randomUUID
? `ionet-${crypto.randomUUID().replace(/-/g, '')}`
: null;
} catch (err) {
randomKey = null;
}
if (!randomKey) {
randomKey = `ionet-${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
}
const otherInfo = {
source: 'ionet',
deployment_id: deployment.id,
deployment_name: safeName,
container_id: activeContainer.container_id || null,
public_url: baseUrl,
};
const payload = {
mode: 'single',
channel: {
name: channelName,
type: 4,
key: randomKey,
base_url: baseUrl,
group: 'default',
tag: 'ionet',
remark: `[IO.NET] Auto-synced from deployment ${deployment.id}`,
other_info: JSON.stringify(otherInfo),
},
};
const createResp = await API.post('/api/channel/', payload);
if (createResp.data?.success) {
showSuccess(t('已同步到渠道'));
} else {
showError(createResp.data?.message || t('同步渠道失败'));
}
} catch (error) {
console.error(error);
showError(t('同步渠道失败'));
}
};
const updateDeploymentName = async (deploymentId, newName) => {
try {
const res = await API.put(`/api/deployments/${deploymentId}/name`, {
name: newName,
});
if (res.data.success) {
showSuccess(t('部署名称更新成功'));
await refresh();
return true;
} else {
showError(res.data.message);
return false;
}
} catch (error) {
console.error(error);
showError(t('更新部署名称失败'));
return false;
}
};
// Batch operations
const batchDeleteDeployments = async () => {
if (selectedKeys.length === 0) return;
try {
const ids = selectedKeys.map((deployment) => deployment.id);
const res = await API.post('/api/deployments/batch_delete', { ids });
if (res.data.success) {
showSuccess(t('批量删除成功'));
setSelectedKeys([]);
await refresh();
} else {
showError(res.data.message);
}
} catch (error) {
console.error(error);
showError(t('批量删除失败'));
}
};
// Table row click handler
const handleRow = (record) => ({
onClick: () => {
// Handle row click if needed
},
});
// Initial load
useEffect(() => {
loadDeployments();
}, []);
return {
// Data
deployments,
loading,
searching,
activePage,
pageSize,
deploymentCount,
compactMode,
setCompactMode,
// Selection
selectedKeys,
setSelectedKeys,
rowSelection,
// Modals
showEdit,
setShowEdit,
editingDeployment,
setEditingDeployment,
closeEdit,
// Column visibility
visibleColumns,
setVisibleColumns: saveColumnVisibility,
showColumnSelector,
setShowColumnSelector,
COLUMN_KEYS,
// Form
formInitValues,
formApi,
setFormApi,
getFormValues,
// Operations
loadDeployments,
searchDeployments,
refresh,
handlePageChange,
handlePageSizeChange,
handleRow,
// Deployment operations
startDeployment,
restartDeployment,
deleteDeployment,
updateDeploymentName,
syncDeploymentToChannel,
// Batch operations
batchDeleteDeployments,
// Translation
t,
};
};

View File

@@ -0,0 +1,137 @@
/*
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 { useCallback, useEffect, useState } from 'react';
import { API } from '../../helpers';
export const useModelDeploymentSettings = () => {
const [loading, setLoading] = useState(true);
const [settings, setSettings] = useState({
'model_deployment.ionet.enabled': false,
});
const [connectionState, setConnectionState] = useState({
loading: false,
ok: null,
error: null,
});
const getSettings = async () => {
try {
setLoading(true);
const res = await API.get('/api/deployments/settings');
const { success, data } = res.data;
if (success) {
setSettings({
'model_deployment.ionet.enabled': data?.enabled === true,
});
}
} catch (error) {
console.error('Failed to get model deployment settings:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
getSettings();
}, []);
const isIoNetEnabled = settings['model_deployment.ionet.enabled'];
const buildConnectionError = (
rawMessage,
fallbackMessage = 'Connection failed',
) => {
const message = (rawMessage || fallbackMessage).trim();
const normalized = message.toLowerCase();
if (normalized.includes('expired') || normalized.includes('expire')) {
return { type: 'expired', message };
}
if (
normalized.includes('invalid') ||
normalized.includes('unauthorized') ||
normalized.includes('api key')
) {
return { type: 'invalid', message };
}
if (normalized.includes('network') || normalized.includes('timeout')) {
return { type: 'network', message };
}
return { type: 'unknown', message };
};
const testConnection = useCallback(async () => {
setConnectionState({ loading: true, ok: null, error: null });
try {
const response = await API.post(
'/api/deployments/settings/test-connection',
{},
{ skipErrorHandler: true },
);
if (response?.data?.success) {
setConnectionState({ loading: false, ok: true, error: null });
return;
}
const message = response?.data?.message || 'Connection failed';
setConnectionState({
loading: false,
ok: false,
error: buildConnectionError(message),
});
} catch (error) {
if (error?.code === 'ERR_NETWORK') {
setConnectionState({
loading: false,
ok: false,
error: { type: 'network', message: 'Network connection failed' },
});
return;
}
const rawMessage =
error?.response?.data?.message || error?.message || 'Unknown error';
setConnectionState({
loading: false,
ok: false,
error: buildConnectionError(rawMessage, 'Connection failed'),
});
}
}, []);
useEffect(() => {
if (!loading && isIoNetEnabled) {
testConnection();
return;
}
setConnectionState({ loading: false, ok: null, error: null });
}, [loading, isIoNetEnabled, testConnection]);
return {
loading,
settings,
isIoNetEnabled,
refresh: getSettings,
connectionLoading: connectionState.loading,
connectionOk: connectionState.ok,
connectionError: connectionState.error,
testConnection,
};
};

View File

@@ -0,0 +1,408 @@
/*
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, useRef, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { API, copy, showError, showInfo, showSuccess } from '../../helpers';
import { Modal } from '@douyinfe/semi-ui';
import { UserContext } from '../../context/User';
import { StatusContext } from '../../context/Status';
export const useModelPricingData = () => {
const { t } = useTranslation();
const [searchValue, setSearchValue] = useState('');
const compositionRef = useRef({ isComposition: false });
const [selectedRowKeys, setSelectedRowKeys] = useState([]);
const [modalImageUrl, setModalImageUrl] = useState('');
const [isModalOpenurl, setIsModalOpenurl] = useState(false);
const [selectedGroup, setSelectedGroup] = useState('all');
const [showModelDetail, setShowModelDetail] = useState(false);
const [selectedModel, setSelectedModel] = useState(null);
const [filterGroup, setFilterGroup] = useState('all'); // 用于 Table 的可用分组筛选,"all" 表示不过滤
const [filterQuotaType, setFilterQuotaType] = useState('all'); // 计费类型筛选: 'all' | 0 | 1
const [filterEndpointType, setFilterEndpointType] = useState('all'); // 端点类型筛选: 'all' | string
const [filterVendor, setFilterVendor] = useState('all'); // 供应商筛选: 'all' | 'unknown' | string
const [filterTag, setFilterTag] = useState('all'); // 模型标签筛选: 'all' | string
const [pageSize, setPageSize] = useState(20);
const [currentPage, setCurrentPage] = useState(1);
const [currency, setCurrency] = useState('USD');
const [showWithRecharge, setShowWithRecharge] = useState(false);
const [tokenUnit, setTokenUnit] = useState('M');
const [models, setModels] = useState([]);
const [vendorsMap, setVendorsMap] = useState({});
const [loading, setLoading] = useState(true);
const [groupRatio, setGroupRatio] = useState({});
const [usableGroup, setUsableGroup] = useState({});
const [endpointMap, setEndpointMap] = useState({});
const [autoGroups, setAutoGroups] = useState([]);
const [statusState] = useContext(StatusContext);
const [userState] = useContext(UserContext);
// 充值汇率price与美元兑人民币汇率usd_exchange_rate
const priceRate = useMemo(
() => statusState?.status?.price ?? 1,
[statusState],
);
const usdExchangeRate = useMemo(
() => statusState?.status?.usd_exchange_rate ?? priceRate,
[statusState, priceRate],
);
const customExchangeRate = useMemo(
() => statusState?.status?.custom_currency_exchange_rate ?? 1,
[statusState],
);
const customCurrencySymbol = useMemo(
() => statusState?.status?.custom_currency_symbol ?? '¤',
[statusState],
);
// 默认货币与站点展示类型同步TOKENS 由视图层走倍率展示
const siteDisplayType = useMemo(
() => statusState?.status?.quota_display_type || 'USD',
[statusState],
);
useEffect(() => {
if (
siteDisplayType === 'USD' ||
siteDisplayType === 'CNY' ||
siteDisplayType === 'CUSTOM'
) {
setCurrency(siteDisplayType);
}
}, [siteDisplayType]);
useEffect(() => {
if (siteDisplayType === 'TOKENS') {
setShowWithRecharge(false);
setCurrency('USD');
}
}, [siteDisplayType]);
const filteredModels = useMemo(() => {
let result = models;
// 分组筛选
if (filterGroup !== 'all') {
result = result.filter((model) =>
model.enable_groups.includes(filterGroup),
);
}
// 计费类型筛选
if (filterQuotaType !== 'all') {
result = result.filter((model) => model.quota_type === filterQuotaType);
}
// 端点类型筛选
if (filterEndpointType !== 'all') {
result = result.filter(
(model) =>
model.supported_endpoint_types &&
model.supported_endpoint_types.includes(filterEndpointType),
);
}
// 供应商筛选
if (filterVendor !== 'all') {
if (filterVendor === 'unknown') {
result = result.filter((model) => !model.vendor_name);
} else {
result = result.filter((model) => model.vendor_name === filterVendor);
}
}
// 标签筛选
if (filterTag !== 'all') {
const tagLower = filterTag.toLowerCase();
result = result.filter((model) => {
if (!model.tags) return false;
const tagsArr = model.tags
.toLowerCase()
.split(/[,;|]+/)
.map((tag) => tag.trim())
.filter(Boolean);
return tagsArr.includes(tagLower);
});
}
// 搜索筛选
if (searchValue.length > 0) {
const searchTerm = searchValue.toLowerCase();
result = result.filter(
(model) =>
(model.model_name &&
model.model_name.toLowerCase().includes(searchTerm)) ||
(model.description &&
model.description.toLowerCase().includes(searchTerm)) ||
(model.tags && model.tags.toLowerCase().includes(searchTerm)) ||
(model.vendor_name &&
model.vendor_name.toLowerCase().includes(searchTerm)),
);
}
return result;
}, [
models,
searchValue,
filterGroup,
filterQuotaType,
filterEndpointType,
filterVendor,
filterTag,
]);
const rowSelection = useMemo(
() => ({
selectedRowKeys,
onChange: (keys) => {
setSelectedRowKeys(keys);
},
}),
[selectedRowKeys],
);
const displayPrice = (usdPrice) => {
let priceInUSD = usdPrice;
if (showWithRecharge) {
priceInUSD = (usdPrice * priceRate) / usdExchangeRate;
}
if (currency === 'CNY') {
return `¥${(priceInUSD * usdExchangeRate).toFixed(3)}`;
} else if (currency === 'CUSTOM') {
return `${customCurrencySymbol}${(priceInUSD * customExchangeRate).toFixed(3)}`;
}
return `$${priceInUSD.toFixed(3)}`;
};
const setModelsFormat = (models, groupRatio, vendorMap) => {
for (let i = 0; i < models.length; i++) {
const m = models[i];
m.key = m.model_name;
m.group_ratio = groupRatio[m.model_name];
if (m.vendor_id && vendorMap[m.vendor_id]) {
const vendor = vendorMap[m.vendor_id];
m.vendor_name = vendor.name;
m.vendor_icon = vendor.icon;
m.vendor_description = vendor.description;
}
}
models.sort((a, b) => {
return a.quota_type - b.quota_type;
});
models.sort((a, b) => {
if (a.model_name.startsWith('gpt') && !b.model_name.startsWith('gpt')) {
return -1;
} else if (
!a.model_name.startsWith('gpt') &&
b.model_name.startsWith('gpt')
) {
return 1;
} else {
return a.model_name.localeCompare(b.model_name);
}
});
setModels(models);
};
const loadPricing = async () => {
setLoading(true);
let url = '/api/pricing';
const res = await API.get(url);
const {
success,
message,
data,
vendors,
group_ratio,
usable_group,
supported_endpoint,
auto_groups,
} = res.data;
if (success) {
setGroupRatio(group_ratio);
setUsableGroup(usable_group);
setSelectedGroup('all');
// 构建供应商 Map 方便查找
const vendorMap = {};
if (Array.isArray(vendors)) {
vendors.forEach((v) => {
vendorMap[v.id] = v;
});
}
setVendorsMap(vendorMap);
setEndpointMap(supported_endpoint || {});
setAutoGroups(auto_groups || []);
setModelsFormat(data, group_ratio, vendorMap);
} else {
showError(message);
}
setLoading(false);
};
const refresh = async () => {
await loadPricing();
};
const copyText = async (text) => {
if (await copy(text)) {
showSuccess(t('已复制:') + text);
} else {
Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text });
}
};
const handleChange = (value) => {
const newSearchValue = value ? value : '';
setSearchValue(newSearchValue);
};
const handleCompositionStart = () => {
compositionRef.current.isComposition = true;
};
const handleCompositionEnd = (event) => {
compositionRef.current.isComposition = false;
const value = event.target.value;
const newSearchValue = value ? value : '';
setSearchValue(newSearchValue);
};
const handleGroupClick = (group) => {
setSelectedGroup(group);
setFilterGroup(group);
if (group === 'all') {
showInfo(t('已切换至最优倍率视图,每个模型使用其最低倍率分组'));
} else {
showInfo(
t('当前查看的分组为:{{group}},倍率为:{{ratio}}', {
group: group,
ratio: groupRatio[group] ?? 1,
}),
);
}
};
const openModelDetail = (model) => {
setSelectedModel(model);
setShowModelDetail(true);
};
const closeModelDetail = () => {
setShowModelDetail(false);
setTimeout(() => {
setSelectedModel(null);
}, 300);
};
useEffect(() => {
refresh().then();
}, []);
// 当筛选条件变化时重置到第一页
useEffect(() => {
setCurrentPage(1);
}, [
filterGroup,
filterQuotaType,
filterEndpointType,
filterVendor,
filterTag,
searchValue,
]);
return {
// 状态
searchValue,
setSearchValue,
selectedRowKeys,
setSelectedRowKeys,
modalImageUrl,
setModalImageUrl,
isModalOpenurl,
setIsModalOpenurl,
selectedGroup,
setSelectedGroup,
showModelDetail,
setShowModelDetail,
selectedModel,
setSelectedModel,
filterGroup,
setFilterGroup,
filterQuotaType,
setFilterQuotaType,
filterEndpointType,
setFilterEndpointType,
filterVendor,
setFilterVendor,
filterTag,
setFilterTag,
pageSize,
setPageSize,
currentPage,
setCurrentPage,
currency,
setCurrency,
siteDisplayType,
showWithRecharge,
setShowWithRecharge,
tokenUnit,
setTokenUnit,
models,
loading,
groupRatio,
usableGroup,
endpointMap,
autoGroups,
// 计算属性
priceRate,
usdExchangeRate,
filteredModels,
rowSelection,
// 供应商
vendorsMap,
// 用户和状态
userState,
statusState,
// 方法
displayPrice,
refresh,
copyText,
handleChange,
handleCompositionStart,
handleCompositionEnd,
handleGroupClick,
openModelDetail,
closeModelDetail,
// 引用
compositionRef,
// 国际化
t,
};
};

View File

@@ -0,0 +1,174 @@
/*
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';
// 工具函数:将 tags 字符串转为小写去重数组
const normalizeTags = (tags = '') =>
tags
.toLowerCase()
.split(/[,;|]+/)
.map((t) => t.trim())
.filter(Boolean);
/**
* 统一计算模型筛选后的各种集合与动态计数,供多个组件复用
*/
export const usePricingFilterCounts = ({
models = [],
filterGroup = 'all',
filterQuotaType = 'all',
filterEndpointType = 'all',
filterVendor = 'all',
filterTag = 'all',
searchValue = '',
}) => {
// 均使用同一份模型列表,避免创建新引用
const allModels = models;
/**
* 通用过滤函数
* @param {Object} model
* @param {Array<string>} ignore 需要忽略的过滤条件 key
* @returns {boolean}
*/
const matchesFilters = (model, ignore = []) => {
// 分组
if (!ignore.includes('group') && filterGroup !== 'all') {
if (!model.enable_groups || !model.enable_groups.includes(filterGroup))
return false;
}
// 计费类型
if (!ignore.includes('quota') && filterQuotaType !== 'all') {
if (model.quota_type !== filterQuotaType) return false;
}
// 端点类型
if (!ignore.includes('endpoint') && filterEndpointType !== 'all') {
if (
!model.supported_endpoint_types ||
!model.supported_endpoint_types.includes(filterEndpointType)
)
return false;
}
// 供应商
if (!ignore.includes('vendor') && filterVendor !== 'all') {
if (filterVendor === 'unknown') {
if (model.vendor_name) return false;
} else if (model.vendor_name !== filterVendor) {
return false;
}
}
// 标签
if (!ignore.includes('tag') && filterTag !== 'all') {
const tagsArr = normalizeTags(model.tags);
if (!tagsArr.includes(filterTag.toLowerCase())) return false;
}
// 搜索
if (!ignore.includes('search') && searchValue) {
const term = searchValue.toLowerCase();
const tags = model.tags ? model.tags.toLowerCase() : '';
if (
!(
model.model_name.toLowerCase().includes(term) ||
(model.description &&
model.description.toLowerCase().includes(term)) ||
tags.includes(term) ||
(model.vendor_name && model.vendor_name.toLowerCase().includes(term))
)
)
return false;
}
return true;
};
// 生成不同视图所需的模型集合
const quotaTypeModels = useMemo(
() => allModels.filter((m) => matchesFilters(m, ['quota'])),
[
allModels,
filterGroup,
filterEndpointType,
filterVendor,
filterTag,
searchValue,
],
);
const endpointTypeModels = useMemo(
() => allModels.filter((m) => matchesFilters(m, ['endpoint'])),
[
allModels,
filterGroup,
filterQuotaType,
filterVendor,
filterTag,
searchValue,
],
);
const vendorModels = useMemo(
() => allModels.filter((m) => matchesFilters(m, ['vendor'])),
[
allModels,
filterGroup,
filterQuotaType,
filterEndpointType,
filterTag,
searchValue,
],
);
const tagModels = useMemo(
() => allModels.filter((m) => matchesFilters(m, ['tag'])),
[
allModels,
filterGroup,
filterQuotaType,
filterEndpointType,
filterVendor,
searchValue,
],
);
const groupCountModels = useMemo(
() => allModels.filter((m) => matchesFilters(m, ['group'])),
[
allModels,
filterQuotaType,
filterEndpointType,
filterVendor,
filterTag,
searchValue,
],
);
return {
quotaTypeModels,
endpointTypeModels,
vendorModels,
groupCountModels,
tagModels,
};
};

497
web/src/hooks/models/useModelsData.jsx vendored Normal file
View File

@@ -0,0 +1,497 @@
/*
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 } from 'react';
import { useTranslation } from 'react-i18next';
import { API, showError, showSuccess } from '../../helpers';
import { ITEMS_PER_PAGE } from '../../constants';
import { useTableCompactMode } from '../common/useTableCompactMode';
export const useModelsData = () => {
const { t } = useTranslation();
const [compactMode, setCompactMode] = useTableCompactMode('models');
// State management
const [models, setModels] = useState([]);
const [loading, setLoading] = useState(true);
const [activePage, setActivePage] = useState(1);
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
const [searching, setSearching] = useState(false);
const [modelCount, setModelCount] = useState(0);
// Modal states
const [showEdit, setShowEdit] = useState(false);
const [editingModel, setEditingModel] = useState({
id: undefined,
});
// Row selection
const [selectedKeys, setSelectedKeys] = useState([]);
const rowSelection = {
getCheckboxProps: (record) => ({
name: record.model_name,
}),
selectedRowKeys: selectedKeys.map((model) => model.id),
onChange: (selectedRowKeys, selectedRows) => {
setSelectedKeys(selectedRows);
},
};
// Form initial values
const formInitValues = {
searchKeyword: '',
searchVendor: '',
};
// ---------- helpers ----------
// Safely extract array items from API payload
const extractItems = (payload) => {
const items = payload?.items || payload || [];
return Array.isArray(items) ? items : [];
};
// Form API reference
const [formApi, setFormApi] = useState(null);
// Get form values helper function
const getFormValues = () => formApi?.getValues() || formInitValues;
// Close edit modal
const closeEdit = () => {
setShowEdit(false);
setTimeout(() => {
setEditingModel({ id: undefined });
}, 500);
};
// Set model format with key field
const setModelFormat = (models) => {
for (let i = 0; i < models.length; i++) {
models[i].key = models[i].id;
}
setModels(models);
};
// Vendor list
const [vendors, setVendors] = useState([]);
const [vendorCounts, setVendorCounts] = useState({});
const [activeVendorKey, setActiveVendorKey] = useState('all');
const [showAddVendor, setShowAddVendor] = useState(false);
const [showEditVendor, setShowEditVendor] = useState(false);
const [editingVendor, setEditingVendor] = useState({ id: undefined });
const [syncing, setSyncing] = useState(false);
const [previewing, setPreviewing] = useState(false);
const vendorMap = useMemo(() => {
const map = {};
vendors.forEach((v) => {
map[v.id] = v;
});
return map;
}, [vendors]);
// Load vendor list
const loadVendors = async () => {
try {
const res = await API.get('/api/vendors/?page_size=1000');
if (res.data.success) {
const items = res.data.data.items || res.data.data || [];
setVendors(Array.isArray(items) ? items : []);
}
} catch (_) {
// ignore
}
};
// Load models data
const loadModels = async (
page = 1,
size = pageSize,
vendorKey = activeVendorKey,
) => {
setLoading(true);
try {
let url = `/api/models/?p=${page}&page_size=${size}`;
if (vendorKey && vendorKey !== 'all') {
// Filter by vendor ID
url = `/api/models/search?vendor=${vendorKey}&p=${page}&page_size=${size}`;
}
const res = await API.get(url);
const { success, message, data } = res.data;
if (success) {
const newPageData = extractItems(data);
setActivePage(data.page || page);
setModelCount(data.total || newPageData.length);
setModelFormat(newPageData);
if (data.vendor_counts) {
const sumAll = Object.values(data.vendor_counts).reduce(
(acc, v) => acc + v,
0,
);
setVendorCounts({ ...data.vendor_counts, all: sumAll });
}
} else {
showError(message);
setModels([]);
}
} catch (error) {
console.error(error);
showError(t('获取模型列表失败'));
setModels([]);
}
setLoading(false);
};
// Refresh data
const refresh = async (page = activePage) => {
await loadModels(page, pageSize);
};
// Sync upstream models/vendors for missing models only
const syncUpstream = async (opts = {}) => {
const locale = opts?.locale;
setSyncing(true);
try {
const body = {};
if (locale) body.locale = locale;
const res = await API.post('/api/models/sync_upstream', body);
const { success, message, data } = res.data || {};
if (success) {
const createdModels = data?.created_models || 0;
const createdVendors = data?.created_vendors || 0;
const skipped = (data?.skipped_models || []).length || 0;
showSuccess(
t(
`已同步:新增 ${createdModels} 模型,新增 ${createdVendors} 供应商,跳过 ${skipped}`,
),
);
await loadVendors();
await refresh();
} else {
showError(message || t('同步失败'));
}
} catch (e) {
showError(t('同步失败'));
}
setSyncing(false);
};
// Preview upstream differences
const previewUpstreamDiff = async (opts = {}) => {
const locale = opts?.locale;
setPreviewing(true);
try {
const url = `/api/models/sync_upstream/preview${locale ? `?locale=${locale}` : ''}`;
const res = await API.get(url);
const { success, message, data } = res.data || {};
if (success) {
return data || { missing: [], conflicts: [] };
}
showError(message || t('预览失败'));
return { missing: [], conflicts: [] };
} catch (e) {
showError(t('预览失败'));
return { missing: [], conflicts: [] };
} finally {
setPreviewing(false);
}
};
// Apply selected overwrite
const applyUpstreamOverwrite = async (payloadOrArray = []) => {
const isArray = Array.isArray(payloadOrArray);
const overwrite = isArray ? payloadOrArray : payloadOrArray.overwrite || [];
const locale = isArray ? undefined : payloadOrArray.locale;
setSyncing(true);
try {
const body = { overwrite };
if (locale) body.locale = locale;
const res = await API.post('/api/models/sync_upstream', body);
const { success, message, data } = res.data || {};
if (success) {
const createdModels = data?.created_models || 0;
const updatedModels = data?.updated_models || 0;
const createdVendors = data?.created_vendors || 0;
const skipped = (data?.skipped_models || []).length || 0;
showSuccess(
t(
`完成:新增 ${createdModels} 模型,更新 ${updatedModels} 模型,新增 ${createdVendors} 供应商,跳过 ${skipped}`,
),
);
await loadVendors();
await refresh();
return true;
}
showError(message || t('同步失败'));
return false;
} catch (e) {
showError(t('同步失败'));
return false;
} finally {
setSyncing(false);
}
};
// Search models with keyword and vendor
const searchModels = async () => {
const { searchKeyword = '', searchVendor = '' } = getFormValues();
if (searchKeyword === '' && searchVendor === '') {
// If keyword is blank, load models instead
await loadModels(1, pageSize);
return;
}
setSearching(true);
try {
const res = await API.get(
`/api/models/search?keyword=${searchKeyword}&vendor=${searchVendor}&p=1&page_size=${pageSize}`,
);
const { success, message, data } = res.data;
if (success) {
const newPageData = extractItems(data);
setActivePage(data.page || 1);
setModelCount(data.total || newPageData.length);
setModelFormat(newPageData);
if (data.vendor_counts) {
const sumAll = Object.values(data.vendor_counts).reduce(
(acc, v) => acc + v,
0,
);
setVendorCounts({ ...data.vendor_counts, all: sumAll });
}
} else {
showError(message);
setModels([]);
}
} catch (error) {
console.error(error);
showError(t('搜索模型失败'));
setModels([]);
}
setSearching(false);
};
// Manage model (enable/disable/delete)
const manageModel = async (id, action, record) => {
let res;
switch (action) {
case 'delete':
res = await API.delete(`/api/models/${id}`);
break;
case 'enable':
res = await API.put('/api/models/?status_only=true', { id, status: 1 });
break;
case 'disable':
res = await API.put('/api/models/?status_only=true', { id, status: 0 });
break;
default:
return;
}
const { success, message } = res.data;
if (success) {
showSuccess(t('操作成功完成!'));
if (action === 'delete') {
await refresh();
} else {
// Update local state for enable/disable
setModels((prevModels) =>
prevModels.map((model) =>
model.id === id
? { ...model, status: action === 'enable' ? 1 : 0 }
: model,
),
);
}
} else {
showError(message);
}
};
// Handle page change
const handlePageChange = (page) => {
setActivePage(page);
loadModels(page, pageSize, activeVendorKey);
};
// Reload models when activeVendorKey changes
useEffect(() => {
loadModels(1, pageSize, activeVendorKey);
}, [activeVendorKey]);
// Handle page size change
const handlePageSizeChange = async (size) => {
setPageSize(size);
setActivePage(1);
await loadModels(1, size, activeVendorKey);
};
// Handle row click and styling
const handleRow = (record, index) => {
const rowStyle =
record.status !== 1
? {
style: {
background: 'var(--semi-color-disabled-border)',
},
}
: {};
return {
...rowStyle,
onClick: (event) => {
// Don't trigger row selection when clicking on buttons
if (event.target.closest('button, .semi-button')) {
return;
}
const newSelectedKeys = selectedKeys.some(
(item) => item.id === record.id,
)
? selectedKeys.filter((item) => item.id !== record.id)
: [...selectedKeys, record];
setSelectedKeys(newSelectedKeys);
},
};
};
// Batch delete models
const batchDeleteModels = async () => {
if (selectedKeys.length === 0) {
showError(t('请至少选择一个模型'));
return;
}
try {
const deletePromises = selectedKeys.map((model) =>
API.delete(`/api/models/${model.id}`),
);
const results = await Promise.all(deletePromises);
let successCount = 0;
results.forEach((res, index) => {
if (res.data.success) {
successCount++;
} else {
showError(
`删除模型 ${selectedKeys[index].model_name} 失败: ${res.data.message}`,
);
}
});
if (successCount > 0) {
showSuccess(t(`成功删除 ${successCount} 个模型`));
setSelectedKeys([]);
await refresh();
}
} catch (error) {
showError(t('批量删除失败'));
}
};
// Copy text helper
const copyText = async (text) => {
try {
await navigator.clipboard.writeText(text);
showSuccess(t('复制成功'));
} catch (error) {
console.error('Copy failed:', error);
showError(t('复制失败'));
}
};
// Initial load
useEffect(() => {
(async () => {
await loadVendors();
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return {
// Data state
models,
loading,
searching,
activePage,
pageSize,
modelCount,
// Selection state
selectedKeys,
rowSelection,
handleRow,
setSelectedKeys,
// Modal state
showEdit,
editingModel,
setEditingModel,
setShowEdit,
closeEdit,
// Form state
formInitValues,
setFormApi,
// Actions
loadModels,
searchModels,
refresh,
manageModel,
batchDeleteModels,
copyText,
// Pagination
setActivePage,
handlePageChange,
handlePageSizeChange,
// UI state
compactMode,
setCompactMode,
// Vendor data
vendors,
vendorMap,
vendorCounts,
activeVendorKey,
setActiveVendorKey,
showAddVendor,
setShowAddVendor,
showEditVendor,
setShowEditVendor,
editingVendor,
setEditingVendor,
loadVendors,
// Translation
t,
// Upstream sync
syncing,
previewing,
syncUpstream,
previewUpstreamDiff,
applyUpstreamOverwrite,
};
};

View File

@@ -0,0 +1,518 @@
/*
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 { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { SSE } from 'sse.js';
import {
API_ENDPOINTS,
MESSAGE_STATUS,
DEBUG_TABS,
} from '../../constants/playground.constants';
import {
getUserIdFromLocalStorage,
handleApiError,
processThinkTags,
processIncompleteThinkTags,
} from '../../helpers';
export const useApiRequest = (
setMessage,
setDebugData,
setActiveDebugTab,
sseSourceRef,
saveMessages,
) => {
const { t } = useTranslation();
// 处理消息自动关闭逻辑的公共函数
const applyAutoCollapseLogic = useCallback(
(message, isThinkingComplete = true) => {
const shouldAutoCollapse =
isThinkingComplete && !message.hasAutoCollapsed;
return {
isThinkingComplete,
hasAutoCollapsed: shouldAutoCollapse || message.hasAutoCollapsed,
isReasoningExpanded: shouldAutoCollapse
? false
: message.isReasoningExpanded,
};
},
[],
);
// 流式消息更新
const streamMessageUpdate = useCallback(
(textChunk, type) => {
setMessage((prevMessage) => {
const lastMessage = prevMessage[prevMessage.length - 1];
if (!lastMessage) return prevMessage;
if (lastMessage.role !== 'assistant') return prevMessage;
if (lastMessage.status === MESSAGE_STATUS.ERROR) {
return prevMessage;
}
if (
lastMessage.status === MESSAGE_STATUS.LOADING ||
lastMessage.status === MESSAGE_STATUS.INCOMPLETE
) {
let newMessage = { ...lastMessage };
if (type === 'reasoning') {
newMessage = {
...newMessage,
reasoningContent:
(lastMessage.reasoningContent || '') + textChunk,
status: MESSAGE_STATUS.INCOMPLETE,
isThinkingComplete: false,
};
} else if (type === 'content') {
const shouldCollapseReasoning =
!lastMessage.content && lastMessage.reasoningContent;
const newContent = (lastMessage.content || '') + textChunk;
let shouldCollapseFromThinkTag = false;
let thinkingCompleteFromTags = lastMessage.isThinkingComplete;
if (
lastMessage.isReasoningExpanded &&
newContent.includes('</think>')
) {
const thinkMatches = newContent.match(/<think>/g);
const thinkCloseMatches = newContent.match(/<\/think>/g);
if (
thinkMatches &&
thinkCloseMatches &&
thinkCloseMatches.length >= thinkMatches.length
) {
shouldCollapseFromThinkTag = true;
thinkingCompleteFromTags = true; // think标签闭合也标记思考完成
}
}
// 如果开始接收content内容且之前有reasoning内容或者think标签已闭合则标记思考完成
const isThinkingComplete =
(lastMessage.reasoningContent &&
!lastMessage.isThinkingComplete) ||
thinkingCompleteFromTags;
const autoCollapseState = applyAutoCollapseLogic(
lastMessage,
isThinkingComplete,
);
newMessage = {
...newMessage,
content: newContent,
status: MESSAGE_STATUS.INCOMPLETE,
...autoCollapseState,
};
}
return [...prevMessage.slice(0, -1), newMessage];
}
return prevMessage;
});
},
[setMessage, applyAutoCollapseLogic],
);
// 完成消息
const completeMessage = useCallback(
(status = MESSAGE_STATUS.COMPLETE) => {
setMessage((prevMessage) => {
const lastMessage = prevMessage[prevMessage.length - 1];
if (
lastMessage.status === MESSAGE_STATUS.COMPLETE ||
lastMessage.status === MESSAGE_STATUS.ERROR
) {
return prevMessage;
}
const autoCollapseState = applyAutoCollapseLogic(lastMessage, true);
const updatedMessages = [
...prevMessage.slice(0, -1),
{
...lastMessage,
status: status,
...autoCollapseState,
},
];
// 在消息完成时保存,传入更新后的消息列表
if (
status === MESSAGE_STATUS.COMPLETE ||
status === MESSAGE_STATUS.ERROR
) {
setTimeout(() => saveMessages(updatedMessages), 0);
}
return updatedMessages;
});
},
[setMessage, applyAutoCollapseLogic, saveMessages],
);
// 非流式请求
const handleNonStreamRequest = useCallback(
async (payload) => {
setDebugData((prev) => ({
...prev,
request: payload,
timestamp: new Date().toISOString(),
response: null,
sseMessages: null, // 非流式请求清除 SSE 消息
isStreaming: false,
}));
setActiveDebugTab(DEBUG_TABS.REQUEST);
try {
const response = await fetch(API_ENDPOINTS.CHAT_COMPLETIONS, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'New-Api-User': getUserIdFromLocalStorage(),
},
body: JSON.stringify(payload),
});
if (!response.ok) {
let errorBody = '';
try {
errorBody = await response.text();
} catch (e) {
errorBody = '无法读取错误响应体';
}
const errorInfo = handleApiError(
new Error(
`HTTP error! status: ${response.status}, body: ${errorBody}`,
),
response,
);
setDebugData((prev) => ({
...prev,
response: JSON.stringify(errorInfo, null, 2),
}));
setActiveDebugTab(DEBUG_TABS.RESPONSE);
throw new Error(
`HTTP error! status: ${response.status}, body: ${errorBody}`,
);
}
const data = await response.json();
setDebugData((prev) => ({
...prev,
response: JSON.stringify(data, null, 2),
}));
setActiveDebugTab(DEBUG_TABS.RESPONSE);
if (data.choices?.[0]) {
const choice = data.choices[0];
let content = choice.message?.content || '';
let reasoningContent =
choice.message?.reasoning_content ||
choice.message?.reasoning ||
'';
const processed = processThinkTags(content, reasoningContent);
setMessage((prevMessage) => {
const newMessages = [...prevMessage];
const lastMessage = newMessages[newMessages.length - 1];
if (lastMessage?.status === MESSAGE_STATUS.LOADING) {
const autoCollapseState = applyAutoCollapseLogic(
lastMessage,
true,
);
newMessages[newMessages.length - 1] = {
...lastMessage,
content: processed.content,
reasoningContent: processed.reasoningContent,
status: MESSAGE_STATUS.COMPLETE,
...autoCollapseState,
};
}
return newMessages;
});
}
} catch (error) {
console.error('Non-stream request error:', error);
const errorInfo = handleApiError(error);
setDebugData((prev) => ({
...prev,
response: JSON.stringify(errorInfo, null, 2),
}));
setActiveDebugTab(DEBUG_TABS.RESPONSE);
setMessage((prevMessage) => {
const newMessages = [...prevMessage];
const lastMessage = newMessages[newMessages.length - 1];
if (lastMessage?.status === MESSAGE_STATUS.LOADING) {
const autoCollapseState = applyAutoCollapseLogic(lastMessage, true);
newMessages[newMessages.length - 1] = {
...lastMessage,
content: t('请求发生错误: ') + error.message,
status: MESSAGE_STATUS.ERROR,
...autoCollapseState,
};
}
return newMessages;
});
}
},
[setDebugData, setActiveDebugTab, setMessage, t, applyAutoCollapseLogic],
);
// SSE请求
const handleSSE = useCallback(
(payload) => {
setDebugData((prev) => ({
...prev,
request: payload,
timestamp: new Date().toISOString(),
response: null,
sseMessages: [], // 新增:存储 SSE 消息数组
isStreaming: true, // 新增:标记流式状态
}));
setActiveDebugTab(DEBUG_TABS.REQUEST);
const source = new SSE(API_ENDPOINTS.CHAT_COMPLETIONS, {
headers: {
'Content-Type': 'application/json',
'New-Api-User': getUserIdFromLocalStorage(),
},
method: 'POST',
payload: JSON.stringify(payload),
});
sseSourceRef.current = source;
let responseData = '';
let hasReceivedFirstResponse = false;
let isStreamComplete = false; // 添加标志位跟踪流是否正常完成
source.addEventListener('message', (e) => {
if (e.data === '[DONE]') {
isStreamComplete = true; // 标记流正常完成
source.close();
sseSourceRef.current = null;
setDebugData((prev) => ({
...prev,
response: responseData,
sseMessages: [...(prev.sseMessages || []), '[DONE]'], // 添加 DONE 标记
isStreaming: false,
}));
completeMessage();
return;
}
try {
const payload = JSON.parse(e.data);
responseData += e.data + '\n';
if (!hasReceivedFirstResponse) {
setActiveDebugTab(DEBUG_TABS.RESPONSE);
hasReceivedFirstResponse = true;
}
// 新增:将 SSE 消息添加到数组
setDebugData((prev) => ({
...prev,
sseMessages: [...(prev.sseMessages || []), e.data],
}));
const delta = payload.choices?.[0]?.delta;
if (delta) {
if (delta.reasoning_content) {
streamMessageUpdate(delta.reasoning_content, 'reasoning');
}
if (delta.reasoning) {
streamMessageUpdate(delta.reasoning, 'reasoning');
}
if (delta.content) {
streamMessageUpdate(delta.content, 'content');
}
}
} catch (error) {
console.error('Failed to parse SSE message:', error);
const errorInfo = `解析错误: ${error.message}`;
setDebugData((prev) => ({
...prev,
response: responseData + `\n\nError: ${errorInfo}`,
sseMessages: [...(prev.sseMessages || []), e.data], // 即使解析失败也保存原始数据
isStreaming: false,
}));
setActiveDebugTab(DEBUG_TABS.RESPONSE);
streamMessageUpdate(t('解析响应数据时发生错误'), 'content');
completeMessage(MESSAGE_STATUS.ERROR);
}
});
source.addEventListener('error', (e) => {
// 只有在流没有正常完成且连接状态异常时才处理错误
if (!isStreamComplete && source.readyState !== 2) {
console.error('SSE Error:', e);
const errorMessage = e.data || t('请求发生错误');
const errorInfo = handleApiError(new Error(errorMessage));
errorInfo.readyState = source.readyState;
setDebugData((prev) => ({
...prev,
response:
responseData +
'\n\nSSE Error:\n' +
JSON.stringify(errorInfo, null, 2),
}));
setActiveDebugTab(DEBUG_TABS.RESPONSE);
streamMessageUpdate(errorMessage, 'content');
completeMessage(MESSAGE_STATUS.ERROR);
sseSourceRef.current = null;
source.close();
}
});
source.addEventListener('readystatechange', (e) => {
// 检查 HTTP 状态错误,但避免与正常关闭重复处理
if (
e.readyState >= 2 &&
source.status !== undefined &&
source.status !== 200 &&
!isStreamComplete
) {
const errorInfo = handleApiError(new Error('HTTP状态错误'));
errorInfo.status = source.status;
errorInfo.readyState = source.readyState;
setDebugData((prev) => ({
...prev,
response:
responseData +
'\n\nHTTP Error:\n' +
JSON.stringify(errorInfo, null, 2),
}));
setActiveDebugTab(DEBUG_TABS.RESPONSE);
source.close();
streamMessageUpdate(t('连接已断开'), 'content');
completeMessage(MESSAGE_STATUS.ERROR);
}
});
try {
source.stream();
} catch (error) {
console.error('Failed to start SSE stream:', error);
const errorInfo = handleApiError(error);
setDebugData((prev) => ({
...prev,
response: 'Stream启动失败:\n' + JSON.stringify(errorInfo, null, 2),
}));
setActiveDebugTab(DEBUG_TABS.RESPONSE);
streamMessageUpdate(t('建立连接时发生错误'), 'content');
completeMessage(MESSAGE_STATUS.ERROR);
}
},
[
setDebugData,
setActiveDebugTab,
streamMessageUpdate,
completeMessage,
t,
applyAutoCollapseLogic,
],
);
// 停止生成
const onStopGenerator = useCallback(() => {
// 如果仍有活动的 SSE 连接,首先关闭
if (sseSourceRef.current) {
sseSourceRef.current.close();
sseSourceRef.current = null;
}
// 无论是否存在 SSE 连接,都尝试处理最后一条正在生成的消息
setMessage((prevMessage) => {
if (prevMessage.length === 0) return prevMessage;
const lastMessage = prevMessage[prevMessage.length - 1];
if (
lastMessage.status === MESSAGE_STATUS.LOADING ||
lastMessage.status === MESSAGE_STATUS.INCOMPLETE
) {
const processed = processIncompleteThinkTags(
lastMessage.content || '',
lastMessage.reasoningContent || '',
);
const autoCollapseState = applyAutoCollapseLogic(lastMessage, true);
const updatedMessages = [
...prevMessage.slice(0, -1),
{
...lastMessage,
status: MESSAGE_STATUS.COMPLETE,
reasoningContent: processed.reasoningContent || null,
content: processed.content,
...autoCollapseState,
},
];
// 停止生成时也保存,传入更新后的消息列表
setTimeout(() => saveMessages(updatedMessages), 0);
return updatedMessages;
}
return prevMessage;
});
}, [setMessage, applyAutoCollapseLogic, saveMessages]);
// 发送请求
const sendRequest = useCallback(
(payload, isStream) => {
if (isStream) {
handleSSE(payload);
} else {
handleNonStreamRequest(payload);
}
},
[handleSSE, handleNonStreamRequest],
);
return {
sendRequest,
onStopGenerator,
streamMessageUpdate,
completeMessage,
};
};

View File

@@ -0,0 +1,95 @@
/*
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 { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { API, processModelsData, processGroupsData } from '../../helpers';
import { API_ENDPOINTS } from '../../constants/playground.constants';
export const useDataLoader = (
userState,
inputs,
handleInputChange,
setModels,
setGroups,
) => {
const { t } = useTranslation();
const loadModels = useCallback(async () => {
try {
const res = await API.get(API_ENDPOINTS.USER_MODELS);
const { success, message, data } = res.data;
if (success) {
const { modelOptions, selectedModel } = processModelsData(
data,
inputs.model,
);
setModels(modelOptions);
if (selectedModel !== inputs.model) {
handleInputChange('model', selectedModel);
}
} else {
showError(t(message));
}
} catch (error) {
showError(t('加载模型失败'));
}
}, [inputs.model, handleInputChange, setModels, t]);
const loadGroups = useCallback(async () => {
try {
const res = await API.get(API_ENDPOINTS.USER_GROUPS);
const { success, message, data } = res.data;
if (success) {
const userGroup =
userState?.user?.group ||
JSON.parse(localStorage.getItem('user'))?.group;
const groupOptions = processGroupsData(data, userGroup);
setGroups(groupOptions);
const hasCurrentGroup = groupOptions.some(
(option) => option.value === inputs.group,
);
if (!hasCurrentGroup) {
handleInputChange('group', groupOptions[0]?.value || '');
}
} else {
showError(t(message));
}
} catch (error) {
showError(t('加载分组失败'));
}
}, [userState, inputs.group, handleInputChange, setGroups, t]);
// 自动加载数据
useEffect(() => {
if (userState?.user) {
loadModels();
loadGroups();
}
}, [userState?.user, loadModels, loadGroups]);
return {
loadModels,
loadGroups,
};
};

View File

@@ -0,0 +1,291 @@
/*
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 { useCallback } from 'react';
import { Toast, Modal } from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';
import { getTextContent } from '../../helpers';
import { ERROR_MESSAGES } from '../../constants/playground.constants';
export const useMessageActions = (
message,
setMessage,
onMessageSend,
saveMessages,
) => {
const { t } = useTranslation();
// 复制消息
const handleMessageCopy = useCallback(
(targetMessage) => {
const textToCopy = getTextContent(targetMessage);
if (!textToCopy) {
Toast.warning({
content: t(ERROR_MESSAGES.NO_TEXT_CONTENT),
duration: 2,
});
return;
}
const copyToClipboard = async (text) => {
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
Toast.success({
content: t('消息已复制到剪贴板'),
duration: 2,
});
} catch (err) {
console.error('Clipboard API 复制失败:', err);
fallbackCopy(text);
}
} else {
fallbackCopy(text);
}
};
const fallbackCopy = (text) => {
try {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.cssText = `
position: fixed;
top: -9999px;
left: -9999px;
opacity: 0;
pointer-events: none;
z-index: -1;
`;
textArea.setAttribute('readonly', '');
document.body.appendChild(textArea);
textArea.select();
textArea.setSelectionRange(0, text.length);
const successful = document.execCommand('copy');
document.body.removeChild(textArea);
if (successful) {
Toast.success({
content: t('消息已复制到剪贴板'),
duration: 2,
});
} else {
throw new Error('execCommand copy failed');
}
} catch (err) {
console.error('回退复制方案也失败:', err);
let errorMessage = t(ERROR_MESSAGES.COPY_FAILED);
if (
window.location.protocol === 'http:' &&
window.location.hostname !== 'localhost'
) {
errorMessage = t(ERROR_MESSAGES.COPY_HTTPS_REQUIRED);
} else if (!navigator.clipboard && !document.execCommand) {
errorMessage = t(ERROR_MESSAGES.BROWSER_NOT_SUPPORTED);
}
Toast.error({
content: errorMessage,
duration: 4,
});
}
};
copyToClipboard(textToCopy);
},
[t],
);
// 重新生成消息
const handleMessageReset = useCallback(
(targetMessage) => {
setMessage((prevMessages) => {
// 使用引用查找索引,防止重复 id 造成误匹配
let messageIndex = prevMessages.findIndex(
(msg) => msg === targetMessage,
);
// 回退到 id 匹配(兼容不同引用场景)
if (messageIndex === -1) {
messageIndex = prevMessages.findIndex(
(msg) => msg.id === targetMessage.id,
);
}
if (messageIndex === -1) return prevMessages;
if (targetMessage.role === 'user') {
const newMessages = prevMessages.slice(0, messageIndex);
const contentToSend = getTextContent(targetMessage);
setTimeout(() => {
onMessageSend(contentToSend);
}, 100);
return newMessages;
} else if (
targetMessage.role === 'assistant' ||
targetMessage.role === 'system'
) {
let userMessageIndex = messageIndex - 1;
while (
userMessageIndex >= 0 &&
prevMessages[userMessageIndex].role !== 'user'
) {
userMessageIndex--;
}
if (userMessageIndex >= 0) {
const userMessage = prevMessages[userMessageIndex];
const newMessages = prevMessages.slice(0, userMessageIndex);
const contentToSend = getTextContent(userMessage);
setTimeout(() => {
onMessageSend(contentToSend);
}, 100);
return newMessages;
}
}
return prevMessages;
});
},
[setMessage, onMessageSend],
);
// 删除消息
const handleMessageDelete = useCallback(
(targetMessage) => {
Modal.confirm({
title: t('确认删除'),
content: t('确定要删除这条消息吗?'),
okText: t('确定'),
cancelText: t('取消'),
okButtonProps: {
type: 'danger',
},
onOk: () => {
setMessage((prevMessages) => {
// 使用引用查找索引,防止重复 id 造成误匹配
let messageIndex = prevMessages.findIndex(
(msg) => msg === targetMessage,
);
// 回退到 id 匹配(兼容不同引用场景)
if (messageIndex === -1) {
messageIndex = prevMessages.findIndex(
(msg) => msg.id === targetMessage.id,
);
}
if (messageIndex === -1) return prevMessages;
let updatedMessages;
if (
targetMessage.role === 'user' &&
messageIndex < prevMessages.length - 1
) {
const nextMessage = prevMessages[messageIndex + 1];
if (nextMessage.role === 'assistant') {
Toast.success({
content: t('已删除消息及其回复'),
duration: 2,
});
updatedMessages = prevMessages.filter(
(_, index) =>
index !== messageIndex && index !== messageIndex + 1,
);
} else {
Toast.success({
content: t('消息已删除'),
duration: 2,
});
updatedMessages = prevMessages.filter(
(msg) => msg.id !== targetMessage.id,
);
}
} else {
Toast.success({
content: t('消息已删除'),
duration: 2,
});
updatedMessages = prevMessages.filter(
(msg) => msg.id !== targetMessage.id,
);
}
// 删除消息后保存,传入更新后的消息列表
setTimeout(() => saveMessages(updatedMessages), 0);
return updatedMessages;
});
},
});
},
[setMessage, t, saveMessages],
);
// 切换角色
const handleRoleToggle = useCallback(
(targetMessage) => {
if (
!(targetMessage.role === 'assistant' || targetMessage.role === 'system')
) {
return;
}
const newRole =
targetMessage.role === 'assistant' ? 'system' : 'assistant';
setMessage((prevMessages) => {
const updatedMessages = prevMessages.map((msg) => {
if (
msg.id === targetMessage.id &&
(msg.role === 'assistant' || msg.role === 'system')
) {
return { ...msg, role: newRole };
}
return msg;
});
// 切换角色后保存,传入更新后的消息列表
setTimeout(() => saveMessages(updatedMessages), 0);
return updatedMessages;
});
Toast.success({
content: t(
`已切换为${newRole === 'system' ? 'System' : 'Assistant'}角色`,
),
duration: 2,
});
},
[setMessage, t, saveMessages],
);
return {
handleMessageCopy,
handleMessageReset,
handleMessageDelete,
handleRoleToggle,
};
};

View File

@@ -0,0 +1,157 @@
/*
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 { useCallback, useState, useRef } from 'react';
import { Toast, Modal } from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';
import {
getTextContent,
buildApiPayload,
createLoadingAssistantMessage,
} from '../../helpers';
import { MESSAGE_ROLES } from '../../constants/playground.constants';
export const useMessageEdit = (
setMessage,
inputs,
parameterEnabled,
sendRequest,
saveMessages,
) => {
const { t } = useTranslation();
const [editingMessageId, setEditingMessageId] = useState(null);
const [editValue, setEditValue] = useState('');
const editingMessageRef = useRef(null);
const handleMessageEdit = useCallback((targetMessage) => {
const editableContent = getTextContent(targetMessage);
setEditingMessageId(targetMessage.id);
editingMessageRef.current = targetMessage;
setEditValue(editableContent);
}, []);
const handleEditSave = useCallback(() => {
if (!editingMessageId || !editValue.trim()) return;
setMessage((prevMessages) => {
let messageIndex = prevMessages.findIndex(
(msg) => msg === editingMessageRef.current,
);
if (messageIndex === -1) {
messageIndex = prevMessages.findIndex(
(msg) => msg.id === editingMessageId,
);
}
const targetMessage = prevMessages[messageIndex];
let newContent;
if (Array.isArray(targetMessage.content)) {
newContent = targetMessage.content.map((item) =>
item.type === 'text' ? { ...item, text: editValue.trim() } : item,
);
} else {
newContent = editValue.trim();
}
const updatedMessages = prevMessages.map((msg) =>
msg.id === editingMessageId ? { ...msg, content: newContent } : msg,
);
// 处理用户消息编辑后的重新生成
if (targetMessage.role === MESSAGE_ROLES.USER) {
const hasSubsequentAssistantReply =
messageIndex < prevMessages.length - 1 &&
prevMessages[messageIndex + 1].role === MESSAGE_ROLES.ASSISTANT;
if (hasSubsequentAssistantReply) {
Modal.confirm({
title: t('消息已编辑'),
content: t('检测到该消息后有AI回复是否删除后续回复并重新生成'),
okText: t('重新生成'),
cancelText: t('仅保存'),
onOk: () => {
const messagesUntilUser = updatedMessages.slice(
0,
messageIndex + 1,
);
setMessage(messagesUntilUser);
// 编辑后保存(重新生成的情况),传入更新后的消息列表
setTimeout(() => saveMessages(messagesUntilUser), 0);
setTimeout(() => {
const payload = buildApiPayload(
messagesUntilUser,
null,
inputs,
parameterEnabled,
);
setMessage((prevMsg) => [
...prevMsg,
createLoadingAssistantMessage(),
]);
sendRequest(payload, inputs.stream);
}, 100);
},
onCancel: () => {
setMessage(updatedMessages);
// 编辑后保存(仅保存的情况),传入更新后的消息列表
setTimeout(() => saveMessages(updatedMessages), 0);
},
});
return prevMessages;
}
}
// 编辑后保存(普通情况),传入更新后的消息列表
setTimeout(() => saveMessages(updatedMessages), 0);
return updatedMessages;
});
setEditingMessageId(null);
editingMessageRef.current = null;
setEditValue('');
Toast.success({ content: t('消息已更新'), duration: 2 });
}, [
editingMessageId,
editValue,
t,
inputs,
parameterEnabled,
sendRequest,
setMessage,
saveMessages,
]);
const handleEditCancel = useCallback(() => {
setEditingMessageId(null);
editingMessageRef.current = null;
setEditValue('');
}, []);
return {
editingMessageId,
editValue,
setEditValue,
handleMessageEdit,
handleEditSave,
handleEditCancel,
};
};

View File

@@ -0,0 +1,306 @@
/*
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, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import {
DEFAULT_MESSAGES,
getDefaultMessages,
DEFAULT_CONFIG,
DEBUG_TABS,
MESSAGE_STATUS,
} from '../../constants/playground.constants';
import {
loadConfig,
saveConfig,
loadMessages,
saveMessages,
} from '../../components/playground/configStorage';
import { processIncompleteThinkTags } from '../../helpers';
export const usePlaygroundState = () => {
const { t } = useTranslation();
// 使用惰性初始化,确保只在组件首次挂载时加载配置和消息
const [savedConfig] = useState(() => loadConfig());
const [initialMessages] = useState(() => {
const loaded = loadMessages();
// 检查是否是旧的中文默认消息,如果是则清除
if (
loaded &&
loaded.length === 2 &&
loaded[0].id === '2' &&
loaded[1].id === '3'
) {
const hasOldChinese =
loaded[0].content === '你好' ||
loaded[1].content === '你好,请问有什么可以帮助您的吗?' ||
loaded[1].content === '你好!很高兴见到你。有什么我可以帮助你的吗?';
if (hasOldChinese) {
// 清除旧的默认消息
localStorage.removeItem('playground_messages');
return null;
}
}
return loaded;
});
// 基础配置状态
const [inputs, setInputs] = useState(
savedConfig.inputs || DEFAULT_CONFIG.inputs,
);
const [parameterEnabled, setParameterEnabled] = useState(
savedConfig.parameterEnabled || DEFAULT_CONFIG.parameterEnabled,
);
const [showDebugPanel, setShowDebugPanel] = useState(
savedConfig.showDebugPanel || DEFAULT_CONFIG.showDebugPanel,
);
const [customRequestMode, setCustomRequestMode] = useState(
savedConfig.customRequestMode || DEFAULT_CONFIG.customRequestMode,
);
const [customRequestBody, setCustomRequestBody] = useState(
savedConfig.customRequestBody || DEFAULT_CONFIG.customRequestBody,
);
// UI状态
const [showSettings, setShowSettings] = useState(false);
const [models, setModels] = useState([]);
const [groups, setGroups] = useState([]);
const [status, setStatus] = useState({});
// 消息相关状态 - 使用加载的消息或默认消息初始化
const [message, setMessage] = useState(
() => initialMessages || getDefaultMessages(t),
);
// 当语言改变时,如果是默认消息则更新
useEffect(() => {
// 只在没有保存的消息时才更新默认消息
if (!initialMessages) {
setMessage(getDefaultMessages(t));
}
}, [t, initialMessages]); // 当语言改变时
// 调试状态
const [debugData, setDebugData] = useState({
request: null,
response: null,
timestamp: null,
previewRequest: null,
previewTimestamp: null,
});
const [activeDebugTab, setActiveDebugTab] = useState(DEBUG_TABS.PREVIEW);
const [previewPayload, setPreviewPayload] = useState(null);
// 编辑状态
const [editingMessageId, setEditingMessageId] = useState(null);
const [editValue, setEditValue] = useState('');
// Refs
const sseSourceRef = useRef(null);
const chatRef = useRef(null);
const saveConfigTimeoutRef = useRef(null);
const saveMessagesTimeoutRef = useRef(null);
// 配置更新函数
const handleInputChange = useCallback((name, value) => {
setInputs((prev) => ({ ...prev, [name]: value }));
}, []);
const handleParameterToggle = useCallback((paramName) => {
setParameterEnabled((prev) => ({
...prev,
[paramName]: !prev[paramName],
}));
}, []);
// 消息保存函数 - 改为立即保存,可以接受参数
const saveMessagesImmediately = useCallback(
(messagesToSave) => {
// 如果提供了参数,使用参数;否则使用当前状态
saveMessages(messagesToSave || message);
},
[message],
);
// 配置保存
const debouncedSaveConfig = useCallback(() => {
if (saveConfigTimeoutRef.current) {
clearTimeout(saveConfigTimeoutRef.current);
}
saveConfigTimeoutRef.current = setTimeout(() => {
const configToSave = {
inputs,
parameterEnabled,
showDebugPanel,
customRequestMode,
customRequestBody,
};
saveConfig(configToSave);
}, 1000);
}, [
inputs,
parameterEnabled,
showDebugPanel,
customRequestMode,
customRequestBody,
]);
// 配置导入/重置
const handleConfigImport = useCallback((importedConfig) => {
if (importedConfig.inputs) {
setInputs((prev) => ({ ...prev, ...importedConfig.inputs }));
}
if (importedConfig.parameterEnabled) {
setParameterEnabled((prev) => ({
...prev,
...importedConfig.parameterEnabled,
}));
}
if (typeof importedConfig.showDebugPanel === 'boolean') {
setShowDebugPanel(importedConfig.showDebugPanel);
}
if (importedConfig.customRequestMode) {
setCustomRequestMode(importedConfig.customRequestMode);
}
if (importedConfig.customRequestBody) {
setCustomRequestBody(importedConfig.customRequestBody);
}
// 如果导入的配置包含消息,也恢复消息
if (importedConfig.messages && Array.isArray(importedConfig.messages)) {
setMessage(importedConfig.messages);
}
}, []);
const handleConfigReset = useCallback((options = {}) => {
const { resetMessages = false } = options;
setInputs(DEFAULT_CONFIG.inputs);
setParameterEnabled(DEFAULT_CONFIG.parameterEnabled);
setShowDebugPanel(DEFAULT_CONFIG.showDebugPanel);
setCustomRequestMode(DEFAULT_CONFIG.customRequestMode);
setCustomRequestBody(DEFAULT_CONFIG.customRequestBody);
// 只有在明确指定时才重置消息
if (resetMessages) {
setMessage([]);
setTimeout(() => {
setMessage(getDefaultMessages(t));
}, 0);
}
}, []);
// 清理定时器
useEffect(() => {
return () => {
if (saveConfigTimeoutRef.current) {
clearTimeout(saveConfigTimeoutRef.current);
}
};
}, []);
// 页面首次加载时,若最后一条消息仍处于 LOADING/INCOMPLETE 状态,自动修复
useEffect(() => {
if (!Array.isArray(message) || message.length === 0) return;
const lastMsg = message[message.length - 1];
if (
lastMsg.status === MESSAGE_STATUS.LOADING ||
lastMsg.status === MESSAGE_STATUS.INCOMPLETE
) {
const processed = processIncompleteThinkTags(
lastMsg.content || '',
lastMsg.reasoningContent || '',
);
const fixedLastMsg = {
...lastMsg,
status: MESSAGE_STATUS.COMPLETE,
content: processed.content,
reasoningContent: processed.reasoningContent || null,
isThinkingComplete: true,
};
const updatedMessages = [...message.slice(0, -1), fixedLastMsg];
setMessage(updatedMessages);
// 保存修复后的消息列表
setTimeout(() => saveMessagesImmediately(updatedMessages), 0);
}
}, []);
return {
// 配置状态
inputs,
parameterEnabled,
showDebugPanel,
customRequestMode,
customRequestBody,
// UI状态
showSettings,
models,
groups,
status,
// 消息状态
message,
// 调试状态
debugData,
activeDebugTab,
previewPayload,
// 编辑状态
editingMessageId,
editValue,
// Refs
sseSourceRef,
chatRef,
saveConfigTimeoutRef,
// 更新函数
setInputs,
setParameterEnabled,
setShowDebugPanel,
setCustomRequestMode,
setCustomRequestBody,
setShowSettings,
setModels,
setGroups,
setStatus,
setMessage,
setDebugData,
setActiveDebugTab,
setPreviewPayload,
setEditingMessageId,
setEditValue,
// 处理函数
handleInputChange,
handleParameterToggle,
debouncedSaveConfig,
saveMessagesImmediately,
handleConfigImport,
handleConfigReset,
};
};

View File

@@ -0,0 +1,149 @@
/*
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 { useCallback, useRef } from 'react';
import { MESSAGE_ROLES } from '../../constants/playground.constants';
export const useSyncMessageAndCustomBody = (
customRequestMode,
customRequestBody,
message,
inputs,
setCustomRequestBody,
setMessage,
debouncedSaveConfig,
) => {
const isUpdatingFromMessage = useRef(false);
const isUpdatingFromCustomBody = useRef(false);
const lastMessageHash = useRef('');
const lastCustomBodyHash = useRef('');
const getMessageHash = useCallback((messages) => {
return JSON.stringify(
messages.map((msg) => ({
id: msg.id,
role: msg.role,
content: msg.content,
})),
);
}, []);
const getCustomBodyHash = useCallback((customBody) => {
try {
const parsed = JSON.parse(customBody);
return JSON.stringify(parsed.messages || []);
} catch {
return '';
}
}, []);
const syncMessageToCustomBody = useCallback(() => {
if (!customRequestMode || isUpdatingFromCustomBody.current) return;
const currentMessageHash = getMessageHash(message);
if (currentMessageHash === lastMessageHash.current) return;
try {
isUpdatingFromMessage.current = true;
let customPayload;
try {
customPayload = JSON.parse(customRequestBody || '{}');
} catch {
customPayload = {
model: inputs.model || 'gpt-4o',
messages: [],
temperature: inputs.temperature || 0.7,
stream: inputs.stream !== false,
};
}
customPayload.messages = message.map((msg) => ({
role: msg.role,
content: msg.content,
}));
const newCustomBody = JSON.stringify(customPayload, null, 2);
setCustomRequestBody(newCustomBody);
lastMessageHash.current = currentMessageHash;
lastCustomBodyHash.current = getCustomBodyHash(newCustomBody);
setTimeout(() => {
debouncedSaveConfig();
}, 0);
} finally {
isUpdatingFromMessage.current = false;
}
}, [
customRequestMode,
customRequestBody,
message,
inputs.model,
inputs.temperature,
inputs.stream,
getMessageHash,
getCustomBodyHash,
setCustomRequestBody,
debouncedSaveConfig,
]);
const syncCustomBodyToMessage = useCallback(() => {
if (!customRequestMode || isUpdatingFromMessage.current) return;
const currentCustomBodyHash = getCustomBodyHash(customRequestBody);
if (currentCustomBodyHash === lastCustomBodyHash.current) return;
try {
isUpdatingFromCustomBody.current = true;
const customPayload = JSON.parse(customRequestBody || '{}');
if (customPayload.messages && Array.isArray(customPayload.messages)) {
const newMessages = customPayload.messages.map((msg, index) => ({
id: msg.id || (index + 1).toString(),
role: msg.role || MESSAGE_ROLES.USER,
content: msg.content || '',
createAt: Date.now(),
...(msg.role === MESSAGE_ROLES.ASSISTANT && {
reasoningContent: msg.reasoningContent || '',
isReasoningExpanded: false,
}),
}));
setMessage(newMessages);
lastCustomBodyHash.current = currentCustomBodyHash;
lastMessageHash.current = getMessageHash(newMessages);
}
} catch (error) {
console.warn('同步自定义请求体到消息失败:', error);
} finally {
isUpdatingFromCustomBody.current = false;
}
}, [
customRequestMode,
customRequestBody,
getCustomBodyHash,
getMessageHash,
setMessage,
]);
return {
syncMessageToCustomBody,
syncCustomBodyToMessage,
};
};

View File

@@ -0,0 +1,360 @@
/*
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, showError, showSuccess, copy } from '../../helpers';
import { ITEMS_PER_PAGE } from '../../constants';
import {
REDEMPTION_ACTIONS,
REDEMPTION_STATUS,
} from '../../constants/redemption.constants';
import { Modal } from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';
import { useTableCompactMode } from '../common/useTableCompactMode';
export const useRedemptionsData = () => {
const { t } = useTranslation();
// Basic state
const [redemptions, setRedemptions] = useState([]);
const [loading, setLoading] = useState(true);
const [searching, setSearching] = useState(false);
const [activePage, setActivePage] = useState(1);
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
const [tokenCount, setTokenCount] = useState(0);
const [selectedKeys, setSelectedKeys] = useState([]);
// Edit state
const [editingRedemption, setEditingRedemption] = useState({
id: undefined,
});
const [showEdit, setShowEdit] = useState(false);
// Form API
const [formApi, setFormApi] = useState(null);
// UI state
const [compactMode, setCompactMode] = useTableCompactMode('redemptions');
// Form state
const formInitValues = {
searchKeyword: '',
};
// Get form values
const getFormValues = () => {
const formValues = formApi ? formApi.getValues() : {};
return {
searchKeyword: formValues.searchKeyword || '',
};
};
// Set redemption data format
const setRedemptionFormat = (redemptions) => {
setRedemptions(redemptions);
};
// Load redemption list
const loadRedemptions = async (page = 1, pageSize) => {
setLoading(true);
try {
const res = await API.get(
`/api/redemption/?p=${page}&page_size=${pageSize}`,
);
const { success, message, data } = res.data;
if (success) {
const newPageData = data.items;
setActivePage(data.page <= 0 ? 1 : data.page);
setTokenCount(data.total);
setRedemptionFormat(newPageData);
} else {
showError(message);
}
} catch (error) {
showError(error.message);
}
setLoading(false);
};
// Search redemption codes
const searchRedemptions = async () => {
const { searchKeyword } = getFormValues();
if (searchKeyword === '') {
await loadRedemptions(1, pageSize);
return;
}
setSearching(true);
try {
const res = await API.get(
`/api/redemption/search?keyword=${searchKeyword}&p=1&page_size=${pageSize}`,
);
const { success, message, data } = res.data;
if (success) {
const newPageData = data.items;
setActivePage(data.page || 1);
setTokenCount(data.total);
setRedemptionFormat(newPageData);
} else {
showError(message);
}
} catch (error) {
showError(error.message);
}
setSearching(false);
};
// Manage redemption codes (CRUD operations)
const manageRedemption = async (id, action, record) => {
setLoading(true);
let data = { id };
let res;
try {
switch (action) {
case REDEMPTION_ACTIONS.DELETE:
res = await API.delete(`/api/redemption/${id}/`);
break;
case REDEMPTION_ACTIONS.ENABLE:
data.status = REDEMPTION_STATUS.UNUSED;
res = await API.put('/api/redemption/?status_only=true', data);
break;
case REDEMPTION_ACTIONS.DISABLE:
data.status = REDEMPTION_STATUS.DISABLED;
res = await API.put('/api/redemption/?status_only=true', data);
break;
default:
throw new Error('Unknown operation type');
}
const { success, message } = res.data;
if (success) {
showSuccess(t('操作成功完成!'));
let redemption = res.data.data;
let newRedemptions = [...redemptions];
if (action !== REDEMPTION_ACTIONS.DELETE) {
record.status = redemption.status;
}
setRedemptions(newRedemptions);
} else {
showError(message);
}
} catch (error) {
showError(error.message);
}
setLoading(false);
};
// Refresh data
const refresh = async (page = activePage) => {
const { searchKeyword } = getFormValues();
if (searchKeyword === '') {
await loadRedemptions(page, pageSize);
} else {
await searchRedemptions();
}
};
// Handle page change
const handlePageChange = (page) => {
setActivePage(page);
const { searchKeyword } = getFormValues();
if (searchKeyword === '') {
loadRedemptions(page, pageSize);
} else {
searchRedemptions();
}
};
// Handle page size change
const handlePageSizeChange = (size) => {
setPageSize(size);
setActivePage(1);
const { searchKeyword } = getFormValues();
if (searchKeyword === '') {
loadRedemptions(1, size);
} else {
searchRedemptions();
}
};
// Row selection configuration
const rowSelection = {
onSelect: (record, selected) => {},
onSelectAll: (selected, selectedRows) => {},
onChange: (selectedRowKeys, selectedRows) => {
setSelectedKeys(selectedRows);
},
};
// Row style handling - using isExpired function
const handleRow = (record, index) => {
// Local isExpired function
const isExpired = (rec) => {
return (
rec.status === REDEMPTION_STATUS.UNUSED &&
rec.expired_time !== 0 &&
rec.expired_time < Math.floor(Date.now() / 1000)
);
};
if (record.status !== REDEMPTION_STATUS.UNUSED || isExpired(record)) {
return {
style: {
background: 'var(--semi-color-disabled-border)',
},
};
} else {
return {};
}
};
// Copy text
const copyText = async (text) => {
if (await copy(text)) {
showSuccess('已复制到剪贴板!');
} else {
Modal.error({
title: '无法复制到剪贴板,请手动复制',
content: text,
size: 'large',
});
}
};
// Batch copy redemption codes
const batchCopyRedemptions = async () => {
if (selectedKeys.length === 0) {
showError(t('请至少选择一个兑换码!'));
return;
}
let keys = '';
for (let i = 0; i < selectedKeys.length; i++) {
keys += selectedKeys[i].name + ' ' + selectedKeys[i].key + '\n';
}
await copyText(keys);
};
// Batch delete redemption codes (clear invalid)
const batchDeleteRedemptions = async () => {
Modal.confirm({
title: t('确定清除所有失效兑换码?'),
content: t('将删除已使用、已禁用及过期的兑换码,此操作不可撤销。'),
onOk: async () => {
setLoading(true);
const res = await API.delete('/api/redemption/invalid');
const { success, message, data } = res.data;
if (success) {
showSuccess(t('已删除 {{count}} 条失效兑换码', { count: data }));
await refresh();
} else {
showError(message);
}
setLoading(false);
},
});
};
// Close edit modal
const closeEdit = () => {
setShowEdit(false);
setTimeout(() => {
setEditingRedemption({
id: undefined,
});
}, 500);
};
// Remove record (for UI update after deletion)
const removeRecord = (key) => {
let newDataSource = [...redemptions];
if (key != null) {
let idx = newDataSource.findIndex((data) => data.key === key);
if (idx > -1) {
newDataSource.splice(idx, 1);
setRedemptions(newDataSource);
}
}
};
// Initialize data loading
useEffect(() => {
loadRedemptions(1, pageSize)
.then()
.catch((reason) => {
showError(reason);
});
}, [pageSize]);
return {
// Data state
redemptions,
loading,
searching,
activePage,
pageSize,
tokenCount,
selectedKeys,
// Edit state
editingRedemption,
showEdit,
// Form state
formApi,
formInitValues,
// UI state
compactMode,
setCompactMode,
// Data operations
loadRedemptions,
searchRedemptions,
manageRedemption,
refresh,
copyText,
removeRecord,
// State updates
setActivePage,
setPageSize,
setSelectedKeys,
setEditingRedemption,
setShowEdit,
setFormApi,
setLoading,
// Event handlers
handlePageChange,
handlePageSizeChange,
rowSelection,
handleRow,
closeEdit,
getFormValues,
// Batch operations
batchCopyRedemptions,
batchDeleteRedemptions,
// Translation function
t,
};
};

View File

@@ -0,0 +1,166 @@
/*
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 { useTranslation } from 'react-i18next';
import { API, showError, showSuccess } from '../../helpers';
import { useTableCompactMode } from '../common/useTableCompactMode';
export const useSubscriptionsData = () => {
const { t } = useTranslation();
const [compactMode, setCompactMode] = useTableCompactMode('subscriptions');
// State management
const [allPlans, setAllPlans] = useState([]);
const [loading, setLoading] = useState(true);
// Pagination (client-side for now)
const [activePage, setActivePage] = useState(1);
const [pageSize, setPageSize] = useState(10);
// Drawer states
const [showEdit, setShowEdit] = useState(false);
const [editingPlan, setEditingPlan] = useState(null);
const [sheetPlacement, setSheetPlacement] = useState('left'); // 'left' | 'right'
// Load subscription plans
const loadPlans = async () => {
setLoading(true);
try {
const res = await API.get('/api/subscription/admin/plans');
if (res.data?.success) {
const next = res.data.data || [];
setAllPlans(next);
// Keep page in range after data changes
const totalPages = Math.max(1, Math.ceil(next.length / pageSize));
setActivePage((p) => Math.min(p || 1, totalPages));
} else {
showError(res.data?.message || t('加载失败'));
}
} catch (e) {
showError(t('请求失败'));
} finally {
setLoading(false);
}
};
// Refresh data
const refresh = async () => {
await loadPlans();
};
const handlePageChange = (page) => {
setActivePage(page);
};
const handlePageSizeChange = (size) => {
setPageSize(size);
setActivePage(1);
};
// Update plan enabled status (single endpoint)
const setPlanEnabled = async (planRecordOrId, enabled) => {
const planId =
typeof planRecordOrId === 'number'
? planRecordOrId
: planRecordOrId?.plan?.id;
if (!planId) return;
setLoading(true);
try {
const res = await API.patch(`/api/subscription/admin/plans/${planId}`, {
enabled: !!enabled,
});
if (res.data?.success) {
showSuccess(enabled ? t('已启用') : t('已禁用'));
await loadPlans();
} else {
showError(res.data?.message || t('操作失败'));
}
} catch (e) {
showError(t('请求失败'));
} finally {
setLoading(false);
}
};
// Modal control functions
const closeEdit = () => {
setShowEdit(false);
setEditingPlan(null);
};
const openCreate = () => {
setSheetPlacement('left');
setEditingPlan(null);
setShowEdit(true);
};
const openEdit = (planRecord) => {
setSheetPlacement('right');
setEditingPlan(planRecord);
setShowEdit(true);
};
// Initialize data on component mount
useEffect(() => {
loadPlans();
}, []);
const planCount = allPlans.length;
const plans = allPlans.slice(
Math.max(0, (activePage - 1) * pageSize),
Math.max(0, (activePage - 1) * pageSize) + pageSize,
);
return {
// Data state
plans,
planCount,
loading,
// Modal state
showEdit,
editingPlan,
sheetPlacement,
setShowEdit,
setEditingPlan,
// UI state
compactMode,
setCompactMode,
// Pagination
activePage,
pageSize,
handlePageChange,
handlePageSizeChange,
// Actions
loadPlans,
setPlanEnabled,
refresh,
closeEdit,
openCreate,
openEdit,
// Translation
t,
};
};

View File

@@ -0,0 +1,376 @@
/*
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 { useTranslation } from 'react-i18next';
import { Modal } from '@douyinfe/semi-ui';
import {
API,
copy,
isAdmin,
showError,
showSuccess,
timestamp2string,
} from '../../helpers';
import { ITEMS_PER_PAGE } from '../../constants';
import { useTableCompactMode } from '../common/useTableCompactMode';
export const useTaskLogsData = () => {
const { t } = useTranslation();
// Define column keys for selection
const COLUMN_KEYS = {
SUBMIT_TIME: 'submit_time',
FINISH_TIME: 'finish_time',
DURATION: 'duration',
CHANNEL: 'channel',
USERNAME: 'username',
PLATFORM: 'platform',
TYPE: 'type',
TASK_ID: 'task_id',
TASK_STATUS: 'task_status',
PROGRESS: 'progress',
FAIL_REASON: 'fail_reason',
RESULT_URL: 'result_url',
};
// Basic state
const [logs, setLogs] = useState([]);
const [loading, setLoading] = useState(false);
const [activePage, setActivePage] = useState(1);
const [logCount, setLogCount] = useState(0);
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
// User and admin
const isAdminUser = isAdmin();
// Role-specific storage key to prevent different roles from overwriting each other
const STORAGE_KEY = isAdminUser
? 'task-logs-table-columns-admin'
: 'task-logs-table-columns-user';
// Modal state
const [isModalOpen, setIsModalOpen] = useState(false);
const [modalContent, setModalContent] = useState('');
// 新增:视频预览弹窗状态
const [isVideoModalOpen, setIsVideoModalOpen] = useState(false);
const [videoUrl, setVideoUrl] = useState('');
// Audio preview modal state
const [isAudioModalOpen, setIsAudioModalOpen] = useState(false);
const [audioClips, setAudioClips] = useState([]);
// User info modal state
const [showUserInfo, setShowUserInfoModal] = useState(false);
const [userInfoData, setUserInfoData] = useState(null);
// Form state
const [formApi, setFormApi] = useState(null);
let now = new Date();
let zeroNow = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const formInitValues = {
channel_id: '',
task_id: '',
dateRange: [
timestamp2string(zeroNow.getTime() / 1000),
timestamp2string(now.getTime() / 1000 + 3600),
],
};
// Column visibility state
const [visibleColumns, setVisibleColumns] = useState({});
const [showColumnSelector, setShowColumnSelector] = useState(false);
// Compact mode
const [compactMode, setCompactMode] = useTableCompactMode('taskLogs');
// Load saved column preferences from localStorage
useEffect(() => {
const savedColumns = localStorage.getItem(STORAGE_KEY);
if (savedColumns) {
try {
const parsed = JSON.parse(savedColumns);
const defaults = getDefaultColumnVisibility();
const merged = { ...defaults, ...parsed };
// For non-admin users, force-hide admin-only columns (does not touch admin settings)
if (!isAdminUser) {
merged[COLUMN_KEYS.CHANNEL] = false;
merged[COLUMN_KEYS.USERNAME] = false;
}
setVisibleColumns(merged);
} catch (e) {
console.error('Failed to parse saved column preferences', e);
initDefaultColumns();
}
} else {
initDefaultColumns();
}
}, []);
// Get default column visibility based on user role
const getDefaultColumnVisibility = () => {
return {
[COLUMN_KEYS.SUBMIT_TIME]: true,
[COLUMN_KEYS.FINISH_TIME]: true,
[COLUMN_KEYS.DURATION]: true,
[COLUMN_KEYS.CHANNEL]: isAdminUser,
[COLUMN_KEYS.USERNAME]: isAdminUser,
[COLUMN_KEYS.PLATFORM]: true,
[COLUMN_KEYS.TYPE]: true,
[COLUMN_KEYS.TASK_ID]: true,
[COLUMN_KEYS.TASK_STATUS]: true,
[COLUMN_KEYS.PROGRESS]: true,
[COLUMN_KEYS.FAIL_REASON]: true,
[COLUMN_KEYS.RESULT_URL]: true,
};
};
// Initialize default column visibility
const initDefaultColumns = () => {
const defaults = getDefaultColumnVisibility();
setVisibleColumns(defaults);
localStorage.setItem(STORAGE_KEY, JSON.stringify(defaults));
};
// Handle column visibility change
const handleColumnVisibilityChange = (columnKey, checked) => {
const updatedColumns = { ...visibleColumns, [columnKey]: checked };
setVisibleColumns(updatedColumns);
};
// Handle "Select All" checkbox
const handleSelectAll = (checked) => {
const allKeys = Object.keys(COLUMN_KEYS).map((key) => COLUMN_KEYS[key]);
const updatedColumns = {};
allKeys.forEach((key) => {
if (
(key === COLUMN_KEYS.CHANNEL || key === COLUMN_KEYS.USERNAME) &&
!isAdminUser
) {
updatedColumns[key] = false;
} else {
updatedColumns[key] = checked;
}
});
setVisibleColumns(updatedColumns);
};
// Persist column settings to the role-specific STORAGE_KEY
useEffect(() => {
if (Object.keys(visibleColumns).length > 0) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(visibleColumns));
}
}, [visibleColumns]);
// Get form values helper function
const getFormValues = () => {
const formValues = formApi ? formApi.getValues() : {};
// 处理时间范围
let start_timestamp = timestamp2string(zeroNow.getTime() / 1000);
let end_timestamp = timestamp2string(now.getTime() / 1000 + 3600);
if (
formValues.dateRange &&
Array.isArray(formValues.dateRange) &&
formValues.dateRange.length === 2
) {
start_timestamp = formValues.dateRange[0];
end_timestamp = formValues.dateRange[1];
}
return {
channel_id: formValues.channel_id || '',
task_id: formValues.task_id || '',
start_timestamp,
end_timestamp,
};
};
// Enrich logs data
const enrichLogs = (items) => {
return items.map((log) => ({
...log,
timestamp2string: timestamp2string(log.created_at),
key: '' + log.id,
}));
};
// Sync page data
const syncPageData = (payload) => {
const items = enrichLogs(payload.items || []);
setLogs(items);
setLogCount(payload.total || 0);
setActivePage(payload.page || 1);
setPageSize(payload.page_size || pageSize);
};
// Load logs function
const loadLogs = async (page = 1, size = pageSize) => {
setLoading(true);
const { channel_id, task_id, start_timestamp, end_timestamp } =
getFormValues();
let localStartTimestamp = parseInt(Date.parse(start_timestamp) / 1000);
let localEndTimestamp = parseInt(Date.parse(end_timestamp) / 1000);
let url = isAdminUser
? `/api/task/?p=${page}&page_size=${size}&channel_id=${channel_id}&task_id=${task_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`
: `/api/task/self?p=${page}&page_size=${size}&task_id=${task_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
const res = await API.get(url);
const { success, message, data } = res.data;
if (success) {
syncPageData(data);
} else {
showError(message);
}
setLoading(false);
};
// Page handlers
const handlePageChange = (page) => {
loadLogs(page, pageSize).then();
};
const handlePageSizeChange = async (size) => {
localStorage.setItem('task-page-size', size + '');
await loadLogs(1, size);
};
// Refresh function
const refresh = async () => {
await loadLogs(1, pageSize);
};
// Copy text function
const copyText = async (text) => {
if (await copy(text)) {
showSuccess(t('已复制:') + text);
} else {
Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text });
}
};
// Modal handlers
const openContentModal = (content) => {
setModalContent(content);
setIsModalOpen(true);
};
// 新增:打开视频预览弹窗
const openVideoModal = (url) => {
setVideoUrl(url);
setIsVideoModalOpen(true);
};
const openAudioModal = (clips) => {
setAudioClips(clips);
setIsAudioModalOpen(true);
};
// User info function
const showUserInfoFunc = async (userId) => {
if (!isAdminUser) {
return;
}
const res = await API.get(`/api/user/${userId}`);
const { success, message, data } = res.data;
if (success) {
setUserInfoData(data);
setShowUserInfoModal(true);
} else {
showError(message);
}
};
// Initialize data
useEffect(() => {
const localPageSize =
parseInt(localStorage.getItem('task-page-size')) || ITEMS_PER_PAGE;
setPageSize(localPageSize);
loadLogs(1, localPageSize).then();
}, []);
return {
// Basic state
logs,
loading,
activePage,
logCount,
pageSize,
isAdminUser,
// Modal state
isModalOpen,
setIsModalOpen,
modalContent,
// 新增:视频弹窗状态
isVideoModalOpen,
setIsVideoModalOpen,
videoUrl,
// Audio preview modal
isAudioModalOpen,
setIsAudioModalOpen,
audioClips,
// Form state
formApi,
setFormApi,
formInitValues,
getFormValues,
// Column visibility
visibleColumns,
showColumnSelector,
setShowColumnSelector,
handleColumnVisibilityChange,
handleSelectAll,
initDefaultColumns,
COLUMN_KEYS,
// Compact mode
compactMode,
setCompactMode,
// User info modal
showUserInfo,
setShowUserInfoModal,
userInfoData,
showUserInfoFunc,
// Functions
loadLogs,
handlePageChange,
handlePageSizeChange,
refresh,
copyText,
openContentModal,
openVideoModal,
openAudioModal,
enrichLogs,
syncPageData,
// Translation
t,
};
};

483
web/src/hooks/tokens/useTokensData.jsx vendored Normal file
View File

@@ -0,0 +1,483 @@
/*
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';
import { useTranslation } from 'react-i18next';
import { Modal } from '@douyinfe/semi-ui';
import {
API,
copy,
showError,
showSuccess,
encodeToBase64,
} from '../../helpers';
import { ITEMS_PER_PAGE } from '../../constants';
import { useTableCompactMode } from '../common/useTableCompactMode';
import { fetchTokenKey as fetchTokenKeyById } from '../../helpers/token';
export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
const { t } = useTranslation();
// Basic state
const [tokens, setTokens] = useState([]);
const [loading, setLoading] = useState(true);
const [activePage, setActivePage] = useState(1);
const [tokenCount, setTokenCount] = useState(0);
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
const [searching, setSearching] = useState(false);
const [searchMode, setSearchMode] = useState(false); // 是否处于搜索结果视图
// Selection state
const [selectedKeys, setSelectedKeys] = useState([]);
// Edit state
const [showEdit, setShowEdit] = useState(false);
const [editingToken, setEditingToken] = useState({
id: undefined,
});
// UI state
const [compactMode, setCompactMode] = useTableCompactMode('tokens');
const [showKeys, setShowKeys] = useState({});
const [resolvedTokenKeys, setResolvedTokenKeys] = useState({});
const [loadingTokenKeys, setLoadingTokenKeys] = useState({});
const keyRequestsRef = useRef({});
// Form state
const [formApi, setFormApi] = useState(null);
const formInitValues = {
searchKeyword: '',
searchToken: '',
};
// Get form values helper function
const getFormValues = () => {
const formValues = formApi ? formApi.getValues() : {};
return {
searchKeyword: formValues.searchKeyword || '',
searchToken: formValues.searchToken || '',
};
};
// Close edit modal
const closeEdit = () => {
setShowEdit(false);
setTimeout(() => {
setEditingToken({
id: undefined,
});
}, 500);
};
// Sync page data from API response
const syncPageData = (payload) => {
setTokens(payload.items || []);
setTokenCount(payload.total || 0);
setActivePage(payload.page || 1);
setPageSize(payload.page_size || pageSize);
setShowKeys({});
};
// Load tokens function
const loadTokens = async (page = 1, size = pageSize) => {
setLoading(true);
setSearchMode(false);
const res = await API.get(`/api/token/?p=${page}&size=${size}`);
const { success, message, data } = res.data;
if (success) {
syncPageData(data);
} else {
showError(message);
}
setLoading(false);
};
// Refresh function
const refresh = async (page = activePage) => {
await loadTokens(page);
setSelectedKeys([]);
};
// Copy text function
const copyText = async (text) => {
if (await copy(text)) {
showSuccess(t('已复制到剪贴板!'));
} else {
Modal.error({
title: t('无法复制到剪贴板,请手动复制'),
content: text,
size: 'large',
});
}
};
const fetchTokenKey = async (tokenOrId, options = {}) => {
const { suppressError = false } = options;
const tokenId =
typeof tokenOrId === 'object' ? tokenOrId?.id : Number(tokenOrId);
if (!tokenId) {
const error = new Error(t('令牌不存在'));
if (!suppressError) {
showError(error.message);
}
throw error;
}
if (resolvedTokenKeys[tokenId]) {
return resolvedTokenKeys[tokenId];
}
if (keyRequestsRef.current[tokenId]) {
return keyRequestsRef.current[tokenId];
}
const request = (async () => {
setLoadingTokenKeys((prev) => ({ ...prev, [tokenId]: true }));
try {
const fullKey = await fetchTokenKeyById(tokenId);
setResolvedTokenKeys((prev) => ({ ...prev, [tokenId]: fullKey }));
return fullKey;
} catch (error) {
const normalizedError = new Error(
error?.message || t('获取令牌密钥失败'),
);
if (!suppressError) {
showError(normalizedError.message);
}
throw normalizedError;
} finally {
delete keyRequestsRef.current[tokenId];
setLoadingTokenKeys((prev) => {
const next = { ...prev };
delete next[tokenId];
return next;
});
}
})();
keyRequestsRef.current[tokenId] = request;
return request;
};
const toggleTokenVisibility = async (record) => {
const tokenId = record?.id;
if (!tokenId) {
return;
}
if (showKeys[tokenId]) {
setShowKeys((prev) => ({ ...prev, [tokenId]: false }));
return;
}
const fullKey = await fetchTokenKey(record);
if (fullKey) {
setShowKeys((prev) => ({ ...prev, [tokenId]: true }));
}
};
const copyTokenKey = async (record) => {
const fullKey = await fetchTokenKey(record);
await copyText(`sk-${fullKey}`);
};
// Open link function for chat integrations
const onOpenLink = async (type, url, record) => {
const fullKey = await fetchTokenKey(record);
if (url && url.startsWith('ccswitch')) {
openCCSwitchModal(fullKey);
return;
}
if (url && url.startsWith('fluent')) {
openFluentNotification(fullKey);
return;
}
let status = localStorage.getItem('status');
let serverAddress = '';
if (status) {
status = JSON.parse(status);
serverAddress = status.server_address;
}
if (serverAddress === '') {
serverAddress = window.location.origin;
}
if (url.includes('{cherryConfig}') === true) {
let cherryConfig = {
id: 'new-api',
baseUrl: serverAddress,
apiKey: `sk-${fullKey}`,
};
let encodedConfig = encodeURIComponent(
encodeToBase64(JSON.stringify(cherryConfig)),
);
url = url.replaceAll('{cherryConfig}', encodedConfig);
} else if (url.includes('{aionuiConfig}') === true) {
let aionuiConfig = {
platform: 'new-api',
baseUrl: serverAddress,
apiKey: `sk-${fullKey}`,
};
let encodedConfig = encodeURIComponent(
encodeToBase64(JSON.stringify(aionuiConfig)),
);
url = url.replaceAll('{aionuiConfig}', encodedConfig);
} else {
let encodedServerAddress = encodeURIComponent(serverAddress);
url = url.replaceAll('{address}', encodedServerAddress);
url = url.replaceAll('{key}', `sk-${fullKey}`);
}
window.open(url, '_blank');
};
// Manage token function (delete, enable, disable)
const manageToken = async (id, action, record) => {
setLoading(true);
let data = { id };
let res;
switch (action) {
case 'delete':
res = await API.delete(`/api/token/${id}/`);
break;
case 'enable':
data.status = 1;
res = await API.put('/api/token/?status_only=true', data);
break;
case 'disable':
data.status = 2;
res = await API.put('/api/token/?status_only=true', data);
break;
}
const { success, message } = res.data;
if (success) {
showSuccess(t('操作成功完成!'));
let token = res.data.data;
let newTokens = [...tokens];
if (action !== 'delete') {
record.status = token.status;
}
setTokens(newTokens);
} else {
showError(message);
}
setLoading(false);
};
// Search tokens function
const searchTokens = async (page = 1, size = pageSize) => {
const normalizedPage = Number.isInteger(page) && page > 0 ? page : 1;
const normalizedSize =
Number.isInteger(size) && size > 0 ? size : pageSize;
const { searchKeyword, searchToken } = getFormValues();
if (searchKeyword === '' && searchToken === '') {
setSearchMode(false);
await loadTokens(1);
return;
}
setSearching(true);
const res = await API.get(
`/api/token/search?keyword=${encodeURIComponent(searchKeyword)}&token=${encodeURIComponent(searchToken)}&p=${normalizedPage}&size=${normalizedSize}`,
);
const { success, message, data } = res.data;
if (success) {
setSearchMode(true);
syncPageData(data);
} else {
showError(message);
}
setSearching(false);
};
// Sort tokens function
const sortToken = (key) => {
if (tokens.length === 0) return;
setLoading(true);
let sortedTokens = [...tokens];
sortedTokens.sort((a, b) => {
return ('' + a[key]).localeCompare(b[key]);
});
if (sortedTokens[0].id === tokens[0].id) {
sortedTokens.reverse();
}
setTokens(sortedTokens);
setLoading(false);
};
// Page handlers
const handlePageChange = (page) => {
if (searchMode) {
searchTokens(page, pageSize).then();
} else {
loadTokens(page, pageSize).then();
}
};
const handlePageSizeChange = async (size) => {
setPageSize(size);
if (searchMode) {
await searchTokens(1, size);
} else {
await loadTokens(1, size);
}
};
// Row selection handlers
const rowSelection = {
onSelect: (record, selected) => {},
onSelectAll: (selected, selectedRows) => {},
onChange: (selectedRowKeys, selectedRows) => {
setSelectedKeys(selectedRows);
},
};
// Handle row styling
const handleRow = (record, index) => {
if (record.status !== 1) {
return {
style: {
background: 'var(--semi-color-disabled-border)',
},
};
} else {
return {};
}
};
// Batch delete tokens
const batchDeleteTokens = async () => {
if (selectedKeys.length === 0) {
showError(t('请先选择要删除的令牌!'));
return;
}
setLoading(true);
try {
const ids = selectedKeys.map((token) => token.id);
const res = await API.post('/api/token/batch', { ids });
if (res?.data?.success) {
const count = res.data.data || 0;
showSuccess(t('已删除 {{count}} 个令牌!', { count }));
await refresh();
setTimeout(() => {
if (tokens.length === 0 && activePage > 1) {
refresh(activePage - 1);
}
}, 100);
} else {
showError(res?.data?.message || t('删除失败'));
}
} catch (error) {
showError(error.message);
} finally {
setLoading(false);
}
};
// Batch copy tokens
const batchCopyTokens = async (copyType) => {
if (selectedKeys.length === 0) {
showError(t('请至少选择一个令牌!'));
return;
}
try {
const keys = await Promise.all(
selectedKeys.map((token) => fetchTokenKey(token, { suppressError: true })),
);
let content = '';
for (let i = 0; i < selectedKeys.length; i++) {
const fullKey = keys[i];
if (copyType === 'name+key') {
content += `${selectedKeys[i].name} sk-${fullKey}\n`;
} else {
content += `sk-${fullKey}\n`;
}
}
await copyText(content);
} catch (error) {
showError(error?.message || t('复制令牌失败'));
}
};
// Initialize data
useEffect(() => {
loadTokens(1)
.then()
.catch((reason) => {
showError(reason);
});
}, [pageSize]);
return {
// Basic state
tokens,
loading,
activePage,
tokenCount,
pageSize,
searching,
// Selection state
selectedKeys,
setSelectedKeys,
// Edit state
showEdit,
setShowEdit,
editingToken,
setEditingToken,
closeEdit,
// UI state
compactMode,
setCompactMode,
showKeys,
setShowKeys,
resolvedTokenKeys,
loadingTokenKeys,
// Form state
formApi,
setFormApi,
formInitValues,
getFormValues,
// Functions
loadTokens,
refresh,
copyText,
fetchTokenKey,
toggleTokenVisibility,
copyTokenKey,
onOpenLink,
manageToken,
searchTokens,
sortToken,
handlePageChange,
handlePageSizeChange,
rowSelection,
handleRow,
batchDeleteTokens,
batchCopyTokens,
syncPageData,
// Translation
t,
};
};

View File

@@ -0,0 +1,865 @@
/*
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 { useTranslation } from 'react-i18next';
import { Modal } from '@douyinfe/semi-ui';
import {
API,
getTodayStartTimestamp,
isAdmin,
showError,
showSuccess,
timestamp2string,
renderQuota,
renderNumber,
getLogOther,
copy,
renderClaudeLogContent,
renderLogContent,
renderAudioModelPrice,
renderClaudeModelPrice,
renderModelPrice,
} from '../../helpers';
import { ITEMS_PER_PAGE } from '../../constants';
import { useTableCompactMode } from '../common/useTableCompactMode';
import ParamOverrideEntry from '../../components/table/usage-logs/components/ParamOverrideEntry';
export const useLogsData = () => {
const { t } = useTranslation();
// Define column keys for selection
const COLUMN_KEYS = {
TIME: 'time',
CHANNEL: 'channel',
USERNAME: 'username',
TOKEN: 'token',
GROUP: 'group',
TYPE: 'type',
MODEL: 'model',
USE_TIME: 'use_time',
PROMPT: 'prompt',
COMPLETION: 'completion',
COST: 'cost',
RETRY: 'retry',
IP: 'ip',
DETAILS: 'details',
};
// Basic state
const [logs, setLogs] = useState([]);
const [expandData, setExpandData] = useState({});
const [showStat, setShowStat] = useState(false);
const [loading, setLoading] = useState(false);
const [loadingStat, setLoadingStat] = useState(false);
const [activePage, setActivePage] = useState(1);
const [logCount, setLogCount] = useState(0);
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
const [logType, setLogType] = useState(0);
// User and admin
const isAdminUser = isAdmin();
// Role-specific storage key to prevent different roles from overwriting each other
const STORAGE_KEY = isAdminUser
? 'logs-table-columns-admin'
: 'logs-table-columns-user';
const BILLING_DISPLAY_MODE_STORAGE_KEY = isAdminUser
? 'logs-billing-display-mode-admin'
: 'logs-billing-display-mode-user';
// Statistics state
const [stat, setStat] = useState({
quota: 0,
token: 0,
});
// Form state
const [formApi, setFormApi] = useState(null);
let now = new Date();
const formInitValues = {
username: '',
token_name: '',
model_name: '',
channel: '',
group: '',
request_id: '',
dateRange: [
timestamp2string(getTodayStartTimestamp()),
timestamp2string(now.getTime() / 1000 + 3600),
],
logType: '0',
};
// Get default column visibility based on user role
const getDefaultColumnVisibility = () => {
return {
[COLUMN_KEYS.TIME]: true,
[COLUMN_KEYS.CHANNEL]: isAdminUser,
[COLUMN_KEYS.USERNAME]: isAdminUser,
[COLUMN_KEYS.TOKEN]: true,
[COLUMN_KEYS.GROUP]: true,
[COLUMN_KEYS.TYPE]: true,
[COLUMN_KEYS.MODEL]: true,
[COLUMN_KEYS.USE_TIME]: true,
[COLUMN_KEYS.PROMPT]: true,
[COLUMN_KEYS.COMPLETION]: true,
[COLUMN_KEYS.COST]: true,
[COLUMN_KEYS.RETRY]: isAdminUser,
[COLUMN_KEYS.IP]: true,
[COLUMN_KEYS.DETAILS]: true,
};
};
const getInitialVisibleColumns = () => {
const defaults = getDefaultColumnVisibility();
const savedColumns = localStorage.getItem(STORAGE_KEY);
if (!savedColumns) {
return defaults;
}
try {
const parsed = JSON.parse(savedColumns);
const merged = { ...defaults, ...parsed };
if (!isAdminUser) {
merged[COLUMN_KEYS.CHANNEL] = false;
merged[COLUMN_KEYS.USERNAME] = false;
merged[COLUMN_KEYS.RETRY] = false;
}
return merged;
} catch (e) {
console.error('Failed to parse saved column preferences', e);
return defaults;
}
};
const getInitialBillingDisplayMode = () => {
const savedMode = localStorage.getItem(BILLING_DISPLAY_MODE_STORAGE_KEY);
if (savedMode === 'price' || savedMode === 'ratio') {
return savedMode;
}
return localStorage.getItem('quota_display_type') === 'TOKENS'
? 'ratio'
: 'price';
};
// Column visibility state
const [visibleColumns, setVisibleColumns] = useState(getInitialVisibleColumns);
const [showColumnSelector, setShowColumnSelector] = useState(false);
const [billingDisplayMode, setBillingDisplayMode] = useState(
getInitialBillingDisplayMode,
);
// Compact mode
const [compactMode, setCompactMode] = useTableCompactMode('logs');
// User info modal state
const [showUserInfo, setShowUserInfoModal] = useState(false);
const [userInfoData, setUserInfoData] = useState(null);
// Channel affinity usage cache stats modal state (admin only)
const [
showChannelAffinityUsageCacheModal,
setShowChannelAffinityUsageCacheModal,
] = useState(false);
const [channelAffinityUsageCacheTarget, setChannelAffinityUsageCacheTarget] =
useState(null);
const [showParamOverrideModal, setShowParamOverrideModal] = useState(false);
const [paramOverrideTarget, setParamOverrideTarget] = useState(null);
// Initialize default column visibility
const initDefaultColumns = () => {
const defaults = getDefaultColumnVisibility();
setVisibleColumns(defaults);
localStorage.setItem(STORAGE_KEY, JSON.stringify(defaults));
};
// Handle column visibility change
const handleColumnVisibilityChange = (columnKey, checked) => {
const updatedColumns = { ...visibleColumns, [columnKey]: checked };
setVisibleColumns(updatedColumns);
};
// Handle "Select All" checkbox
const handleSelectAll = (checked) => {
const allKeys = Object.keys(COLUMN_KEYS).map((key) => COLUMN_KEYS[key]);
const updatedColumns = {};
allKeys.forEach((key) => {
if (
(key === COLUMN_KEYS.CHANNEL ||
key === COLUMN_KEYS.USERNAME ||
key === COLUMN_KEYS.RETRY) &&
!isAdminUser
) {
updatedColumns[key] = false;
} else {
updatedColumns[key] = checked;
}
});
setVisibleColumns(updatedColumns);
};
// Persist column settings to the role-specific STORAGE_KEY
useEffect(() => {
if (Object.keys(visibleColumns).length > 0) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(visibleColumns));
}
}, [visibleColumns]);
useEffect(() => {
localStorage.setItem(BILLING_DISPLAY_MODE_STORAGE_KEY, billingDisplayMode);
}, [BILLING_DISPLAY_MODE_STORAGE_KEY, billingDisplayMode]);
// 获取表单值的辅助函数,确保所有值都是字符串
const getFormValues = () => {
const formValues = formApi ? formApi.getValues() : {};
let start_timestamp = timestamp2string(getTodayStartTimestamp());
let end_timestamp = timestamp2string(now.getTime() / 1000 + 3600);
if (
formValues.dateRange &&
Array.isArray(formValues.dateRange) &&
formValues.dateRange.length === 2
) {
start_timestamp = formValues.dateRange[0];
end_timestamp = formValues.dateRange[1];
}
return {
username: formValues.username || '',
token_name: formValues.token_name || '',
model_name: formValues.model_name || '',
start_timestamp,
end_timestamp,
channel: formValues.channel || '',
group: formValues.group || '',
request_id: formValues.request_id || '',
logType: formValues.logType ? parseInt(formValues.logType) : 0,
};
};
// Statistics functions
const getLogSelfStat = async () => {
const {
token_name,
model_name,
start_timestamp,
end_timestamp,
group,
logType: formLogType,
} = getFormValues();
const currentLogType = formLogType !== undefined ? formLogType : logType;
let localStartTimestamp = Date.parse(start_timestamp) / 1000;
let localEndTimestamp = Date.parse(end_timestamp) / 1000;
let url = `/api/log/self/stat?type=${currentLogType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&group=${group}`;
url = encodeURI(url);
let res = await API.get(url);
const { success, message, data } = res.data;
if (success) {
setStat(data);
} else {
showError(message);
}
};
const getLogStat = async () => {
const {
username,
token_name,
model_name,
start_timestamp,
end_timestamp,
channel,
group,
logType: formLogType,
} = getFormValues();
const currentLogType = formLogType !== undefined ? formLogType : logType;
let localStartTimestamp = Date.parse(start_timestamp) / 1000;
let localEndTimestamp = Date.parse(end_timestamp) / 1000;
let url = `/api/log/stat?type=${currentLogType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&channel=${channel}&group=${group}`;
url = encodeURI(url);
let res = await API.get(url);
const { success, message, data } = res.data;
if (success) {
setStat(data);
} else {
showError(message);
}
};
const handleEyeClick = async () => {
if (loadingStat) {
return;
}
setLoadingStat(true);
if (isAdminUser) {
await getLogStat();
} else {
await getLogSelfStat();
}
setShowStat(true);
setLoadingStat(false);
};
// User info function
const showUserInfoFunc = async (userId) => {
if (!isAdminUser) {
return;
}
const res = await API.get(`/api/user/${userId}`);
const { success, message, data } = res.data;
if (success) {
setUserInfoData(data);
setShowUserInfoModal(true);
} else {
showError(message);
}
};
const openChannelAffinityUsageCacheModal = (affinity) => {
const a = affinity || {};
setChannelAffinityUsageCacheTarget({
rule_name: a.rule_name || a.reason || '',
using_group: a.using_group || '',
key_hint: a.key_hint || '',
key_fp: a.key_fp || '',
});
setShowChannelAffinityUsageCacheModal(true);
};
const openParamOverrideModal = (log, other) => {
const lines = Array.isArray(other?.po) ? other.po.filter(Boolean) : [];
if (lines.length === 0) {
return;
}
setParamOverrideTarget({
lines,
modelName: log?.model_name || '',
requestId: log?.request_id || '',
requestPath: other?.request_path || '',
});
setShowParamOverrideModal(true);
};
// Format logs data
const setLogsFormat = (logs) => {
const requestConversionDisplayValue = (conversionChain) => {
const chain = Array.isArray(conversionChain)
? conversionChain.filter(Boolean)
: [];
if (chain.length <= 1) {
return t('原生格式');
}
return `${chain.join(' -> ')}`;
};
let expandDatesLocal = {};
for (let i = 0; i < logs.length; i++) {
logs[i].timestamp2string = timestamp2string(logs[i].created_at);
logs[i].key = logs[i].id;
let other = getLogOther(logs[i].other);
let expandDataLocal = [];
if (isAdminUser && (logs[i].type === 0 || logs[i].type === 2 || logs[i].type === 6)) {
expandDataLocal.push({
key: t('渠道信息'),
value: `${logs[i].channel} - ${logs[i].channel_name || '[未知]'}`,
});
}
if (logs[i].request_id) {
expandDataLocal.push({
key: t('Request ID'),
value: logs[i].request_id,
});
}
if (other?.ws || other?.audio) {
expandDataLocal.push({
key: t('语音输入'),
value: other.audio_input,
});
expandDataLocal.push({
key: t('语音输出'),
value: other.audio_output,
});
expandDataLocal.push({
key: t('文字输入'),
value: other.text_input,
});
expandDataLocal.push({
key: t('文字输出'),
value: other.text_output,
});
}
if (other?.cache_tokens > 0) {
expandDataLocal.push({
key: t('缓存 Tokens'),
value: other.cache_tokens,
});
}
if (other?.cache_creation_tokens > 0) {
expandDataLocal.push({
key: t('缓存创建 Tokens'),
value: other.cache_creation_tokens,
});
}
if (logs[i].type === 2) {
expandDataLocal.push({
key: t('日志详情'),
value: other?.claude
? renderClaudeLogContent(
other?.model_ratio,
other.completion_ratio,
other.model_price,
other.group_ratio,
other?.user_group_ratio,
other.cache_ratio || 1.0,
other.cache_creation_ratio || 1.0,
other.cache_creation_tokens_5m || 0,
other.cache_creation_ratio_5m ||
other.cache_creation_ratio ||
1.0,
other.cache_creation_tokens_1h || 0,
other.cache_creation_ratio_1h ||
other.cache_creation_ratio ||
1.0,
billingDisplayMode,
)
: renderLogContent(
other?.model_ratio,
other.completion_ratio,
other.model_price,
other.group_ratio,
other?.user_group_ratio,
other.cache_ratio || 1.0,
false,
1.0,
other.web_search || false,
other.web_search_call_count || 0,
other.file_search || false,
other.file_search_call_count || 0,
billingDisplayMode,
),
});
if (logs[i]?.content) {
expandDataLocal.push({
key: t('其他详情'),
value: logs[i].content,
});
}
if (isAdminUser && other?.reject_reason) {
expandDataLocal.push({
key: t('拦截原因'),
value: other.reject_reason,
});
}
}
if (logs[i].type === 2) {
let modelMapped =
other?.is_model_mapped &&
other?.upstream_model_name &&
other?.upstream_model_name !== '';
if (modelMapped) {
expandDataLocal.push({
key: t('请求并计费模型'),
value: logs[i].model_name,
});
expandDataLocal.push({
key: t('实际模型'),
value: other.upstream_model_name,
});
}
const isViolationFeeLog =
other?.violation_fee === true ||
Boolean(other?.violation_fee_code) ||
Boolean(other?.violation_fee_marker);
let content = '';
if (!isViolationFeeLog) {
if (other?.ws || other?.audio) {
content = renderAudioModelPrice(
other?.text_input,
other?.text_output,
other?.model_ratio,
other?.model_price,
other?.completion_ratio,
other?.audio_input,
other?.audio_output,
other?.audio_ratio,
other?.audio_completion_ratio,
other?.group_ratio,
other?.user_group_ratio,
other?.cache_tokens || 0,
other?.cache_ratio || 1.0,
billingDisplayMode,
);
} else if (other?.claude) {
content = renderClaudeModelPrice(
logs[i].prompt_tokens,
logs[i].completion_tokens,
other.model_ratio,
other.model_price,
other.completion_ratio,
other.group_ratio,
other?.user_group_ratio,
other.cache_tokens || 0,
other.cache_ratio || 1.0,
other.cache_creation_tokens || 0,
other.cache_creation_ratio || 1.0,
other.cache_creation_tokens_5m || 0,
other.cache_creation_ratio_5m ||
other.cache_creation_ratio ||
1.0,
other.cache_creation_tokens_1h || 0,
other.cache_creation_ratio_1h ||
other.cache_creation_ratio ||
1.0,
billingDisplayMode,
);
} else {
content = renderModelPrice(
logs[i].prompt_tokens,
logs[i].completion_tokens,
other?.model_ratio,
other?.model_price,
other?.completion_ratio,
other?.group_ratio,
other?.user_group_ratio,
other?.cache_tokens || 0,
other?.cache_ratio || 1.0,
other?.image || false,
other?.image_ratio || 0,
other?.image_output || 0,
other?.web_search || false,
other?.web_search_call_count || 0,
other?.web_search_price || 0,
other?.file_search || false,
other?.file_search_call_count || 0,
other?.file_search_price || 0,
other?.audio_input_seperate_price || false,
other?.audio_input_token_count || 0,
other?.audio_input_price || 0,
other?.image_generation_call || false,
other?.image_generation_call_price || 0,
billingDisplayMode,
);
}
expandDataLocal.push({
key: t('计费过程'),
value: content,
});
}
if (other?.reasoning_effort) {
expandDataLocal.push({
key: t('Reasoning Effort'),
value: other.reasoning_effort,
});
}
}
if (logs[i].type === 6) {
if (other?.task_id) {
expandDataLocal.push({
key: t('任务ID'),
value: other.task_id,
});
}
if (other?.reason) {
expandDataLocal.push({
key: t('失败原因'),
value: (
<div style={{ maxWidth: 600, whiteSpace: 'normal', wordBreak: 'break-word', lineHeight: 1.6 }}>
{other.reason}
</div>
),
});
}
}
if (other?.request_path) {
expandDataLocal.push({
key: t('请求路径'),
value: other.request_path,
});
}
if (Array.isArray(other?.po) && other.po.length > 0) {
expandDataLocal.push({
key: t('参数覆盖'),
value: (
<ParamOverrideEntry
count={other.po.length}
t={t}
onOpen={(event) => {
event.stopPropagation();
openParamOverrideModal(logs[i], other);
}}
/>
),
});
}
if (other?.billing_source === 'subscription') {
const planId = other?.subscription_plan_id;
const planTitle = other?.subscription_plan_title || '';
const subscriptionId = other?.subscription_id;
const unit = t('额度');
const pre = other?.subscription_pre_consumed ?? 0;
const postDelta = other?.subscription_post_delta ?? 0;
const finalConsumed = other?.subscription_consumed ?? pre + postDelta;
const remain = other?.subscription_remain;
const total = other?.subscription_total;
// Use multiple Description items to avoid an overlong single line.
if (planId) {
expandDataLocal.push({
key: t('订阅套餐'),
value: `#${planId} ${planTitle}`.trim(),
});
}
if (subscriptionId) {
expandDataLocal.push({
key: t('订阅实例'),
value: `#${subscriptionId}`,
});
}
const settlementLines = [
`${t('预扣')}${pre} ${unit}`,
`${t('结算差额')}${postDelta > 0 ? '+' : ''}${postDelta} ${unit}`,
`${t('最终抵扣')}${finalConsumed} ${unit}`,
]
.filter(Boolean)
.join('\n');
expandDataLocal.push({
key: t('订阅结算'),
value: (
<div style={{ whiteSpace: 'pre-line' }}>{settlementLines}</div>
),
});
if (remain !== undefined && total !== undefined) {
expandDataLocal.push({
key: t('订阅剩余'),
value: `${remain}/${total} ${unit}`,
});
}
expandDataLocal.push({
key: t('订阅说明'),
value: t(
'token 会按倍率换算成“额度/次数”,请求结束后再做差额结算(补扣/返还)。',
),
});
}
if (isAdminUser && logs[i].type !== 6) {
expandDataLocal.push({
key: t('请求转换'),
value: requestConversionDisplayValue(other?.request_conversion),
});
}
if (isAdminUser && logs[i].type !== 6) {
let localCountMode = '';
if (other?.admin_info?.local_count_tokens) {
localCountMode = t('本地计费');
} else {
localCountMode = t('上游返回');
}
expandDataLocal.push({
key: t('计费模式'),
value: localCountMode,
});
}
expandDatesLocal[logs[i].key] = expandDataLocal;
}
setExpandData(expandDatesLocal);
setLogs(logs);
};
// Load logs function
const loadLogs = async (startIdx, pageSize, customLogType = null) => {
setLoading(true);
let url = '';
const {
username,
token_name,
model_name,
start_timestamp,
end_timestamp,
channel,
group,
request_id,
logType: formLogType,
} = getFormValues();
const currentLogType =
customLogType !== null
? customLogType
: formLogType !== undefined
? formLogType
: logType;
let localStartTimestamp = Date.parse(start_timestamp) / 1000;
let localEndTimestamp = Date.parse(end_timestamp) / 1000;
if (isAdminUser) {
url = `/api/log/?p=${startIdx}&page_size=${pageSize}&type=${currentLogType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&channel=${channel}&group=${group}&request_id=${request_id}`;
} else {
url = `/api/log/self/?p=${startIdx}&page_size=${pageSize}&type=${currentLogType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&group=${group}&request_id=${request_id}`;
}
url = encodeURI(url);
const res = await API.get(url);
const { success, message, data } = res.data;
if (success) {
const newPageData = data.items;
setActivePage(data.page);
setPageSize(data.page_size);
setLogCount(data.total);
setLogsFormat(newPageData);
} else {
showError(message);
}
setLoading(false);
};
// Page handlers
const handlePageChange = (page) => {
setActivePage(page);
loadLogs(page, pageSize).then((r) => {});
};
const handlePageSizeChange = async (size) => {
localStorage.setItem('page-size', size + '');
setPageSize(size);
setActivePage(1);
loadLogs(activePage, size)
.then()
.catch((reason) => {
showError(reason);
});
};
// Refresh function
const refresh = async () => {
setActivePage(1);
handleEyeClick();
await loadLogs(1, pageSize);
};
// Copy text function
const copyText = async (e, text) => {
e.stopPropagation();
if (await copy(text)) {
showSuccess('已复制:' + text);
} else {
Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text });
}
};
// Initialize data
useEffect(() => {
const localPageSize =
parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE;
setPageSize(localPageSize);
loadLogs(activePage, localPageSize)
.then()
.catch((reason) => {
showError(reason);
});
}, []);
// Initialize statistics when formApi is available
useEffect(() => {
if (formApi) {
handleEyeClick();
}
}, [formApi]);
// Check if any record has expandable content
const hasExpandableRows = () => {
return logs.some(
(log) => expandData[log.key] && expandData[log.key].length > 0,
);
};
return {
// Basic state
logs,
expandData,
showStat,
loading,
loadingStat,
activePage,
logCount,
pageSize,
logType,
stat,
isAdminUser,
// Form state
formApi,
setFormApi,
formInitValues,
getFormValues,
// Column visibility
visibleColumns,
showColumnSelector,
setShowColumnSelector,
billingDisplayMode,
setBillingDisplayMode,
handleColumnVisibilityChange,
handleSelectAll,
initDefaultColumns,
COLUMN_KEYS,
// Compact mode
compactMode,
setCompactMode,
// User info modal
showUserInfo,
setShowUserInfoModal,
userInfoData,
showUserInfoFunc,
// Channel affinity usage cache stats modal
showChannelAffinityUsageCacheModal,
setShowChannelAffinityUsageCacheModal,
channelAffinityUsageCacheTarget,
openChannelAffinityUsageCacheModal,
showParamOverrideModal,
setShowParamOverrideModal,
paramOverrideTarget,
// Functions
loadLogs,
handlePageChange,
handlePageSizeChange,
refresh,
copyText,
handleEyeClick,
setLogsFormat,
hasExpandableRows,
setLogType,
openParamOverrideModal,
// Translation
t,
};
};

321
web/src/hooks/users/useUsersData.jsx vendored Normal file
View File

@@ -0,0 +1,321 @@
/*
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 { useTranslation } from 'react-i18next';
import { API, showError, showSuccess } from '../../helpers';
import { ITEMS_PER_PAGE } from '../../constants';
import { useTableCompactMode } from '../common/useTableCompactMode';
export const useUsersData = () => {
const { t } = useTranslation();
const [compactMode, setCompactMode] = useTableCompactMode('users');
// State management
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [activePage, setActivePage] = useState(1);
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
const [searching, setSearching] = useState(false);
const [groupOptions, setGroupOptions] = useState([]);
const [userCount, setUserCount] = useState(0);
// Modal states
const [showAddUser, setShowAddUser] = useState(false);
const [showEditUser, setShowEditUser] = useState(false);
const [editingUser, setEditingUser] = useState({
id: undefined,
});
// Form initial values
const formInitValues = {
searchKeyword: '',
searchGroup: '',
};
// Form API reference
const [formApi, setFormApi] = useState(null);
// Get form values helper function
const getFormValues = () => {
const formValues = formApi ? formApi.getValues() : {};
return {
searchKeyword: formValues.searchKeyword || '',
searchGroup: formValues.searchGroup || '',
};
};
// Set user format with key field
const setUserFormat = (users) => {
for (let i = 0; i < users.length; i++) {
users[i].key = users[i].id;
}
setUsers(users);
};
// Load users data
const loadUsers = async (startIdx, pageSize) => {
setLoading(true);
const res = await API.get(`/api/user/?p=${startIdx}&page_size=${pageSize}`);
const { success, message, data } = res.data;
if (success) {
const newPageData = data.items;
setActivePage(data.page);
setUserCount(data.total);
setUserFormat(newPageData);
} else {
showError(message);
}
setLoading(false);
};
// Search users with keyword and group
const searchUsers = async (
startIdx,
pageSize,
searchKeyword = null,
searchGroup = null,
) => {
// If no parameters passed, get values from form
if (searchKeyword === null || searchGroup === null) {
const formValues = getFormValues();
searchKeyword = formValues.searchKeyword;
searchGroup = formValues.searchGroup;
}
if (searchKeyword === '' && searchGroup === '') {
// If keyword is blank, load files instead
await loadUsers(startIdx, pageSize);
return;
}
setSearching(true);
const res = await API.get(
`/api/user/search?keyword=${searchKeyword}&group=${searchGroup}&p=${startIdx}&page_size=${pageSize}`,
);
const { success, message, data } = res.data;
if (success) {
const newPageData = data.items;
setActivePage(data.page);
setUserCount(data.total);
setUserFormat(newPageData);
} else {
showError(message);
}
setSearching(false);
};
// Manage user operations (promote, demote, enable, disable, delete)
const manageUser = async (userId, action, record) => {
// Trigger loading state to force table re-render
setLoading(true);
const res = await API.post('/api/user/manage', {
id: userId,
action,
});
const { success, message } = res.data;
if (success) {
showSuccess(t('操作成功完成!'));
const user = res.data.data;
// Create a new array and new object to ensure React detects changes
const newUsers = users.map((u) => {
if (u.id === userId) {
if (action === 'delete') {
return { ...u, DeletedAt: new Date() };
}
return { ...u, status: user.status, role: user.role };
}
return u;
});
setUsers(newUsers);
} else {
showError(message);
}
setLoading(false);
};
const resetUserPasskey = async (user) => {
if (!user) {
return;
}
try {
const res = await API.delete(`/api/user/${user.id}/reset_passkey`);
const { success, message } = res.data;
if (success) {
showSuccess(t('Passkey 已重置'));
} else {
showError(message || t('操作失败,请重试'));
}
} catch (error) {
showError(t('操作失败,请重试'));
}
};
const resetUserTwoFA = async (user) => {
if (!user) {
return;
}
try {
const res = await API.delete(`/api/user/${user.id}/2fa`);
const { success, message } = res.data;
if (success) {
showSuccess(t('二步验证已重置'));
} else {
showError(message || t('操作失败,请重试'));
}
} catch (error) {
showError(t('操作失败,请重试'));
}
};
// Handle page change
const handlePageChange = (page) => {
setActivePage(page);
const { searchKeyword, searchGroup } = getFormValues();
if (searchKeyword === '' && searchGroup === '') {
loadUsers(page, pageSize).then();
} else {
searchUsers(page, pageSize, searchKeyword, searchGroup).then();
}
};
// Handle page size change
const handlePageSizeChange = async (size) => {
localStorage.setItem('page-size', size + '');
setPageSize(size);
setActivePage(1);
loadUsers(activePage, size)
.then()
.catch((reason) => {
showError(reason);
});
};
// Handle table row styling for disabled/deleted users
const handleRow = (record, index) => {
if (record.DeletedAt !== null || record.status !== 1) {
return {
style: {
background: 'var(--semi-color-disabled-border)',
},
};
} else {
return {};
}
};
// Refresh data
const refresh = async (page = activePage) => {
const { searchKeyword, searchGroup } = getFormValues();
if (searchKeyword === '' && searchGroup === '') {
await loadUsers(page, pageSize);
} else {
await searchUsers(page, pageSize, searchKeyword, searchGroup);
}
};
// Fetch groups data
const fetchGroups = async () => {
try {
let res = await API.get(`/api/group/`);
if (res === undefined) {
return;
}
setGroupOptions(
res.data.data.map((group) => ({
label: group,
value: group,
})),
);
} catch (error) {
showError(error.message);
}
};
// Modal control functions
const closeAddUser = () => {
setShowAddUser(false);
};
const closeEditUser = () => {
setShowEditUser(false);
setEditingUser({
id: undefined,
});
};
// Initialize data on component mount
useEffect(() => {
loadUsers(0, pageSize)
.then()
.catch((reason) => {
showError(reason);
});
fetchGroups().then();
}, []);
return {
// Data state
users,
loading,
activePage,
pageSize,
userCount,
searching,
groupOptions,
// Modal state
showAddUser,
showEditUser,
editingUser,
setShowAddUser,
setShowEditUser,
setEditingUser,
// Form state
formInitValues,
formApi,
setFormApi,
// UI state
compactMode,
setCompactMode,
// Actions
loadUsers,
searchUsers,
manageUser,
resetUserPasskey,
resetUserTwoFA,
handlePageChange,
handlePageSizeChange,
handleRow,
refresh,
closeAddUser,
closeEditUser,
getFormValues,
// Translation
t,
};
};