fix: channel affinity (#2799)

* fix: channel affinity log styles

* fix: Issue with incorrect data storage when switching key sources

* feat: support not retrying after a single rule configuration fails

* fix: render channel affinity tooltip as multiline content

* feat: channel affinity cache hit

* fix: prevent ChannelAffinityUsageCacheModal infinite loading and hide data before fetch

* chore: format backend with gofmt and frontend with prettier/eslint autofix
This commit is contained in:
Seefs
2026-02-02 14:37:31 +08:00
committed by GitHub
parent 80a609b7c6
commit f244a9e661
61 changed files with 2012 additions and 1004 deletions

View File

@@ -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 (
<PlaygroundProvider value={playgroundContextValue}>
<div className='h-full'>
<Layout className='h-full bg-transparent flex flex-col md:flex-row'>
{(showSettings || !isMobile) && (
<Layout.Sider
className={`
<Layout className='h-full bg-transparent flex flex-col md:flex-row'>
{(showSettings || !isMobile) && (
<Layout.Sider
className={`
bg-transparent border-r-0 flex-shrink-0 overflow-auto mt-[60px]
${
isMobile
@@ -468,93 +471,93 @@ const Playground = () => {
: 'relative z-[1] w-80 h-[calc(100vh-66px)]'
}
`}
width={isMobile ? '100%' : 320}
>
<OptimizedSettingsPanel
inputs={inputs}
parameterEnabled={parameterEnabled}
models={models}
groups={groups}
styleState={styleState}
showSettings={showSettings}
showDebugPanel={showDebugPanel}
customRequestMode={customRequestMode}
customRequestBody={customRequestBody}
onInputChange={handleInputChange}
onParameterToggle={handleParameterToggle}
onCloseSettings={() => setShowSettings(false)}
onConfigImport={handleConfigImport}
onConfigReset={handleConfigReset}
onCustomRequestModeChange={setCustomRequestMode}
onCustomRequestBodyChange={setCustomRequestBody}
previewPayload={previewPayload}
messages={message}
/>
</Layout.Sider>
)}
<Layout.Content className='relative flex-1 overflow-hidden'>
<div className='overflow-hidden flex flex-col lg:flex-row h-[calc(100vh-66px)] mt-[60px]'>
<div className='flex-1 flex flex-col'>
<ChatArea
chatRef={chatRef}
message={message}
width={isMobile ? '100%' : 320}
>
<OptimizedSettingsPanel
inputs={inputs}
parameterEnabled={parameterEnabled}
models={models}
groups={groups}
styleState={styleState}
showSettings={showSettings}
showDebugPanel={showDebugPanel}
roleInfo={roleInfo}
onMessageSend={onMessageSend}
onMessageCopy={messageActions.handleMessageCopy}
onMessageReset={messageActions.handleMessageReset}
onMessageDelete={messageActions.handleMessageDelete}
onStopGenerator={onStopGenerator}
onClearMessages={handleClearMessages}
onToggleDebugPanel={() => 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}
/>
</Layout.Sider>
)}
<Layout.Content className='relative flex-1 overflow-hidden'>
<div className='overflow-hidden flex flex-col lg:flex-row h-[calc(100vh-66px)] mt-[60px]'>
<div className='flex-1 flex flex-col'>
<ChatArea
chatRef={chatRef}
message={message}
inputs={inputs}
styleState={styleState}
showDebugPanel={showDebugPanel}
roleInfo={roleInfo}
onMessageSend={onMessageSend}
onMessageCopy={messageActions.handleMessageCopy}
onMessageReset={messageActions.handleMessageReset}
onMessageDelete={messageActions.handleMessageDelete}
onStopGenerator={onStopGenerator}
onClearMessages={handleClearMessages}
onToggleDebugPanel={() => setShowDebugPanel(!showDebugPanel)}
renderCustomChatContent={renderCustomChatContent}
renderChatBoxAction={renderChatBoxAction}
/>
</div>
{/* 调试面板 - 桌面端 */}
{showDebugPanel && !isMobile && (
<div className='w-96 flex-shrink-0 h-full'>
<OptimizedDebugPanel
debugData={debugData}
activeDebugTab={activeDebugTab}
onActiveDebugTabChange={setActiveDebugTab}
styleState={styleState}
customRequestMode={customRequestMode}
/>
</div>
)}
</div>
{/* 调试面板 - 桌面端 */}
{showDebugPanel && !isMobile && (
<div className='w-96 flex-shrink-0 h-full'>
{/* 调试面板 - 移动端覆盖层 */}
{showDebugPanel && isMobile && (
<div className='fixed top-0 left-0 right-0 bottom-0 z-[1000] bg-white overflow-auto shadow-lg'>
<OptimizedDebugPanel
debugData={debugData}
activeDebugTab={activeDebugTab}
onActiveDebugTabChange={setActiveDebugTab}
styleState={styleState}
showDebugPanel={showDebugPanel}
onCloseDebugPanel={() => setShowDebugPanel(false)}
customRequestMode={customRequestMode}
/>
</div>
)}
</div>
{/* 调试面板 - 移动端覆盖层 */}
{showDebugPanel && isMobile && (
<div className='fixed top-0 left-0 right-0 bottom-0 z-[1000] bg-white overflow-auto shadow-lg'>
<OptimizedDebugPanel
debugData={debugData}
activeDebugTab={activeDebugTab}
onActiveDebugTabChange={setActiveDebugTab}
styleState={styleState}
showDebugPanel={showDebugPanel}
onCloseDebugPanel={() => setShowDebugPanel(false)}
customRequestMode={customRequestMode}
/>
</div>
)}
{/* 浮动按钮 */}
<FloatingButtons
styleState={styleState}
showSettings={showSettings}
showDebugPanel={showDebugPanel}
onToggleSettings={() => setShowSettings(!showSettings)}
onToggleDebugPanel={() => setShowDebugPanel(!showDebugPanel)}
/>
</Layout.Content>
</Layout>
</div>
{/* 浮动按钮 */}
<FloatingButtons
styleState={styleState}
showSettings={showSettings}
showDebugPanel={showDebugPanel}
onToggleSettings={() => setShowSettings(!showSettings)}
onToggleDebugPanel={() => setShowDebugPanel(!showDebugPanel)}
/>
</Layout.Content>
</Layout>
</div>
</PlaygroundProvider>
);
};

View File

@@ -26,12 +26,12 @@ const PrivacyPolicy = () => {
return (
<DocumentRenderer
apiEndpoint="/api/privacy-policy"
apiEndpoint='/api/privacy-policy'
title={t('隐私政策')}
cacheKey="privacy_policy"
cacheKey='privacy_policy'
emptyMessage={t('加载隐私政策内容失败...')}
/>
);
};
export default PrivacyPolicy;
export default PrivacyPolicy;

View File

@@ -199,9 +199,9 @@ export default function SettingGlobalModel(props) {
'global.pass_through_request_enabled': value,
})
}
extraText={
t('开启后,所有请求将直接透传给上游,不会进行任何处理(重定向和渠道适配也将失效),请谨慎开启')
}
extraText={t(
'开启后,所有请求将直接透传给上游,不会进行任何处理(重定向和渠道适配也将失效),请谨慎开启',
)}
/>
</Col>
</Row>
@@ -210,11 +210,7 @@ export default function SettingGlobalModel(props) {
<Form.TextArea
label={t('禁用思考处理的模型列表')}
field={'global.thinking_model_blacklist'}
placeholder={
t('例如:') +
'\n' +
thinkingExample
}
placeholder={t('例如:') + '\n' + thinkingExample}
rows={4}
rules={[
{
@@ -270,12 +266,12 @@ export default function SettingGlobalModel(props) {
<Row style={{ marginTop: 10 }}>
<Col span={24}>
<Form.TextArea
label={t('参数配置')}
field={chatCompletionsToResponsesPolicyKey}
placeholder={
t('例如(指定渠道):') +
'\n' +
<Form.TextArea
label={t('参数配置')}
field={chatCompletionsToResponsesPolicyKey}
placeholder={
t('例如(指定渠道):') +
'\n' +
chatCompletionsToResponsesPolicyExample +
'\n\n' +
t('例如(全渠道):') +
@@ -370,7 +366,9 @@ export default function SettingGlobalModel(props) {
<Col span={24}>
<Banner
type='warning'
description={t('警告启用保活后如果已经写入保活数据后渠道出错系统无法重试如果必须开启推荐设置尽可能大的Ping间隔')}
description={t(
'警告启用保活后如果已经写入保活数据后渠道出错系统无法重试如果必须开启推荐设置尽可能大的Ping间隔',
)}
/>
</Col>
</Row>

View File

@@ -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]);

View File

@@ -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 }}
>
<Form.Section
<Form.Section
text={
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div
style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
>
<span>{t('模型部署设置')}</span>
</div>
}
@@ -186,7 +194,9 @@ export default function SettingModelDeployment(props) {
<Card
title={
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div
style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
>
<Cloud size={18} />
<span>io.net</span>
</div>
@@ -226,18 +236,16 @@ export default function SettingModelDeployment(props) {
}
disabled={!inputs['model_deployment.ionet.enabled']}
extraText={t('请使用 Project 为 io.cloud 的密钥')}
mode="password"
mode='password'
/>
<div style={{ display: 'flex', gap: '12px' }}>
<Button
type="outline"
size="small"
type='outline'
size='small'
icon={<Zap size={16} />}
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) {
}}
>
<div>
<Text strong style={{ display: 'block', marginBottom: '8px' }}>
<Text
strong
style={{ display: 'block', marginBottom: '8px' }}
>
{t('获取 io.net API Key')}
</Text>
<ul
@@ -287,14 +298,16 @@ export default function SettingModelDeployment(props) {
}}
>
<li>{t('访问 io.net 控制台的 API Keys 页面')}</li>
<li>{t('创建或选择密钥时,将 Project 设置为 io.cloud')}</li>
<li>
{t('创建或选择密钥时,将 Project 设置为 io.cloud')}
</li>
<li>{t('复制生成的密钥并粘贴到此处')}</li>
</ul>
</div>
<Button
icon={<ArrowUpRight size={16} />}
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) {
</Card>
<Row>
<Button size='default' type="primary" onClick={onSubmit}>
<Button size='default' type='primary' onClick={onSubmit}>
{t('保存设置')}
</Button>
</Row>

View File

@@ -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) {
</Text>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} sm={12}>
<Form.Switch
field='skip_retry_on_failure'
label={t('失败后不重试')}
/>
<Text type='tertiary' size='small'>
{t('开启后,若该规则命中且请求失败,将不会切换渠道重试。')}
</Text>
</Col>
</Row>
</Collapse.Panel>
</Collapse>

View File

@@ -172,7 +172,9 @@ export default function SettingsCreditLimit(props) {
<Form.Switch
label={t('对免费模型启用预消耗')}
field={'quota_setting.enable_free_model_pre_consume'}
extraText={t('开启后对免费模型倍率为0或者价格为0的模型也会预消耗额度')}
extraText={t(
'开启后对免费模型倍率为0或者价格为0的模型也会预消耗额度',
)}
onChange={(value) =>
setInputs({
...inputs,

View File

@@ -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,
});

View File

@@ -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('兑换码管理'),

View File

@@ -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 <https://www.gnu.org/licenses/>.
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) => (
<div className='flex gap-2'>
<Button
type='tertiary'
size='small'
onClick={() => openProductModal(record)}
>
{t('编辑')}
</Button>
<Button
type='danger'
theme='borderless'
size='small'
icon={<Trash2 size={14} />}
onClick={() => deleteProduct(record.productId)}
/>
</div>
),
},
];
if (inputs.CreemWebhookSecret && inputs.CreemWebhookSecret !== '') {
options.push({ key: 'CreemWebhookSecret', value: inputs.CreemWebhookSecret });
}
return (
<Spin spinning={loading}>
<Form
initValues={inputs}
onValueChange={handleFormChange}
getFormApi={(api) => (formApiRef.current = api)}
>
<Form.Section text={t('Creem 设置')}>
<Text>
{t('Creem 介绍')}
<a href='https://creem.io' target='_blank' rel='noreferrer'>
Creem Official Site
</a>
<br />
</Text>
<Banner type='info' description={t('Creem Setting Tips')} />
// Save test mode setting
options.push({ key: 'CreemTestMode', value: inputs.CreemTestMode ? 'true' : 'false' });
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input
field='CreemApiKey'
label={t('API 密钥')}
placeholder={t('Creem API 密钥,敏感信息不显示')}
type='password'
/>
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input
field='CreemWebhookSecret'
label={t('Webhook 密钥')}
placeholder={t(
'用于验证回调 new-api 的 webhook 请求的密钥,敏感信息不显示',
)}
type='password'
/>
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Switch
field='CreemTestMode'
label={t('测试模式')}
extraText={t('启用后将使用 Creem Test Mode')}
/>
</Col>
</Row>
// Save products as JSON string
options.push({ key: 'CreemProducts', value: JSON.stringify(products) });
<div style={{ marginTop: 24 }}>
<div className='flex justify-between items-center mb-4'>
<Text strong>{t('产品配置')}</Text>
<Button
type='primary'
icon={<Plus size={16} />}
onClick={() => openProductModal()}
>
{t('添加产品')}
</Button>
</div>
// 发送请求
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) => (
<div className='flex gap-2'>
<Button
type='tertiary'
size='small'
onClick={() => openProductModal(record)}
>
{t('编辑')}
</Button>
<Button
type='danger'
theme='borderless'
size='small'
icon={<Trash2 size={14} />}
onClick={() => deleteProduct(record.productId)}
/>
<Table
columns={columns}
dataSource={products}
pagination={false}
empty={
<div className='text-center py-8'>
<Text type='tertiary'>{t('暂无产品配置')}</Text>
</div>
),
},
];
}
/>
</div>
return (
<Spin spinning={loading}>
<Form
initValues={inputs}
onValueChange={handleFormChange}
getFormApi={(api) => (formApiRef.current = api)}
<Button onClick={submitCreemSetting} style={{ marginTop: 16 }}>
{t('更新 Creem 设置')}
</Button>
</Form.Section>
</Form>
{/* 产品配置模态框 */}
<Modal
title={editingProduct ? t('编辑产品') : t('添加产品')}
visible={showProductModal}
onOk={saveProduct}
onCancel={closeProductModal}
maskClosable={false}
size='small'
centered
>
<div className='space-y-4'>
<div>
<Text strong className='block mb-2'>
{t('产品名称')}
</Text>
<Input
value={productForm.name}
onChange={(value) =>
setProductForm({ ...productForm, name: value })
}
placeholder={t('例如:基础套餐')}
size='large'
/>
</div>
<div>
<Text strong className='block mb-2'>
{t('产品ID')}
</Text>
<Input
value={productForm.productId}
onChange={(value) =>
setProductForm({ ...productForm, productId: value })
}
placeholder={t('例如prod_6I8rBerHpPxyoiU9WK4kot')}
size='large'
disabled={!!editingProduct}
/>
</div>
<div>
<Text strong className='block mb-2'>
{t('货币')}
</Text>
<Select
value={productForm.currency}
onChange={(value) =>
setProductForm({ ...productForm, currency: value })
}
size='large'
className='w-full'
>
<Form.Section text={t('Creem 设置')}>
<Text>
{t('Creem 介绍')}
<a
href='https://creem.io'
target='_blank'
rel='noreferrer'
>Creem Official Site</a>
<br />
</Text>
<Banner
type='info'
description={t('Creem Setting Tips')}
/>
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input
field='CreemApiKey'
label={t('API 密钥')}
placeholder={t('Creem API 密钥,敏感信息不显示')}
type='password'
/>
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input
field='CreemWebhookSecret'
label={t('Webhook 密钥')}
placeholder={t('用于验证回调 new-api 的 webhook 请求的密钥,敏感信息不显示')}
type='password'
/>
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Switch
field='CreemTestMode'
label={t('测试模式')}
extraText={t('启用后将使用 Creem Test Mode')}
/>
</Col>
</Row>
<div style={{ marginTop: 24 }}>
<div className='flex justify-between items-center mb-4'>
<Text strong>{t('产品配置')}</Text>
<Button
type='primary'
icon={<Plus size={16} />}
onClick={() => openProductModal()}
>
{t('添加产品')}
</Button>
</div>
<Table
columns={columns}
dataSource={products}
pagination={false}
empty={
<div className='text-center py-8'>
<Text type='tertiary'>{t('暂无产品配置')}</Text>
</div>
}
/>
</div>
<Button onClick={submitCreemSetting} style={{ marginTop: 16 }}>
{t('更新 Creem 设置')}
</Button>
</Form.Section>
</Form>
{/* 产品配置模态框 */}
<Modal
title={editingProduct ? t('编辑产品') : t('添加产品')}
visible={showProductModal}
onOk={saveProduct}
onCancel={closeProductModal}
maskClosable={false}
size='small'
centered
>
<div className='space-y-4'>
<div>
<Text strong className='block mb-2'>
{t('产品名称')}
</Text>
<Input
value={productForm.name}
onChange={(value) => setProductForm({ ...productForm, name: value })}
placeholder={t('例如:基础套餐')}
size='large'
/>
</div>
<div>
<Text strong className='block mb-2'>
{t('产品ID')}
</Text>
<Input
value={productForm.productId}
onChange={(value) => setProductForm({ ...productForm, productId: value })}
placeholder={t('例如prod_6I8rBerHpPxyoiU9WK4kot')}
size='large'
disabled={!!editingProduct}
/>
</div>
<div>
<Text strong className='block mb-2'>
{t('货币')}
</Text>
<Select
value={productForm.currency}
onChange={(value) => setProductForm({ ...productForm, currency: value })}
size='large'
className='w-full'
>
<Select.Option value='USD'>{t('USD (美元)')}</Select.Option>
<Select.Option value='EUR'>{t('EUR (欧元)')}</Select.Option>
</Select>
</div>
<div>
<Text strong className='block mb-2'>
{t('价格')} ({productForm.currency === 'EUR' ? t('欧元') : t('美元')})
</Text>
<InputNumber
value={productForm.price}
onChange={(value) => setProductForm({ ...productForm, price: value })}
placeholder={t('例如4.99')}
min={0.01}
precision={2}
size='large'
className='w-full'
defaultValue={4.49}
/>
</div>
<div>
<Text strong className='block mb-2'>
{t('充值额度')}
</Text>
<InputNumber
value={productForm.quota}
onChange={(value) => setProductForm({ ...productForm, quota: value })}
placeholder={t('例如100000')}
min={1}
precision={0}
size='large'
className='w-full'
/>
</div>
</div>
</Modal>
</Spin>
);
}
<Select.Option value='USD'>{t('USD (美元)')}</Select.Option>
<Select.Option value='EUR'>{t('EUR (欧元)')}</Select.Option>
</Select>
</div>
<div>
<Text strong className='block mb-2'>
{t('价格')} (
{productForm.currency === 'EUR' ? t('欧元') : t('美元')})
</Text>
<InputNumber
value={productForm.price}
onChange={(value) =>
setProductForm({ ...productForm, price: value })
}
placeholder={t('例如4.99')}
min={0.01}
precision={2}
size='large'
className='w-full'
defaultValue={4.49}
/>
</div>
<div>
<Text strong className='block mb-2'>
{t('充值额度')}
</Text>
<InputNumber
value={productForm.quota}
onChange={(value) =>
setProductForm({ ...productForm, quota: value })
}
placeholder={t('例如100000')}
min={1}
precision={0}
size='large'
className='w-full'
/>
</div>
</div>
</Modal>
</Spin>
);
}

View File

@@ -168,7 +168,8 @@ export default function SettingsPerformance(props) {
for (let key in props.options) {
if (Object.keys(inputs).includes(key)) {
if (typeof inputs[key] === 'boolean') {
currentInputs[key] = props.options[key] === 'true' || props.options[key] === true;
currentInputs[key] =
props.options[key] === 'true' || props.options[key] === true;
} else if (typeof inputs[key] === 'number') {
currentInputs[key] = parseInt(props.options[key]) || inputs[key];
} else {
@@ -184,9 +185,14 @@ export default function SettingsPerformance(props) {
fetchStats();
}, [props.options]);
const diskCacheUsagePercent = stats?.cache_stats?.disk_cache_max_bytes > 0
? (stats.cache_stats.current_disk_usage_bytes / stats.cache_stats.disk_cache_max_bytes * 100).toFixed(1)
: 0;
const diskCacheUsagePercent =
stats?.cache_stats?.disk_cache_max_bytes > 0
? (
(stats.cache_stats.current_disk_usage_bytes /
stats.cache_stats.disk_cache_max_bytes) *
100
).toFixed(1)
: 0;
return (
<>
@@ -199,7 +205,9 @@ export default function SettingsPerformance(props) {
<Form.Section text={t('磁盘缓存设置(磁盘换内存)')}>
<Banner
type='info'
description={t('启用磁盘缓存后,大请求体将临时存储到磁盘而非内存,可显著降低内存占用,适用于处理包含大量图片/文件的请求。建议在 SSD 环境下使用。')}
description={t(
'启用磁盘缓存后,大请求体将临时存储到磁盘而非内存,可显著降低内存占用,适用于处理包含大量图片/文件的请求。建议在 SSD 环境下使用。',
)}
style={{ marginBottom: 16 }}
/>
<Row gutter={16}>
@@ -211,7 +219,9 @@ export default function SettingsPerformance(props) {
size='default'
checkedText=''
uncheckedText=''
onChange={handleFieldChange('performance_setting.disk_cache_enabled')}
onChange={handleFieldChange(
'performance_setting.disk_cache_enabled',
)}
/>
</Col>
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
@@ -221,7 +231,9 @@ export default function SettingsPerformance(props) {
extraText={t('请求体超过此大小时使用磁盘缓存')}
min={1}
max={1024}
onChange={handleFieldChange('performance_setting.disk_cache_threshold_mb')}
onChange={handleFieldChange(
'performance_setting.disk_cache_threshold_mb',
)}
disabled={!inputs['performance_setting.disk_cache_enabled']}
/>
</Col>
@@ -239,7 +251,9 @@ export default function SettingsPerformance(props) {
}
min={100}
max={102400}
onChange={handleFieldChange('performance_setting.disk_cache_max_size_mb')}
onChange={handleFieldChange(
'performance_setting.disk_cache_max_size_mb',
)}
disabled={!inputs['performance_setting.disk_cache_enabled']}
/>
</Col>
@@ -251,7 +265,9 @@ export default function SettingsPerformance(props) {
label={t('缓存目录')}
extraText={t('留空使用系统临时目录')}
placeholder={t('例如 /var/cache/new-api')}
onChange={handleFieldChange('performance_setting.disk_cache_path')}
onChange={handleFieldChange(
'performance_setting.disk_cache_path',
)}
showClear
disabled={!inputs['performance_setting.disk_cache_enabled']}
/>
@@ -290,38 +306,98 @@ export default function SettingsPerformance(props) {
{stats && (
<>
{/* 缓存使用情况 */}
<Row gutter={16} style={{ marginBottom: 16, display: 'flex', alignItems: 'stretch' }}>
<Row
gutter={16}
style={{
marginBottom: 16,
display: 'flex',
alignItems: 'stretch',
}}
>
<Col xs={24} md={12} style={{ display: 'flex' }}>
<div style={{ padding: 16, background: 'var(--semi-color-fill-0)', borderRadius: 8, flex: 1, display: 'flex', flexDirection: 'column' }}>
<Text strong style={{ marginBottom: 8, display: 'block' }}>{t('请求体磁盘缓存')}</Text>
<div
style={{
padding: 16,
background: 'var(--semi-color-fill-0)',
borderRadius: 8,
flex: 1,
display: 'flex',
flexDirection: 'column',
}}
>
<Text strong style={{ marginBottom: 8, display: 'block' }}>
{t('请求体磁盘缓存')}
</Text>
<Progress
percent={parseFloat(diskCacheUsagePercent)}
showInfo
style={{ marginBottom: 8 }}
stroke={parseFloat(diskCacheUsagePercent) > 80 ? 'var(--semi-color-danger)' : 'var(--semi-color-primary)'}
stroke={
parseFloat(diskCacheUsagePercent) > 80
? 'var(--semi-color-danger)'
: 'var(--semi-color-primary)'
}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
marginBottom: 8,
}}
>
<Text type='tertiary'>
{formatBytes(stats.cache_stats.current_disk_usage_bytes)} / {formatBytes(stats.cache_stats.disk_cache_max_bytes)}
{formatBytes(
stats.cache_stats.current_disk_usage_bytes,
)}{' '}
/ {formatBytes(stats.cache_stats.disk_cache_max_bytes)}
</Text>
<Text type='tertiary'>
{t('活跃文件')}: {stats.cache_stats.active_disk_files}
</Text>
</div>
<div style={{ marginTop: 'auto' }}>
<Tag color='blue'>{t('磁盘命中')}: {stats.cache_stats.disk_cache_hits}</Tag>
<Tag color='blue'>
{t('磁盘命中')}: {stats.cache_stats.disk_cache_hits}
</Tag>
</div>
</div>
</Col>
<Col xs={24} md={12} style={{ display: 'flex' }}>
<div style={{ padding: 16, background: 'var(--semi-color-fill-0)', borderRadius: 8, flex: 1, display: 'flex', flexDirection: 'column' }}>
<Text strong style={{ marginBottom: 8, display: 'block' }}>{t('请求体内存缓存')}</Text>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<Text>{t('当前缓存大小')}: {formatBytes(stats.cache_stats.current_memory_usage_bytes)}</Text>
<Text>{t('活跃缓存数')}: {stats.cache_stats.active_memory_buffers}</Text>
<div
style={{
padding: 16,
background: 'var(--semi-color-fill-0)',
borderRadius: 8,
flex: 1,
display: 'flex',
flexDirection: 'column',
}}
>
<Text strong style={{ marginBottom: 8, display: 'block' }}>
{t('请求体内存缓存')}
</Text>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
marginBottom: 8,
}}
>
<Text>
{t('当前缓存大小')}:{' '}
{formatBytes(
stats.cache_stats.current_memory_usage_bytes,
)}
</Text>
<Text>
{t('活跃缓存数')}:{' '}
{stats.cache_stats.active_memory_buffers}
</Text>
</div>
<div style={{ marginTop: 'auto' }}>
<Tag color='green'>{t('内存命中')}: {stats.cache_stats.memory_cache_hits}</Tag>
<Tag color='green'>
{t('内存命中')}: {stats.cache_stats.memory_cache_hits}
</Tag>
</div>
</div>
</Col>
@@ -331,20 +407,56 @@ export default function SettingsPerformance(props) {
{stats.disk_space_info?.total > 0 && (
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={24}>
<div style={{ padding: 16, background: 'var(--semi-color-fill-0)', borderRadius: 8 }}>
<Text strong style={{ marginBottom: 8, display: 'block' }}>{t('缓存目录磁盘空间')}</Text>
<div
style={{
padding: 16,
background: 'var(--semi-color-fill-0)',
borderRadius: 8,
}}
>
<Text
strong
style={{ marginBottom: 8, display: 'block' }}
>
{t('缓存目录磁盘空间')}
</Text>
<Progress
percent={parseFloat(stats.disk_space_info.used_percent.toFixed(1))}
percent={parseFloat(
stats.disk_space_info.used_percent.toFixed(1),
)}
showInfo
style={{ marginBottom: 8 }}
stroke={stats.disk_space_info.used_percent > 90 ? 'var(--semi-color-danger)' : stats.disk_space_info.used_percent > 70 ? 'var(--semi-color-warning)' : 'var(--semi-color-primary)'}
stroke={
stats.disk_space_info.used_percent > 90
? 'var(--semi-color-danger)'
: stats.disk_space_info.used_percent > 70
? 'var(--semi-color-warning)'
: 'var(--semi-color-primary)'
}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
<Text type='tertiary'>{t('已用')}: {formatBytes(stats.disk_space_info.used)}</Text>
<Text type='tertiary'>{t('可用')}: {formatBytes(stats.disk_space_info.free)}</Text>
<Text type='tertiary'>{t('总计')}: {formatBytes(stats.disk_space_info.total)}</Text>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 8,
}}
>
<Text type='tertiary'>
{t('已用')}: {formatBytes(stats.disk_space_info.used)}
</Text>
<Text type='tertiary'>
{t('可用')}: {formatBytes(stats.disk_space_info.free)}
</Text>
<Text type='tertiary'>
{t('总计')}:{' '}
{formatBytes(stats.disk_space_info.total)}
</Text>
</div>
{stats.disk_space_info.free < inputs['performance_setting.disk_cache_max_size_mb'] * 1024 * 1024 && (
{stats.disk_space_info.free <
inputs['performance_setting.disk_cache_max_size_mb'] *
1024 *
1024 && (
<Banner
type='warning'
description={t('磁盘可用空间小于缓存最大总量设置')}
@@ -361,14 +473,32 @@ export default function SettingsPerformance(props) {
<Col span={24}>
<Descriptions
data={[
{ key: t('已分配内存'), value: formatBytes(stats.memory_stats.alloc) },
{ key: t('分配内存'), value: formatBytes(stats.memory_stats.total_alloc) },
{ key: t('系统内存'), value: formatBytes(stats.memory_stats.sys) },
{
key: t('分配内存'),
value: formatBytes(stats.memory_stats.alloc),
},
{
key: t('总分配内存'),
value: formatBytes(stats.memory_stats.total_alloc),
},
{
key: t('系统内存'),
value: formatBytes(stats.memory_stats.sys),
},
{ key: t('GC 次数'), value: stats.memory_stats.num_gc },
{ key: t('Goroutine 数'), value: stats.memory_stats.num_goroutine },
{
key: t('Goroutine 数'),
value: stats.memory_stats.num_goroutine,
},
{ key: t('缓存目录'), value: stats.disk_cache_info.path },
{ key: t('目录文件数'), value: stats.disk_cache_info.file_count },
{ key: t('目录总大小'), value: formatBytes(stats.disk_cache_info.total_size) },
{
key: t('目录文件数'),
value: stats.disk_cache_info.file_count,
},
{
key: t('目录总大小'),
value: formatBytes(stats.disk_cache_info.total_size),
},
]}
/>
</Col>

View File

@@ -205,7 +205,10 @@ export default function GroupRatioSettings(props) {
},
]}
onChange={(value) =>
setInputs({ ...inputs, 'group_ratio_setting.group_special_usable_group': value })
setInputs({
...inputs,
'group_ratio_setting.group_special_usable_group': value,
})
}
/>
</Col>

View File

@@ -26,12 +26,12 @@ const UserAgreement = () => {
return (
<DocumentRenderer
apiEndpoint="/api/user-agreement"
apiEndpoint='/api/user-agreement'
title={t('用户协议')}
cacheKey="user_agreement"
cacheKey='user_agreement'
emptyMessage={t('加载用户协议内容失败...')}
/>
);
};
export default UserAgreement;
export default UserAgreement;