Align console behavior with operator routing and image workflow expectations

Constraint: Must preserve current exchange-rate logic, same-group routing boundary, and temporary image-file behavior while fixing UX-visible inconsistencies.
Rejected: Large frontend chunk refactor | introduced risky circular chunking and did not improve deliverability for this rollout.
Confidence: medium
Scope-risk: moderate
Directive: Keep channel failover constrained to the selected group and preserve Beijing-day semantics for user cache dashboard metrics.
Tested: go test ./controller ./model ./service ./common -count=1; npm run build; remote smoke on source deployment at http://38.76.218.56:18081/ plus /api/status and /api/image-studio/settings.
Not-tested: Browser-driven end-to-end verification of every console page interaction.
This commit is contained in:
ikmkj
2026-05-05 15:27:51 +08:00
parent 92ee465a41
commit 8ee89478a9
46 changed files with 1341 additions and 289 deletions

1
web/package.json vendored
View File

@@ -10,6 +10,7 @@
"@visactor/react-vchart": "~1.8.8",
"@visactor/vchart": "~1.8.8",
"@visactor/vchart-semi-theme": "~1.8.8",
"antd": "^5.29.3",
"axios": "1.13.5",
"clsx": "^2.1.1",
"dayjs": "^1.11.11",

8
web/src/App.jsx vendored
View File

