You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

627 lines
20 KiB

  1. /*
  2. Copyright (C) 2025 QuantumNous
  3. This program is free software: you can redistribute it and/or modify
  4. it under the terms of the GNU Affero General Public License as
  5. published by the Free Software Foundation, either version 3 of the
  6. License, or (at your option) any later version.
  7. This program is distributed in the hope that it will be useful,
  8. but WITHOUT ANY WARRANTY; without even the implied warranty of
  9. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  10. GNU Affero General Public License for more details.
  11. You should have received a copy of the GNU Affero General Public License
  12. along with this program. If not, see <https://www.gnu.org/licenses/>.
  13. For commercial licensing, please contact support@quantumnous.com
  14. */
  15. import React, { useState, useMemo, useRef } from 'react';
  16. import {
  17. Table,
  18. Button,
  19. Modal,
  20. Form,
  21. Select,
  22. Space,
  23. Tag,
  24. Typography,
  25. Empty,
  26. Popconfirm,
  27. Input,
  28. RadioGroup,
  29. Radio,
  30. TreeSelect,
  31. } from '@douyinfe/semi-ui';
  32. import {
  33. IconEdit,
  34. IconDelete,
  35. IconChevronDown,
  36. IconChevronRight,
  37. IconSearch,
  38. } from '@douyinfe/semi-icons';
  39. import { useTranslation } from 'react-i18next';
  40. import { channelPricingApi } from '../../../helpers/api';
  41. import { showError, showSuccess } from '../../../helpers';
  42. const ChannelPricingView = ({ channels, channelPricings, tags, onRefresh }) => {
  43. const { t } = useTranslation();
  44. const [expandedRowKeys, setExpandedRowKeys] = useState([]);
  45. const [editModalVisible, setEditModalVisible] = useState(false);
  46. const [editingRecord, setEditingRecord] = useState(null);
  47. const [currentQuotaType, setCurrentQuotaType] = useState(0); // 0: 按量计费, 1: 按次计费
  48. const [searchText, setSearchText] = useState('');
  49. const formRef = useRef();
  50. const [pricingSubMode, setPricingSubMode] = useState('ratio'); // 'ratio' | 'token-price'
  51. const [tokenPrices, setTokenPrices] = useState({
  52. inputTokenPrice: '',
  53. outputTokenPrice: ''
  54. });
  55. // Ensure arrays are valid
  56. const safeChannels = Array.isArray(channels) ? channels : [];
  57. const safeChannelPricings = Array.isArray(channelPricings) ? channelPricings : [];
  58. const safeTags = Array.isArray(tags) ? tags : [];
  59. // Helper for colored tag style
  60. const getTagStyle = (color, extra = {}) => ({
  61. backgroundColor: color,
  62. color: '#fff',
  63. borderColor: color,
  64. ...extra
  65. });
  66. // Convert tags to TreeSelect tree structure
  67. const tagTreeData = useMemo(() => {
  68. return safeTags.map((tag) => ({
  69. value: String(tag.id),
  70. label: <Tag style={getTagStyle(tag.color)}>{tag.name}</Tag>,
  71. key: String(tag.id),
  72. color: tag.color,
  73. }));
  74. }, [safeTags]);
  75. // Build model -> channel pricing mapping
  76. const modelData = useMemo(() => {
  77. // Build pricing lookup map
  78. const pricingMap = new Map();
  79. safeChannelPricings.forEach((cp) => {
  80. const key = `${cp.model_name}:${cp.channel_id}`;
  81. pricingMap.set(key, cp);
  82. });
  83. // Get all unique model names from channels' models field
  84. const modelNames = new Set();
  85. const channelModelsMap = new Map(); // channelId -> Set of models
  86. safeChannels.forEach((ch) => {
  87. if (ch.models) {
  88. const models = typeof ch.models === 'string' ? ch.models.split(',') : ch.models;
  89. const modelSet = new Set(models.map((m) => m.trim()).filter(Boolean));
  90. channelModelsMap.set(ch.id, modelSet);
  91. modelSet.forEach((modelName) => modelNames.add(modelName));
  92. }
  93. });
  94. // Build model data with channel pricing info
  95. return Array.from(modelNames).map((modelName) => {
  96. const channelPrices = safeChannels
  97. .filter((ch) => {
  98. const channelModels = channelModelsMap.get(ch.id);
  99. return channelModels && channelModels.has(modelName);
  100. })
  101. .map((ch) => {
  102. const key = `${modelName}:${ch.id}`;
  103. const pricing = pricingMap.get(key);
  104. return {
  105. channelId: ch.id,
  106. channelName: ch.name,
  107. channelType: ch.type,
  108. pricing: pricing || null,
  109. };
  110. });
  111. return {
  112. modelName,
  113. channelPrices,
  114. hasChannelPricing: channelPrices.some((cp) => cp.pricing !== null),
  115. };
  116. });
  117. }, [safeChannels, safeChannelPricings]);
  118. // Filter model data by search text
  119. const filteredModelData = useMemo(() => {
  120. if (!searchText) return modelData;
  121. const lowerSearch = searchText.toLowerCase();
  122. return modelData.filter(item =>
  123. item.modelName.toLowerCase().includes(lowerSearch)
  124. );
  125. }, [modelData, searchText]);
  126. // 价格转倍率计算函数(参考 ModelSettingsVisualEditor.jsx)
  127. const calculateRatioFromTokenPrice = (tokenPrice) => {
  128. return tokenPrice / 2;
  129. };
  130. const calculateCompletionRatioFromPrices = (modelTokenPrice, completionTokenPrice) => {
  131. if (!modelTokenPrice || modelTokenPrice === 0) {
  132. return 0;
  133. }
  134. return completionTokenPrice / modelTokenPrice;
  135. };
  136. // 价格变化处理函数
  137. const handleInputTokenPriceChange = (value) => {
  138. const price = parseFloat(value) || 0;
  139. const ratio = calculateRatioFromTokenPrice(price);
  140. setTokenPrices(prev => ({ ...prev, inputTokenPrice: value }));
  141. if (formRef.current) {
  142. formRef.current.setValue('model_ratio', ratio);
  143. }
  144. };
  145. const handleOutputTokenPriceChange = (value) => {
  146. const outputPrice = parseFloat(value) || 0;
  147. const inputPrice = parseFloat(tokenPrices.inputTokenPrice) || 0;
  148. setTokenPrices(prev => ({ ...prev, outputTokenPrice: value }));
  149. if (inputPrice > 0 && formRef.current) {
  150. const completionRatio = calculateCompletionRatioFromPrices(inputPrice, outputPrice);
  151. formRef.current.setValue('completion_ratio', completionRatio);
  152. }
  153. };
  154. const handleEdit = (modelName, channelId, pricing) => {
  155. const quotaType = pricing?.quota_type ?? 0;
  156. setCurrentQuotaType(quotaType);
  157. // 初始化子模式和价格(根据现有倍率反推)
  158. if (quotaType === 0 && pricing) {
  159. const ratio = pricing.model_ratio || 0;
  160. const completionRatio = pricing.completion_ratio || 0;
  161. const inputTokenPrice = ratio * 2;
  162. const outputTokenPrice = inputTokenPrice * completionRatio;
  163. setTokenPrices({
  164. inputTokenPrice: inputTokenPrice.toString(),
  165. outputTokenPrice: outputTokenPrice.toString()
  166. });
  167. setPricingSubMode('ratio'); // 默认显示倍率模式
  168. } else {
  169. setTokenPrices({ inputTokenPrice: '', outputTokenPrice: '' });
  170. setPricingSubMode('ratio');
  171. }
  172. setEditingRecord({
  173. modelName,
  174. channelId,
  175. pricing: pricing || {
  176. model_name: modelName,
  177. channel_id: channelId,
  178. quota_type: 0,
  179. model_ratio: 0,
  180. completion_ratio: 0,
  181. model_price: 0,
  182. tag_ids: '',
  183. },
  184. });
  185. setEditModalVisible(true);
  186. };
  187. const handleDelete = async (id) => {
  188. try {
  189. const res = await channelPricingApi.delete(id);
  190. if (res.data.success) {
  191. showSuccess(t('删除成功'));
  192. onRefresh();
  193. }
  194. } catch (e) {
  195. showError(e);
  196. }
  197. };
  198. const handleSubmit = async () => {
  199. try {
  200. const values = await formRef.current.validate();
  201. // Convert TreeSelect array ['1', '2'] to comma-separated string '1,2'
  202. let tagIdsStr = '';
  203. if (Array.isArray(values.tag_ids) && values.tag_ids.length > 0) {
  204. tagIdsStr = values.tag_ids.filter(Boolean).join(',');
  205. }
  206. const data = {
  207. model_name: editingRecord.modelName,
  208. channel_id: editingRecord.channelId,
  209. ...values,
  210. tag_ids: tagIdsStr,
  211. };
  212. if (editingRecord.pricing?.id) {
  213. data.id = editingRecord.pricing.id;
  214. }
  215. const res = await channelPricingApi.create(data);
  216. if (res.data.success) {
  217. showSuccess(t('保存成功'));
  218. setEditModalVisible(false);
  219. onRefresh();
  220. }
  221. } catch (e) {
  222. showError(e);
  223. }
  224. };
  225. // Channel pricing sub-table columns
  226. const channelColumns = [
  227. {
  228. title: t('渠道'),
  229. dataIndex: 'channelName',
  230. key: 'channelName',
  231. width: 200,
  232. },
  233. {
  234. title: t('计费类型'),
  235. key: 'quotaType',
  236. width: 100,
  237. render: (_, record) => {
  238. if (!record.pricing) return <Typography.Text type="tertiary">-</Typography.Text>;
  239. return record.pricing.quota_type === 1 ? t('按次') : t('按量');
  240. },
  241. },
  242. {
  243. title: t('模型倍率'),
  244. key: 'modelRatio',
  245. width: 100,
  246. render: (_, record) => {
  247. if (!record.pricing) return '-';
  248. // 按次计费时不显示倍率
  249. if (record.pricing.quota_type === 1) return '-';
  250. return record.pricing.model_ratio;
  251. },
  252. },
  253. {
  254. title: t('补全倍率'),
  255. key: 'completionRatio',
  256. width: 100,
  257. render: (_, record) => {
  258. if (!record.pricing) return '-';
  259. // 按次计费时不显示倍率
  260. if (record.pricing.quota_type === 1) return '-';
  261. return record.pricing.completion_ratio;
  262. },
  263. },
  264. {
  265. title: t('固定价格'),
  266. key: 'modelPrice',
  267. width: 100,
  268. render: (_, record) => {
  269. if (!record.pricing) return '-';
  270. // 按量计费时不显示固定价格
  271. if (record.pricing.quota_type === 0) return '-';
  272. return record.pricing.model_price;
  273. },
  274. },
  275. {
  276. title: t('标签'),
  277. key: 'tags',
  278. width: 150,
  279. render: (_, record) => {
  280. if (!record.pricing?.tags?.length) return '-';
  281. return record.pricing.tags.map((tag) => (
  282. <Tag key={tag.id} size="small" style={getTagStyle(tag.color, { marginRight: 4 })}>
  283. {tag.name}
  284. </Tag>
  285. ));
  286. },
  287. },
  288. {
  289. title: t('操作'),
  290. key: 'action',
  291. width: 120,
  292. render: (_, record) => (
  293. <Space>
  294. <Button
  295. size="small"
  296. icon={<IconEdit />}
  297. onClick={() => handleEdit(record.modelName, record.channelId, record.pricing)}
  298. />
  299. {record.pricing && (
  300. <Popconfirm
  301. title={t('确定删除此渠道定价?')}
  302. onConfirm={() => handleDelete(record.pricing.id)}
  303. >
  304. <Button size="small" icon={<IconDelete />} type="danger" />
  305. </Popconfirm>
  306. )}
  307. </Space>
  308. ),
  309. },
  310. ];
  311. // Main table columns
  312. const mainColumns = [
  313. {
  314. title: t('模型名称'),
  315. dataIndex: 'modelName',
  316. key: 'modelName',
  317. render: (text, record) => (
  318. <div style={{ display: 'flex', alignItems: 'center' }}>
  319. <Typography.Text strong>{text}</Typography.Text>
  320. {record.hasChannelPricing && (
  321. <Tag color="blue" size="small" style={{ marginLeft: 8 }}>
  322. {t('已配置')}
  323. </Tag>
  324. )}
  325. </div>
  326. ),
  327. },
  328. {
  329. title: t('渠道数量'),
  330. key: 'channelCount',
  331. width: 120,
  332. render: (_, record) =>
  333. `${record.channelPrices.filter((cp) => cp.pricing).length} / ${record.channelPrices.length}`,
  334. },
  335. {
  336. title: t('操作'),
  337. key: 'action',
  338. width: 80,
  339. render: (_, record) => {
  340. const isExpanded = expandedRowKeys.includes(record.modelName);
  341. return (
  342. <Button
  343. size="small"
  344. icon={isExpanded ? <IconChevronDown /> : <IconChevronRight />}
  345. onClick={(e) => {
  346. e.stopPropagation();
  347. if (isExpanded) {
  348. setExpandedRowKeys(expandedRowKeys.filter(k => k !== record.modelName));
  349. } else {
  350. setExpandedRowKeys([...expandedRowKeys, record.modelName]);
  351. }
  352. }}
  353. >
  354. {isExpanded ? t('收起') : t('展开')}
  355. </Button>
  356. );
  357. },
  358. },
  359. ];
  360. // Expanded row render
  361. const expandedRowRender = (record) => {
  362. return (
  363. <Table
  364. columns={channelColumns}
  365. dataSource={record.channelPrices.map((cp) => ({
  366. ...cp,
  367. key: `${record.modelName}-${cp.channelId}`,
  368. modelName: record.modelName,
  369. }))}
  370. pagination={false}
  371. size="small"
  372. bordered
  373. />
  374. );
  375. };
  376. if (modelData.length === 0) {
  377. return (
  378. <Empty
  379. description={t('暂无渠道定价数据,请先创建渠道并设置模型')}
  380. />
  381. );
  382. }
  383. // Parse tag_ids for editing (returns flat array for TreeSelect multiple mode)
  384. const getInitialTagIds = (pricing) => {
  385. if (!pricing) return [];
  386. let ids = [];
  387. // 如果有 tags 数组(包含完整标签对象),从中提取 ID
  388. if (pricing.tags && Array.isArray(pricing.tags) && pricing.tags.length > 0) {
  389. ids = pricing.tags.map(tag => String(tag.id));
  390. }
  391. // 否则从 tag_ids 字段解析
  392. else if (pricing.tag_ids) {
  393. if (Array.isArray(pricing.tag_ids)) {
  394. ids = pricing.tag_ids.map(String);
  395. } else if (typeof pricing.tag_ids === 'string' && pricing.tag_ids.length > 0) {
  396. ids = pricing.tag_ids.split(',').map(String);
  397. }
  398. }
  399. // TreeSelect multiple mode needs flat array: ['1', '2']
  400. return ids;
  401. };
  402. return (
  403. <div>
  404. {/* Search Bar */}
  405. <div style={{ marginBottom: 16 }}>
  406. <Input
  407. prefix={<IconSearch />}
  408. placeholder={t('搜索模型名称')}
  409. value={searchText}
  410. onChange={(value) => setSearchText(value)}
  411. style={{ width: 250 }}
  412. showClear
  413. />
  414. {searchText && (
  415. <Typography.Text type="tertiary" style={{ marginLeft: 12 }}>
  416. {t('找到 {{count}} 个模型', { count: filteredModelData.length })}
  417. </Typography.Text>
  418. )}
  419. </div>
  420. <Table
  421. columns={mainColumns}
  422. dataSource={filteredModelData}
  423. pagination={false}
  424. rowKey="modelName"
  425. expandedRowKeys={expandedRowKeys}
  426. onExpandedRowsChange={(keys) => {
  427. console.log('Expanded keys:', keys);
  428. setExpandedRowKeys(keys);
  429. }}
  430. expandedRowRender={expandedRowRender}
  431. expandIcon={false}
  432. />
  433. {/* Edit Modal */}
  434. <Modal
  435. title={t('编辑渠道定价')}
  436. visible={editModalVisible}
  437. onCancel={() => {
  438. setEditModalVisible(false);
  439. setPricingSubMode('ratio');
  440. setTokenPrices({ inputTokenPrice: '', outputTokenPrice: '' });
  441. }}
  442. onOk={handleSubmit}
  443. >
  444. <Form
  445. key={editingRecord?.pricing?.id || 'new'}
  446. getFormApi={(api) => (formRef.current = api)}
  447. initValues={{
  448. quota_type: editingRecord?.pricing?.quota_type ?? 0,
  449. model_ratio: editingRecord?.pricing?.model_ratio ?? 0,
  450. completion_ratio: editingRecord?.pricing?.completion_ratio ?? 0,
  451. model_price: editingRecord?.pricing?.model_price ?? 0,
  452. tag_ids: getInitialTagIds(editingRecord?.pricing),
  453. cache_ratio: editingRecord?.pricing?.cache_ratio ?? 0,
  454. cache_creation_ratio: editingRecord?.pricing?.cache_creation_ratio ?? 0,
  455. image_ratio: editingRecord?.pricing?.image_ratio ?? 0,
  456. audio_ratio: editingRecord?.pricing?.audio_ratio ?? 0,
  457. audio_completion_ratio: editingRecord?.pricing?.audio_completion_ratio ?? 0,
  458. }}
  459. >
  460. <Form.Input
  461. field="model_name"
  462. label={t('模型名称')}
  463. disabled
  464. initValue={editingRecord?.modelName}
  465. />
  466. <Form.Select
  467. field="quota_type"
  468. label={t('计费类型')}
  469. rules={[{ required: true }]}
  470. onChange={(value) => setCurrentQuotaType(value)}
  471. >
  472. <Select.Option value={0}>{t('按量计费')}</Select.Option>
  473. <Select.Option value={1}>{t('按次计费')}</Select.Option>
  474. </Form.Select>
  475. <Typography.Text
  476. size="small"
  477. type="tertiary"
  478. style={{ display: 'block', marginTop: -8, marginBottom: 8 }}
  479. >
  480. {currentQuotaType === 0
  481. ? t('按量计费:根据 token 数量计费,使用模型倍率和补全倍率计算')
  482. : t('按次计费:每次请求使用固定价格计费')}
  483. </Typography.Text>
  484. {currentQuotaType === 0 && (
  485. <>
  486. <Form.Section text={t('价格设置方式')}>
  487. <div style={{ marginBottom: '16px' }}>
  488. <RadioGroup
  489. type="button"
  490. value={pricingSubMode}
  491. onChange={(e) => {
  492. const newMode = e.target.value;
  493. // 切换到倍率模式时,从价格计算倍率
  494. if (newMode === 'ratio' && formRef.current) {
  495. const inputPrice = parseFloat(tokenPrices.inputTokenPrice) || 0;
  496. const outputPrice = parseFloat(tokenPrices.outputTokenPrice) || 0;
  497. if (inputPrice > 0) {
  498. formRef.current.setValue('model_ratio', calculateRatioFromTokenPrice(inputPrice));
  499. formRef.current.setValue('completion_ratio', calculateCompletionRatioFromPrices(inputPrice, outputPrice));
  500. }
  501. }
  502. setPricingSubMode(newMode);
  503. }}
  504. >
  505. <Radio value="ratio">{t('按倍率设置')}</Radio>
  506. <Radio value="token-price">{t('按价格设置')}</Radio>
  507. </RadioGroup>
  508. </div>
  509. </Form.Section>
  510. <div style={{ display: pricingSubMode === 'ratio' ? 'block' : 'none' }}>
  511. <Form.InputNumber
  512. field="model_ratio"
  513. label={t('模型倍率')}
  514. min={0}
  515. step={0.1}
  516. />
  517. <Form.InputNumber
  518. field="completion_ratio"
  519. label={t('补全倍率')}
  520. min={0}
  521. step={0.1}
  522. />
  523. </div>
  524. <div style={{ display: pricingSubMode === 'token-price' ? 'block' : 'none' }}>
  525. <Form.Input
  526. field="input_token_price"
  527. label={t('输入价格')}
  528. suffix={t('$/1M tokens')}
  529. placeholder="0"
  530. onChange={handleInputTokenPriceChange}
  531. initValue={tokenPrices.inputTokenPrice}
  532. />
  533. <Form.Input
  534. field="output_token_price"
  535. label={t('输出价格')}
  536. suffix={t('$/1M tokens')}
  537. placeholder="0"
  538. onChange={handleOutputTokenPriceChange}
  539. initValue={tokenPrices.outputTokenPrice}
  540. />
  541. </div>
  542. <Form.Section text={t('高级比例(留空使用全局默认值)')}>
  543. <Form.InputNumber field="cache_ratio" label={t('缓存读取倍率')} min={0} step={0.01} placeholder={t('全局默认值')} />
  544. <Form.InputNumber field="cache_creation_ratio" label={t('缓存创建倍率(5分钟)')} min={0} step={0.01} placeholder={t('全局默认值(1小时自动按 1.6x 计算)')} />
  545. <Form.InputNumber field="image_ratio" label={t('图片倍率')} min={0} step={0.01} placeholder={t('全局默认值')} />
  546. <Form.InputNumber field="audio_ratio" label={t('音频输入倍率')} min={0} step={0.01} placeholder={t('全局默认值')} />
  547. <Form.InputNumber field="audio_completion_ratio" label={t('音频输出倍率')} min={0} step={0.01} placeholder={t('全局默认值')} />
  548. </Form.Section>
  549. </>
  550. )}
  551. {currentQuotaType === 1 && (
  552. <Form.InputNumber
  553. field="model_price"
  554. label={t('固定价格')}
  555. min={0}
  556. step={0.01}
  557. />
  558. )}
  559. <Form.TreeSelect
  560. field="tag_ids"
  561. label={t('标签')}
  562. multiple
  563. filterTreeNode
  564. style={{ width: '100%' }}
  565. placeholder={t('请选择标签')}
  566. treeData={tagTreeData}
  567. leafOnly
  568. renderSelectedItem={(node, { onClose }) => ({
  569. isRenderInTag: false,
  570. content: (
  571. <Tag closable onClose={onClose} style={getTagStyle(node.color, { marginRight: 4 })}>
  572. {node.label}
  573. </Tag>
  574. )
  575. })}
  576. />
  577. </Form>
  578. </Modal>
  579. </div>
  580. );
  581. };
  582. export default ChannelPricingView;