+
@@ -549,9 +553,7 @@ const EditTagModal = (props) => {
field='param_override'
label={t('参数覆盖')}
placeholder={
- t(
- '此项可选,用于覆盖请求参数。不支持覆盖 stream 参数',
- ) +
+ t('此项可选,用于覆盖请求参数。不支持覆盖 stream 参数') +
'\n' +
t('旧格式(直接覆盖):') +
'\n{\n "temperature": 0,\n "max_tokens": 1000\n}' +
diff --git a/web/src/components/table/channels/modals/ModelSelectModal.jsx b/web/src/components/table/channels/modals/ModelSelectModal.jsx
index eda7f80b5..b38580b66 100644
--- a/web/src/components/table/channels/modals/ModelSelectModal.jsx
+++ b/web/src/components/table/channels/modals/ModelSelectModal.jsx
@@ -104,7 +104,9 @@ const ModelSelectModal = ({
}, [normalizedRedirectModels, normalizedSelectedSet]);
const filteredModels = models.filter((m) =>
- String(m || '').toLowerCase().includes(keyword.toLowerCase()),
+ String(m || '')
+ .toLowerCase()
+ .includes(keyword.toLowerCase()),
);
// 分类模型:新获取的模型和已有模型
diff --git a/web/src/components/table/model-deployments/modals/ConfirmationDialog.jsx b/web/src/components/table/model-deployments/modals/ConfirmationDialog.jsx
index f462292a3..5e90b153b 100644
--- a/web/src/components/table/model-deployments/modals/ConfirmationDialog.jsx
+++ b/web/src/components/table/model-deployments/modals/ConfirmationDialog.jsx
@@ -30,7 +30,7 @@ const ConfirmationDialog = ({
type = 'danger',
deployment,
t,
- loading = false
+ loading = false,
}) => {
const [confirmText, setConfirmText] = useState('');
@@ -66,17 +66,17 @@ const ConfirmationDialog = ({
okButtonProps={{
disabled: !isConfirmed,
type: type === 'danger' ? 'danger' : 'primary',
- loading
+ loading,
}}
width={480}
>
-
-
+
+
{t('此操作具有风险,请确认要继续执行')}。
{t('请输入部署名称以完成二次确认')}:
-
+
{requiredText || t('未知部署')}
@@ -87,7 +87,7 @@ const ConfirmationDialog = ({
autoFocus
/>
{!isConfirmed && confirmText && (
-
+
{t('部署名称不匹配,请检查后重新输入')}
)}
diff --git a/web/src/components/table/model-deployments/modals/ExtendDurationModal.jsx b/web/src/components/table/model-deployments/modals/ExtendDurationModal.jsx
index 3b357bc94..e4e9b7bb9 100644
--- a/web/src/components/table/model-deployments/modals/ExtendDurationModal.jsx
+++ b/web/src/components/table/model-deployments/modals/ExtendDurationModal.jsx
@@ -130,9 +130,7 @@ const ExtendDurationModal = ({
? details.locations
.map((location) =>
Number(
- location?.id ??
- location?.location_id ??
- location?.locationId,
+ location?.id ?? location?.location_id ?? location?.locationId,
),
)
.filter((id) => Number.isInteger(id) && id > 0)
@@ -181,9 +179,7 @@ const ExtendDurationModal = ({
} else {
const message = response.data.message || '';
setPriceEstimation(null);
- setPriceError(
- t('价格计算失败') + (message ? `: ${message}` : ''),
- );
+ setPriceError(t('价格计算失败') + (message ? `: ${message}` : ''));
}
} catch (error) {
if (costRequestIdRef.current !== requestId) {
@@ -192,9 +188,7 @@ const ExtendDurationModal = ({
const message = error?.response?.data?.message || error.message || '';
setPriceEstimation(null);
- setPriceError(
- t('价格计算失败') + (message ? `: ${message}` : ''),
- );
+ setPriceError(t('价格计算失败') + (message ? `: ${message}` : ''));
} finally {
if (costRequestIdRef.current === requestId) {
setCostLoading(false);
@@ -269,11 +263,8 @@ const ExtendDurationModal = ({
const newTotalTime = `${currentRemainingTime} + ${durationHours}${t('小时')}`;
const priceData = priceEstimation || {};
- const breakdown =
- priceData.price_breakdown || priceData.PriceBreakdown || {};
- const currencyLabel = (
- priceData.currency || priceData.Currency || 'USDC'
- )
+ const breakdown = priceData.price_breakdown || priceData.PriceBreakdown || {};
+ const currencyLabel = (priceData.currency || priceData.Currency || 'USDC')
.toString()
.toUpperCase();
@@ -316,7 +307,10 @@ const ExtendDurationModal = ({
confirmLoading={loading}
okButtonProps={{
disabled:
- !deployment?.id || detailsLoading || !durationHours || durationHours < 1,
+ !deployment?.id ||
+ detailsLoading ||
+ !durationHours ||
+ durationHours < 1,
}}
width={600}
className='extend-duration-modal'
@@ -357,9 +351,7 @@ const ExtendDurationModal = ({
{t('延长容器时长将会产生额外费用,请确认您有足够的账户余额。')}
-
- {t('延长操作一旦确认无法撤销,费用将立即扣除。')}
-
+ {t('延长操作一旦确认无法撤销,费用将立即扣除。')}
}
/>
@@ -370,7 +362,9 @@ const ExtendDurationModal = ({
onValueChange={(values) => {
if (values.duration_hours !== undefined) {
const numericValue = Number(values.duration_hours);
- setDurationHours(Number.isFinite(numericValue) ? numericValue : 0);
+ setDurationHours(
+ Number.isFinite(numericValue) ? numericValue : 0,
+ );
}
}}
>
diff --git a/web/src/components/table/tokens/modals/EditTokenModal.jsx b/web/src/components/table/tokens/modals/EditTokenModal.jsx
index cc9f51b0e..fce482014 100644
--- a/web/src/components/table/tokens/modals/EditTokenModal.jsx
+++ b/web/src/components/table/tokens/modals/EditTokenModal.jsx
@@ -378,7 +378,12 @@ const EditTokenModal = (props) => {
/>
)}
-
+
{
placeholder={t('允许的IP,一行一个,不填写则不限制')}
autosize
rows={1}
- extraText={t('请勿过度信任此功能,IP可能被伪造,请配合nginx和cdn等网关使用')}
+ extraText={t(
+ '请勿过度信任此功能,IP可能被伪造,请配合nginx和cdn等网关使用',
+ )}
showClear
style={{ width: '100%' }}
/>
diff --git a/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx b/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx
index 2fb0cde8b..f298fa078 100644
--- a/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx
+++ b/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx
@@ -20,6 +20,7 @@ For commercial licensing, please contact support@quantumnous.com
import React from 'react';
import {
Avatar,
+ Button,
Space,
Tag,
Tooltip,
@@ -71,6 +72,34 @@ function formatRatio(ratio) {
return String(ratio);
}
+function buildChannelAffinityTooltip(affinity, t) {
+ if (!affinity) {
+ return null;
+ }
+
+ const keySource = affinity.key_source || '-';
+ const keyPath = affinity.key_path || affinity.key_key || '-';
+ const keyHint = affinity.key_hint || '';
+ const keyFp = affinity.key_fp ? `#${affinity.key_fp}` : '';
+ const keyText = `${keySource}:${keyPath}${keyFp}`;
+
+ const lines = [
+ t('渠道亲和性'),
+ `${t('规则')}:${affinity.rule_name || '-'}`,
+ `${t('分组')}:${affinity.selected_group || '-'}`,
+ `${t('Key')}:${keyText}`,
+ ...(keyHint ? [`${t('Key 摘要')}:${keyHint}`] : []),
+ ];
+
+ return (
+
+ {lines.map((line, i) => (
+
{line}
+ ))}
+
+ );
+}
+
// Render functions
function renderType(type, t) {
switch (type) {
@@ -250,6 +279,7 @@ export const getLogsColumns = ({
COLUMN_KEYS,
copyText,
showUserInfoFunc,
+ openChannelAffinityUsageCacheModal,
isAdminUser,
}) => {
return [
@@ -532,42 +562,39 @@ export const getLogsColumns = ({
return isAdminUser ? (
{content}
- {affinity ? (
-
- {t('渠道亲和性')}
-
-
- {t('规则')}:{affinity.rule_name || '-'}
-
-
-
-
- {t('分组')}:{affinity.selected_group || '-'}
-
-
-
-
- {t('Key')}:
- {(affinity.key_source || '-') +
- ':' +
- (affinity.key_path || affinity.key_key || '-') +
- (affinity.key_fp ? `#${affinity.key_fp}` : '')}
-
+ {affinity ? (
+
+ {buildChannelAffinityTooltip(affinity, t)}
+
+
-
- }
- >
-
-
-
-
- {t('优选')}
-
-
-
-
+
+ }
+ >
+
+
+
+
+ {t('优选')}
+
+
+
+
) : null}
) : (
diff --git a/web/src/components/table/usage-logs/UsageLogsTable.jsx b/web/src/components/table/usage-logs/UsageLogsTable.jsx
index 5218a622e..103dc9302 100644
--- a/web/src/components/table/usage-logs/UsageLogsTable.jsx
+++ b/web/src/components/table/usage-logs/UsageLogsTable.jsx
@@ -40,6 +40,7 @@ const LogsTable = (logsData) => {
handlePageSizeChange,
copyText,
showUserInfoFunc,
+ openChannelAffinityUsageCacheModal,
hasExpandableRows,
isAdminUser,
t,
@@ -53,9 +54,17 @@ const LogsTable = (logsData) => {
COLUMN_KEYS,
copyText,
showUserInfoFunc,
+ openChannelAffinityUsageCacheModal,
isAdminUser,
});
- }, [t, COLUMN_KEYS, copyText, showUserInfoFunc, isAdminUser]);
+ }, [
+ t,
+ COLUMN_KEYS,
+ copyText,
+ showUserInfoFunc,
+ openChannelAffinityUsageCacheModal,
+ isAdminUser,
+ ]);
// Filter columns based on visibility settings
const getVisibleColumns = () => {
diff --git a/web/src/components/table/usage-logs/index.jsx b/web/src/components/table/usage-logs/index.jsx
index 21e934340..7d2d47c37 100644
--- a/web/src/components/table/usage-logs/index.jsx
+++ b/web/src/components/table/usage-logs/index.jsx
@@ -24,6 +24,7 @@ import LogsActions from './UsageLogsActions';
import LogsFilters from './UsageLogsFilters';
import ColumnSelectorModal from './modals/ColumnSelectorModal';
import UserInfoModal from './modals/UserInfoModal';
+import ChannelAffinityUsageCacheModal from './modals/ChannelAffinityUsageCacheModal';
import { useLogsData } from '../../../hooks/usage-logs/useUsageLogsData';
import { useIsMobile } from '../../../hooks/common/useIsMobile';
import { createCardProPagination } from '../../../helpers/utils';
@@ -37,6 +38,7 @@ const LogsPage = () => {
{/* Modals */}
+
{/* Main Content */}
.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React, { useEffect, useMemo, useRef, useState } from 'react';
+import { Modal, Descriptions, Spin, Typography } from '@douyinfe/semi-ui';
+import { API, showError, timestamp2string } from '../../../../helpers';
+
+const { Text } = Typography;
+
+function formatRate(hit, total) {
+ if (!total || total <= 0) return '-';
+ const r = (Number(hit || 0) / Number(total || 0)) * 100;
+ if (!Number.isFinite(r)) return '-';
+ return `${r.toFixed(2)}%`;
+}
+
+function formatTokenRate(n, d) {
+ const nn = Number(n || 0);
+ const dd = Number(d || 0);
+ if (!dd || dd <= 0) return '-';
+ const r = (nn / dd) * 100;
+ if (!Number.isFinite(r)) return '-';
+ return `${r.toFixed(2)}%`;
+}
+
+const ChannelAffinityUsageCacheModal = ({
+ t,
+ showChannelAffinityUsageCacheModal,
+ setShowChannelAffinityUsageCacheModal,
+ channelAffinityUsageCacheTarget,
+}) => {
+ const [loading, setLoading] = useState(false);
+ const [stats, setStats] = useState(null);
+ const requestSeqRef = useRef(0);
+
+ const params = useMemo(() => {
+ const x = channelAffinityUsageCacheTarget || {};
+ return {
+ rule_name: (x.rule_name || '').trim(),
+ using_group: (x.using_group || '').trim(),
+ key_hint: (x.key_hint || '').trim(),
+ key_fp: (x.key_fp || '').trim(),
+ };
+ }, [channelAffinityUsageCacheTarget]);
+
+ useEffect(() => {
+ if (!showChannelAffinityUsageCacheModal) {
+ requestSeqRef.current += 1; // invalidate inflight request
+ setLoading(false);
+ setStats(null);
+ return;
+ }
+ if (!params.rule_name || !params.key_fp) {
+ setLoading(false);
+ setStats(null);
+ return;
+ }
+
+ const reqSeq = (requestSeqRef.current += 1);
+ setStats(null);
+ setLoading(true);
+ (async () => {
+ try {
+ const res = await API.get('/api/log/channel_affinity_usage_cache', {
+ params,
+ disableDuplicate: true,
+ });
+ if (reqSeq !== requestSeqRef.current) return;
+ const { success, message, data } = res.data || {};
+ if (!success) {
+ setStats(null);
+ showError(t(message || '请求失败'));
+ return;
+ }
+ setStats(data || {});
+ } catch (e) {
+ if (reqSeq !== requestSeqRef.current) return;
+ setStats(null);
+ showError(t('请求失败'));
+ } finally {
+ if (reqSeq !== requestSeqRef.current) return;
+ setLoading(false);
+ }
+ })();
+ }, [
+ showChannelAffinityUsageCacheModal,
+ params.rule_name,
+ params.using_group,
+ params.key_hint,
+ params.key_fp,
+ t,
+ ]);
+
+ const rows = useMemo(() => {
+ const s = stats || {};
+ const hit = Number(s.hit || 0);
+ const total = Number(s.total || 0);
+ const windowSeconds = Number(s.window_seconds || 0);
+ const lastSeenAt = Number(s.last_seen_at || 0);
+ const promptTokens = Number(s.prompt_tokens || 0);
+ const completionTokens = Number(s.completion_tokens || 0);
+ const totalTokens = Number(s.total_tokens || 0);
+ const cachedTokens = Number(s.cached_tokens || 0);
+ const promptCacheHitTokens = Number(s.prompt_cache_hit_tokens || 0);
+
+ return [
+ { key: t('规则'), value: s.rule_name || params.rule_name || '-' },
+ { key: t('分组'), value: s.using_group || params.using_group || '-' },
+ {
+ key: t('Key 摘要'),
+ value: params.key_hint || '-',
+ },
+ {
+ key: t('Key 指纹'),
+ value: s.key_fp || params.key_fp || '-',
+ },
+ { key: t('TTL(秒)'), value: windowSeconds > 0 ? windowSeconds : '-' },
+ {
+ key: t('命中率'),
+ value: `${hit}/${total} (${formatRate(hit, total)})`,
+ },
+ {
+ key: t('Prompt tokens'),
+ value: promptTokens,
+ },
+ {
+ key: t('Cached tokens'),
+ value: `${cachedTokens} (${formatTokenRate(cachedTokens, promptTokens)})`,
+ },
+ {
+ key: t('Prompt cache hit tokens'),
+ value: promptCacheHitTokens,
+ },
+ {
+ key: t('Completion tokens'),
+ value: completionTokens,
+ },
+ {
+ key: t('Total tokens'),
+ value: totalTokens,
+ },
+ {
+ key: t('最近一次'),
+ value: lastSeenAt > 0 ? timestamp2string(lastSeenAt) : '-',
+ },
+ ];
+ }, [stats, params, t]);
+
+ return (
+
setShowChannelAffinityUsageCacheModal(false)}
+ footer={null}
+ centered
+ closable
+ maskClosable
+ width={640}
+ >
+
+
+
+ {t(
+ '命中判定:usage 中存在 cached tokens(例如 cached_tokens/prompt_cache_hit_tokens)即视为命中。',
+ )}
+
+
+
+ {stats ? (
+
+ ) : (
+
+
+ {loading ? t('加载中...') : t('暂无数据')}
+
+
+ )}
+
+
+
+ );
+};
+
+export default ChannelAffinityUsageCacheModal;
diff --git a/web/src/components/topup/RechargeCard.jsx b/web/src/components/topup/RechargeCard.jsx
index 15c37dffb..264c965b7 100644
--- a/web/src/components/topup/RechargeCard.jsx
+++ b/web/src/components/topup/RechargeCard.jsx
@@ -87,7 +87,12 @@ const RechargeCard = ({
const onlineFormApiRef = useRef(null);
const redeemFormApiRef = useRef(null);
const showAmountSkeleton = useMinimumLoadingTime(amountLoading);
- console.log(' enabled screem ?', enableCreemTopUp, ' products ?', creemProducts);
+ console.log(
+ ' enabled screem ?',
+ enableCreemTopUp,
+ ' products ?',
+ creemProducts,
+ );
return (
{/* 卡片头部 */}
@@ -503,7 +508,8 @@ const RechargeCard = ({
{t('充值额度')}: {product.quota}
- {product.currency === 'EUR' ? '€' : '$'}{product.price}
+ {product.currency === 'EUR' ? '€' : '$'}
+ {product.price}
))}
diff --git a/web/src/components/topup/index.jsx b/web/src/components/topup/index.jsx
index 7618d7778..2392b45ba 100644
--- a/web/src/components/topup/index.jsx
+++ b/web/src/components/topup/index.jsx
@@ -651,7 +651,8 @@ const TopUp = () => {
{t('产品名称')}:{selectedCreemProduct.name}
- {t('价格')}:{selectedCreemProduct.currency === 'EUR' ? '€' : '$'}{selectedCreemProduct.price}
+ {t('价格')}:{selectedCreemProduct.currency === 'EUR' ? '€' : '$'}
+ {selectedCreemProduct.price}
{t('充值额度')}:{selectedCreemProduct.quota}
diff --git a/web/src/helpers/api.js b/web/src/helpers/api.js
index 6e09bf43c..666d6b7b0 100644
--- a/web/src/helpers/api.js
+++ b/web/src/helpers/api.js
@@ -236,9 +236,7 @@ async function prepareOAuthState(options = {}) {
if (shouldLogout) {
try {
await API.get('/api/user/logout', { skipErrorHandler: true });
- } catch (err) {
-
- }
+ } catch (err) {}
localStorage.removeItem('user');
updateAPI();
}
diff --git a/web/src/helpers/dashboard.jsx b/web/src/helpers/dashboard.jsx
index 8df375f11..d93d04619 100644
--- a/web/src/helpers/dashboard.jsx
+++ b/web/src/helpers/dashboard.jsx
@@ -261,7 +261,7 @@ export const processRawData = (
};
// 检查数据是否跨年
- const showYear = isDataCrossYear(data.map(item => item.created_at));
+ const showYear = isDataCrossYear(data.map((item) => item.created_at));
data.forEach((item) => {
result.uniqueModels.add(item.model_name);
@@ -269,7 +269,11 @@ export const processRawData = (
result.totalQuota += item.quota;
result.totalTimes += item.count;
- const timeKey = timestamp2string1(item.created_at, dataExportDefaultTime, showYear);
+ const timeKey = timestamp2string1(
+ item.created_at,
+ dataExportDefaultTime,
+ showYear,
+ );
if (!result.timePoints.includes(timeKey)) {
result.timePoints.push(timeKey);
}
@@ -328,10 +332,14 @@ export const aggregateDataByTimeAndModel = (data, dataExportDefaultTime) => {
const aggregatedData = new Map();
// 检查数据是否跨年
- const showYear = isDataCrossYear(data.map(item => item.created_at));
+ const showYear = isDataCrossYear(data.map((item) => item.created_at));
data.forEach((item) => {
- const timeKey = timestamp2string1(item.created_at, dataExportDefaultTime, showYear);
+ const timeKey = timestamp2string1(
+ item.created_at,
+ dataExportDefaultTime,
+ showYear,
+ );
const modelKey = item.model_name;
const key = `${timeKey}-${modelKey}`;
@@ -372,7 +380,7 @@ export const generateChartTimePoints = (
);
const showYear = isDataCrossYear(generatedTimestamps);
- chartTimePoints = generatedTimestamps.map(ts =>
+ chartTimePoints = generatedTimestamps.map((ts) =>
timestamp2string1(ts, dataExportDefaultTime, showYear),
);
}
diff --git a/web/src/helpers/statusCodeRules.js b/web/src/helpers/statusCodeRules.js
index a0d5e75f9..fc60a5a59 100644
--- a/web/src/helpers/statusCodeRules.js
+++ b/web/src/helpers/statusCodeRules.js
@@ -1,3 +1,21 @@
+/*
+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 .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
export function parseHttpStatusCodeRules(input) {
const raw = (input ?? '').toString().trim();
if (raw.length === 0) {
@@ -35,7 +53,9 @@ export function parseHttpStatusCodeRules(input) {
}
const merged = mergeRanges(ranges);
- const tokens = merged.map((r) => (r.start === r.end ? `${r.start}` : `${r.start}-${r.end}`));
+ const tokens = merged.map((r) =>
+ r.start === r.end ? `${r.start}` : `${r.start}-${r.end}`,
+ );
const normalized = tokens.join(',');
return {
@@ -78,7 +98,9 @@ function isNumber(s) {
function mergeRanges(ranges) {
if (!Array.isArray(ranges) || ranges.length === 0) return [];
- const sorted = [...ranges].sort((a, b) => (a.start !== b.start ? a.start - b.start : a.end - b.end));
+ const sorted = [...ranges].sort((a, b) =>
+ a.start !== b.start ? a.start - b.start : a.end - b.end,
+ );
const merged = [sorted[0]];
for (let i = 1; i < sorted.length; i += 1) {
diff --git a/web/src/helpers/utils.jsx b/web/src/helpers/utils.jsx
index a54676e47..5ce83e678 100644
--- a/web/src/helpers/utils.jsx
+++ b/web/src/helpers/utils.jsx
@@ -217,7 +217,11 @@ export function timestamp2string(timestamp) {
);
}
-export function timestamp2string1(timestamp, dataExportDefaultTime = 'hour', showYear = false) {
+export function timestamp2string1(
+ timestamp,
+ dataExportDefaultTime = 'hour',
+ showYear = false,
+) {
let date = new Date(timestamp * 1000);
let year = date.getFullYear();
let month = (date.getMonth() + 1).toString();
@@ -248,7 +252,9 @@ export function timestamp2string1(timestamp, dataExportDefaultTime = 'hour', sho
nextDay = '0' + nextDay;
}
// 周视图结束日期也仅在跨年时显示年份
- let nextStr = showYear ? nextWeekYear + '-' + nextMonth + '-' + nextDay : nextMonth + '-' + nextDay;
+ let nextStr = showYear
+ ? nextWeekYear + '-' + nextMonth + '-' + nextDay
+ : nextMonth + '-' + nextDay;
str += ' - ' + nextStr;
}
return str;
@@ -257,7 +263,9 @@ export function timestamp2string1(timestamp, dataExportDefaultTime = 'hour', sho
// 检查时间戳数组是否跨年
export function isDataCrossYear(timestamps) {
if (!timestamps || timestamps.length === 0) return false;
- const years = new Set(timestamps.map(ts => new Date(ts * 1000).getFullYear()));
+ const years = new Set(
+ timestamps.map((ts) => new Date(ts * 1000).getFullYear()),
+ );
return years.size > 1;
}
diff --git a/web/src/hooks/model-deployments/useModelDeploymentSettings.js b/web/src/hooks/model-deployments/useModelDeploymentSettings.js
index c53fe55b8..e3578006b 100644
--- a/web/src/hooks/model-deployments/useModelDeploymentSettings.js
+++ b/web/src/hooks/model-deployments/useModelDeploymentSettings.js
@@ -55,13 +55,20 @@ export const useModelDeploymentSettings = () => {
const isIoNetEnabled = settings['model_deployment.ionet.enabled'];
- const buildConnectionError = (rawMessage, fallbackMessage = 'Connection failed') => {
+ 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')) {
+ if (
+ normalized.includes('invalid') ||
+ normalized.includes('unauthorized') ||
+ normalized.includes('api key')
+ ) {
return { type: 'invalid', message };
}
if (normalized.includes('network') || normalized.includes('timeout')) {
@@ -85,7 +92,11 @@ export const useModelDeploymentSettings = () => {
}
const message = response?.data?.message || 'Connection failed';
- setConnectionState({ loading: false, ok: false, error: buildConnectionError(message) });
+ setConnectionState({
+ loading: false,
+ ok: false,
+ error: buildConnectionError(message),
+ });
} catch (error) {
if (error?.code === 'ERR_NETWORK') {
setConnectionState({
@@ -95,8 +106,13 @@ export const useModelDeploymentSettings = () => {
});
return;
}
- const rawMessage = error?.response?.data?.message || error?.message || 'Unknown error';
- setConnectionState({ loading: false, ok: false, error: buildConnectionError(rawMessage, 'Connection failed') });
+ const rawMessage =
+ error?.response?.data?.message || error?.message || 'Unknown error';
+ setConnectionState({
+ loading: false,
+ ok: false,
+ error: buildConnectionError(rawMessage, 'Connection failed'),
+ });
}
}, []);
diff --git a/web/src/hooks/playground/useApiRequest.jsx b/web/src/hooks/playground/useApiRequest.jsx
index 12db9f5ca..8ec50cf45 100644
--- a/web/src/hooks/playground/useApiRequest.jsx
+++ b/web/src/hooks/playground/useApiRequest.jsx
@@ -231,7 +231,10 @@ export const useApiRequest = (
if (data.choices?.[0]) {
const choice = data.choices[0];
let content = choice.message?.content || '';
- let reasoningContent = choice.message?.reasoning_content || choice.message?.reasoning || '';
+ let reasoningContent =
+ choice.message?.reasoning_content ||
+ choice.message?.reasoning ||
+ '';
const processed = processThinkTags(content, reasoningContent);
@@ -318,8 +321,8 @@ export const useApiRequest = (
isStreamComplete = true; // 标记流正常完成
source.close();
sseSourceRef.current = null;
- setDebugData((prev) => ({
- ...prev,
+ setDebugData((prev) => ({
+ ...prev,
response: responseData,
sseMessages: [...(prev.sseMessages || []), '[DONE]'], // 添加 DONE 标记
isStreaming: false,
diff --git a/web/src/hooks/playground/usePlaygroundState.js b/web/src/hooks/playground/usePlaygroundState.js
index 9574a4c3c..79be10134 100644
--- a/web/src/hooks/playground/usePlaygroundState.js
+++ b/web/src/hooks/playground/usePlaygroundState.js
@@ -36,18 +36,23 @@ 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 === '你好' ||
+ 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');
@@ -81,8 +86,10 @@ export const usePlaygroundState = () => {
const [status, setStatus] = useState({});
// 消息相关状态 - 使用加载的消息或默认消息初始化
- const [message, setMessage] = useState(() => initialMessages || getDefaultMessages(t));
-
+ const [message, setMessage] = useState(
+ () => initialMessages || getDefaultMessages(t),
+ );
+
// 当语言改变时,如果是默认消息则更新
useEffect(() => {
// 只在没有保存的消息时才更新默认消息
diff --git a/web/src/hooks/usage-logs/useUsageLogsData.jsx b/web/src/hooks/usage-logs/useUsageLogsData.jsx
index 4c7fa147c..13437774c 100644
--- a/web/src/hooks/usage-logs/useUsageLogsData.jsx
+++ b/web/src/hooks/usage-logs/useUsageLogsData.jsx
@@ -112,6 +112,14 @@ export const useLogsData = () => {
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);
+
// Load saved column preferences from localStorage
useEffect(() => {
const savedColumns = localStorage.getItem(STORAGE_KEY);
@@ -304,6 +312,17 @@ export const useLogsData = () => {
}
};
+ 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);
+ };
+
// Format logs data
const setLogsFormat = (logs) => {
const requestConversionDisplayValue = (conversionChain) => {
@@ -372,9 +391,13 @@ export const useLogsData = () => {
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_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,
+ other.cache_creation_ratio_1h ||
+ other.cache_creation_ratio ||
+ 1.0,
)
: renderLogContent(
other?.model_ratio,
@@ -524,8 +547,8 @@ export const useLogsData = () => {
localCountMode = t('上游返回');
}
expandDataLocal.push({
- key: t('计费模式'),
- value: localCountMode,
+ key: t('计费模式'),
+ value: localCountMode,
});
}
expandDatesLocal[logs[i].key] = expandDataLocal;
@@ -680,6 +703,12 @@ export const useLogsData = () => {
userInfoData,
showUserInfoFunc,
+ // Channel affinity usage cache stats modal
+ showChannelAffinityUsageCacheModal,
+ setShowChannelAffinityUsageCacheModal,
+ channelAffinityUsageCacheTarget,
+ openChannelAffinityUsageCacheModal,
+
// Functions
loadLogs,
handlePageChange,
diff --git a/web/src/pages/Playground/index.jsx b/web/src/pages/Playground/index.jsx
index 6b7f8d16a..68a97c335 100644
--- a/web/src/pages/Playground/index.jsx
+++ b/web/src/pages/Playground/index.jsx
@@ -438,14 +438,17 @@ const Playground = () => {
}, [setMessage, saveMessagesImmediately]);
// 处理粘贴图片
- const handlePasteImage = useCallback((base64Data) => {
- if (!inputs.imageEnabled) {
- return;
- }
- // 添加图片到 imageUrls 数组
- const newUrls = [...(inputs.imageUrls || []), base64Data];
- handleInputChange('imageUrls', newUrls);
- }, [inputs.imageEnabled, inputs.imageUrls, handleInputChange]);
+ const handlePasteImage = useCallback(
+ (base64Data) => {
+ if (!inputs.imageEnabled) {
+ return;
+ }
+ // 添加图片到 imageUrls 数组
+ const newUrls = [...(inputs.imageUrls || []), base64Data];
+ handleInputChange('imageUrls', newUrls);
+ },
+ [inputs.imageEnabled, inputs.imageUrls, handleInputChange],
+ );
// Playground Context 值
const playgroundContextValue = {
@@ -457,10 +460,10 @@ const Playground = () => {
return (
-
- {(showSettings || !isMobile) && (
-
+ {(showSettings || !isMobile) && (
+ {
: 'relative z-[1] w-80 h-[calc(100vh-66px)]'
}
`}
- width={isMobile ? '100%' : 320}
- >
- setShowSettings(false)}
- onConfigImport={handleConfigImport}
- onConfigReset={handleConfigReset}
- onCustomRequestModeChange={setCustomRequestMode}
- onCustomRequestBodyChange={setCustomRequestBody}
- previewPayload={previewPayload}
- messages={message}
- />
-
- )}
-
-
-
-
-
+ setShowDebugPanel(!showDebugPanel)}
- renderCustomChatContent={renderCustomChatContent}
- renderChatBoxAction={renderChatBoxAction}
+ customRequestMode={customRequestMode}
+ customRequestBody={customRequestBody}
+ onInputChange={handleInputChange}
+ onParameterToggle={handleParameterToggle}
+ onCloseSettings={() => setShowSettings(false)}
+ onConfigImport={handleConfigImport}
+ onConfigReset={handleConfigReset}
+ onCustomRequestModeChange={setCustomRequestMode}
+ onCustomRequestBodyChange={setCustomRequestBody}
+ previewPayload={previewPayload}
+ messages={message}
/>
+
+ )}
+
+
+
+
+ setShowDebugPanel(!showDebugPanel)}
+ renderCustomChatContent={renderCustomChatContent}
+ renderChatBoxAction={renderChatBoxAction}
+ />
+
+
+ {/* 调试面板 - 桌面端 */}
+ {showDebugPanel && !isMobile && (
+
+
+
+ )}
- {/* 调试面板 - 桌面端 */}
- {showDebugPanel && !isMobile && (
-
+ {/* 调试面板 - 移动端覆盖层 */}
+ {showDebugPanel && isMobile && (
+
setShowDebugPanel(false)}
customRequestMode={customRequestMode}
/>
)}
-
- {/* 调试面板 - 移动端覆盖层 */}
- {showDebugPanel && isMobile && (
-
- setShowDebugPanel(false)}
- customRequestMode={customRequestMode}
- />
-
- )}
-
- {/* 浮动按钮 */}
- setShowSettings(!showSettings)}
- onToggleDebugPanel={() => setShowDebugPanel(!showDebugPanel)}
- />
-
-
-
+ {/* 浮动按钮 */}
+
setShowSettings(!showSettings)}
+ onToggleDebugPanel={() => setShowDebugPanel(!showDebugPanel)}
+ />
+
+
+
);
};
diff --git a/web/src/pages/PrivacyPolicy/index.jsx b/web/src/pages/PrivacyPolicy/index.jsx
index 026290b18..d2c4a872d 100644
--- a/web/src/pages/PrivacyPolicy/index.jsx
+++ b/web/src/pages/PrivacyPolicy/index.jsx
@@ -26,12 +26,12 @@ const PrivacyPolicy = () => {
return (
);
};
-export default PrivacyPolicy;
\ No newline at end of file
+export default PrivacyPolicy;
diff --git a/web/src/pages/Setting/Model/SettingGlobalModel.jsx b/web/src/pages/Setting/Model/SettingGlobalModel.jsx
index 9878875c7..4b8f9f4d9 100644
--- a/web/src/pages/Setting/Model/SettingGlobalModel.jsx
+++ b/web/src/pages/Setting/Model/SettingGlobalModel.jsx
@@ -199,9 +199,9 @@ export default function SettingGlobalModel(props) {
'global.pass_through_request_enabled': value,
})
}
- extraText={
- t('开启后,所有请求将直接透传给上游,不会进行任何处理(重定向和渠道适配也将失效),请谨慎开启')
- }
+ extraText={t(
+ '开启后,所有请求将直接透传给上游,不会进行任何处理(重定向和渠道适配也将失效),请谨慎开启',
+ )}
/>
@@ -210,11 +210,7 @@ export default function SettingGlobalModel(props) {
-
diff --git a/web/src/pages/Setting/Model/SettingGrokModel.jsx b/web/src/pages/Setting/Model/SettingGrokModel.jsx
index 3fdf2ca09..3d721c798 100644
--- a/web/src/pages/Setting/Model/SettingGrokModel.jsx
+++ b/web/src/pages/Setting/Model/SettingGrokModel.jsx
@@ -49,8 +49,7 @@ export default function SettingGrokModel(props) {
.validate()
.then(() => {
const updateArray = compareObjects(inputs, inputsRow);
- if (!updateArray.length)
- return showWarning(t('你似乎并没有修改什么'));
+ if (!updateArray.length) return showWarning(t('你似乎并没有修改什么'));
const requestQueue = updateArray.map((item) => {
const value = String(inputs[item.key]);
diff --git a/web/src/pages/Setting/Model/SettingModelDeployment.jsx b/web/src/pages/Setting/Model/SettingModelDeployment.jsx
index 88a043b10..fdfbb448e 100644
--- a/web/src/pages/Setting/Model/SettingModelDeployment.jsx
+++ b/web/src/pages/Setting/Model/SettingModelDeployment.jsx
@@ -18,7 +18,15 @@ For commercial licensing, please contact support@quantumnous.com
*/
import React, { useEffect, useState, useRef } from 'react';
-import { Button, Col, Form, Row, Spin, Card, Typography } from '@douyinfe/semi-ui';
+import {
+ Button,
+ Col,
+ Form,
+ Row,
+ Spin,
+ Card,
+ Typography,
+} from '@douyinfe/semi-ui';
import {
compareObjects,
API,
@@ -88,9 +96,7 @@ export default function SettingModelDeployment(props) {
showError(t('网络连接失败,请检查网络设置或稍后重试'));
} else {
const rawMessage =
- error?.response?.data?.message ||
- error?.message ||
- '';
+ error?.response?.data?.message || error?.message || '';
const localizedMessage = rawMessage
? getLocalizedMessage(rawMessage)
: t('未知错误');
@@ -104,7 +110,7 @@ export default function SettingModelDeployment(props) {
function onSubmit() {
const updateArray = compareObjects(inputs, inputsRow);
if (!updateArray.length) return showWarning(t('你似乎并没有修改什么'));
-
+
const requestQueue = updateArray.map((item) => {
let value = String(inputs[item.key]);
return API.put('/api/option/', {
@@ -112,7 +118,7 @@ export default function SettingModelDeployment(props) {
value,
});
});
-
+
setLoading(true);
Promise.all(requestQueue)
.then((res) => {
@@ -141,7 +147,7 @@ export default function SettingModelDeployment(props) {
'model_deployment.ionet.api_key': '',
'model_deployment.ionet.enabled': false,
};
-
+
const currentInputs = {};
for (let key in defaultInputs) {
if (props.options.hasOwnProperty(key)) {
@@ -150,7 +156,7 @@ export default function SettingModelDeployment(props) {
currentInputs[key] = defaultInputs[key];
}
}
-
+
setInputs(currentInputs);
setInputsRow(structuredClone(currentInputs));
refForm.current?.setValues(currentInputs);
@@ -165,9 +171,11 @@ export default function SettingModelDeployment(props) {
getFormApi={(formAPI) => (refForm.current = formAPI)}
style={{ marginBottom: 15 }}
>
-
+
{t('模型部署设置')}
}
@@ -186,7 +194,9 @@ export default function SettingModelDeployment(props) {
+
io.net
@@ -226,18 +236,16 @@ export default function SettingModelDeployment(props) {
}
disabled={!inputs['model_deployment.ionet.enabled']}
extraText={t('请使用 Project 为 io.cloud 的密钥')}
- mode="password"
+ mode='password'
/>
}
onClick={testApiKey}
loading={testing}
- disabled={
- !inputs['model_deployment.ionet.enabled']
- }
+ disabled={!inputs['model_deployment.ionet.enabled']}
style={{
height: '32px',
fontSize: '13px',
@@ -271,7 +279,10 @@ export default function SettingModelDeployment(props) {
}}
>
-
+
{t('获取 io.net API Key')}
- {t('访问 io.net 控制台的 API Keys 页面')}
- - {t('创建或选择密钥时,将 Project 设置为 io.cloud')}
+ -
+ {t('创建或选择密钥时,将 Project 设置为 io.cloud')}
+
- {t('复制生成的密钥并粘贴到此处')}
}
- type="primary"
- theme="solid"
+ type='primary'
+ theme='solid'
style={{ width: '100%' }}
onClick={() =>
window.open('https://ai.io.net/ai/api-keys', '_blank')
@@ -308,7 +321,7 @@ export default function SettingModelDeployment(props) {
-
diff --git a/web/src/pages/Setting/Operation/SettingsChannelAffinity.jsx b/web/src/pages/Setting/Operation/SettingsChannelAffinity.jsx
index 86c2bc321..b89f99113 100644
--- a/web/src/pages/Setting/Operation/SettingsChannelAffinity.jsx
+++ b/web/src/pages/Setting/Operation/SettingsChannelAffinity.jsx
@@ -73,6 +73,7 @@ const RULE_TEMPLATES = {
key_sources: [{ type: 'gjson', path: 'prompt_cache_key' }],
value_regex: '',
ttl_seconds: 0,
+ skip_retry_on_failure: false,
include_using_group: true,
include_rule_name: true,
},
@@ -83,6 +84,7 @@ const RULE_TEMPLATES = {
key_sources: [{ type: 'gjson', path: 'metadata.user_id' }],
value_regex: '',
ttl_seconds: 0,
+ skip_retry_on_failure: false,
include_using_group: true,
include_rule_name: true,
},
@@ -112,6 +114,7 @@ const RULES_JSON_PLACEHOLDER = `[
],
"value_regex": "^[-0-9A-Za-z._:]{1,128}$",
"ttl_seconds": 600,
+ "skip_retry_on_failure": false,
"include_using_group": true,
"include_rule_name": true
}
@@ -153,7 +156,12 @@ const normalizeKeySource = (src) => {
const type = (src?.type || '').trim();
const key = (src?.key || '').trim();
const path = (src?.path || '').trim();
- return { type, key, path };
+
+ if (type === 'gjson') {
+ return { type, key: '', path };
+ }
+
+ return { type, key, path: '' };
};
const makeUniqueName = (existingNames, baseName) => {
@@ -229,6 +237,7 @@ export default function SettingsChannelAffinity(props) {
user_agent_include_text: (r.user_agent_include || []).join('\n'),
value_regex: r.value_regex || '',
ttl_seconds: Number(r.ttl_seconds || 0),
+ skip_retry_on_failure: !!r.skip_retry_on_failure,
include_using_group: r.include_using_group ?? true,
include_rule_name: r.include_rule_name ?? true,
};
@@ -523,6 +532,7 @@ export default function SettingsChannelAffinity(props) {
key_sources: [{ type: 'gjson', path: '' }],
value_regex: '',
ttl_seconds: 0,
+ skip_retry_on_failure: false,
include_using_group: true,
include_rule_name: true,
};
@@ -583,6 +593,9 @@ export default function SettingsChannelAffinity(props) {
ttl_seconds: Number(values.ttl_seconds || 0),
include_using_group: !!values.include_using_group,
include_rule_name: !!values.include_rule_name,
+ ...(values.skip_retry_on_failure
+ ? { skip_retry_on_failure: true }
+ : {}),
...(userAgentInclude.length > 0
? { user_agent_include: userAgentInclude }
: {}),
@@ -1041,6 +1054,18 @@ export default function SettingsChannelAffinity(props) {
+
+
+
+
+
+ {t('开启后,若该规则命中且请求失败,将不会切换渠道重试。')}
+
+
+
diff --git a/web/src/pages/Setting/Operation/SettingsCreditLimit.jsx b/web/src/pages/Setting/Operation/SettingsCreditLimit.jsx
index a5208a502..52476932b 100644
--- a/web/src/pages/Setting/Operation/SettingsCreditLimit.jsx
+++ b/web/src/pages/Setting/Operation/SettingsCreditLimit.jsx
@@ -172,7 +172,9 @@ export default function SettingsCreditLimit(props) {
setInputs({
...inputs,
diff --git a/web/src/pages/Setting/Operation/SettingsMonitoring.jsx b/web/src/pages/Setting/Operation/SettingsMonitoring.jsx
index 6e1743478..29b55e56c 100644
--- a/web/src/pages/Setting/Operation/SettingsMonitoring.jsx
+++ b/web/src/pages/Setting/Operation/SettingsMonitoring.jsx
@@ -18,13 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/
import React, { useEffect, useState, useRef } from 'react';
-import {
- Button,
- Col,
- Form,
- Row,
- Spin,
-} from '@douyinfe/semi-ui';
+import { Button, Col, Form, Row, Spin } from '@douyinfe/semi-ui';
import {
compareObjects,
API,
@@ -46,7 +40,8 @@ export default function SettingsMonitoring(props) {
AutomaticEnableChannelEnabled: false,
AutomaticDisableKeywords: '',
AutomaticDisableStatusCodes: '401',
- AutomaticRetryStatusCodes: '100-199,300-399,401-407,409-499,500-503,505-523,525-599',
+ AutomaticRetryStatusCodes:
+ '100-199,300-399,401-407,409-499,500-503,505-523,525-599',
'monitor_setting.auto_test_channel_enabled': false,
'monitor_setting.auto_test_channel_minutes': 10,
});
diff --git a/web/src/pages/Setting/Operation/SettingsSidebarModulesAdmin.jsx b/web/src/pages/Setting/Operation/SettingsSidebarModulesAdmin.jsx
index 5f351c105..817bca51a 100644
--- a/web/src/pages/Setting/Operation/SettingsSidebarModulesAdmin.jsx
+++ b/web/src/pages/Setting/Operation/SettingsSidebarModulesAdmin.jsx
@@ -252,7 +252,11 @@ export default function SettingsSidebarModulesAdmin(props) {
modules: [
{ key: 'channel', title: t('渠道管理'), description: t('API渠道配置') },
{ key: 'models', title: t('模型管理'), description: t('AI模型配置') },
- { key: 'deployment', title: t('模型部署'), description: t('模型部署管理') },
+ {
+ key: 'deployment',
+ title: t('模型部署'),
+ description: t('模型部署管理'),
+ },
{
key: 'redemption',
title: t('兑换码管理'),
diff --git a/web/src/pages/Setting/Payment/SettingsPaymentGatewayCreem.jsx b/web/src/pages/Setting/Payment/SettingsPaymentGatewayCreem.jsx
index 32e2e6fbc..41de8d20e 100644
--- a/web/src/pages/Setting/Payment/SettingsPaymentGatewayCreem.jsx
+++ b/web/src/pages/Setting/Payment/SettingsPaymentGatewayCreem.jsx
@@ -1,385 +1,422 @@
+/*
+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 .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
import React, { useEffect, useState, useRef } from 'react';
import {
- Banner,
- Button,
- Form,
- Row,
- Col,
- Typography,
- Spin,
- Table,
- Modal,
- Input,
- InputNumber,
- Select,
+ Banner,
+ Button,
+ Form,
+ Row,
+ Col,
+ Typography,
+ Spin,
+ Table,
+ Modal,
+ Input,
+ InputNumber,
+ Select,
} from '@douyinfe/semi-ui';
const { Text } = Typography;
-import {
- API,
- showError,
- showSuccess,
-} from '../../../helpers';
+import { API, showError, showSuccess } from '../../../helpers';
import { useTranslation } from 'react-i18next';
import { Plus, Trash2 } from 'lucide-react';
export default function SettingsPaymentGatewayCreem(props) {
- const { t } = useTranslation();
- const [loading, setLoading] = useState(false);
- const [inputs, setInputs] = useState({
- CreemApiKey: '',
- CreemWebhookSecret: '',
- CreemProducts: '[]',
- CreemTestMode: false,
- });
- const [originInputs, setOriginInputs] = useState({});
- const [products, setProducts] = useState([]);
- const [showProductModal, setShowProductModal] = useState(false);
- const [editingProduct, setEditingProduct] = useState(null);
- const [productForm, setProductForm] = useState({
+ const { t } = useTranslation();
+ const [loading, setLoading] = useState(false);
+ const [inputs, setInputs] = useState({
+ CreemApiKey: '',
+ CreemWebhookSecret: '',
+ CreemProducts: '[]',
+ CreemTestMode: false,
+ });
+ const [originInputs, setOriginInputs] = useState({});
+ const [products, setProducts] = useState([]);
+ const [showProductModal, setShowProductModal] = useState(false);
+ const [editingProduct, setEditingProduct] = useState(null);
+ const [productForm, setProductForm] = useState({
+ name: '',
+ productId: '',
+ price: 0,
+ quota: 0,
+ currency: 'USD',
+ });
+ const formApiRef = useRef(null);
+
+ useEffect(() => {
+ if (props.options && formApiRef.current) {
+ const currentInputs = {
+ CreemApiKey: props.options.CreemApiKey || '',
+ CreemWebhookSecret: props.options.CreemWebhookSecret || '',
+ CreemProducts: props.options.CreemProducts || '[]',
+ CreemTestMode: props.options.CreemTestMode === 'true',
+ };
+ setInputs(currentInputs);
+ setOriginInputs({ ...currentInputs });
+ formApiRef.current.setValues(currentInputs);
+
+ // Parse products
+ try {
+ const parsedProducts = JSON.parse(currentInputs.CreemProducts);
+ setProducts(parsedProducts);
+ } catch (e) {
+ setProducts([]);
+ }
+ }
+ }, [props.options]);
+
+ const handleFormChange = (values) => {
+ setInputs(values);
+ };
+
+ const submitCreemSetting = async () => {
+ setLoading(true);
+ try {
+ const options = [];
+
+ if (inputs.CreemApiKey && inputs.CreemApiKey !== '') {
+ options.push({ key: 'CreemApiKey', value: inputs.CreemApiKey });
+ }
+
+ if (inputs.CreemWebhookSecret && inputs.CreemWebhookSecret !== '') {
+ options.push({
+ key: 'CreemWebhookSecret',
+ value: inputs.CreemWebhookSecret,
+ });
+ }
+
+ // Save test mode setting
+ options.push({
+ key: 'CreemTestMode',
+ value: inputs.CreemTestMode ? 'true' : 'false',
+ });
+
+ // Save products as JSON string
+ options.push({ key: 'CreemProducts', value: JSON.stringify(products) });
+
+ // 发送请求
+ const requestQueue = options.map((opt) =>
+ API.put('/api/option/', {
+ key: opt.key,
+ value: opt.value,
+ }),
+ );
+
+ const results = await Promise.all(requestQueue);
+
+ // 检查所有请求是否成功
+ const errorResults = results.filter((res) => !res.data.success);
+ if (errorResults.length > 0) {
+ errorResults.forEach((res) => {
+ showError(res.data.message);
+ });
+ } else {
+ showSuccess(t('更新成功'));
+ // 更新本地存储的原始值
+ setOriginInputs({ ...inputs });
+ props.refresh?.();
+ }
+ } catch (error) {
+ showError(t('更新失败'));
+ }
+ setLoading(false);
+ };
+
+ const openProductModal = (product = null) => {
+ if (product) {
+ setEditingProduct(product);
+ setProductForm({ ...product });
+ } else {
+ setEditingProduct(null);
+ setProductForm({
name: '',
productId: '',
price: 0,
quota: 0,
currency: 'USD',
+ });
+ }
+ setShowProductModal(true);
+ };
+
+ const closeProductModal = () => {
+ setShowProductModal(false);
+ setEditingProduct(null);
+ setProductForm({
+ name: '',
+ productId: '',
+ price: 0,
+ quota: 0,
+ currency: 'USD',
});
- const formApiRef = useRef(null);
+ };
- useEffect(() => {
- if (props.options && formApiRef.current) {
- const currentInputs = {
- CreemApiKey: props.options.CreemApiKey || '',
- CreemWebhookSecret: props.options.CreemWebhookSecret || '',
- CreemProducts: props.options.CreemProducts || '[]',
- CreemTestMode: props.options.CreemTestMode === 'true',
- };
- setInputs(currentInputs);
- setOriginInputs({ ...currentInputs });
- formApiRef.current.setValues(currentInputs);
+ const saveProduct = () => {
+ if (
+ !productForm.name ||
+ !productForm.productId ||
+ productForm.price <= 0 ||
+ productForm.quota <= 0 ||
+ !productForm.currency
+ ) {
+ showError(t('请填写完整的产品信息'));
+ return;
+ }
- // Parse products
- try {
- const parsedProducts = JSON.parse(currentInputs.CreemProducts);
- setProducts(parsedProducts);
- } catch (e) {
- setProducts([]);
- }
- }
- }, [props.options]);
+ let newProducts = [...products];
+ if (editingProduct) {
+ // 编辑现有产品
+ const index = newProducts.findIndex(
+ (p) => p.productId === editingProduct.productId,
+ );
+ if (index !== -1) {
+ newProducts[index] = { ...productForm };
+ }
+ } else {
+ // 添加新产品
+ if (newProducts.find((p) => p.productId === productForm.productId)) {
+ showError(t('产品ID已存在'));
+ return;
+ }
+ newProducts.push({ ...productForm });
+ }
- const handleFormChange = (values) => {
- setInputs(values);
- };
+ setProducts(newProducts);
+ closeProductModal();
+ };
- const submitCreemSetting = async () => {
- setLoading(true);
- try {
- const options = [];
+ const deleteProduct = (productId) => {
+ const newProducts = products.filter((p) => p.productId !== productId);
+ setProducts(newProducts);
+ };
- if (inputs.CreemApiKey && inputs.CreemApiKey !== '') {
- options.push({ key: 'CreemApiKey', value: inputs.CreemApiKey });
- }
+ const columns = [
+ {
+ title: t('产品名称'),
+ dataIndex: 'name',
+ key: 'name',
+ },
+ {
+ title: t('产品ID'),
+ dataIndex: 'productId',
+ key: 'productId',
+ },
+ {
+ title: t('展示价格'),
+ dataIndex: 'price',
+ key: 'price',
+ render: (price, record) =>
+ `${record.currency === 'EUR' ? '€' : '$'}${price}`,
+ },
+ {
+ title: t('充值额度'),
+ dataIndex: 'quota',
+ key: 'quota',
+ },
+ {
+ title: t('操作'),
+ key: 'action',
+ render: (_, record) => (
+
+ openProductModal(record)}
+ >
+ {t('编辑')}
+
+ }
+ onClick={() => deleteProduct(record.productId)}
+ />
+
+ ),
+ },
+ ];
- if (inputs.CreemWebhookSecret && inputs.CreemWebhookSecret !== '') {
- options.push({ key: 'CreemWebhookSecret', value: inputs.CreemWebhookSecret });
- }
+ return (
+
+
+
+ {t('Creem 介绍')}
+
+ Creem Official Site
+
+
+
+
- // Save test mode setting
- options.push({ key: 'CreemTestMode', value: inputs.CreemTestMode ? 'true' : 'false' });
+
+
+
+
+
+
+
+
+
+
+
- // Save products as JSON string
- options.push({ key: 'CreemProducts', value: JSON.stringify(products) });
+
+
+ {t('产品配置')}
+ }
+ onClick={() => openProductModal()}
+ >
+ {t('添加产品')}
+
+
- // 发送请求
- const requestQueue = options.map(opt =>
- API.put('/api/option/', {
- key: opt.key,
- value: opt.value,
- })
- );
-
- const results = await Promise.all(requestQueue);
-
- // 检查所有请求是否成功
- const errorResults = results.filter(res => !res.data.success);
- if (errorResults.length > 0) {
- errorResults.forEach(res => {
- showError(res.data.message);
- });
- } else {
- showSuccess(t('更新成功'));
- // 更新本地存储的原始值
- setOriginInputs({ ...inputs });
- props.refresh?.();
- }
- } catch (error) {
- showError(t('更新失败'));
- }
- setLoading(false);
- };
-
- const openProductModal = (product = null) => {
- if (product) {
- setEditingProduct(product);
- setProductForm({ ...product });
- } else {
- setEditingProduct(null);
- setProductForm({
- name: '',
- productId: '',
- price: 0,
- quota: 0,
- currency: 'USD',
- });
- }
- setShowProductModal(true);
- };
-
- const closeProductModal = () => {
- setShowProductModal(false);
- setEditingProduct(null);
- setProductForm({
- name: '',
- productId: '',
- price: 0,
- quota: 0,
- currency: 'USD',
- });
- };
-
- const saveProduct = () => {
- if (!productForm.name || !productForm.productId || productForm.price <= 0 || productForm.quota <= 0 || !productForm.currency) {
- showError(t('请填写完整的产品信息'));
- return;
- }
-
- let newProducts = [...products];
- if (editingProduct) {
- // 编辑现有产品
- const index = newProducts.findIndex(p => p.productId === editingProduct.productId);
- if (index !== -1) {
- newProducts[index] = { ...productForm };
- }
- } else {
- // 添加新产品
- if (newProducts.find(p => p.productId === productForm.productId)) {
- showError(t('产品ID已存在'));
- return;
- }
- newProducts.push({ ...productForm });
- }
-
- setProducts(newProducts);
- closeProductModal();
- };
-
- const deleteProduct = (productId) => {
- const newProducts = products.filter(p => p.productId !== productId);
- setProducts(newProducts);
- };
-
- const columns = [
- {
- title: t('产品名称'),
- dataIndex: 'name',
- key: 'name',
- },
- {
- title: t('产品ID'),
- dataIndex: 'productId',
- key: 'productId',
- },
- {
- title: t('展示价格'),
- dataIndex: 'price',
- key: 'price',
- render: (price, record) => `${record.currency === 'EUR' ? '€' : '$'}${price}`,
- },
- {
- title: t('充值额度'),
- dataIndex: 'quota',
- key: 'quota',
- },
- {
- title: t('操作'),
- key: 'action',
- render: (_, record) => (
-
-
openProductModal(record)}
- >
- {t('编辑')}
-
-
}
- onClick={() => deleteProduct(record.productId)}
- />
+
+ {t('暂无产品配置')}
- ),
- },
- ];
+ }
+ />
+
- return (
-
-
+
+ {/* 产品配置模态框 */}
+
+
+
+
+ {t('产品名称')}
+
+
+ setProductForm({ ...productForm, name: value })
+ }
+ placeholder={t('例如:基础套餐')}
+ size='large'
+ />
+
+
+
+ {t('产品ID')}
+
+
+ setProductForm({ ...productForm, productId: value })
+ }
+ placeholder={t('例如:prod_6I8rBerHpPxyoiU9WK4kot')}
+ size='large'
+ disabled={!!editingProduct}
+ />
+
+
+
+ {t('货币')}
+
+