ModelPricingDetail.tsx 44 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616
  1. import React, { useEffect, useMemo, useState } from 'react';
  2. import { useNavigate, useParams } from 'react-router-dom';
  3. import { getPricingMappingLookup, modelApi, ParsedPricingResponse } from '../services/modelApi';
  4. import { ArrowLeft, Loader2, Image, Video, Mic, FileText, CheckCircle2, XCircle, Copy } from '../icons/commonIcons';
  5. import { copyToClipboard } from '../utils/clipboard';
  6. const pricingKeyLabels: Record<string, string> = {
  7. input: '输入', output: '输出', input_thinking: '输入(思考)', output_thinking: '输出(思考)',
  8. input_cache_hit: '输入(缓存命中)', input_cache_hit_thinking: '输入(缓存命中·思考)',
  9. input_batch: '输入(批量)', input_batch_thinking: '输入(批量·思考)',
  10. output_batch: '输出(批量)', output_thinking_batch: '输出(批量·思考)',
  11. cache_write: '缓存写入', cache_read: '缓存读取',
  12. explicit_cache_create: '显式缓存写入', explicit_cache_hit: '显式缓存命中',
  13. explicit_cache_create_thinking: '显式缓存写入(思考)', explicit_cache_hit_thinking: '显式缓存命中(思考)',
  14. image_input: '图片输入', text_input: '文本输入'
  15. };
  16. const limitKeyLabels: Record<string, string> = {
  17. max_input_length: '最大输入长度', max_output_length: '最大输出长度',
  18. rpm: '每分钟请求数 (RPM)', tpm: 'TPM', context_length: '上下文长度', note: '备注'
  19. };
  20. const labelTranslations: Record<string, string> = {
  21. image_generation: '图像生成', video_generation: '视频生成', audio_generation: '音频生成',
  22. image_input: '图片输入', text_input: '文本输入', model_experience: '模型体验',
  23. function_calling: 'function calling', structured_output: '结构化输出',
  24. internet_search: '网页搜索', web_search: '网页搜索', prefix_continuation: '前缀续写',
  25. cache: 'cache缓存', batch_inference: '批量推理', model_tuning: '模型微调',
  26. model_optimization: '模型优化', text: '文本', image: '图片', video: '视频', audio: '音频',
  27. input: '输入', output: '输出', tuning: '微调'
  28. };
  29. const formatValue = (value: any, unit?: string): string => {
  30. if (value === undefined || value === null || value === '') return '—';
  31. return `${value}${unit ? ` ${unit}` : ''}`;
  32. };
  33. // 渲染价格单元格(支持原价划线 + 折扣价 + 折扣标签)
  34. const renderPriceCell = (value: any, originalValue: any, unit: string, discountRate?: number) => {
  35. const hasDiscount = originalValue !== undefined && originalValue !== null
  36. && Number(originalValue) > 0
  37. && Math.abs(Number(originalValue) - Number(value)) > 0.000001;
  38. // 优先用传入的 discountRate,否则从原价/折扣价推算
  39. const rate = discountRate ?? (hasDiscount && Number(originalValue) > 0
  40. ? Number(value) / Number(originalValue)
  41. : 1);
  42. const tenths = Math.round(rate * 10);
  43. const discountLabel = hasDiscount && tenths < 10 ? `${tenths}折` : undefined;
  44. return (
  45. <span className="flex flex-col items-end gap-0.5">
  46. {hasDiscount && (
  47. <span className="flex items-center gap-1">
  48. <span className="line-through text-gray-400 text-xs">{formatValue(originalValue, unit)}</span>
  49. {discountLabel && (
  50. <span className="px-1 py-0.5 bg-red-500 text-white text-[10px] font-bold rounded leading-none">
  51. {discountLabel}
  52. </span>
  53. )}
  54. </span>
  55. )}
  56. <span className={hasDiscount ? 'text-blue-600 font-semibold' : 'text-gray-900 font-medium'}>
  57. {formatValue(value, unit)}
  58. </span>
  59. </span>
  60. );
  61. };
  62. const getRangeLabel = (item: any): string => item?.input_range || item?.range || item?.inputRange || item?.tier_range || '—';
  63. const translateLabel = (keyOrLabel: string): string => {
  64. if (!keyOrLabel) return '—';
  65. if (/[\u4e00-\u9fa5]/.test(keyOrLabel)) return keyOrLabel;
  66. if (labelTranslations[keyOrLabel]) return labelTranslations[keyOrLabel];
  67. if (keyOrLabel.includes('_')) return keyOrLabel.replace(/_/g, ' ');
  68. return keyOrLabel;
  69. };
  70. const getDisplayLabel = (key: string): string => pricingKeyLabels[key] || translateLabel(key) || key;
  71. // API示例代码生成(根据模型分类返回对应示例)
  72. const generateApiExamples = (modelCode: string, categories: number[] = []) => {
  73. const baseUrl = import.meta.env.VITE_OPENAPI_BASE_URL || window.location.origin;
  74. const api = `${baseUrl}/api/v1`;
  75. // 判断主分类(优先级:图像编辑 > 图像生成 > 视频 > TTS > STT > Embedding > Rerank > LLM)
  76. const has = (c: number) => categories.includes(c);
  77. const code = modelCode.toLowerCase();
  78. const isRealtime = code.includes('realtime');
  79. const isClone = code.includes('clone');
  80. // realtime 模型:WebSocket 协议,平台不支持代理,不可通过本平台 API 调用
  81. if (isRealtime) {
  82. return { _isUnsupported: true, reason: '该模型使用 WebSocket 实时流协议,当前平台仅支持 HTTP 接口代理,暂不支持通过平台 API Key 调用此模型。' } as any;
  83. }
  84. // cosyvoice-clone 系列:两步流程(先创建音色,再合成)
  85. if (isClone && has(2)) {
  86. return {
  87. curl: `# ${modelCode} 声音克隆模型,需要两步调用\n\n# 第一步:上传参考音频,创建克隆音色(3~10秒清晰人声)\ncurl ${baseUrl}/api/audio/voice/create \\\n -H "Authorization: Bearer YOUR_API_KEY" \\\n -F "file=@/path/to/reference.mp3" \\\n -F "target_model=${modelCode}" \\\n -F "prefix=my_voice" \\\n -F "voice_name=我的音色"\n# 返回 voice_id,等待 status 变为 OK\n\n# 第二步:用 voice_id 合成语音\ncurl ${baseUrl}/api/v1/audio/speech \\\n -H "Authorization: Bearer YOUR_API_KEY" \\\n -H "Content-Type: application/json" \\\n -d '{"model": "${modelCode}", "input": "你好,世界", "voice": "YOUR_VOICE_ID"}' \\\n --output cloned.mp3`,
  88. python: `import requests\nimport time\n\nheaders = {"Authorization": "Bearer YOUR_API_KEY"}\nbase = "${baseUrl}"\n\n# 第一步:上传参考音频,创建克隆音色\nwith open("/path/to/reference.mp3", "rb") as f:\n resp = requests.post(\n f"{base}/api/audio/voice/create",\n headers=headers,\n files={"file": f},\n data={"target_model": "${modelCode}", "prefix": "my_voice", "voice_name": "我的音色"}\n )\nvoice_id = resp.json()["data"]["voice_id"]\nprint(f"voice_id: {voice_id}") # 等待 status 变为 OK\n\n# 第二步:用 voice_id 合成语音\nresp = requests.post(\n f"{base}/api/v1/audio/speech",\n headers={**headers, "Content-Type": "application/json"},\n json={"model": "${modelCode}", "input": "你好,世界", "voice": voice_id}\n)\nwith open("cloned.mp3", "wb") as f:\n f.write(resp.content)`,
  89. nodejs: `import fs from 'fs';\n\nconst base = '${baseUrl}';\nconst headers = { 'Authorization': 'Bearer YOUR_API_KEY' };\n\n// 第一步:上传参考音频,创建克隆音色\nconst form = new FormData();\nform.append('file', fs.createReadStream('/path/to/reference.mp3'));\nform.append('target_model', '${modelCode}');\nform.append('prefix', 'my_voice');\nform.append('voice_name', '我的音色');\nconst step1 = await fetch(\`\${base}/api/audio/voice/create\`, { method: 'POST', headers, body: form });\nconst { voice_id } = (await step1.json()).data;\nconsole.log('voice_id:', voice_id); // 等待 status 变为 OK\n\n// 第二步:用 voice_id 合成语音\nconst step2 = await fetch(\`\${base}/api/v1/audio/speech\`, {\n method: 'POST',\n headers: { ...headers, 'Content-Type': 'application/json' },\n body: JSON.stringify({ model: '${modelCode}', input: '你好,世界', voice: voice_id })\n});\nfs.writeFileSync('cloned.mp3', Buffer.from(await step2.arrayBuffer()));`,
  90. java: `// 第一步:POST ${baseUrl}/api/audio/voice/create\n// multipart: file=<音频>, target_model=${modelCode}, prefix=my_voice, voice_name=我的音色\n// 返回 voice_id,等待 status=OK\n\n// 第二步:POST ${baseUrl}/api/v1/audio/speech\n// JSON: { "model": "${modelCode}", "input": "你好,世界", "voice": "<voice_id>" }`
  91. };
  92. }
  93. // OCR 模型:必须传图片
  94. const OCR_MODELS = ['qwen-vl-ocr'];
  95. if (OCR_MODELS.includes(modelCode)) {
  96. return {
  97. curl: `curl ${api}/chat/completions \\
  98. -H "Authorization: Bearer YOUR_API_KEY" \\
  99. -H "Content-Type: application/json" \\
  100. -d '{
  101. "model": "${modelCode}",
  102. "messages": [
  103. {
  104. "role": "user",
  105. "content": [
  106. {"type": "image_url", "image_url": {"url": "https://example.com/your-image.jpg"}},
  107. {"type": "text", "text": "请识别图片中的文字"}
  108. ]
  109. }
  110. ]
  111. }'`,
  112. python: `from openai import OpenAI\n\nclient = OpenAI(api_key="YOUR_API_KEY", base_url="${api}")\n\nresponse = client.chat.completions.create(\n model="${modelCode}",\n messages=[\n {\n "role": "user",\n "content": [\n {"type": "image_url", "image_url": {"url": "https://example.com/your-image.jpg"}},\n {"type": "text", "text": "请识别图片中的文字"}\n ]\n }\n ]\n)\nprint(response.choices[0].message.content)`,
  113. nodejs: `import OpenAI from 'openai';\n\nconst client = new OpenAI({ apiKey: 'YOUR_API_KEY', baseURL: '${api}' });\n\nconst res = await client.chat.completions.create({\n model: '${modelCode}',\n messages: [\n {\n role: 'user',\n content: [\n { type: 'image_url', image_url: { url: 'https://example.com/your-image.jpg' } },\n { type: 'text', text: '请识别图片中的文字' }\n ]\n }\n ]\n});\nconsole.log(res.choices[0].message.content);`,
  114. java: `// ${modelCode} 是 OCR 模型,消息中必须包含图片\n// 调用 ${api}/chat/completions\n// content 需包含 image_url 和 text 两个 part`
  115. };
  116. }
  117. if (has(6)) {
  118. // 图像编辑(图生图)
  119. return {
  120. curl: `curl ${api}/images/edits \\
  121. -H "Authorization: Bearer YOUR_API_KEY" \\
  122. -F "model=${modelCode}" \\
  123. -F "prompt=把图片变成油画风格" \\
  124. -F "image=@/path/to/image.png"`,
  125. python: `import requests\n\nresponse = requests.post(\n "${api}/images/edits",\n headers={"Authorization": "Bearer YOUR_API_KEY"},\n data={"model": "${modelCode}", "prompt": "把图片变成油画风格"},\n files={"image": open("/path/to/image.png", "rb")}\n)\nprint(response.json())`,
  126. nodejs: `const form = new FormData();\nform.append("model", "${modelCode}");\nform.append("prompt", "把图片变成油画风格");\nform.append("image", fs.createReadStream("/path/to/image.png"));\n\nconst res = await fetch("${api}/images/edits", {\n method: "POST",\n headers: { "Authorization": "Bearer YOUR_API_KEY" },\n body: form\n});\nconsole.log(await res.json());`,
  127. java: `// 使用 multipart/form-data 调用 ${api}/images/edits\n// model=${modelCode}, prompt=把图片变成油画风格, image=<file>`
  128. };
  129. }
  130. if (has(4)) {
  131. // 图像生成(文生图)
  132. return {
  133. curl: `curl ${api}/images/generations \\
  134. -H "Authorization: Bearer YOUR_API_KEY" \\
  135. -H "Content-Type: application/json" \\
  136. -d '{"model": "${modelCode}", "prompt": "一只可爱的猫咪", "n": 1}'`,
  137. python: `from openai import OpenAI\n\nclient = OpenAI(api_key="YOUR_API_KEY", base_url="${api}")\nresponse = client.images.generate(model="${modelCode}", prompt="一只可爱的猫咪", n=1)\nprint(response.data[0].url)`,
  138. nodejs: `import OpenAI from 'openai';\nconst client = new OpenAI({ apiKey: 'YOUR_API_KEY', baseURL: '${api}' });\nconst res = await client.images.generate({ model: '${modelCode}', prompt: '一只可爱的猫咪', n: 1 });\nconsole.log(res.data[0].url);`,
  139. java: `// 调用 ${api}/images/generations\n// model=${modelCode}, prompt=一只可爱的猫咪`
  140. };
  141. }
  142. if (has(5)) {
  143. // 视频生成
  144. return {
  145. curl: `curl ${api}/videos/generations \\
  146. -H "Authorization: Bearer YOUR_API_KEY" \\
  147. -H "Content-Type: application/json" \\
  148. -d '{"model": "${modelCode}", "prompt": "一段美丽的风景视频"}'`,
  149. python: `import requests\nresponse = requests.post(\n "${api}/videos/generations",\n headers={"Authorization": "Bearer YOUR_API_KEY", "Content-Type": "application/json"},\n json={"model": "${modelCode}", "prompt": "一段美丽的风景视频"}\n)\nprint(response.json())`,
  150. nodejs: `const res = await fetch("${api}/videos/generations", {\n method: "POST",\n headers: { "Authorization": "Bearer YOUR_API_KEY", "Content-Type": "application/json" },\n body: JSON.stringify({ model: "${modelCode}", prompt: "一段美丽的风景视频" })\n});\nconsole.log(await res.json());`,
  151. java: `// 调用 ${api}/videos/generations\n// model=${modelCode}`
  152. };
  153. }
  154. if (has(2)) {
  155. // TTS — plus 模型只支持 longanyang/longanhuan,其他用 longxiaochun_v3
  156. const ttsVoice = code.includes('plus') ? 'longanyang' : 'longxiaochun_v3';
  157. return {
  158. curl: `curl ${api}/audio/speech \\
  159. -H "Authorization: Bearer YOUR_API_KEY" \\
  160. -H "Content-Type: application/json" \\
  161. -d '{"model": "${modelCode}", "input": "你好,世界", "voice": "${ttsVoice}", "response_format": "mp3"}' \\
  162. --output speech.mp3`,
  163. python: `from openai import OpenAI\nclient = OpenAI(api_key="YOUR_API_KEY", base_url="${api}")\nresponse = client.audio.speech.create(model="${modelCode}", input="你好,世界", voice="${ttsVoice}", response_format="mp3")\nresponse.stream_to_file("speech.mp3")`,
  164. nodejs: `import OpenAI from 'openai';\nconst client = new OpenAI({ apiKey: 'YOUR_API_KEY', baseURL: '${api}' });\nconst mp3 = await client.audio.speech.create({ model: '${modelCode}', input: '你好,世界', voice: '${ttsVoice}', response_format: 'mp3' });\nconst buffer = Buffer.from(await mp3.arrayBuffer());\nawait fs.promises.writeFile('speech.mp3', buffer);`,
  165. java: `// 调用 ${api}/audio/speech\n// model=${modelCode}, voice=${ttsVoice}`
  166. };
  167. }
  168. if (has(3)) {
  169. // STT
  170. return {
  171. curl: `curl ${api}/audio/transcriptions \\
  172. -H "Authorization: Bearer YOUR_API_KEY" \\
  173. -F "model=${modelCode}" \\
  174. -F "file=@/path/to/audio.mp3"`,
  175. python: `from openai import OpenAI\nclient = OpenAI(api_key="YOUR_API_KEY", base_url="${api}")\nwith open("/path/to/audio.mp3", "rb") as f:\n transcript = client.audio.transcriptions.create(model="${modelCode}", file=f)\nprint(transcript.text)`,
  176. nodejs: `import OpenAI from 'openai';\nconst client = new OpenAI({ apiKey: 'YOUR_API_KEY', baseURL: '${api}' });\nconst transcript = await client.audio.transcriptions.create({ model: '${modelCode}', file: fs.createReadStream('/path/to/audio.mp3') });\nconsole.log(transcript.text);`,
  177. java: `// 调用 ${api}/audio/transcriptions\n// model=${modelCode}`
  178. };
  179. }
  180. if (has(7)) {
  181. // Embedding
  182. return {
  183. curl: `curl ${api}/embeddings \\
  184. -H "Authorization: Bearer YOUR_API_KEY" \\
  185. -H "Content-Type: application/json" \\
  186. -d '{"model": "${modelCode}", "input": "你好,世界"}'`,
  187. python: `from openai import OpenAI\nclient = OpenAI(api_key="YOUR_API_KEY", base_url="${api}")\nresponse = client.embeddings.create(model="${modelCode}", input="你好,世界")\nprint(response.data[0].embedding)`,
  188. nodejs: `import OpenAI from 'openai';\nconst client = new OpenAI({ apiKey: 'YOUR_API_KEY', baseURL: '${api}' });\nconst res = await client.embeddings.create({ model: '${modelCode}', input: '你好,世界' });\nconsole.log(res.data[0].embedding);`,
  189. java: `// 调用 ${api}/embeddings\n// model=${modelCode}`
  190. };
  191. }
  192. if (has(8)) {
  193. // Rerank
  194. return {
  195. curl: `curl ${api}/rerank \\
  196. -H "Authorization: Bearer YOUR_API_KEY" \\
  197. -H "Content-Type: application/json" \\
  198. -d '{"model": "${modelCode}", "query": "什么是人工智能", "documents": ["人工智能是计算机科学的一个分支", "机器学习是AI的子领域"]}'`,
  199. python: `import requests\nresponse = requests.post(\n "${api}/rerank",\n headers={"Authorization": "Bearer YOUR_API_KEY", "Content-Type": "application/json"},\n json={"model": "${modelCode}", "query": "什么是人工智能", "documents": ["人工智能是计算机科学的一个分支", "机器学习是AI的子领域"]}\n)\nprint(response.json())`,
  200. nodejs: `const res = await fetch("${api}/rerank", {\n method: "POST",\n headers: { "Authorization": "Bearer YOUR_API_KEY", "Content-Type": "application/json" },\n body: JSON.stringify({ model: "${modelCode}", query: "什么是人工智能", documents: ["人工智能是计算机科学的一个分支", "机器学习是AI的子领域"] })\n});\nconsole.log(await res.json());`,
  201. java: `// 调用 ${api}/rerank\n// model=${modelCode}`
  202. };
  203. }
  204. if (has(1)) {
  205. // 多模态(图文/视频理解)—— 返回两套示例
  206. return {
  207. _isMultimodal: true,
  208. text: {
  209. curl: `curl ${api}/chat/completions \\
  210. -H "Authorization: Bearer YOUR_API_KEY" \\
  211. -H "Content-Type: application/json" \\
  212. -d '{
  213. "model": "${modelCode}",
  214. "messages": [
  215. {"role": "user", "content": "你好,请介绍一下你自己"}
  216. ],
  217. "stream": false
  218. }'`,
  219. python: `from openai import OpenAI\n\nclient = OpenAI(api_key="YOUR_API_KEY", base_url="${api}")\n\nresponse = client.chat.completions.create(\n model="${modelCode}",\n messages=[{"role": "user", "content": "你好,请介绍一下你自己"}]\n)\nprint(response.choices[0].message.content)`,
  220. nodejs: `import OpenAI from 'openai';\n\nconst client = new OpenAI({ apiKey: 'YOUR_API_KEY', baseURL: '${api}' });\n\nconst res = await client.chat.completions.create({\n model: '${modelCode}',\n messages: [{ role: 'user', content: '你好,请介绍一下你自己' }]\n});\nconsole.log(res.choices[0].message.content);`,
  221. java: `OpenAIClient client = OpenAIOkHttpClient.builder()\n .apiKey("YOUR_API_KEY")\n .baseUrl("${api}")\n .build();\n\nChatCompletionCreateParams params = ChatCompletionCreateParams.builder()\n .model("${modelCode}")\n .addMessage(ChatCompletionMessageParam.ofUser(\n ChatCompletionUserMessageParam.builder().content("你好,请介绍一下你自己").build()))\n .build();\n\nChatCompletion completion = client.chat().completions().create(params);\nSystem.out.println(completion.choices().get(0).message().content());`
  222. },
  223. multimodal: {
  224. curl: `curl ${api}/chat/completions \\
  225. -H "Authorization: Bearer YOUR_API_KEY" \\
  226. -H "Content-Type: application/json" \\
  227. -d '{
  228. "model": "${modelCode}",
  229. "messages": [
  230. {
  231. "role": "user",
  232. "content": [
  233. {"type": "image_url", "image_url": {"url": "https://example.com/your-image.jpg"}},
  234. {"type": "text", "text": "请描述这张图片的内容"}
  235. ]
  236. }
  237. ],
  238. "stream": false
  239. }'`,
  240. python: `from openai import OpenAI\n\nclient = OpenAI(api_key="YOUR_API_KEY", base_url="${api}")\n\nresponse = client.chat.completions.create(\n model="${modelCode}",\n messages=[\n {\n "role": "user",\n "content": [\n {"type": "image_url", "image_url": {"url": "https://example.com/your-image.jpg"}},\n {"type": "text", "text": "请描述这张图片的内容"}\n ]\n }\n ]\n)\nprint(response.choices[0].message.content)`,
  241. nodejs: `import OpenAI from 'openai';\n\nconst client = new OpenAI({ apiKey: 'YOUR_API_KEY', baseURL: '${api}' });\n\nconst res = await client.chat.completions.create({\n model: '${modelCode}',\n messages: [\n {\n role: 'user',\n content: [\n { type: 'image_url', image_url: { url: 'https://example.com/your-image.jpg' } },\n { type: 'text', text: '请描述这张图片的内容' }\n ]\n }\n ]\n});\nconsole.log(res.choices[0].message.content);`,
  242. java: `OpenAIClient client = OpenAIOkHttpClient.builder()\n .apiKey("YOUR_API_KEY")\n .baseUrl("${api}")\n .build();\n\n// content 为多模态列表,包含 image_url 和 text\n// 请参考 OpenAI Java SDK 的多模态消息构建方式`
  243. }
  244. } as any;
  245. }
  246. // 默认:LLM
  247. return {
  248. curl: `curl ${api}/chat/completions \\
  249. -H "Authorization: Bearer YOUR_API_KEY" \\
  250. -H "Content-Type: application/json" \\
  251. -d '{
  252. "model": "${modelCode}",
  253. "messages": [
  254. {"role": "user", "content": "你好"}
  255. ],
  256. "stream": false
  257. }'`,
  258. python: `from openai import OpenAI\n\nclient = OpenAI(\n api_key="YOUR_API_KEY",\n base_url="${api}"\n)\n\nresponse = client.chat.completions.create(\n model="${modelCode}",\n messages=[{"role": "user", "content": "你好"}]\n)\nprint(response.choices[0].message.content)`,
  259. nodejs: `import OpenAI from 'openai';\n\nconst client = new OpenAI({ apiKey: 'YOUR_API_KEY', baseURL: '${api}' });\n\nconst res = await client.chat.completions.create({\n model: '${modelCode}',\n messages: [{ role: 'user', content: '你好' }]\n});\nconsole.log(res.choices[0].message.content);`,
  260. java: `OpenAIClient client = OpenAIOkHttpClient.builder()\n .apiKey("YOUR_API_KEY")\n .baseUrl("${api}")\n .build();\n\nChatCompletionCreateParams params = ChatCompletionCreateParams.builder()\n .model("${modelCode}")\n .addMessage(ChatCompletionMessageParam.ofUser(\n ChatCompletionUserMessageParam.builder().content("你好").build()))\n .build();\n\nChatCompletion completion = client.chat().completions().create(params);\nSystem.out.println(completion.choices().get(0).message().content());`
  261. };
  262. };
  263. const ModelPricingDetail: React.FC = () => {
  264. const navigate = useNavigate();
  265. const { modelCode } = useParams();
  266. const [data, setData] = useState<ParsedPricingResponse | null>(null);
  267. const [loading, setLoading] = useState(true);
  268. const [error, setError] = useState<string | null>(null);
  269. const [activeTab, setActiveTab] = useState<'curl' | 'python' | 'nodejs' | 'java'>('curl');
  270. const [activeInputMode, setActiveInputMode] = useState<'text' | 'multimodal'>('text');
  271. const [copied, setCopied] = useState(false);
  272. useEffect(() => {
  273. const fetchDetail = async () => {
  274. if (!modelCode) { setError('模型代码缺失'); setLoading(false); return; }
  275. try {
  276. setLoading(true);
  277. let resolvedCode = modelCode;
  278. try {
  279. const mapping = await getPricingMappingLookup();
  280. resolvedCode = mapping.get(modelCode) || modelCode;
  281. } catch { resolvedCode = modelCode; }
  282. const response = await modelApi.getParsedPricing(resolvedCode);
  283. setData(response?.data ?? null);
  284. } catch { setError('获取模型详情失败'); }
  285. finally { setLoading(false); }
  286. };
  287. fetchDetail();
  288. }, [modelCode]);
  289. const pricingView = useMemo(() => {
  290. if (!data?.model_pricing) return null;
  291. if (Array.isArray(data.model_pricing)) {
  292. const arr = data.model_pricing as any[];
  293. const looksLikeList = arr.length > 0 && arr.every((it) => (it && (it.item || it.name) && (it.price !== undefined || it.amount !== undefined || it.value !== undefined)) && !it.input_range && !it.range && !it.tier_range && !it.inputRange);
  294. if (looksLikeList) return { type: 'list', items: arr } as const;
  295. const tiers = arr;
  296. const columns = new Set<string>();
  297. tiers.forEach((tier) => { Object.keys(tier || {}).forEach((key) => {
  298. // 过滤掉元数据字段和 _original 后缀字段(原价字段,不单独渲染)
  299. if (!['unit', 'input_range', 'range', 'inputRange', 'tier_range', 'discount_rate'].includes(key) && !key.endsWith('_original')) {
  300. columns.add(key);
  301. }
  302. }); });
  303. return { type: 'tier', tiers, columns: Array.from(columns) } as const;
  304. }
  305. if (typeof data.model_pricing === 'object') {
  306. const pricing = data.model_pricing as Record<string, any>;
  307. const entries = Object.entries(pricing || {});
  308. const looksLikeNamedPriceObjects = entries.length > 0 && entries.every(([, v]) => v && typeof v === 'object' && (v.price !== undefined || v.amount !== undefined || v.value !== undefined || v.price_cny_per_image !== undefined));
  309. if (looksLikeNamedPriceObjects) {
  310. const items = entries.map(([k, v]) => ({ item: translateLabel(v.item ?? v.name ?? k), price: v.price ?? v.amount ?? v.value ?? v.price_cny_per_image, unit: v.unit ?? v.unit_text ?? '' }));
  311. return { type: 'list', items } as const;
  312. }
  313. return { type: 'simple', pricing } as const;
  314. }
  315. return null;
  316. }, [data]);
  317. const featureList = useMemo(() => {
  318. const features = data?.model_capabilities?.features;
  319. if (!features || typeof features !== 'object') return [];
  320. return Object.entries(features).map(([key, value]) => ({ key, label: translateLabel(key), enabled: Boolean(value) }));
  321. }, [data]);
  322. const [showTier, setShowTier] = useState(false);
  323. const [selectedTierIndex, setSelectedTierIndex] = useState(0);
  324. useEffect(() => { setSelectedTierIndex(0); setShowTier(false); }, [pricingView?.type]);
  325. const apiExamples = useMemo(() => data?.model_code ? generateApiExamples(data.model_code, data.categories || []) : null, [data?.model_code, data?.categories]);
  326. const renderModelInfo = () => (
  327. <div className="space-y-6">
  328. {data?.model_intro && (
  329. <div className="bg-white rounded-2xl border border-gray-100 p-6">
  330. <h2 className="text-lg font-semibold text-gray-900 mb-3">模型介绍</h2>
  331. <p className="text-sm text-gray-700 leading-relaxed">{data.model_intro}</p>
  332. </div>
  333. )}
  334. {data?.model_tags?.length ? (
  335. <div className="bg-white rounded-2xl border border-gray-100 p-6">
  336. <h2 className="text-lg font-semibold text-gray-900 mb-3">模型标签</h2>
  337. <div className="flex flex-wrap gap-2">
  338. {data.model_tags.map((tag) => (<span key={tag} className="px-3 py-1 rounded-full bg-blue-50 text-blue-700 text-xs font-semibold">{tag}</span>))}
  339. </div>
  340. </div>
  341. ) : null}
  342. {data?.model_capabilities && (
  343. <div className="bg-white rounded-2xl border border-gray-100 p-6">
  344. <h2 className="text-lg font-semibold text-gray-900 mb-4">模型能力</h2>
  345. <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
  346. <div>
  347. <div className="text-sm font-semibold text-gray-800 mb-2">输入模态</div>
  348. <div className="flex items-center gap-3 flex-wrap">
  349. {(data.model_capabilities.input_modalities || []).map((item: string) => {
  350. const icon = item?.toLowerCase().includes('image') ? <Image className="w-4 h-4" /> : item?.toLowerCase().includes('video') ? <Video className="w-4 h-4" /> : item?.toLowerCase().includes('audio') ? <Mic className="w-4 h-4" /> : <FileText className="w-4 h-4" />;
  351. return (<div key={item} className="flex items-center gap-2 bg-white border border-gray-100 rounded-lg px-3 py-2 text-sm"><div className="w-6 h-6 flex items-center justify-center text-gray-600">{icon}</div><div className="text-gray-700">{translateLabel(item)}</div></div>);
  352. })}
  353. </div>
  354. </div>
  355. <div>
  356. <div className="text-sm font-semibold text-gray-800 mb-2">输出模态</div>
  357. <div className="flex items-center gap-3 flex-wrap">
  358. {(data.model_capabilities.output_modalities || []).map((item: string) => {
  359. const icon = item?.toLowerCase().includes('image') ? <Image className="w-4 h-4" /> : item?.toLowerCase().includes('video') ? <Video className="w-4 h-4" /> : item?.toLowerCase().includes('audio') ? <Mic className="w-4 h-4" /> : <FileText className="w-4 h-4" />;
  360. return (<div key={item} className="flex items-center gap-2 bg-white border border-gray-100 rounded-lg px-3 py-2 text-sm"><div className="w-6 h-6 flex items-center justify-center text-gray-600">{icon}</div><div className="text-gray-700">{translateLabel(item)}</div></div>);
  361. })}
  362. </div>
  363. </div>
  364. </div>
  365. {featureList.length ? (
  366. <div className="mt-4">
  367. <div className="text-sm font-semibold text-gray-800 mb-2">功能特性</div>
  368. <div className="grid grid-cols-1 md:grid-cols-2 gap-2">
  369. {featureList.map((feature) => (
  370. <div key={feature.key} className="flex items-center justify-between bg-white rounded-lg px-3 py-3 text-sm border-b border-gray-100">
  371. <span className="text-gray-600">{feature.label}</span>
  372. {feature.enabled ? <span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-emerald-50 text-emerald-600 border border-emerald-100"><CheckCircle2 className="w-4 h-4" /></span> : <span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-white text-gray-400 border border-gray-200"><XCircle className="w-4 h-4" /></span>}
  373. </div>
  374. ))}
  375. </div>
  376. </div>
  377. ) : null}
  378. </div>
  379. )}
  380. {pricingView && (
  381. <div className="bg-white rounded-2xl border border-gray-100 p-6">
  382. <div className="flex items-center justify-between mb-4">
  383. <h2 className="text-lg font-semibold text-gray-900">价格信息</h2>
  384. {pricingView.type === 'tier' && (
  385. <div className="flex items-center gap-3">
  386. <button type="button" onClick={() => setShowTier((s) => !s)} className="text-sm px-3 py-1 rounded-full border border-gray-200 bg-white text-gray-700 hover:bg-gray-50">{showTier ? '隐藏阶梯表' : '阶梯计费'}</button>
  387. <select value={selectedTierIndex} onChange={(e) => setSelectedTierIndex(Number(e.target.value))} className="text-sm px-3 py-1 rounded-full border border-gray-100 bg-white">
  388. {pricingView.tiers.map((t: any, idx: number) => (<option key={idx} value={idx}>{getRangeLabel(t)}</option>))}
  389. </select>
  390. </div>
  391. )}
  392. </div>
  393. {pricingView.type === 'simple' && (() => {
  394. const p = pricingView.pricing as any;
  395. const unit = p.unit;
  396. const entries = Object.entries(p).filter(([k, v]) => k !== 'unit' && v !== null && v !== undefined && !Array.isArray(v) && typeof v !== 'object');
  397. const isInputKey = (k: string) => /(^|_)input|image_input|text_input|输入/i.test(k);
  398. const isOutputKey = (k: string) => /(^|_)output|输出/i.test(k);
  399. const left: [string, any][] = [], right: [string, any][] = [], other: [string, any][] = [];
  400. entries.forEach((entry) => { const [key] = entry; if (isInputKey(key)) left.push(entry); else if (isOutputKey(key)) right.push(entry); else other.push(entry); });
  401. other.forEach((it) => { if (left.length <= right.length) left.push(it); else right.push(it); });
  402. const renderEntry = ([key, value]: [string, any]) => (<div key={key} className="flex items-center justify-between bg-gray-50 rounded-lg px-3 py-2 text-sm"><span className="text-gray-600">{getDisplayLabel(key)}</span>{renderPriceCell(value, p[`${key}_original`], unit, p.discount_rate)}</div>);
  403. return (<div className="grid grid-cols-1 md:grid-cols-2 gap-3"><div className="space-y-3 w-full">{left.map(renderEntry)}</div><div className="space-y-3 w-full">{right.map(renderEntry)}</div></div>);
  404. })()}
  405. {pricingView.type === 'list' && (() => {
  406. const items = pricingView.items || [];
  407. const isInput = (l: string) => /(^|_)input|image_input|text_input|输入/i.test(String(l));
  408. const isOutput = (l: string) => /(^|_)output|输出/i.test(String(l));
  409. const left: any[] = [], right: any[] = [], other: any[] = [];
  410. items.forEach((it) => { const rawLabel = it.item || it.name || it.title || ''; if (isInput(rawLabel)) left.push(it); else if (isOutput(rawLabel)) right.push(it); else other.push(it); });
  411. other.forEach((it) => { if (left.length <= right.length) left.push(it); else right.push(it); });
  412. const renderPill = (it: any, idx: number) => (<div key={idx} className="flex items-center justify-between bg-gray-50 rounded-md px-4 py-3 text-sm w-full"><div className="text-sm text-gray-600">{translateLabel(it.item || it.name || it.title || '—')}</div>{renderPriceCell(it.price ?? it.amount ?? it.value, it.price_original, it.unit || '', it.discount_rate)}</div>);
  413. return (<div className="grid grid-cols-1 md:grid-cols-2 gap-4"><div className="space-y-3">{left.map(renderPill)}</div><div className="space-y-3">{right.map(renderPill)}</div></div>);
  414. })()}
  415. {pricingView.type === 'tier' && !showTier && (() => {
  416. const tier = pricingView.tiers[selectedTierIndex] || {};
  417. const cols = pricingView.columns || [];
  418. const preferredOrder = ['input', 'input_thinking', 'input_cache_hit', 'input_cache_hit_thinking', 'input_batch', 'input_batch_thinking', 'output', 'output_thinking', 'output_batch', 'output_thinking_batch', 'cache_write', 'cache_read', 'explicit_cache_create', 'explicit_cache_hit', 'explicit_cache_create_thinking', 'explicit_cache_hit_thinking', 'image_input', 'text_input'];
  419. const inputCols = cols.filter((c: string) => /(^|_)input|image_input|text_input/i.test(c));
  420. const outputCols = cols.filter((c: string) => /(^|_)output/i.test(c));
  421. const remaining = cols.filter((c: string) => !inputCols.includes(c) && !outputCols.includes(c));
  422. const sortByPreferred = (arr: string[]) => arr.slice().sort((a, b) => { const ia = preferredOrder.indexOf(a), ib = preferredOrder.indexOf(b); if (ia === -1 && ib === -1) return a.localeCompare(b); if (ia === -1) return 1; if (ib === -1) return -1; return ia - ib; });
  423. let left = sortByPreferred(inputCols), right = sortByPreferred(outputCols);
  424. if (left.length === 0 && right.length === 0) { const half = Math.ceil(cols.length / 2); left = cols.slice(0, half); right = cols.slice(half); }
  425. else { sortByPreferred(remaining).forEach((c: string) => { if (left.length > right.length) right.push(c); else left.push(c); }); }
  426. const renderRow = (col: string) => (<div key={col} className="flex items-center justify-between bg-gray-50 rounded-md px-4 py-3 text-sm w-full"><div className="text-sm text-gray-600">{getDisplayLabel(col)}</div>{renderPriceCell(tier?.[col], tier?.[`${col}_original`], tier?.unit, tier?.discount_rate)}</div>);
  427. return (<div className="grid grid-cols-1 md:grid-cols-2 gap-6">{left.length > 0 && <div className="space-y-3 w-full">{left.map(renderRow)}</div>}{right.length > 0 && <div className="space-y-3 w-full">{right.map(renderRow)}</div>}</div>);
  428. })()}
  429. {pricingView.type === 'tier' && showTier && (
  430. <div className="overflow-x-auto">
  431. <table className="min-w-full text-sm"><thead><tr className="text-left text-gray-500"><th className="py-2 pr-4">输入范围</th>{pricingView.columns.map((col) => (<th key={col} className="py-2 pr-4">{getDisplayLabel(col)}</th>))}</tr></thead>
  432. <tbody>{pricingView.tiers.map((tier, index) => (<tr key={index} className="border-t border-gray-100 even:bg-gray-50"><td className="py-2 pr-4 text-gray-700">{getRangeLabel(tier)}</td>{pricingView.columns.map((col) => (<td key={col} className="py-2 pr-4">{renderPriceCell((tier as any)?.[col], (tier as any)?.[`${col}_original`], (tier as any)?.unit)}</td>))}</tr>))}</tbody></table>
  433. </div>
  434. )}
  435. </div>
  436. )}
  437. {Array.isArray(data?.tool_call_pricing) && data.tool_call_pricing.length > 0 && (
  438. <div className="bg-white rounded-2xl border border-gray-100 p-6">
  439. <h2 className="text-lg font-semibold text-gray-900 mb-4">工具调用价格</h2>
  440. <div className="overflow-x-auto"><table className="min-w-full text-sm"><thead><tr className="text-left text-gray-500"><th className="py-2 pr-4">工具</th><th className="py-2 pr-4">价格</th></tr></thead><tbody>{data.tool_call_pricing.map((item: any, index: number) => (<tr key={index} className="border-t border-gray-100"><td className="py-2 pr-4 text-gray-700">{item.tool || '—'}</td><td className="py-2 pr-4 text-gray-900">{formatValue(item.price ?? item.amount ?? item.value, item.unit)}</td></tr>))}</tbody></table></div>
  441. </div>
  442. )}
  443. {data?.model_limits && (
  444. <div className="bg-white rounded-2xl border border-gray-100 p-6">
  445. <h2 className="text-lg font-semibold text-gray-900 mb-4">模型限制</h2>
  446. <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
  447. {Object.entries(data.model_limits).map(([key, value]) => key !== 'note' && (<div key={key} className="flex items-center justify-between bg-gray-50 rounded-lg px-3 py-2 text-sm"><span className="text-gray-600">{limitKeyLabels[key] || key.replace(/_/g, ' ')}</span><span className="text-gray-900 font-medium">{formatValue(value)}</span></div>))}
  448. </div>
  449. </div>
  450. )}
  451. </div>
  452. );
  453. const renderApiExamples = () => {
  454. if (!apiExamples) return null;
  455. // realtime 等不支持的模型:显示提示卡片,不展示代码和调试台
  456. if ((apiExamples as any)._isUnsupported) {
  457. return (
  458. <div className="sticky top-6">
  459. <div className="bg-amber-50 border border-amber-200 rounded-2xl p-6">
  460. <div className="flex items-start gap-3">
  461. <span className="text-amber-500 text-xl leading-none mt-0.5">⚠</span>
  462. <div>
  463. <div className="text-sm font-semibold text-amber-800 mb-1">暂不支持通过平台 API 调用</div>
  464. <div className="text-sm text-amber-700">{(apiExamples as any).reason}</div>
  465. </div>
  466. </div>
  467. </div>
  468. </div>
  469. );
  470. }
  471. const isMultimodal = (apiExamples as any)._isMultimodal === true;
  472. const examples = isMultimodal
  473. ? (apiExamples as any)[activeInputMode]
  474. : apiExamples;
  475. const tabs = [
  476. { key: 'curl' as const, label: 'cURL' },
  477. { key: 'python' as const, label: 'Python' },
  478. { key: 'nodejs' as const, label: 'Node.js' },
  479. { key: 'java' as const, label: 'Java' }
  480. ];
  481. return (
  482. <div className="sticky top-6 space-y-6">
  483. <div className="bg-white rounded-2xl border border-gray-100 p-6">
  484. <div className="flex items-center justify-between mb-4">
  485. <h2 className="text-lg font-semibold text-gray-900">API 调用示例</h2>
  486. <button
  487. onClick={() => {
  488. copyToClipboard(examples[activeTab] || '');
  489. setCopied(true);
  490. setTimeout(() => setCopied(false), 2000);
  491. }}
  492. className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border transition-all ${copied ? 'border-green-400 bg-green-50 text-green-600' : 'border-gray-200 bg-white text-gray-500 hover:border-blue-400 hover:text-blue-600 hover:bg-blue-50'}`}
  493. >
  494. {copied
  495. ? <><CheckCircle2 className="w-3.5 h-3.5" />已复制</>
  496. : <><Copy className="w-3.5 h-3.5" />复制代码</>
  497. }
  498. </button>
  499. </div>
  500. {isMultimodal && (
  501. <div className="flex gap-2 mb-4">
  502. <button
  503. onClick={() => setActiveInputMode('text')}
  504. className={`px-3 py-1 rounded-full text-xs font-medium border transition-colors ${activeInputMode === 'text' ? 'bg-blue-600 text-white border-blue-600' : 'bg-white text-gray-600 border-gray-200 hover:border-blue-400'}`}
  505. >纯文本</button>
  506. <button
  507. onClick={() => setActiveInputMode('multimodal')}
  508. className={`px-3 py-1 rounded-full text-xs font-medium border transition-colors ${activeInputMode === 'multimodal' ? 'bg-blue-600 text-white border-blue-600' : 'bg-white text-gray-600 border-gray-200 hover:border-blue-400'}`}
  509. >带图片</button>
  510. </div>
  511. )}
  512. <div className="flex border-b border-gray-200 mb-4">
  513. {tabs.map((tab) => (
  514. <button key={tab.key} onClick={() => setActiveTab(tab.key)} className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors ${activeTab === tab.key ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>{tab.label}</button>
  515. ))}
  516. </div>
  517. <pre className="bg-gray-900 text-gray-100 text-xs rounded-xl p-4 overflow-x-auto whitespace-pre-wrap max-h-[400px] overflow-y-auto">{examples[activeTab]}</pre>
  518. <p className="mt-4 text-xs text-gray-500">请将 YOUR_API_KEY 替换为您在开放平台获取的 API Key</p>
  519. {data?.categories?.includes(2) && !isMultimodal && (() => {
  520. const isPlus = (data.model_code || '').toLowerCase().includes('plus');
  521. const voices = isPlus
  522. ? [{ id: 'longanyang', name: '龙安阳' }, { id: 'longanhuan', name: '龙安欢' }]
  523. : [
  524. { id: 'longxiaochun_v3', name: '龙小淳' }, { id: 'longhua_v3', name: '龙华' },
  525. { id: 'longcheng_v3', name: '龙成' }, { id: 'longwan_v3', name: '龙婉' },
  526. { id: 'longxiaoxia_v3', name: '龙小夏' }, { id: 'longshu_v3', name: '龙书' },
  527. { id: 'longfei_v3', name: '龙飞' }, { id: 'longxiu_v3', name: '龙秀' },
  528. { id: 'longmiao_v3', name: '龙苗' }, { id: 'longyingxiao_v3', name: '龙盈晓' },
  529. { id: 'longhuhu_v3', name: '龙虎虎' }, { id: 'longniuniu_v3', name: '龙妞妞' },
  530. { id: 'longpaopao_v3', name: '龙泡泡' }, { id: 'longjielidou_v3', name: '龙节力豆' },
  531. { id: 'longxian_v3', name: '龙鲜' }, { id: 'longjiaxin_v3', name: '龙嘉欣' },
  532. { id: 'longanyue_v3', name: '龙安悦' }, { id: 'longlaotie_v3', name: '龙老铁' },
  533. { id: 'longshange_v3', name: '龙山歌' }, { id: 'loongkyong_v3', name: '韩语女声' },
  534. { id: 'loongriko_v3', name: '日语女声' }, { id: 'longhouge_v3', name: '龙猴哥' },
  535. { id: 'longjiqi_v3', name: '龙机器' }, { id: 'longdaiyu_v3', name: '龙黛玉' },
  536. { id: 'longlaobo_v3', name: '龙老伯' }, { id: 'longlaoyi_v3', name: '龙老姨' },
  537. ];
  538. return (
  539. <div className="mt-4 pt-4 border-t border-gray-100">
  540. <div className="text-xs font-medium text-gray-600 mb-2">
  541. 支持的音色 {isPlus ? '(plus 专属)' : ''}
  542. </div>
  543. <div className="flex flex-wrap gap-1.5">
  544. {voices.map(v => (
  545. <span key={v.id} className="px-2 py-1 bg-gray-50 border border-gray-200 rounded text-xs text-gray-600 font-mono">{v.id}<span className="text-gray-400 ml-1 font-sans">({v.name})</span></span>
  546. ))}
  547. </div>
  548. </div>
  549. );
  550. })()}
  551. </div>
  552. </div>
  553. );
  554. };
  555. const isRealtimeModel = (data?.model_code || modelCode || '').toLowerCase().includes('realtime');
  556. return (
  557. <div className="w-full flex-1">
  558. <div className="mb-4">
  559. <button onClick={() => navigate('/models')} className="inline-flex items-center gap-2 text-sm text-gray-600 hover:text-blue-600"><ArrowLeft className="w-4 h-4" />返回模型广场</button>
  560. </div>
  561. <div className="bg-white rounded-2xl border border-gray-100 p-6 shadow-sm mb-6">
  562. <div className="flex items-center justify-between">
  563. <div><h1 className="text-2xl font-bold text-gray-900">模型详情</h1><p className="text-sm text-gray-500 mt-1">{data?.model_code || modelCode}</p></div>
  564. {data?.is_api_enabled && !isRealtimeModel && <span className="px-3 py-1 rounded-full bg-green-50 text-green-700 text-xs font-semibold">支持API调用</span>}
  565. {isRealtimeModel && <span className="px-3 py-1 rounded-full bg-amber-50 text-amber-700 text-xs font-semibold">实时流协议·暂不支持平台API</span>}
  566. </div>
  567. </div>
  568. {loading && (<div className="bg-white rounded-2xl border border-gray-100 p-8 flex items-center justify-center text-gray-500"><Loader2 className="w-5 h-5 animate-spin mr-2" />加载中...</div>)}
  569. {!loading && error && (<div className="bg-red-50 border border-red-200 rounded-2xl p-6 text-red-600">{error}</div>)}
  570. {!loading && !error && data && (
  571. <div className={data.is_api_enabled && !isRealtimeModel ? "grid grid-cols-1 lg:grid-cols-2 gap-6" : ""}>
  572. <div className={data.is_api_enabled && !isRealtimeModel ? "" : "w-full"}>{renderModelInfo()}</div>
  573. {(data.is_api_enabled || isRealtimeModel) && <div>{renderApiExamples()}</div>}
  574. </div>
  575. )}
  576. </div>
  577. );
  578. };
  579. export default ModelPricingDetail;