Merge remote-tracking branch 'origin/main' into nightly

# Conflicts:
#	web/src/helpers/render.jsx
#	web/src/hooks/usage-logs/useUsageLogsData.jsx
#	web/src/i18n/locales/en.json
This commit is contained in:
CaIon
2026-04-09 17:12:21 +08:00
100 changed files with 6875 additions and 2699 deletions
+131 -6
View File
@@ -34,8 +34,14 @@ import {
updateChartSpec,
updateMapValue,
initializeMaps,
processUserData,
} from '../../helpers/dashboard';
const USER_COLORS = [
'#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6',
'#ec4899', '#06b6d4', '#f97316', '#6366f1', '#14b8a6',
];
export const useDashboardCharts = (
dataExportDefaultTime,
setTrendData,
@@ -179,7 +185,6 @@ export const useDashboardCharts = (
},
});
// 模型消耗趋势折线图
const [spec_model_line, setSpecModelLine] = useState({
type: 'line',
data: [
@@ -197,7 +202,7 @@ export const useDashboardCharts = (
},
title: {
visible: true,
text: t('模型消耗趋势'),
text: t('调用趋势'),
subtext: '',
},
tooltip: {
@@ -215,7 +220,6 @@ export const useDashboardCharts = (
},
});
// 模型调用次数排行柱状图
const [spec_rank_bar, setSpecRankBar] = useState({
type: 'bar',
data: [
@@ -259,6 +263,82 @@ export const useDashboardCharts = (
},
});
// ========== Admin: 用户消耗排行 ==========
const [spec_user_rank, setSpecUserRank] = useState({
type: 'bar',
data: [{ id: 'userRankData', values: [] }],
xField: 'rawQuota',
yField: 'User',
seriesField: 'User',
direction: 'horizontal',
legends: { visible: false },
title: {
visible: true,
text: t('用户消耗排行'),
subtext: '',
},
bar: {
state: { hover: { stroke: '#000', lineWidth: 1 } },
},
label: {
visible: true,
position: 'outside',
formatMethod: (value, datum) => renderQuota(datum['rawQuota'] || 0, 2),
},
axes: [{
orient: 'left',
type: 'band',
label: { visible: true },
}, {
orient: 'bottom',
type: 'linear',
visible: false,
}],
tooltip: {
mark: {
content: [{
key: (datum) => datum['User'],
value: (datum) => renderQuota(datum['rawQuota'] || 0, 4),
}],
},
},
color: { type: 'ordinal', range: USER_COLORS },
});
// ========== Admin: 用户消耗趋势 ==========
const [spec_user_trend, setSpecUserTrend] = useState({
type: 'area',
data: [{ id: 'userTrendData', values: [] }],
xField: 'Time',
yField: 'rawQuota',
seriesField: 'User',
stack: false,
legends: { visible: true, selectMode: 'single' },
title: {
visible: true,
text: t('用户消耗趋势'),
subtext: '',
},
axes: [{
orient: 'left',
label: {
formatMethod: (value) => renderQuota(value, 2),
},
}],
area: { style: { fillOpacity: 0.15 } },
line: { style: { lineWidth: 2 } },
point: { visible: false },
tooltip: {
mark: {
content: [{
key: (datum) => datum['User'],
value: (datum) => renderQuota(datum['rawQuota'] || 0, 4),
}],
},
},
color: { type: 'ordinal', range: USER_COLORS },
});
// ========== 数据处理函数 ==========
const generateModelColors = useCallback((uniqueModels, modelColors) => {
const newModelColors = {};
@@ -426,6 +506,51 @@ export const useDashboardCharts = (
],
);
// ========== 用户维度图表数据处理 ==========
const updateUserChartData = useCallback(
(data) => {
const { rankingData, trendData: userTrend } = processUserData(
data,
dataExportDefaultTime,
10,
);
const userRankValues = rankingData.map((item) => ({
User: item.User,
rawQuota: item.Quota,
Quota: getQuotaWithUnit(item.Quota, 4),
})).sort((a, b) => b.rawQuota - a.rawQuota);
const totalUserQuota = rankingData.reduce((s, i) => s + i.Quota, 0);
setSpecUserRank((prev) => ({
...prev,
data: [{ id: 'userRankData', values: userRankValues }],
title: {
...prev.title,
subtext: `${t('总计')}${renderQuota(totalUserQuota, 2)}`,
},
}));
const userTrendValues = userTrend.map((item) => ({
Time: item.Time,
User: item.User,
rawQuota: item.Quota,
Usage: item.Quota ? getQuotaWithUnit(item.Quota, 4) : 0,
}));
setSpecUserTrend((prev) => ({
...prev,
data: [{ id: 'userTrendData', values: userTrendValues }],
title: {
...prev.title,
subtext: `${t('总计')}${renderQuota(totalUserQuota, 2)}`,
},
}));
},
[dataExportDefaultTime, t],
);
// ========== 初始化图表主题 ==========
useEffect(() => {
initVChartSemiTheme({
@@ -434,14 +559,14 @@ export const useDashboardCharts = (
}, []);
return {
// 图表规格
spec_pie,
spec_line,
spec_model_line,
spec_rank_bar,
// 函数
spec_user_rank,
spec_user_trend,
updateChartData,
updateUserChartData,
generateModelColors,
};
};
+22
View File
@@ -213,6 +213,27 @@ export const useDashboardData = (userState, userDispatch, statusState) => {
}
}, [activeUptimeTab]);
const loadUserQuotaData = useCallback(async () => {
if (!isAdminUser) return [];
try {
const { start_timestamp, end_timestamp } = inputs;
const localStartTimestamp = Date.parse(start_timestamp) / 1000;
const localEndTimestamp = Date.parse(end_timestamp) / 1000;
const url = `/api/data/users?start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
const res = await API.get(url);
const { success, message, data } = res.data;
if (success) {
return data || [];
} else {
showError(message);
return [];
}
} catch (err) {
console.error(err);
return [];
}
}, [inputs, isAdminUser]);
const getUserData = useCallback(async () => {
let res = await API.get(`/api/user/self`);
const { success, message, data } = res.data;
@@ -311,6 +332,7 @@ export const useDashboardData = (userState, userDispatch, statusState) => {
showSearchModal,
handleCloseModal,
loadQuotaData,
loadUserQuotaData,
loadUptimeData,
getUserData,
refresh,
+8 -1
View File
@@ -167,7 +167,14 @@ export const usePlaygroundState = () => {
// 配置导入/重置
const handleConfigImport = useCallback((importedConfig) => {
if (importedConfig.inputs) {
setInputs((prev) => ({ ...prev, ...importedConfig.inputs }));
const parsedMaxTokens = parseInt(importedConfig.inputs.max_tokens, 10);
setInputs((prev) => ({
...prev,
...importedConfig.inputs,
max_tokens: Number.isNaN(parsedMaxTokens)
? importedConfig.inputs.max_tokens
: parsedMaxTokens,
}));
}
if (importedConfig.parameterEnabled) {
setParameterEnabled((prev) => ({
+23 -6
View File
@@ -31,6 +31,7 @@ import { ITEMS_PER_PAGE } from '../../constants';
import { useTableCompactMode } from '../common/useTableCompactMode';
import {
fetchTokenKey as fetchTokenKeyById,
fetchTokenKeysBatch,
getServerAddress,
encodeChannelConnectionString,
} from '../../helpers/token';
@@ -41,6 +42,7 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
// Basic state
const [tokens, setTokens] = useState([]);
const [loading, setLoading] = useState(true);
const [groupRatios, setGroupRatios] = useState({});
const [activePage, setActivePage] = useState(1);
const [tokenCount, setTokenCount] = useState(0);
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
@@ -408,14 +410,17 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
return;
}
try {
const keys = await Promise.all(
selectedKeys.map((token) => fetchTokenKey(token, { suppressError: true })),
);
const ids = selectedKeys.map((token) => token.id);
const keysMap = await fetchTokenKeysBatch(ids);
setResolvedTokenKeys((prev) => ({ ...prev, ...keysMap }));
let content = '';
for (let i = 0; i < selectedKeys.length; i++) {
const fullKey = keys[i];
for (const token of selectedKeys) {
const fullKey = keysMap[token.id];
if (!fullKey) continue;
if (copyType === 'name+key') {
content += `${selectedKeys[i].name} sk-${fullKey}\n`;
content += `${token.name} sk-${fullKey}\n`;
} else {
content += `sk-${fullKey}\n`;
}
@@ -433,6 +438,17 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
.catch((reason) => {
showError(reason);
});
API.get('/api/user/self/groups')
.then((res) => {
if (res.data.success && res.data.data) {
const ratios = {};
for (const [name, info] of Object.entries(res.data.data)) {
ratios[name] = info.ratio;
}
setGroupRatios(ratios);
}
})
.catch(() => {});
}, [pageSize]);
return {
@@ -443,6 +459,7 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
tokenCount,
pageSize,
searching,
groupRatios,
// Selection state
selectedKeys,
+5 -1
View File
@@ -37,6 +37,7 @@ import {
renderClaudeModelPrice,
renderModelPrice,
renderTieredModelPrice,
renderTaskBillingProcess,
} from '../../helpers';
import { ITEMS_PER_PAGE } from '../../constants';
import { useTableCompactMode } from '../common/useTableCompactMode';
@@ -475,7 +476,10 @@ export const useLogsData = () => {
completion_tokens: logs[i].completion_tokens,
displayMode: billingDisplayMode,
};
if (other?.ws || other?.audio) {
const isTaskLog = other?.is_task === true || other?.task_id != null;
if (isTaskLog && other?.model_price === -1) {
content = renderTaskBillingProcess(other, logs[i].content);
} else if (other?.ws || other?.audio) {
content = renderAudioModelPrice(logOpts);
} else if (other?.claude) {
content = renderClaudeModelPrice(logOpts);