@@ -43,11 +43,9 @@ import Pricing from './pages/Pricing';
import Task from './pages/Task';
import ModelPage from './pages/Model';
import ModelDeploymentPage from './pages/ModelDeployment';
import Playground from './pages/Playground';
import Subscription from './pages/Subscription';
import OAuth2Callback from './components/auth/OAuth2Callback';
import PersonalSetting from './components/settings/PersonalSetting';
import ImageStudio from './pages/ImageStudio';
import Setup from './pages/Setup';
import SetupCheck from './components/layout/SetupCheck';
@@ -56,6 +54,8 @@ const Dashboard = lazy(() => import('./pages/Dashboard'));
const About = lazy(() => import('./pages/About'));
const UserAgreement = lazy(() => import('./pages/UserAgreement'));
const PrivacyPolicy = lazy(() => import('./pages/PrivacyPolicy'));
const Playground = lazy(() => import('./pages/Playground'));
const ImageStudio = lazy(() => import('./pages/ImageStudio'));
function DynamicOAuth2Callback() {
const { provider } = useParams();
@@ -152,7 +152,9 @@ function App() {
path='/console/playground'
element={
<PrivateRoute>
<Playground />
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
<Playground />
</Suspense>
</PrivateRoute>
}
/>

View File

@@ -88,13 +88,14 @@ const Dashboard = () => {
// ========== 数据处理 ==========
const initChart = async () => {
await dashboardData.loadQuotaData().then((data) => {
if (data && data.length > 0) {
dashboardCharts.updateChartData(data);
}
});
await dashboardData.loadUptimeData();
await dashboardData.loadCacheStats?.();
const [data] = await Promise.all([
dashboardData.loadQuotaData(),
dashboardData.loadUptimeData(),
dashboardData.loadCacheStats?.(),
]);
if (data && data.length > 0) {
dashboardCharts.updateChartData(data);
}
};
const handleRefresh = async () => {

View File

@@ -20,6 +20,7 @@ For commercial licensing, please contact support@quantumnous.com
import React, { useEffect, useState } from 'react';
import { Card, Spin } from '@douyinfe/semi-ui';
import SettingsDrawing from '../../pages/Setting/Drawing/SettingsDrawing';
import SettingsImageStudio from '../../pages/Setting/Drawing/SettingsImageStudio';
import { API, showError, toBoolean } from '../../helpers';
const DrawingSetting = () => {
@@ -76,6 +77,9 @@ const DrawingSetting = () => {
<Card style={{ marginTop: '10px' }}>
<SettingsDrawing options={inputs} refresh={onRefresh} />
</Card>
<Card style={{ marginTop: '10px' }}>
<SettingsImageStudio options={inputs} refresh={onRefresh} />
</Card>
</Spin>
</>
);

View File

@@ -409,10 +409,10 @@ const NotificationSettings = ({
}
placeholder={t('请输入预警额度')}
data={[
{ value: 100000, label: '0.2$' },
{ value: 500000, label: '1$' },
{ value: 1000000, label: '2$' },
{ value: 5000000, label: '10$' },
{ value: 100000, label: '0.2' },
{ value: 500000, label: '1' },
{ value: 1000000, label: '2' },
{ value: 5000000, label: '10' },
]}
onChange={(val) => handleFormChange('warningThreshold', val)}
prefix={<IconBell />}
@@ -515,7 +515,7 @@ const NotificationSettings = ({
title: '额度预警通知',
content:
'您的额度即将用尽,当前剩余额度为 {{value}}',
values: ['$0.99'],
values: ['0.99'],
timestamp: 1739950503,
}}
title='webhook'

View File

@@ -574,7 +574,7 @@ export const getChannelsColumns = ({
},
{
key: COLUMN_KEYS.PRIORITY,
title: t('优先级'),
title: t('同组优先级'),
dataIndex: 'priority',
render: (text, record, index) => {
if (record.children === undefined) {
@@ -602,9 +602,9 @@ export const getChannelsColumns = ({
keepFocus={true}
onBlur={(e) => {
Modal.warning({
title: t('修改子渠道优先级'),
title: t('修改子渠道同组优先级'),
content:
t('确定要修改所有子渠道优先级为 ') +
t('确定要修改所有子渠道同组优先级为 ') +
e.target.value +
t(' 吗?'),
onOk: () => {
@@ -629,7 +629,7 @@ export const getChannelsColumns = ({
},
{
key: COLUMN_KEYS.WEIGHT,
title: t('权重'),
title: t('同优先级顺序'),
dataIndex: 'weight',
render: (text, record, index) => {
if (record.children === undefined) {
@@ -657,9 +657,9 @@ export const getChannelsColumns = ({
keepFocus={true}
onBlur={(e) => {
Modal.warning({
title: t('修改子渠道权重'),
title: t('修改子渠道同优先级顺序'),
content:
t('确定要修改所有子渠道权重为 ') +
t('确定要修改所有子渠道同优先级顺序为 ') +
e.target.value +
t(' 吗?'),
onOk: () => {

View File

@@ -3540,8 +3540,8 @@ const EditChannelModal = (props) => {
<Col span={12}>
<Form.InputNumber
field='priority'
label={t('渠道优先级')}
placeholder={t('渠道优先级')}
label={t('同组优先级')}
placeholder={t('同组优先级')}
min={0}
onNumberChange={(value) =>
handleInputChange('priority', value)
@@ -3552,8 +3552,8 @@ const EditChannelModal = (props) => {
<Col span={12}>
<Form.InputNumber
field='weight'
label={t('渠道权重')}
placeholder={t('渠道权重')}
label={t('同优先级顺序')}
placeholder={t('同优先级顺序')}
min={0}
onNumberChange={(value) =>
handleInputChange('weight', value)

View File

@@ -519,7 +519,7 @@ const ViewDetailsModal = ({ visible, onCancel, deployment, t }) => {
<div className='flex items-center justify-between p-3 bg-green-50 rounded-lg'>
<Text>{t('已支付金额')}</Text>
<Text strong className='text-lg text-green-600'>
$
{details.amount_paid
? details.amount_paid.toFixed(2)
: '0.00'}{' '}

View File

@@ -61,8 +61,8 @@ const PricingDisplaySettings = ({
];
const currencyItems = [
{ value: 'USD', label: 'USD ($)' },
{ value: 'CNY', label: 'CNY (¥)' },
{ value: 'USD', label: 'USD ()' },
{ value: 'CNY', label: 'CNY ()' },
{ value: 'CUSTOM', label: t('自定义货币') },
];

View File

@@ -307,12 +307,12 @@ const EditRedemptionModal = (props) => {
Number(values.quota) || 0,
)}
data={[
{ value: 500000, label: '1$' },
{ value: 5000000, label: '10$' },
{ value: 25000000, label: '50$' },
{ value: 50000000, label: '100$' },
{ value: 250000000, label: '500$' },
{ value: 500000000, label: '1000$' },
{ value: 500000, label: '1' },
{ value: 5000000, label: '10' },
{ value: 25000000, label: '50' },
{ value: 50000000, label: '100' },
{ value: 250000000, label: '500' },
{ value: 500000000, label: '1000' },
]}
showClear
/>

View File

@@ -605,12 +605,12 @@ const EditTokenModal = (props) => {
: [{ required: true, message: t('请输入额度') }]
}
data={[
{ value: 500000, label: '1$' },
{ value: 5000000, label: '10$' },
{ value: 25000000, label: '50$' },
{ value: 50000000, label: '100$' },
{ value: 250000000, label: '500$' },
{ value: 500000000, label: '1000$' },
{ value: 500000, label: '1' },
{ value: 5000000, label: '10' },
{ value: 25000000, label: '50' },
{ value: 50000000, label: '100' },
{ value: 250000000, label: '500' },
{ value: 500000000, label: '1000' },
]}
/>
</Col>

View File

@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useEffect, useRef, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
Avatar,
Typography,
@@ -105,6 +105,21 @@ const RechargeCard = ({
const [activeTab, setActiveTab] = useState('topup');
const shouldShowSubscription =
!subscriptionLoading && subscriptionPlans.length > 0;
const nonWaffoPayMethods = useMemo(
() => (payMethods || []).filter((method) => method.type !== 'waffo'),
[payMethods],
);
const currencyConfig = useMemo(() => getCurrencyConfig(), []);
const usdExchangeRate = useMemo(() => {
const statusStr = localStorage.getItem('status');
try {
if (statusStr) {
const status = JSON.parse(statusStr);
return status?.usd_exchange_rate || 7;
}
} catch (e) {}
return 7;
}, []);
useEffect(() => {
if (initialTabSetRef.current) return;
@@ -291,11 +306,11 @@ const RechargeCard = ({
style={{ width: '100%' }}
/>
</Col>
{payMethods && payMethods.filter(m => m.type !== 'waffo').length > 0 && (
{nonWaffoPayMethods.length > 0 && (
<Col xs={24} sm={24} md={24} lg={14} xl={14}>
<Form.Slot label={t('选择支付方式')}>
<Space wrap>
{payMethods.filter(m => m.type !== 'waffo').map((payMethod) => {
{nonWaffoPayMethods.map((payMethod) => {
const minTopupVal = Number(payMethod.min_topup) || 0;
const isStripe = payMethod.type === 'stripe';
const disabled =
@@ -366,22 +381,18 @@ const RechargeCard = ({
label={
<div className='flex items-center gap-2'>
<span>{t('选择充值额度')}</span>
{(() => {
const { symbol, rate, type } = getCurrencyConfig();
if (type === 'USD') return null;
return (
<span
style={{
color: 'var(--semi-color-text-2)',
fontSize: '12px',
fontWeight: 'normal',
}}
>
(1 $ = {rate.toFixed(2)} {symbol})
</span>
);
})()}
{currencyConfig.type !== 'USD' && (
<span
style={{
color: 'var(--semi-color-text-2)',
fontSize: '12px',
fontWeight: 'normal',
}}
>
(1 = {currencyConfig.rate.toFixed(2)}{' '}
{currencyConfig.symbol})
</span>
)}
</div>
}
>
@@ -396,15 +407,7 @@ const RechargeCard = ({
const save = originalPrice - discountedPrice;
// 根据当前货币类型换算显示金额和数量
const { symbol, rate, type } = getCurrencyConfig();
const statusStr = localStorage.getItem('status');
let usdRate = 7; // 默认CNY汇率
try {
if (statusStr) {
const s = JSON.parse(statusStr);
usdRate = s?.usd_exchange_rate || 7;
}
} catch (e) { }
const { symbol, rate, type } = currencyConfig;
let displayValue = preset.value; // 显示的数量
let displayActualPay = actualPay;
@@ -412,16 +415,16 @@ const RechargeCard = ({
if (type === 'USD') {
// 数量保持USD价格从CNY转USD
displayActualPay = actualPay / usdRate;
displaySave = save / usdRate;
displayActualPay = actualPay / usdExchangeRate;
displaySave = save / usdExchangeRate;
} else if (type === 'CNY') {
// 数量转CNY价格已是CNY
displayValue = preset.value * usdRate;
displayValue = preset.value * usdExchangeRate;
} else if (type === 'CUSTOM') {
// 数量和价格都转自定义货币
displayValue = preset.value * rate;
displayActualPay = (actualPay / usdRate) * rate;
displaySave = (save / usdRate) * rate;
displayActualPay = (actualPay / usdExchangeRate) * rate;
displaySave = (save / usdExchangeRate) * rate;
}
return (
@@ -540,7 +543,7 @@ const RechargeCard = ({
{t('充值额度')}: {product.quota}
</div>
<div className='text-lg font-semibold text-blue-600'>
{product.currency === 'EUR' ? '€' : '$'}
{product.currency === 'EUR' ? '€' : ''}
{product.price}
</div>
</Card>

View File

@@ -753,7 +753,7 @@ const TopUp = () => {
{/* Creem 充值确认模态框 */}
<Modal
title={t('确定要充值 $')}
title={t('确定要充值 ')}
visible={creemOpen}
onOk={onlineCreemTopUp}
onCancel={handleCreemCancel}
@@ -768,7 +768,7 @@ const TopUp = () => {
{t('产品名称')}{selectedCreemProduct.name}
</p>
<p>
{t('价格')}{selectedCreemProduct.currency === 'EUR' ? '€' : '$'}
{t('价格')}{selectedCreemProduct.currency === 'EUR' ? '€' : ''}
{selectedCreemProduct.price}
</p>
<p>

View File

@@ -197,7 +197,7 @@ const TopupHistoryModal = ({ visible, onCancel, t }) => {
title: t('支付金额'),
dataIndex: 'money',
key: 'money',
render: (money) => <Text type='danger'>¥{money.toFixed(2)}</Text>,
render: (money) => <Text type='danger'>{money.toFixed(2)}</Text>,
},
{
title: t('状态'),

View File

@@ -999,9 +999,9 @@ export function renderQuotaNumberWithDigit(num, digits = 2) {
const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD';
num = num.toFixed(digits);
if (quotaDisplayType === 'CNY') {
return '¥' + num;
return '' + num;
} else if (quotaDisplayType === 'USD') {
return '$' + num;
return '' + num;
} else if (quotaDisplayType === 'CUSTOM') {
const statusStr = localStorage.getItem('status');
let symbol = '¤';
@@ -1077,7 +1077,7 @@ export function renderQuotaWithAmount(amount) {
: amount;
if (quotaDisplayType === 'CNY') {
return '¥' + formattedAmount;
return '' + formattedAmount;
} else if (quotaDisplayType === 'CUSTOM') {
const statusStr = localStorage.getItem('status');
let symbol = '¤';
@@ -1089,7 +1089,7 @@ export function renderQuotaWithAmount(amount) {
} catch (e) {}
return symbol + formattedAmount;
}
return '$' + formattedAmount;
return '' + formattedAmount;
}
/**
@@ -1100,11 +1100,11 @@ export function getCurrencyConfig() {
const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD';
const statusStr = localStorage.getItem('status');
let symbol = '$';
let symbol = '';
let rate = 1;
if (quotaDisplayType === 'CNY') {
symbol = '¥';
symbol = '';
try {
if (statusStr) {
const s = JSON.parse(statusStr);
@@ -1144,7 +1144,7 @@ export function renderQuota(quota, digits = 2) {
return renderNumber(quota);
}
const resultUSD = quota / quotaPerUnit;
let symbol = '$';
let symbol = '';
let value = resultUSD;
if (quotaDisplayType === 'CNY') {
const statusStr = localStorage.getItem('status');
@@ -1156,7 +1156,7 @@ export function renderQuota(quota, digits = 2) {
}
} catch (e) {}
value = resultUSD * usdRate;
symbol = '¥';
symbol = '';
} else if (quotaDisplayType === 'CUSTOM') {
const statusStr = localStorage.getItem('status');
let symbolCustom = '¤';
@@ -2375,7 +2375,7 @@ export function renderAudioModelPrice(
]);
}
// 1 ratio = $0.002 / 1K tokens
// 1 ratio = 0.002 / 1K tokens
if (modelPrice !== -1) {
return i18next.t(
'模型价格:{{symbol}}{{price}} * {{ratioType}}{{ratio}} = {{symbol}}{{total}}',

View File

@@ -676,9 +676,9 @@ export const calculateModelPrice = ({
};
}
let symbol = '$';
let symbol = '';
if (currency === 'CNY') {
symbol = '¥';
symbol = '';
} else if (currency === 'CUSTOM') {
try {
const statusStr = localStorage.getItem('status');

View File

@@ -620,7 +620,7 @@ export const useChannelsData = () => {
switch (type) {
case 'priority':
if (data.priority === undefined || data.priority === '') {
showInfo('优先级必须是整数!');
showInfo('同组优先级必须是整数!');
return;
}
data.priority = parseInt(data.priority);
@@ -631,7 +631,7 @@ export const useChannelsData = () => {
data.weight < 0 ||
data.weight === ''
) {
showInfo('权重必须是非负整数!');
showInfo('同优先级顺序必须是非负整数!');
return;
}
data.weight = parseInt(data.weight);

View File

@@ -243,9 +243,11 @@ export const useDashboardData = (userState, userDispatch, statusState) => {
}, [userDispatch]);
const refresh = useCallback(async () => {
const data = await loadQuotaData();
await loadUptimeData();
await loadCacheStats();
const [data] = await Promise.all([
loadQuotaData(),
loadUptimeData(),
loadCacheStats(),
]);
return data;
}, [loadQuotaData, loadUptimeData, loadCacheStats]);

View File

@@ -185,7 +185,7 @@ export const useModelPricingData = () => {
}
if (currency === 'CNY') {
return `¥${(priceInUSD * usdExchangeRate).toFixed(3)}`;
return `${(priceInUSD * usdExchangeRate).toFixed(3)}`;
} else if (currency === 'CUSTOM') {
return `${customCurrencySymbol}${(priceInUSD * customExchangeRate).toFixed(3)}`;
}

View File

@@ -30,7 +30,7 @@
"• 需要特定的请求头或认证": "• Specific headers or authentication are required",
"© {{currentYear}}": "© {{currentYear}}",
"| 基于": " | Based on ",
"$/1M tokens": "$/1M tokens",
"$/1M tokens": "/1M tokens",
"0 - 最低": "0 - Lowest",
"0 表示不限": "0 means unlimited",
"0.002-1之间的小数": "Decimal between 0.002-1",
@@ -435,7 +435,7 @@
"例如0001": "e.g.: 0001",
"例如1000": "e.g.: 1000",
"例如100000": "e.g.: 100000",
"例如2就是最低充值2$": "e.g.: 2, means minimum top-up is $2",
"例如2就是最低充值2$": "e.g.: 2, means minimum top-up is 2",
"例如2000": "e.g.: 2000",
"例如4.99": "e.g.: 4.99",
"例如401, 403, 429, 500-599": "e.g. 401,403,429,500-599",
@@ -745,7 +745,7 @@
"剩余时间": "Remaining Time",
"剩余额度": "Remaining quota",
"剩余额度/总额度": "Remaining/Total",
"剩余额度$": "Remaining quota $",
"剩余额度$": "Remaining quota ",
"功能特性": "Features",
"加入渠道": "Join Channel",
"加入预填组": "Join Pre-filled Group",
@@ -2170,7 +2170,7 @@
"确定清除所有失效兑换码?": "Are you sure you want to clear all invalid redemption codes?",
"确定要修改所有子渠道优先级为 ": "Confirm to modify all sub-channel priorities to ",
"确定要修改所有子渠道权重为 ": "Confirm to modify all sub-channel weights to ",
"确定要充值 $": "Confirm to recharge $",
"确定要充值 $": "Confirm to recharge ",
"确定要删除供应商 \"{{name}}\" 吗?此操作不可撤销。": "Are you sure you want to delete supplier \"{{name}}\"? This operation is irreversible.",
"确定要删除所有已自动禁用的密钥吗?": "Are you sure you want to delete all automatically disabled keys?",
"确定要删除所选的 {{count}} 个令牌吗_one": "Are you sure you want to delete the selected {{count}} token?",

View File

@@ -33,7 +33,7 @@
"• 需要特定的请求头或认证": "• Des en-têtes ou une authentification spécifiques sont requis",
"© {{currentYear}}": "© {{currentYear}}",
"| 基于": " | Basé sur ",
"$/1M tokens": "$/1M tokens",
"$/1M tokens": "/1M tokens",
"0 - 最低": "0 - La plus basse",
"0 表示不限": "0 signifie illimité",
"0.002-1之间的小数": "Décimal entre 0,002-1",
@@ -430,7 +430,7 @@
"例如0001": "Par exemple : 0001",
"例如1000": "Par exemple : 1000",
"例如100000": "Ex. : 100000",
"例如2就是最低充值2$": "Par exemple : 2, c'est-à-dire un minimum de 2$ de recharge",
"例如2就是最低充值2$": "Par exemple : 2, c'est-à-dire un minimum de 2 de recharge",
"例如2000": "Par exemple : 2000",
"例如4.99": "Ex. : 4.99",
"例如401, 403, 429, 500-599": "ex. : 401, 403, 429, 500-599",
@@ -729,7 +729,7 @@
"剩余时间": "Remaining Time",
"剩余额度": "Quota restant",
"剩余额度/总额度": "Restant/Total",
"剩余额度$": "Quota restant $",
"剩余额度$": "Quota restant ",
"功能特性": "Fonctionnalités",
"加入渠道": "Join Channel",
"加入预填组": "Rejoindre un groupe pré-rempli",
@@ -2135,7 +2135,7 @@
"确定清除所有失效兑换码?": "Êtes-vous sûr de vouloir effacer tous les codes d'échange non valides ?",
"确定要修改所有子渠道优先级为 ": "Confirmer la modification de toutes les priorités des sous-canaux en ",
"确定要修改所有子渠道权重为 ": "Confirmer la modification de tous les poids des sous-canaux en ",
"确定要充值 $": "Confirmer la recharge de $",
"确定要充值 $": "Confirmer la recharge de ",
"确定要删除供应商 \"{{name}}\" 吗?此操作不可撤销。": "Êtes-vous sûr de vouloir supprimer le fournisseur \"{{name}}\" ? Cette opération est irréversible.",
"确定要删除所有已自动禁用的密钥吗?": "Êtes-vous sûr de vouloir supprimer toutes les clés désactivées automatiquement ?",
"确定要删除所选的 {{count}} 个令牌吗_one": "Êtes-vous sûr de vouloir supprimer le jeton sélectionné ?",

View File

@@ -29,7 +29,7 @@
"• 需要特定的请求头或认证": "• Specific headers or authentication are required",
"© {{currentYear}}": "© {{currentYear}}",
"| 基于": "| ベース: ",
"$/1M tokens": "$/1M tokens",
"$/1M tokens": "/1M tokens",
"0 - 最低": "0 - 最低",
"0 表示不限": "0 は無制限を意味します",
"0.002-1之间的小数": "0.0021の小数",
@@ -426,7 +426,7 @@
"例如0001": "例0001",
"例如1000": "例1000",
"例如100000": "e.g.: 100000",
"例如2就是最低充值2$": "例2最低チャージ額$2",
"例如2就是最低充值2$": "例2最低チャージ額2",
"例如2000": "例2000",
"例如4.99": "e.g.: 4.99",
"例如401, 403, 429, 500-599": "例401, 403, 429, 500-599",
@@ -720,7 +720,7 @@
"剩余时间": "Remaining Time",
"剩余额度": "残りクォータ",
"剩余额度/总额度": "残りクォータ/総クォータ",
"剩余额度$": "残高 ($)",
"剩余额度$": "残高 ()",
"功能特性": "機能",
"加入渠道": "Join Channel",
"加入预填组": "事前入力グループへの参加",
@@ -2118,7 +2118,7 @@
"确定清除所有失效兑换码?": "すべての無効な引き換えコードを削除してもよろしいですか?",
"确定要修改所有子渠道优先级为 ": "すべてのサブチャネルの優先度を",
"确定要修改所有子渠道权重为 ": "すべてのサブチャネルのウェイトを",
"确定要充值 $": "Confirm to recharge $",
"确定要充值 $": "Confirm to recharge ",
"确定要删除供应商 \"{{name}}\" 吗?此操作不可撤销。": "プロバイダー「{{name}}」を削除してもよろしいですか?この操作は元に戻すことができません。",
"确定要删除所有已自动禁用的密钥吗?": "自動的に無効になったすべてのAPIキーを削除してもよろしいですか",
"确定要删除所选的 {{count}} 个令牌吗_one": "選択した{{count}}個のトークンを削除してもよろしいですか_one",

View File

@@ -36,7 +36,7 @@
"• 需要特定的请求头或认证": "• Требуются специальные заголовки или авторизация",
"© {{currentYear}}": "© {{currentYear}}",
"| 基于": "| Основано на",
"$/1M tokens": "$/1M токенов",
"$/1M tokens": "/1M токенов",
"0 - 最低": "0 - Минимум",
"0 表示不限": "0 означает без лимита",
"0.002-1之间的小数": "Десятичное число между 0.002-1",
@@ -433,7 +433,7 @@
"例如0001": "например: 0001",
"例如1000": "например: 1000",
"例如100000": "Например: 100000",
"例如2就是最低充值2$": "например: 2, это минимальное пополнение 2$",
"例如2就是最低充值2$": "например: 2, это минимальное пополнение 2",
"例如2000": "например: 2000",
"例如4.99": "Например: 4.99",
"例如401, 403, 429, 500-599": "напр.: 401, 403, 429, 500-599",
@@ -735,7 +735,7 @@
"剩余时间": "Remaining Time",
"剩余额度": "Оставшаяся квота",
"剩余额度/总额度": "Оставшаяся квота/Общая квота",
"剩余额度$": "Оставшаяся квота$",
"剩余额度$": "Оставшаяся квота",
"功能特性": "Функциональные возможности",
"加入渠道": "Join Channel",
"加入预填组": "Присоединиться к группе предварительного заполнения",
@@ -2147,7 +2147,7 @@
"确定清除所有失效兑换码?": "Подтвердить очистку всех недействительных кодов купонов?",
"确定要修改所有子渠道优先级为 ": "Подтвердить изменение приоритета всех дочерних каналов на ",
"确定要修改所有子渠道权重为 ": "Подтвердить изменение веса всех дочерних каналов на ",
"确定要充值 $": "Подтвердить пополнение на $",
"确定要充值 $": "Подтвердить пополнение на ",
"确定要删除供应商 \"{{name}}\" 吗?此操作不可撤销。": "Подтвердить удаление поставщика \"{{name}}\"? Это действие нельзя отменить.",
"确定要删除所有已自动禁用的密钥吗?": "Подтвердить удаление всех автоматически отключенных ключей?",
"确定要删除所选的 {{count}} 个令牌吗_one": "Подтвердить удаление выбранного {{count}} токена?",

View File

@@ -29,7 +29,7 @@
"• 需要特定的请求头或认证": "• Specific headers or authentication are required",
"© {{currentYear}}": "© {{currentYear}}",
"| 基于": " | Dựa trên ",
"$/1M tokens": "$/1M tokens",
"$/1M tokens": "/1M tokens",
"0 - 最低": "0 - Thấp nhất",
"0 表示不限": "0 nghĩa là không giới hạn",
"0.002-1之间的小数": "Số thập phân giữa 0.002-1",
@@ -427,7 +427,7 @@
"例如0001": "ví dụ: 0001",
"例如1000": "ví dụ: 1000",
"例如100000": "Ví dụ: 100000",
"例如2就是最低充值2$": "ví dụ: 2, nghĩa là nạp tối thiểu $2",
"例如2就是最低充值2$": "ví dụ: 2, nghĩa là nạp tối thiểu 2",
"例如2000": "ví dụ: 2000",
"例如4.99": "Ví dụ: 4.99",
"例如401, 403, 429, 500-599": "VD: 401, 403, 429, 500-599",
@@ -721,7 +721,7 @@
"剩余时间": "Remaining Time",
"剩余额度": "Hạn ngạch còn lại",
"剩余额度/总额度": "Còn lại/Tổng cộng",
"剩余额度$": "Hạn ngạch còn lại $",
"剩余额度$": "Hạn ngạch còn lại ",
"功能特性": "Tính năng",
"加入渠道": "Join Channel",
"加入预填组": "Tham gia nhóm điền sẵn",
@@ -2347,7 +2347,7 @@
"确定清除所有失效兑换码?": "Bạn có chắc chắn muốn xóa tất cả các mã đổi thưởng không hợp lệ không?",
"确定要修改所有子渠道优先级为 ": "Xác nhận sửa đổi tất cả các ưu tiên kênh con thành ",
"确定要修改所有子渠道权重为 ": "Xác nhận sửa đổi tất cả các trọng số kênh con thành ",
"确定要充值 $": "Confirm to recharge $",
"确定要充值 $": "Confirm to recharge ",
"确定要删除供应商 \"{{name}}\" 吗?此操作不可撤销。": "Bạn có chắc chắn muốn xóa nhà cung cấp \"{{name}}\" không? Thao tác này không thể hoàn tác.",
"确定要删除吗?": "Bạn có chắc chắn muốn xóa không?",
"确定要删除所有已自动禁用的密钥吗?": "Bạn có chắc chắn muốn xóa tất cả các khóa bị vô hiệu hóa tự động không?",

View File

@@ -24,7 +24,7 @@
"• 需要特定的请求头或认证": "• 需要特定的请求头或认证",
"© {{currentYear}}": "© {{currentYear}}",
"| 基于": "| 基于",
"$/1M tokens": "$/1M tokens",
"$/1M tokens": "/1M tokens",
"0 - 最低": "0 - 最低",
"0.002-1之间的小数": "0.002-1之间的小数",
"0.1以上的小数": "0.1以上的小数",
@@ -350,7 +350,7 @@
"例如0001": "例如0001",
"例如1000": "例如1000",
"例如100000": "例如100000",
"例如2就是最低充值2$": "例如2就是最低充值2$",
"例如2就是最低充值2$": "例如2就是最低充值2",
"例如2000": "例如2000",
"例如4.99": "例如4.99",
"例如7就是7元/美金": "例如7就是7元/美金",
@@ -594,7 +594,8 @@
"剩余时间": "剩余时间",
"剩余额度": "剩余额度",
"剩余额度/总额度": "剩余额度/总额度",
"剩余额度$": "剩余额度$",
"剩余额度$": "剩余额度",
"剩余额度¥": "剩余额度¥",
"功能特性": "功能特性",
"加入渠道": "加入渠道",
"加入预填组": "加入预填组",
@@ -1749,7 +1750,8 @@
"确定清除所有失效兑换码?": "确定清除所有失效兑换码?",
"确定要修改所有子渠道优先级为 ": "确定要修改所有子渠道优先级为 ",
"确定要修改所有子渠道权重为 ": "确定要修改所有子渠道权重为 ",
"确定要充值 $": "确定要充值 $",
"确定要充值 $": "确定要充值 ",
"确定要充值 ¥": "确定要充值 ¥",
"确定要删除供应商 \"{{name}}\" 吗?此操作不可撤销。": "确定要删除供应商 \"{{name}}\" 吗?此操作不可撤销。",
"确定要删除所有已自动禁用的密钥吗?": "确定要删除所有已自动禁用的密钥吗?",
"确定要删除所选的 {{count}} 个令牌吗_other": "确定要删除所选的 {{count}} 个令牌吗?",

View File

@@ -24,7 +24,7 @@
"• 需要特定的请求头或认证": "• 需要特定的請求頭或認證",
"© {{currentYear}}": "© {{currentYear}}",
"| 基于": "| 基於",
"$/1M tokens": "$/1M tokens",
"$/1M tokens": "/1M tokens",
"0 - 最低": "0 - 最低",
"0.002-1之间的小数": "0.002-1之間的小數",
"0.1以上的小数": "0.1以上的小數",
@@ -339,7 +339,7 @@
"例如0001": "例如0001",
"例如1000": "例如1000",
"例如100000": "例如100000",
"例如2就是最低充值2$": "例如2就是最低儲值2$",
"例如2就是最低充值2$": "例如2就是最低儲值2",
"例如2000": "例如2000",
"例如4.99": "例如4.99",
"例如7就是7元/美金": "例如7就是7元/美金",
@@ -584,7 +584,8 @@
"剩余时间": "剩餘時間",
"剩余额度": "剩餘額度",
"剩余额度/总额度": "剩餘額度/總額度",
"剩余额度$": "剩餘額度$",
"剩余额度$": "剩餘額度",
"剩余额度¥": "剩餘額度¥",
"功能特性": "功能特性",
"加入渠道": "加入管道",
"加入预填组": "加入預填組",
@@ -1743,7 +1744,8 @@
"确定清除所有失效兑换码?": "確定清除所有失效兌換碼?",
"确定要修改所有子渠道优先级为 ": "確定要修改所有子管道優先級為 ",
"确定要修改所有子渠道权重为 ": "確定要修改所有子管道權重為 ",
"确定要充值 $": "確定要儲值 $",
"确定要充值 $": "確定要儲值 ",
"确定要充值 ¥": "確定要儲值 ¥",
"确定要删除供应商 \"{{name}}\" 吗?此操作不可撤销。": "確定要刪除供應商 \"{{name}}\" 嗎?此操作不可撤銷。",
"确定要删除所有已自动禁用的密钥吗?": "確定要刪除所有已自動禁用的密鑰嗎?",
"确定要删除所选的 {{count}} 个令牌吗_other": "確定要刪除所選的 {{count}} 個令牌嗎?",

View File

@@ -125,6 +125,15 @@ const QUALITY_OPTIONS = [
{ value: 'high', label: 'High', description: '更高质量,通常更慢' },
];
const DEFAULT_STUDIO_SETTINGS = {
enabled: true,
default_model: 'gpt-image-2',
allow_open_original: true,
retention_minutes: 30,
allowed_models: [],
model_settings: [],
};
const sortModels = (models = []) => {
return [...models].sort((a, b) => {
if (a.model_name === 'gpt-image-2') return -1;
@@ -159,6 +168,8 @@ const getSizeMeta = (size) => {
return SIZE_OPTIONS.find((item) => item.value === size) || SIZE_OPTIONS[0];
};
const getSizeRatio = (size) => getSizeMeta(size)?.ratioLabel || 'Auto';
const getImageMimeType = (item) => {
const format = String(item?.output_format || '').trim().toLowerCase();
if (format === 'jpeg' || format === 'jpg') {
@@ -208,6 +219,8 @@ const normalizeImageResults = (payload) => {
.map((item, index) => ({
id: `${Date.now()}-${index}`,
src: buildImageSrc(item, urls),
rawUrl: item?.url || '',
taskResultUrl: item?.task_result_url || '',
revisedPrompt: item?.revised_prompt || '',
downloadName: `gpt-image-${index + 1}.${getImageExtension(item)}`,
}))
@@ -236,12 +249,14 @@ const ImageStudio = () => {
const [submitting, setSubmitting] = useState(false);
const [activeTab, setActiveTab] = useState('generate');
const [tokens, setTokens] = useState([]);
const [pricingModels, setPricingModels] = useState([]);
const [imageStudioModels, setImageStudioModels] = useState([]);
const [studioSettings, setStudioSettings] = useState(DEFAULT_STUDIO_SETTINGS);
const [selectedTokenId, setSelectedTokenId] = useState('');
const [selectedModel, setSelectedModel] = useState('');
const [prompt, setPrompt] = useState('');
const [selectedSize, setSelectedSize] = useState('auto');
const [selectedQuality, setSelectedQuality] = useState('auto');
const [selectedRatio, setSelectedRatio] = useState('Auto');
const [referenceImages, setReferenceImages] = useState([]);
const [results, setResults] = useState([]);
const [resultMeta, setResultMeta] = useState(null);
@@ -269,9 +284,9 @@ const ImageStudio = () => {
const loadData = async () => {
setLoading(true);
try {
const [tokensRes, pricingRes] = await Promise.all([
const [tokensRes, settingsRes] = await Promise.all([
API.get('/api/token/?p=1&size=1000'),
API.get('/api/pricing'),
API.get('/api/image-studio/settings'),
]);
const tokenPayload = tokensRes?.data?.data;
@@ -283,10 +298,21 @@ const ImageStudio = () => {
);
setTokens(imageTokens);
const pricingPayload = Array.isArray(pricingRes?.data?.data)
? pricingRes.data.data
const nextSettings = settingsRes?.data?.data || DEFAULT_STUDIO_SETTINGS;
const nextModels = Array.isArray(nextSettings?.models)
? nextSettings.models
: [];
setPricingModels(pricingPayload);
setImageStudioModels(nextModels);
setStudioSettings({
...DEFAULT_STUDIO_SETTINGS,
...nextSettings,
allowed_models: Array.isArray(nextSettings?.allowed_models)
? nextSettings.allowed_models
: [],
model_settings: Array.isArray(nextSettings?.model_settings)
? nextSettings.model_settings
: [],
});
} catch (error) {
showError(t('加载图片制作配置失败,请稍后重试'));
} finally {
@@ -298,12 +324,14 @@ const ImageStudio = () => {
}, [t]);
const imageGroupModels = useMemo(() => {
return pricingModels.filter(
return imageStudioModels.filter(
(model) =>
Array.isArray(model?.enable_groups) &&
model.enable_groups.includes(IMAGE_GROUP),
model.enable_groups.includes(IMAGE_GROUP) &&
(studioSettings.allowed_models.length === 0 ||
studioSettings.allowed_models.includes(model.model_name)),
);
}, [pricingModels]);
}, [imageStudioModels, studioSettings.allowed_models]);
const endpointType = activeTab === 'edit' ? 'image-edit' : 'image-generation';
@@ -329,6 +357,52 @@ const ImageStudio = () => {
return sortModels(matched);
}, [endpointModels, selectedToken]);
const selectedModelSetting = useMemo(() => {
return (
studioSettings.model_settings.find((item) => item?.model === selectedModel) ||
null
);
}, [selectedModel, studioSettings.model_settings]);
const ratioOptions = useMemo(() => {
const sizes =
Array.isArray(selectedModelSetting?.allowed_sizes) &&
selectedModelSetting.allowed_sizes.length > 0
? selectedModelSetting.allowed_sizes
: SIZE_OPTIONS.map((item) => item.value);
const seen = new Set();
return sizes
.map((size) => getSizeRatio(size))
.filter((ratio) => {
if (seen.has(ratio)) return false;
seen.add(ratio);
return true;
})
.map((ratio) => ({ label: ratio, value: ratio }));
}, [selectedModelSetting]);
const sizeRatioOptions = useMemo(() => {
return ratioOptions.map((item) => ({
label:
item.value === 'Auto'
? 'Auto'
: `${item.value} · ${t('按该比例筛选可选分辨率')}`,
value: item.value,
}));
}, [ratioOptions, t]);
const resolutionCandidates = useMemo(() => {
const allowed =
Array.isArray(selectedModelSetting?.allowed_sizes) &&
selectedModelSetting.allowed_sizes.length > 0
? selectedModelSetting.allowed_sizes
: SIZE_OPTIONS.map((item) => item.value);
return allowed.filter((size) => {
if (selectedRatio === 'Auto') return size === 'auto';
return getSizeRatio(size) === selectedRatio;
});
}, [selectedModelSetting, selectedRatio]);
useEffect(() => {
if (!tokens.length) {
if (selectedTokenId) {
@@ -355,11 +429,12 @@ const ImageStudio = () => {
}
const preferredModel =
availableModels.find((model) => model.model_name === studioSettings.default_model) ||
availableModels.find((model) => model.model_name === 'gpt-image-2') ||
availableModels[0];
setSelectedModel(preferredModel?.model_name || '');
}, [availableModels, selectedModel]);
}, [availableModels, selectedModel, studioSettings.default_model]);
const tokenOptions = useMemo(() => {
return tokens.map((token) => ({
@@ -375,35 +450,36 @@ const ImageStudio = () => {
}));
}, [availableModels]);
const sizeRatioOptions = useMemo(() => {
return SIZE_OPTIONS.map((item) => ({
label:
item.value === 'auto'
? 'Auto'
: `${item.ratioLabel} · ${item.usage}`,
value: item.value,
}));
}, []);
const sizeResolutionOptions = useMemo(() => {
return SIZE_OPTIONS.map((item) => ({
label:
item.value === 'auto'
? 'Auto默认'
: `${item.resolutionLabel} · 官方预设`,
value: item.value,
}));
}, []);
return resolutionCandidates.map((value) => {
const item = getSizeMeta(value);
return {
label:
item.value === 'auto'
? 'Auto默认'
: `${item.resolutionLabel} · 官方预设`,
value: item.value,
};
});
}, [resolutionCandidates]);
const qualityOptions = useMemo(() => {
return QUALITY_OPTIONS.map((item) => ({
const allowed =
Array.isArray(selectedModelSetting?.allowed_qualities) &&
selectedModelSetting.allowed_qualities.length > 0
? selectedModelSetting.allowed_qualities
: QUALITY_OPTIONS.map((item) => item.value);
return QUALITY_OPTIONS.filter((item) => allowed.includes(item.value)).map((item) => ({
label: `${item.label} · ${item.description}`,
value: item.value,
}));
}, []);
}, [selectedModelSetting]);
const sizeMeta = getSizeMeta(selectedSize);
const tokenModelLimits = parseTokenModelLimits(selectedToken);
const allowRatioSelection = selectedModelSetting?.allow_ratio !== false;
const allowResolutionSelection = selectedModelSetting?.allow_resolution !== false;
const allowQualitySelection = selectedModelSetting?.allow_quality !== false;
const noImageTokens = !loading && tokens.length === 0;
const noImageModels = !loading && imageGroupModels.length === 0;
@@ -414,8 +490,57 @@ const ImageStudio = () => {
!!selectedToken &&
availableModels.length === 0;
useEffect(() => {
if (selectedModelSetting?.default_size) {
setSelectedSize(selectedModelSetting.default_size);
setSelectedRatio(getSizeRatio(selectedModelSetting.default_size));
return;
}
if (!selectedSize) {
setSelectedSize('auto');
setSelectedRatio('Auto');
}
}, [selectedModel, selectedModelSetting]);
useEffect(() => {
if (!allowRatioSelection) {
const nextRatio =
getSizeRatio(selectedModelSetting?.default_size || resolutionCandidates[0] || 'auto') ||
'Auto';
if (selectedRatio !== nextRatio) {
setSelectedRatio(nextRatio);
}
}
}, [allowRatioSelection, resolutionCandidates, selectedModelSetting, selectedRatio]);
useEffect(() => {
if (!resolutionCandidates.length) {
setSelectedSize('auto');
return;
}
if (!resolutionCandidates.includes(selectedSize)) {
setSelectedSize(resolutionCandidates[0]);
}
}, [resolutionCandidates, selectedSize]);
useEffect(() => {
const allowedValues = qualityOptions.map((item) => item.value);
if (!allowedValues.length) {
setSelectedQuality('auto');
return;
}
if (!allowedValues.includes(selectedQuality)) {
setSelectedQuality(
selectedModelSetting?.default_quality && allowedValues.includes(selectedModelSetting.default_quality)
? selectedModelSetting.default_quality
: allowedValues[0],
);
}
}, [qualityOptions, selectedModelSetting, selectedQuality]);
const canSubmit =
!submitting &&
studioSettings.enabled &&
!!selectedTokenId &&
!!selectedModel &&
prompt.trim().length > 0 &&
@@ -602,6 +727,10 @@ const ImageStudio = () => {
};
const handleSubmit = async () => {
if (!studioSettings.enabled) {
showError(t('图片制作功能暂未开放'));
return;
}
if (!selectedTokenId) {
showError(t('请先选择 image 分组令牌'));
return;
@@ -704,7 +833,11 @@ const ImageStudio = () => {
</div>
<Space wrap>
<Tag color='blue'>{t('模型优先默认 gpt-image-2')}</Tag>
<Tag color='amber'>{t('结果为临时图片,约 30 分钟后自动删除')}</Tag>
<Tag color='amber'>
{t('结果为临时图片,约 {{minutes}} 分钟后自动删除', {
minutes: studioSettings.retention_minutes || 30,
})}
</Tag>
</Space>
</div>
</Card>
@@ -1028,12 +1161,17 @@ const ImageStudio = () => {
<div className='space-y-2'>
<Text strong>{t('比例')}</Text>
<Select
value={selectedSize}
value={selectedRatio}
optionList={sizeRatioOptions}
onChange={(value) => setSelectedSize(String(value || 'auto'))}
disabled={submitting}
onChange={(value) => setSelectedRatio(String(value || 'Auto'))}
disabled={submitting || !allowRatioSelection}
style={{ width: '100%' }}
/>
{!allowRatioSelection && (
<Text type='tertiary' size='small'>
{t('当前模型已固定比例,不允许在图片制作中手动切换。')}
</Text>
)}
</div>
<div className='space-y-2'>
@@ -1042,9 +1180,14 @@ const ImageStudio = () => {
value={selectedSize}
optionList={sizeResolutionOptions}
onChange={(value) => setSelectedSize(String(value || 'auto'))}
disabled={submitting}
disabled={submitting || !allowResolutionSelection}
style={{ width: '100%' }}
/>
{!allowResolutionSelection && (
<Text type='tertiary' size='small'>
{t('当前模型已固定分辨率,不允许在图片制作中手动切换。')}
</Text>
)}
</div>
</div>
@@ -1055,9 +1198,14 @@ const ImageStudio = () => {
value={selectedQuality}
optionList={qualityOptions}
onChange={(value) => setSelectedQuality(String(value || 'auto'))}
disabled={submitting}
disabled={submitting || !allowQualitySelection}
style={{ width: '100%' }}
/>
{!allowQualitySelection && (
<Text type='tertiary' size='small'>
{t('当前模型已固定质量,不允许在图片制作中手动切换。')}
</Text>
)}
</div>
<div className='space-y-2'>
@@ -1228,16 +1376,18 @@ const ImageStudio = () => {
>
{t('预览图片')}
</Button>
{studioSettings.allow_open_original && (
<a
href={item.taskResultUrl || item.rawUrl || item.src}
target='_blank'
rel='noreferrer'
className='text-sm text-sky-600 hover:text-sky-700'
>
{t('打开原图')}
</a>
)}
<a
href={item.src}
target='_blank'
rel='noreferrer'
className='text-sm text-sky-600 hover:text-sky-700'
>
{t('打开原图')}
</a>
<a
href={item.src}
href={item.taskResultUrl || item.rawUrl || item.src}
download={item.downloadName}
className='text-sm text-emerald-600 hover:text-emerald-700'
>

View File

@@ -0,0 +1,396 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
Button,
Col,
Form,
Row,
Spin,
Tag,
Typography,
} from '@douyinfe/semi-ui';
import { API, compareObjects, showError, showSuccess, showWarning } from '../../../helpers';
import { useTranslation } from 'react-i18next';
const { Text } = Typography;
const DEFAULT_SETTINGS = {
enabled: true,
default_model: 'gpt-image-2',
allow_open_original: true,
retention_minutes: 30,
allowed_models: [],
model_settings: [],
};
const SIZE_OPTIONS = [
'auto',
'1024x1024',
'1536x1024',
'1024x1536',
'2048x2048',
'2048x1152',
'1152x2048',
'3840x2160',
'2160x3840',
];
const QUALITY_OPTIONS = ['auto', 'low', 'medium', 'high'];
const normalizeSettings = (value) => {
const next = { ...DEFAULT_SETTINGS, ...(value || {}) };
next.allowed_models = Array.isArray(next.allowed_models)
? next.allowed_models.filter(Boolean)
: [];
next.model_settings = Array.isArray(next.model_settings)
? next.model_settings.map((item) => ({
model: item?.model || '',
enabled: item?.enabled !== false,
allow_generate: item?.allow_generate !== false,
allow_edit: !!item?.allow_edit,
allow_ratio: item?.allow_ratio !== false,
allow_resolution: item?.allow_resolution !== false,
allow_quality: item?.allow_quality !== false,
allowed_sizes: Array.isArray(item?.allowed_sizes)
? item.allowed_sizes.filter(Boolean)
: ['auto'],
allowed_qualities: Array.isArray(item?.allowed_qualities)
? item.allowed_qualities.filter(Boolean)
: ['auto', 'low', 'medium', 'high'],
default_size: item?.default_size || 'auto',
default_quality: item?.default_quality || 'auto',
}))
: [];
return next;
};
export default function SettingsImageStudio({ options, refresh }) {
const { t } = useTranslation();
const refForm = useRef(null);
const [loading, setLoading] = useState(false);
const [inputs, setInputs] = useState(DEFAULT_SETTINGS);
const [inputsRow, setInputsRow] = useState(DEFAULT_SETTINGS);
const pricingModels = useMemo(() => {
try {
return JSON.parse(options?.ImageStudioAvailableModels || '[]');
} catch {
return [];
}
}, [options?.ImageStudioAvailableModels]);
const modelOptions = useMemo(
() =>
pricingModels.map((modelName) => ({
label: modelName,
value: modelName,
})),
[pricingModels],
);
const saveSettings = async () => {
const updateArray = compareObjects(inputs, inputsRow);
if (!updateArray.length) {
return showWarning(t('你似乎并没有修改什么'));
}
setLoading(true);
try {
await API.put('/api/option/', {
key: 'ImageStudioSettings',
value: JSON.stringify(inputs),
});
await API.put('/api/option/', {
key: 'ImageStudioRetentionMinutes',
value: String(inputs.retention_minutes || 30),
});
showSuccess(t('保存成功'));
refresh();
} catch (error) {
showError(t('保存失败,请重试'));
} finally {
setLoading(false);
}
};
useEffect(() => {
const raw = options?.ImageStudioSettings;
let parsed = DEFAULT_SETTINGS;
if (raw) {
try {
parsed = normalizeSettings(JSON.parse(raw));
} catch {
parsed = DEFAULT_SETTINGS;
}
}
if (options?.ImageStudioRetentionMinutes) {
parsed.retention_minutes = Number(options.ImageStudioRetentionMinutes) || 30;
}
setInputs(parsed);
setInputsRow(structuredClone(parsed));
refForm.current?.setValues(parsed);
}, [options]);
const updateField = (key, value) => {
setInputs((prev) => ({ ...prev, [key]: value }));
};
const updateModelSetting = (modelName, patch) => {
setInputs((prev) => {
const current = [...prev.model_settings];
const index = current.findIndex((item) => item.model === modelName);
if (index >= 0) {
current[index] = { ...current[index], ...patch };
} else {
current.push(
normalizeSettings({
model_settings: [{ model: modelName, ...patch }],
}).model_settings[0],
);
}
return { ...prev, model_settings: current };
});
};
const selectedModelSettings = useMemo(() => {
const result = {};
inputs.model_settings.forEach((item) => {
result[item.model] = item;
});
return result;
}, [inputs.model_settings]);
return (
<Spin spinning={loading}>
<Form values={inputs} getFormApi={(formApi) => (refForm.current = formApi)}>
<Form.Section text={t('图片制作设置')}>
<Row gutter={16}>
<Col xs={24} md={8}>
<Form.Switch
field='enabled'
label={t('启用图片制作')}
checkedText=''
uncheckedText=''
onChange={(value) => updateField('enabled', value)}
/>
</Col>
<Col xs={24} md={8}>
<Form.Switch
field='allow_open_original'
label={t('允许打开原图')}
checkedText=''
uncheckedText=''
onChange={(value) => updateField('allow_open_original', value)}
/>
</Col>
<Col xs={24} md={8}>
<Form.InputNumber
field='retention_minutes'
label={t('图片保留时长(分钟)')}
min={1}
max={240}
onChange={(value) => updateField('retention_minutes', Number(value || 30))}
/>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} md={12}>
<Form.Select
field='default_model'
label={t('默认模型')}
optionList={modelOptions}
onChange={(value) => updateField('default_model', String(value || ''))}
/>
</Col>
<Col xs={24} md={12}>
<Form.Select
field='allowed_models'
label={t('允许使用的模型')}
optionList={modelOptions}
multiple
onChange={(value) => updateField('allowed_models', Array.isArray(value) ? value : [])}
/>
</Col>
</Row>
<div style={{ marginTop: 12 }}>
<Text strong>{t('按模型控制参数')}</Text>
<div style={{ marginTop: 8, display: 'grid', gap: 12 }}>
{inputs.allowed_models.map((modelName) => {
const setting = selectedModelSettings[modelName] || {
model: modelName,
enabled: true,
allow_generate: true,
allow_edit: false,
allow_ratio: true,
allow_resolution: true,
allow_quality: true,
allowed_sizes: ['auto'],
allowed_qualities: ['auto', 'low', 'medium', 'high'],
default_size: 'auto',
default_quality: 'auto',
};
return (
<div
key={modelName}
style={{
border: '1px solid var(--semi-color-border)',
borderRadius: 12,
padding: 12,
}}
>
<div style={{ marginBottom: 12 }}>
<Tag color='blue'>{modelName}</Tag>
</div>
<Row gutter={16}>
<Col xs={24} md={6}>
<Form.Switch
field={`model_settings_${modelName}_enabled`}
label={t('启用该模型')}
checkedText=''
uncheckedText=''
initValue={setting.enabled}
onChange={(value) => updateModelSetting(modelName, { enabled: value })}
/>
</Col>
<Col xs={24} md={6}>
<Form.Switch
field={`model_settings_${modelName}_generate`}
label={t('允许生成')}
checkedText=''
uncheckedText=''
initValue={setting.allow_generate}
onChange={(value) =>
updateModelSetting(modelName, { allow_generate: value })
}
/>
</Col>
<Col xs={24} md={6}>
<Form.Switch
field={`model_settings_${modelName}_edit`}
label={t('允许编辑')}
checkedText=''
uncheckedText=''
initValue={setting.allow_edit}
onChange={(value) => updateModelSetting(modelName, { allow_edit: value })}
/>
</Col>
<Col xs={24} md={6}>
<Form.Switch
field={`model_settings_${modelName}_quality`}
label={t('允许选择质量')}
checkedText=''
uncheckedText=''
initValue={setting.allow_quality}
onChange={(value) =>
updateModelSetting(modelName, { allow_quality: value })
}
/>
</Col>
</Row>
<Row gutter={16} style={{ marginTop: 8 }}>
<Col xs={24} md={6}>
<Form.Switch
field={`model_settings_${modelName}_ratio`}
label={t('允许切换比例')}
checkedText=''
uncheckedText=''
initValue={setting.allow_ratio}
onChange={(value) =>
updateModelSetting(modelName, { allow_ratio: value })
}
/>
</Col>
<Col xs={24} md={6}>
<Form.Switch
field={`model_settings_${modelName}_resolution`}
label={t('允许切换分辨率')}
checkedText=''
uncheckedText=''
initValue={setting.allow_resolution}
onChange={(value) =>
updateModelSetting(modelName, { allow_resolution: value })
}
/>
</Col>
</Row>
<Row gutter={16} style={{ marginTop: 8 }}>
<Col xs={24} md={12}>
<Form.Select
field={`model_settings_${modelName}_sizes`}
label={t('允许分辨率')}
multiple
optionList={SIZE_OPTIONS.map((item) => ({ label: item, value: item }))}
initValue={setting.allowed_sizes}
onChange={(value) =>
updateModelSetting(modelName, {
allowed_sizes: Array.isArray(value) ? value : [],
})
}
/>
</Col>
<Col xs={24} md={12}>
<Form.Select
field={`model_settings_${modelName}_qualities`}
label={t('允许质量')}
multiple
optionList={QUALITY_OPTIONS.map((item) => ({ label: item, value: item }))}
initValue={setting.allowed_qualities}
onChange={(value) =>
updateModelSetting(modelName, {
allowed_qualities: Array.isArray(value) ? value : [],
})
}
/>
</Col>
</Row>
<Row gutter={16} style={{ marginTop: 8 }}>
<Col xs={24} md={12}>
<Form.Select
field={`model_settings_${modelName}_default_size`}
label={t('默认分辨率')}
optionList={SIZE_OPTIONS.map((item) => ({ label: item, value: item }))}
initValue={setting.default_size}
onChange={(value) =>
updateModelSetting(modelName, { default_size: String(value || 'auto') })
}
/>
</Col>
<Col xs={24} md={12}>
<Form.Select
field={`model_settings_${modelName}_default_quality`}
label={t('默认质量')}
optionList={QUALITY_OPTIONS.map((item) => ({ label: item, value: item }))}
initValue={setting.default_quality}
onChange={(value) =>
updateModelSetting(modelName, {
default_quality: String(value || 'auto'),
})
}
/>
</Col>
</Row>
</div>
);
})}
</div>
</div>
<div style={{ marginTop: 16 }}>
<Text type='tertiary' size='small'>
{t('说明:这里只控制图片制作页面可见和可选参数,不影响 image 分组本身的渠道与模型能力配置。')}
</Text>
<div style={{ marginTop: 6 }}>
<Text type='tertiary' size='small'>
{t('建议:先确定允许模型,再按模型限制生成/编辑、比例、分辨率、质量和默认值,避免前后端参数不一致。')}
</Text>
</div>
</div>
<Row style={{ marginTop: 16 }}>
<Button onClick={saveSettings}>{t('保存图片制作设置')}</Button>
</Row>
</Form.Section>
</Form>
</Spin>
);
}

View File

@@ -220,8 +220,8 @@ export default function GeneralSettings(props) {
'general_setting.quota_display_type',
)}
>
<Select.Option value='USD'>USD ($)</Select.Option>
<Select.Option value='CNY'>CNY (¥)</Select.Option>
<Select.Option value='USD'>USD ()</Select.Option>
<Select.Option value='CNY'>CNY ()</Select.Option>
<Select.Option value='TOKENS'>Tokens</Select.Option>
<Select.Option value='CUSTOM'>
{t('自定义货币')}

View File

@@ -269,7 +269,7 @@ export default function SettingsPaymentGateway(props) {
<Form.InputNumber
field='MinTopUp'
label={t('最低充值美元数量')}
placeholder={t('例如2就是最低充值2$')}
placeholder={t('例如2就是最低充值2')}
/>
</Col>
</Row>

View File

@@ -221,7 +221,7 @@ export default function SettingsPaymentGatewayCreem(props) {
dataIndex: 'price',
key: 'price',
render: (price, record) =>
`${record.currency === 'EUR' ? '€' : '$'}${price}`,
`${record.currency === 'EUR' ? '€' : ''}${price}`,
},
{
title: t('充值额度'),

View File

@@ -237,7 +237,7 @@ export default function SettingsPaymentGateway(props) {
<Form.InputNumber
field='StripeMinTopUp'
label={t('最低充值美元数量')}
placeholder={t('例如2就是最低充值2$')}
placeholder={t('例如2就是最低充值2')}
/>
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>

View File

@@ -411,7 +411,7 @@ export default function ModelPricingEditor({
label={t('固定价格')}
value={selectedModel.fixedPrice}
placeholder={t('输入每次调用价格')}
suffix={t('$/次')}
suffix={t('/次')}
onChange={(value) => handleNumericFieldChange('fixedPrice', value)}
extraText={t('适合 MJ / 任务类等按次收费模型。')}
/>
@@ -428,7 +428,7 @@ export default function ModelPricingEditor({
<PriceInput
label={t('输入价格')}
value={selectedModel.inputPrice}
placeholder={t('输入 $/1M tokens')}
placeholder={t('输入 /1M tokens')}
onChange={(value) => handleNumericFieldChange('inputPrice', value)}
/>
{selectedModel.completionRatioLocked ? (
@@ -450,7 +450,7 @@ export default function ModelPricingEditor({
<PriceInput
label={t('补全价格')}
value={selectedModel.completionPrice}
placeholder={t('输入 $/1M tokens')}
placeholder={t('输入 /1M tokens')}
onChange={(value) =>
handleNumericFieldChange('completionPrice', value)
}
@@ -493,7 +493,7 @@ export default function ModelPricingEditor({
<PriceInput
label={t('缓存读取价格')}
value={selectedModel.cachePrice}
placeholder={t('输入 $/1M tokens')}
placeholder={t('输入 /1M tokens')}
onChange={(value) => handleNumericFieldChange('cachePrice', value)}
headerAction={
<Switch
@@ -515,7 +515,7 @@ export default function ModelPricingEditor({
<PriceInput
label={t('缓存创建价格')}
value={selectedModel.createCachePrice}
placeholder={t('输入 $/1M tokens')}
placeholder={t('输入 /1M tokens')}
onChange={(value) =>
handleNumericFieldChange('createCachePrice', value)
}
@@ -562,7 +562,7 @@ export default function ModelPricingEditor({
<PriceInput
label={t('图片输入价格')}
value={selectedModel.imagePrice}
placeholder={t('输入 $/1M tokens')}
placeholder={t('输入 /1M tokens')}
onChange={(value) => handleNumericFieldChange('imagePrice', value)}
headerAction={
<Switch
@@ -584,7 +584,7 @@ export default function ModelPricingEditor({
<PriceInput
label={t('音频输入价格')}
value={selectedModel.audioInputPrice}
placeholder={t('输入 $/1M tokens')}
placeholder={t('输入 /1M tokens')}
onChange={(value) =>
handleNumericFieldChange('audioInputPrice', value)
}
@@ -614,7 +614,7 @@ export default function ModelPricingEditor({
<PriceInput
label={t('音频补全价格')}
value={selectedModel.audioOutputPrice}
placeholder={t('输入 $/1M tokens')}
placeholder={t('输入 /1M tokens')}
onChange={(value) =>
handleNumericFieldChange('audioOutputPrice', value)
}

View File

@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { API, showError, showSuccess } from '../../../../helpers';
export const PAGE_SIZE = 10;
export const PRICE_SUFFIX = '$/1M tokens';
export const PRICE_SUFFIX = '/1M tokens';
const EMPTY_CANDIDATE_MODEL_NAMES = [];
const EMPTY_MODEL = {
@@ -245,7 +245,7 @@ export const getModelWarnings = (model, t) => {
export const buildSummaryText = (model, t) => {
if (model.billingMode === 'per-request' && hasValue(model.fixedPrice)) {
return `${t('按次')} $${model.fixedPrice} / ${t('次')}`;
return `${t('按次')} ${model.fixedPrice} / ${t('次')}`;
}
if (hasValue(model.inputPrice)) {
@@ -259,7 +259,7 @@ export const buildSummaryText = (model, t) => {
].filter(hasValue).length;
const extraLabel =
extraCount > 0 ? `${t('额外价格项')} ${extraCount}` : '';
return `${t('输入')} $${model.inputPrice}${extraLabel}`;
return `${t('输入')} ${model.inputPrice}${extraLabel}`;
}
return t('未设置价格');

4
web/vite.config.js vendored
View File

@@ -29,6 +29,10 @@ export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@douyinfe/semi-ui/dist/css/semi.css': path.resolve(
__dirname,
'./node_modules/@douyinfe/semi-ui/dist/css/semi.css',
),
},
},
plugins: [