PersonalSetting.js 68 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589
  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, { useContext, useEffect, useState } from 'react';
  16. import { useNavigate } from 'react-router-dom';
  17. import {
  18. API,
  19. copy,
  20. isRoot,
  21. isAdmin,
  22. showError,
  23. showInfo,
  24. showSuccess,
  25. renderQuota,
  26. renderQuotaWithPrompt,
  27. stringToColor,
  28. onGitHubOAuthClicked,
  29. onOIDCClicked,
  30. onLinuxDOOAuthClicked,
  31. renderModelTag,
  32. getModelCategories
  33. } from '../../helpers';
  34. import TwoFASetting from './TwoFASetting';
  35. import Turnstile from 'react-turnstile';
  36. import { UserContext } from '../../context/User';
  37. import { useTheme } from '../../context/Theme';
  38. import {
  39. Avatar,
  40. Banner,
  41. Button,
  42. Card,
  43. Empty,
  44. Image,
  45. Input,
  46. Layout,
  47. Modal,
  48. Skeleton,
  49. Space,
  50. Tag,
  51. Typography,
  52. Collapsible,
  53. Radio,
  54. RadioGroup,
  55. AutoComplete,
  56. Checkbox,
  57. Tabs,
  58. TabPane
  59. } from '@douyinfe/semi-ui';
  60. import { IllustrationNoContent, IllustrationNoContentDark } from '@douyinfe/semi-illustrations';
  61. import {
  62. IconMail,
  63. IconLock,
  64. IconShield,
  65. IconUser,
  66. IconSetting,
  67. IconBell,
  68. IconGithubLogo,
  69. IconKey,
  70. IconDelete,
  71. IconChevronDown,
  72. IconChevronUp
  73. } from '@douyinfe/semi-icons';
  74. import { SiTelegram, SiWechat, SiLinux } from 'react-icons/si';
  75. import { Bell, Shield, Webhook, Globe, Settings, UserPlus, ShieldCheck } from 'lucide-react';
  76. import TelegramLoginButton from 'react-telegram-login';
  77. import { useTranslation } from 'react-i18next';
  78. const PersonalSetting = () => {
  79. const [userState, userDispatch] = useContext(UserContext);
  80. let navigate = useNavigate();
  81. const { t } = useTranslation();
  82. const theme = useTheme();
  83. const [inputs, setInputs] = useState({
  84. wechat_verification_code: '',
  85. email_verification_code: '',
  86. email: '',
  87. self_account_deletion_confirmation: '',
  88. original_password: '',
  89. set_new_password: '',
  90. set_new_password_confirmation: '',
  91. });
  92. const [status, setStatus] = useState({});
  93. const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);
  94. const [showWeChatBindModal, setShowWeChatBindModal] = useState(false);
  95. const [showEmailBindModal, setShowEmailBindModal] = useState(false);
  96. const [showAccountDeleteModal, setShowAccountDeleteModal] = useState(false);
  97. const [turnstileEnabled, setTurnstileEnabled] = useState(false);
  98. const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
  99. const [turnstileToken, setTurnstileToken] = useState('');
  100. const [loading, setLoading] = useState(false);
  101. const [disableButton, setDisableButton] = useState(false);
  102. const [countdown, setCountdown] = useState(30);
  103. const [systemToken, setSystemToken] = useState('');
  104. const [models, setModels] = useState([]);
  105. const [isModelsExpanded, setIsModelsExpanded] = useState(() => {
  106. // Initialize from localStorage if available
  107. const savedState = localStorage.getItem('modelsExpanded');
  108. return savedState ? JSON.parse(savedState) : false;
  109. });
  110. const [activeModelCategory, setActiveModelCategory] = useState('all');
  111. const MODELS_DISPLAY_COUNT = 25; // 默认显示的模型数量
  112. const [notificationSettings, setNotificationSettings] = useState({
  113. warningType: 'email',
  114. warningThreshold: 100000,
  115. webhookUrl: '',
  116. webhookSecret: '',
  117. notificationEmail: '',
  118. acceptUnsetModelRatioModel: false,
  119. recordIpLog: false,
  120. });
  121. const [modelsLoading, setModelsLoading] = useState(true);
  122. const [showWebhookDocs, setShowWebhookDocs] = useState(true);
  123. useEffect(() => {
  124. let status = localStorage.getItem('status');
  125. if (status) {
  126. status = JSON.parse(status);
  127. setStatus(status);
  128. if (status.turnstile_check) {
  129. setTurnstileEnabled(true);
  130. setTurnstileSiteKey(status.turnstile_site_key);
  131. }
  132. }
  133. getUserData().then((res) => {
  134. console.log(userState);
  135. });
  136. loadModels().then();
  137. }, []);
  138. useEffect(() => {
  139. let countdownInterval = null;
  140. if (disableButton && countdown > 0) {
  141. countdownInterval = setInterval(() => {
  142. setCountdown(countdown - 1);
  143. }, 1000);
  144. } else if (countdown === 0) {
  145. setDisableButton(false);
  146. setCountdown(30);
  147. }
  148. return () => clearInterval(countdownInterval); // Clean up on unmount
  149. }, [disableButton, countdown]);
  150. useEffect(() => {
  151. if (userState?.user?.setting) {
  152. const settings = JSON.parse(userState.user.setting);
  153. setNotificationSettings({
  154. warningType: settings.notify_type || 'email',
  155. warningThreshold: settings.quota_warning_threshold || 500000,
  156. webhookUrl: settings.webhook_url || '',
  157. webhookSecret: settings.webhook_secret || '',
  158. notificationEmail: settings.notification_email || '',
  159. acceptUnsetModelRatioModel:
  160. settings.accept_unset_model_ratio_model || false,
  161. recordIpLog: settings.record_ip_log || false,
  162. });
  163. }
  164. }, [userState?.user?.setting]);
  165. // Save models expanded state to localStorage whenever it changes
  166. useEffect(() => {
  167. localStorage.setItem('modelsExpanded', JSON.stringify(isModelsExpanded));
  168. }, [isModelsExpanded]);
  169. const handleInputChange = (name, value) => {
  170. setInputs((inputs) => ({ ...inputs, [name]: value }));
  171. };
  172. const generateAccessToken = async () => {
  173. const res = await API.get('/api/user/token');
  174. const { success, message, data } = res.data;
  175. if (success) {
  176. setSystemToken(data);
  177. await copy(data);
  178. showSuccess(t('令牌已重置并已复制到剪贴板'));
  179. } else {
  180. showError(message);
  181. }
  182. };
  183. const getUserData = async () => {
  184. let res = await API.get(`/api/user/self`);
  185. const { success, message, data } = res.data;
  186. if (success) {
  187. userDispatch({ type: 'login', payload: data });
  188. } else {
  189. showError(message);
  190. }
  191. };
  192. const loadModels = async () => {
  193. setModelsLoading(true);
  194. try {
  195. let res = await API.get(`/api/user/models`);
  196. const { success, message, data } = res.data;
  197. if (success) {
  198. if (data != null) {
  199. setModels(data);
  200. }
  201. } else {
  202. showError(message);
  203. }
  204. } catch (error) {
  205. showError(t('加载模型列表失败'));
  206. } finally {
  207. setModelsLoading(false);
  208. }
  209. };
  210. const handleSystemTokenClick = async (e) => {
  211. e.target.select();
  212. await copy(e.target.value);
  213. showSuccess(t('系统令牌已复制到剪切板'));
  214. };
  215. const deleteAccount = async () => {
  216. if (inputs.self_account_deletion_confirmation !== userState.user.username) {
  217. showError(t('请输入你的账户名以确认删除!'));
  218. return;
  219. }
  220. const res = await API.delete('/api/user/self');
  221. const { success, message } = res.data;
  222. if (success) {
  223. showSuccess(t('账户已删除!'));
  224. await API.get('/api/user/logout');
  225. userDispatch({ type: 'logout' });
  226. localStorage.removeItem('user');
  227. navigate('/login');
  228. } else {
  229. showError(message);
  230. }
  231. };
  232. const bindWeChat = async () => {
  233. if (inputs.wechat_verification_code === '') return;
  234. const res = await API.get(
  235. `/api/oauth/wechat/bind?code=${inputs.wechat_verification_code}`,
  236. );
  237. const { success, message } = res.data;
  238. if (success) {
  239. showSuccess(t('微信账户绑定成功!'));
  240. setShowWeChatBindModal(false);
  241. } else {
  242. showError(message);
  243. }
  244. };
  245. const changePassword = async () => {
  246. if (inputs.original_password === '') {
  247. showError(t('请输入原密码!'));
  248. return;
  249. }
  250. if (inputs.set_new_password === '') {
  251. showError(t('请输入新密码!'));
  252. return;
  253. }
  254. if (inputs.original_password === inputs.set_new_password) {
  255. showError(t('新密码需要和原密码不一致!'));
  256. return;
  257. }
  258. if (inputs.set_new_password !== inputs.set_new_password_confirmation) {
  259. showError(t('两次输入的密码不一致!'));
  260. return;
  261. }
  262. const res = await API.put(`/api/user/self`, {
  263. original_password: inputs.original_password,
  264. password: inputs.set_new_password,
  265. });
  266. const { success, message } = res.data;
  267. if (success) {
  268. showSuccess(t('密码修改成功!'));
  269. setShowWeChatBindModal(false);
  270. } else {
  271. showError(message);
  272. }
  273. setShowChangePasswordModal(false);
  274. };
  275. const sendVerificationCode = async () => {
  276. if (inputs.email === '') {
  277. showError(t('请输入邮箱!'));
  278. return;
  279. }
  280. setDisableButton(true);
  281. if (turnstileEnabled && turnstileToken === '') {
  282. showInfo(t('请稍后几秒重试,Turnstile 正在检查用户环境!'));
  283. return;
  284. }
  285. setLoading(true);
  286. const res = await API.get(
  287. `/api/verification?email=${inputs.email}&turnstile=${turnstileToken}`,
  288. );
  289. const { success, message } = res.data;
  290. if (success) {
  291. showSuccess(t('验证码发送成功,请检查邮箱!'));
  292. } else {
  293. showError(message);
  294. }
  295. setLoading(false);
  296. };
  297. const bindEmail = async () => {
  298. if (inputs.email_verification_code === '') {
  299. showError(t('请输入邮箱验证码!'));
  300. return;
  301. }
  302. setLoading(true);
  303. const res = await API.get(
  304. `/api/oauth/email/bind?email=${inputs.email}&code=${inputs.email_verification_code}`,
  305. );
  306. const { success, message } = res.data;
  307. if (success) {
  308. showSuccess(t('邮箱账户绑定成功!'));
  309. setShowEmailBindModal(false);
  310. userState.user.email = inputs.email;
  311. } else {
  312. showError(message);
  313. }
  314. setLoading(false);
  315. };
  316. const getUsername = () => {
  317. if (userState.user) {
  318. return userState.user.username;
  319. } else {
  320. return 'null';
  321. }
  322. };
  323. const getAvatarText = () => {
  324. const username = getUsername();
  325. if (username && username.length > 0) {
  326. // 获取前两个字符,支持中文和英文
  327. return username.slice(0, 2).toUpperCase();
  328. }
  329. return 'NA';
  330. };
  331. const copyText = async (text) => {
  332. if (await copy(text)) {
  333. showSuccess(t('已复制:') + text);
  334. } else {
  335. // setSearchKeyword(text);
  336. Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text });
  337. }
  338. };
  339. const handleNotificationSettingChange = (type, value) => {
  340. setNotificationSettings((prev) => ({
  341. ...prev,
  342. [type]: value.target ? value.target.value !== undefined ? value.target.value : value.target.checked : value, // handle checkbox properly
  343. }));
  344. };
  345. const saveNotificationSettings = async () => {
  346. try {
  347. const res = await API.put('/api/user/setting', {
  348. notify_type: notificationSettings.warningType,
  349. quota_warning_threshold: parseFloat(
  350. notificationSettings.warningThreshold,
  351. ),
  352. webhook_url: notificationSettings.webhookUrl,
  353. webhook_secret: notificationSettings.webhookSecret,
  354. notification_email: notificationSettings.notificationEmail,
  355. accept_unset_model_ratio_model:
  356. notificationSettings.acceptUnsetModelRatioModel,
  357. record_ip_log: notificationSettings.recordIpLog,
  358. });
  359. if (res.data.success) {
  360. showSuccess(t('设置保存成功'));
  361. await getUserData();
  362. } else {
  363. showError(res.data.message);
  364. }
  365. } catch (error) {
  366. showError(t('设置保存失败'));
  367. }
  368. };
  369. return (
  370. <div className="bg-gray-50 mt-[60px]">
  371. <div className="flex justify-center">
  372. <div className="w-full">
  373. {/* 主卡片容器 */}
  374. <Card className="!rounded-2xl shadow-lg border-0">
  375. {/* 顶部用户信息区域 */}
  376. <Card
  377. className="!rounded-2xl !border-0 !shadow-lg overflow-hidden"
  378. style={{
  379. background: theme === 'dark'
  380. ? 'linear-gradient(135deg, #1e293b 0%, #334155 50%, #475569 100%)'
  381. : 'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 50%, #cbd5e1 100%)',
  382. position: 'relative'
  383. }}
  384. bodyStyle={{ padding: 0 }}
  385. >
  386. {/* 装饰性背景元素 */}
  387. <div className="absolute inset-0 overflow-hidden">
  388. <div className="absolute -top-10 -right-10 w-40 h-40 bg-slate-400 dark:bg-slate-500 opacity-5 rounded-full"></div>
  389. <div className="absolute -bottom-16 -left-16 w-48 h-48 bg-slate-300 dark:bg-slate-400 opacity-8 rounded-full"></div>
  390. <div className="absolute top-1/2 right-1/4 w-24 h-24 bg-slate-400 dark:bg-slate-500 opacity-6 rounded-full"></div>
  391. </div>
  392. <div className="relative p-4 sm:p-6 md:p-8 text-gray-600 dark:text-gray-300">
  393. <div className="flex justify-between items-start mb-4 sm:mb-6">
  394. <div className="flex items-center flex-1 min-w-0">
  395. <Avatar
  396. size='large'
  397. className="mr-3 sm:mr-4 shadow-md flex-shrink-0 bg-slate-500 dark:bg-slate-400"
  398. >
  399. {getAvatarText()}
  400. </Avatar>
  401. <div className="flex-1 min-w-0">
  402. <div className="text-base sm:text-lg font-semibold truncate text-gray-800 dark:text-gray-100">
  403. {getUsername()}
  404. </div>
  405. <div className="mt-1 flex flex-wrap gap-1 sm:gap-2">
  406. {isRoot() ? (
  407. <Tag
  408. size='small'
  409. className="!rounded-full bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300"
  410. style={{ fontWeight: '500' }}
  411. >
  412. {t('超级管理员')}
  413. </Tag>
  414. ) : isAdmin() ? (
  415. <Tag
  416. size='small'
  417. className="!rounded-full bg-gray-50 dark:bg-gray-700 text-gray-600 dark:text-gray-300"
  418. style={{ fontWeight: '500' }}
  419. >
  420. {t('管理员')}
  421. </Tag>
  422. ) : (
  423. <Tag
  424. size='small'
  425. className="!rounded-full bg-slate-50 dark:bg-slate-700 text-slate-600 dark:text-slate-300"
  426. style={{ fontWeight: '500' }}
  427. >
  428. {t('普通用户')}
  429. </Tag>
  430. )}
  431. <Tag
  432. size='small'
  433. className="!rounded-full bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300"
  434. style={{ fontWeight: '500' }}
  435. >
  436. ID: {userState?.user?.id}
  437. </Tag>
  438. </div>
  439. </div>
  440. </div>
  441. <div className="w-10 h-10 sm:w-12 sm:h-12 rounded-lg flex items-center justify-center shadow-md flex-shrink-0 ml-2 bg-slate-400 dark:bg-slate-500">
  442. <IconUser size="default" className="text-white" />
  443. </div>
  444. </div>
  445. <div className="mb-4 sm:mb-6">
  446. <div className="text-xs sm:text-sm mb-1 sm:mb-2 text-gray-500 dark:text-gray-400">
  447. {t('当前余额')}
  448. </div>
  449. <div className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-wide text-gray-900 dark:text-gray-100">
  450. {renderQuota(userState?.user?.quota)}
  451. </div>
  452. </div>
  453. <div className="flex flex-col sm:flex-row sm:justify-between sm:items-end">
  454. <div className="grid grid-cols-3 gap-2 sm:flex sm:space-x-6 lg:space-x-8 mb-3 sm:mb-0">
  455. <div className="text-center sm:text-left">
  456. <div className="text-xs text-gray-400 dark:text-gray-500">
  457. {t('历史消耗')}
  458. </div>
  459. <div className="text-xs sm:text-sm font-medium truncate text-gray-600 dark:text-gray-300">
  460. {renderQuota(userState?.user?.used_quota)}
  461. </div>
  462. </div>
  463. <div className="text-center sm:text-left">
  464. <div className="text-xs text-gray-400 dark:text-gray-500">
  465. {t('请求次数')}
  466. </div>
  467. <div className="text-xs sm:text-sm font-medium truncate text-gray-600 dark:text-gray-300">
  468. {userState.user?.request_count || 0}
  469. </div>
  470. </div>
  471. <div className="text-center sm:text-left">
  472. <div className="text-xs text-gray-400 dark:text-gray-500">
  473. {t('用户分组')}
  474. </div>
  475. <div className="text-xs sm:text-sm font-medium truncate text-gray-600 dark:text-gray-300">
  476. {userState?.user?.group || t('默认')}
  477. </div>
  478. </div>
  479. </div>
  480. </div>
  481. <div className="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-slate-300 via-slate-400 to-slate-500 dark:from-slate-600 dark:via-slate-500 dark:to-slate-400 opacity-40"></div>
  482. </div>
  483. </Card>
  484. {/* 主内容区域 - 使用Tabs组织不同功能模块 */}
  485. <div className="p-4">
  486. <Tabs type='line' defaultActiveKey='models' className="modern-tabs">
  487. {/* 可用模型Tab */}
  488. <TabPane
  489. tab={
  490. <div className="flex items-center">
  491. <Settings size={16} className="mr-2" />
  492. {t('可用模型')}
  493. </div>
  494. }
  495. itemKey='models'
  496. >
  497. <div className="gap-6 py-4">
  498. {/* 可用模型部分 */}
  499. <div className="bg-gray-50 dark:bg-gray-800 rounded-xl">
  500. <div className="flex items-center mb-4">
  501. <div className="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3">
  502. <Settings size={20} className="text-slate-600 dark:text-slate-300" />
  503. </div>
  504. <div>
  505. <Typography.Title heading={6} className="mb-0">{t('模型列表')}</Typography.Title>
  506. <div className="text-gray-500 text-sm">{t('点击模型名称可复制')}</div>
  507. </div>
  508. </div>
  509. {modelsLoading ? (
  510. // 骨架屏加载状态 - 模拟实际加载后的布局
  511. <div className="space-y-4">
  512. {/* 模拟分类标签 */}
  513. <div className="mb-4" style={{ borderBottom: '1px solid var(--semi-color-border)' }}>
  514. <div className="flex overflow-x-auto py-2 gap-2">
  515. {Array.from({ length: 8 }).map((_, index) => (
  516. <Skeleton.Button key={`cat-${index}`} style={{
  517. width: index === 0 ? 130 : 100 + Math.random() * 50,
  518. height: 36,
  519. borderRadius: 8
  520. }} />
  521. ))}
  522. </div>
  523. </div>
  524. {/* 模拟模型标签列表 */}
  525. <div className="flex flex-wrap gap-2">
  526. {Array.from({ length: 20 }).map((_, index) => (
  527. <Skeleton.Button
  528. key={`model-${index}`}
  529. style={{
  530. width: 100 + Math.random() * 100,
  531. height: 32,
  532. borderRadius: 16,
  533. margin: '4px'
  534. }}
  535. />
  536. ))}
  537. </div>
  538. </div>
  539. ) : models.length === 0 ? (
  540. <div className="py-8">
  541. <Empty
  542. image={<IllustrationNoContent style={{ width: 150, height: 150 }} />}
  543. darkModeImage={<IllustrationNoContentDark style={{ width: 150, height: 150 }} />}
  544. description={t('没有可用模型')}
  545. style={{ padding: '24px 0' }}
  546. />
  547. </div>
  548. ) : (
  549. <>
  550. {/* 模型分类标签页 */}
  551. <div className="mb-4">
  552. <Tabs
  553. type="card"
  554. activeKey={activeModelCategory}
  555. onChange={key => setActiveModelCategory(key)}
  556. className="mt-2"
  557. >
  558. {Object.entries(getModelCategories(t)).map(([key, category]) => {
  559. // 计算该分类下的模型数量
  560. const modelCount = key === 'all'
  561. ? models.length
  562. : models.filter(model => category.filter({ model_name: model })).length;
  563. if (modelCount === 0 && key !== 'all') return null;
  564. return (
  565. <TabPane
  566. tab={
  567. <span className="flex items-center gap-2">
  568. {category.icon && <span className="w-4 h-4">{category.icon}</span>}
  569. {category.label}
  570. <Tag
  571. color={activeModelCategory === key ? 'red' : 'grey'}
  572. size='small'
  573. shape='circle'
  574. >
  575. {modelCount}
  576. </Tag>
  577. </span>
  578. }
  579. itemKey={key}
  580. key={key}
  581. />
  582. );
  583. })}
  584. </Tabs>
  585. </div>
  586. <div className="bg-white dark:bg-gray-700 rounded-lg p-3">
  587. {(() => {
  588. // 根据当前选中的分类过滤模型
  589. const categories = getModelCategories(t);
  590. const filteredModels = activeModelCategory === 'all'
  591. ? models
  592. : models.filter(model => categories[activeModelCategory].filter({ model_name: model }));
  593. // 如果过滤后没有模型,显示空状态
  594. if (filteredModels.length === 0) {
  595. return (
  596. <Empty
  597. image={<IllustrationNoContent style={{ width: 120, height: 120 }} />}
  598. darkModeImage={<IllustrationNoContentDark style={{ width: 120, height: 120 }} />}
  599. description={t('该分类下没有可用模型')}
  600. style={{ padding: '16px 0' }}
  601. />
  602. );
  603. }
  604. if (filteredModels.length <= MODELS_DISPLAY_COUNT) {
  605. return (
  606. <Space wrap>
  607. {filteredModels.map((model) => (
  608. renderModelTag(model, {
  609. size: 'large',
  610. shape: 'circle',
  611. onClick: () => copyText(model),
  612. })
  613. ))}
  614. </Space>
  615. );
  616. } else {
  617. return (
  618. <>
  619. <Collapsible isOpen={isModelsExpanded}>
  620. <Space wrap>
  621. {filteredModels.map((model) => (
  622. renderModelTag(model, {
  623. size: 'large',
  624. shape: 'circle',
  625. onClick: () => copyText(model),
  626. })
  627. ))}
  628. <Tag
  629. color='grey'
  630. type='light'
  631. className="cursor-pointer !rounded-lg"
  632. onClick={() => setIsModelsExpanded(false)}
  633. icon={<IconChevronUp />}
  634. >
  635. {t('收起')}
  636. </Tag>
  637. </Space>
  638. </Collapsible>
  639. {!isModelsExpanded && (
  640. <Space wrap>
  641. {filteredModels
  642. .slice(0, MODELS_DISPLAY_COUNT)
  643. .map((model) => (
  644. renderModelTag(model, {
  645. size: 'large',
  646. shape: 'circle',
  647. onClick: () => copyText(model),
  648. })
  649. ))}
  650. <Tag
  651. color='grey'
  652. type='light'
  653. className="cursor-pointer !rounded-lg"
  654. onClick={() => setIsModelsExpanded(true)}
  655. icon={<IconChevronDown />}
  656. >
  657. {t('更多')} {filteredModels.length - MODELS_DISPLAY_COUNT} {t('个模型')}
  658. </Tag>
  659. </Space>
  660. )}
  661. </>
  662. );
  663. }
  664. })()}
  665. </div>
  666. </>
  667. )}
  668. </div>
  669. </div>
  670. </TabPane>
  671. {/* 账户绑定Tab */}
  672. <TabPane
  673. tab={
  674. <div className="flex items-center">
  675. <UserPlus size={16} className="mr-2" />
  676. {t('账户绑定')}
  677. </div>
  678. }
  679. itemKey='account'
  680. >
  681. <div className="py-4">
  682. <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
  683. {/* 邮箱绑定 */}
  684. <Card
  685. className="!rounded-xl transition-shadow"
  686. bodyStyle={{ padding: '16px' }}
  687. shadows='hover'
  688. >
  689. <div className="flex items-center justify-between">
  690. <div className="flex items-center flex-1">
  691. <div className="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3">
  692. <IconMail size="default" className="text-slate-600 dark:text-slate-300" />
  693. </div>
  694. <div className="flex-1 min-w-0">
  695. <div className="font-medium text-gray-900">{t('邮箱')}</div>
  696. <div className="text-sm text-gray-500 truncate">
  697. {userState.user && userState.user.email !== ''
  698. ? userState.user.email
  699. : t('未绑定')}
  700. </div>
  701. </div>
  702. </div>
  703. <Button
  704. type="primary"
  705. theme="outline"
  706. size="small"
  707. onClick={() => setShowEmailBindModal(true)}
  708. className="!rounded-lg"
  709. >
  710. {userState.user && userState.user.email !== ''
  711. ? t('修改绑定')
  712. : t('绑定')}
  713. </Button>
  714. </div>
  715. </Card>
  716. {/* 微信绑定 */}
  717. <Card
  718. className="!rounded-xl transition-shadow"
  719. bodyStyle={{ padding: '16px' }}
  720. shadows='hover'
  721. >
  722. <div className="flex items-center justify-between">
  723. <div className="flex items-center flex-1">
  724. <div className="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3">
  725. <SiWechat size={20} className="text-slate-600 dark:text-slate-300" />
  726. </div>
  727. <div className="flex-1 min-w-0">
  728. <div className="font-medium text-gray-900">{t('微信')}</div>
  729. <div className="text-sm text-gray-500 truncate">
  730. {userState.user && userState.user.wechat_id !== ''
  731. ? t('已绑定')
  732. : t('未绑定')}
  733. </div>
  734. </div>
  735. </div>
  736. <Button
  737. type="primary"
  738. theme="outline"
  739. size="small"
  740. disabled={!status.wechat_login}
  741. onClick={() => setShowWeChatBindModal(true)}
  742. className="!rounded-lg"
  743. >
  744. {userState.user && userState.user.wechat_id !== ''
  745. ? t('修改绑定')
  746. : status.wechat_login
  747. ? t('绑定')
  748. : t('未启用')}
  749. </Button>
  750. </div>
  751. </Card>
  752. {/* GitHub绑定 */}
  753. <Card
  754. className="!rounded-xl transition-shadow"
  755. bodyStyle={{ padding: '16px' }}
  756. shadows='hover'
  757. >
  758. <div className="flex items-center justify-between">
  759. <div className="flex items-center flex-1">
  760. <div className="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3">
  761. <IconGithubLogo size="default" className="text-slate-600 dark:text-slate-300" />
  762. </div>
  763. <div className="flex-1 min-w-0">
  764. <div className="font-medium text-gray-900">{t('GitHub')}</div>
  765. <div className="text-sm text-gray-500 truncate">
  766. {userState.user && userState.user.github_id !== ''
  767. ? userState.user.github_id
  768. : t('未绑定')}
  769. </div>
  770. </div>
  771. </div>
  772. <Button
  773. type="primary"
  774. theme="outline"
  775. size="small"
  776. onClick={() => onGitHubOAuthClicked(status.github_client_id)}
  777. disabled={
  778. (userState.user && userState.user.github_id !== '') ||
  779. !status.github_oauth
  780. }
  781. className="!rounded-lg"
  782. >
  783. {status.github_oauth ? t('绑定') : t('未启用')}
  784. </Button>
  785. </div>
  786. </Card>
  787. {/* OIDC绑定 */}
  788. <Card
  789. className="!rounded-xl transition-shadow"
  790. bodyStyle={{ padding: '16px' }}
  791. shadows='hover'
  792. >
  793. <div className="flex items-center justify-between">
  794. <div className="flex items-center flex-1">
  795. <div className="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3">
  796. <IconShield size="default" className="text-slate-600 dark:text-slate-300" />
  797. </div>
  798. <div className="flex-1 min-w-0">
  799. <div className="font-medium text-gray-900">{t('OIDC')}</div>
  800. <div className="text-sm text-gray-500 truncate">
  801. {userState.user && userState.user.oidc_id !== ''
  802. ? userState.user.oidc_id
  803. : t('未绑定')}
  804. </div>
  805. </div>
  806. </div>
  807. <Button
  808. type="primary"
  809. theme="outline"
  810. size="small"
  811. onClick={() => onOIDCClicked(
  812. status.oidc_authorization_endpoint,
  813. status.oidc_client_id,
  814. )}
  815. disabled={
  816. (userState.user && userState.user.oidc_id !== '') ||
  817. !status.oidc_enabled
  818. }
  819. className="!rounded-lg"
  820. >
  821. {status.oidc_enabled ? t('绑定') : t('未启用')}
  822. </Button>
  823. </div>
  824. </Card>
  825. {/* Telegram绑定 */}
  826. <Card
  827. className="!rounded-xl transition-shadow"
  828. bodyStyle={{ padding: '16px' }}
  829. shadows='hover'
  830. >
  831. <div className="flex items-center justify-between">
  832. <div className="flex items-center flex-1">
  833. <div className="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3">
  834. <SiTelegram size={20} className="text-slate-600 dark:text-slate-300" />
  835. </div>
  836. <div className="flex-1 min-w-0">
  837. <div className="font-medium text-gray-900">{t('Telegram')}</div>
  838. <div className="text-sm text-gray-500 truncate">
  839. {userState.user && userState.user.telegram_id !== ''
  840. ? userState.user.telegram_id
  841. : t('未绑定')}
  842. </div>
  843. </div>
  844. </div>
  845. <div className="flex-shrink-0">
  846. {status.telegram_oauth ? (
  847. userState.user.telegram_id !== '' ? (
  848. <Button disabled={true} size="small" className="!rounded-lg">
  849. {t('已绑定')}
  850. </Button>
  851. ) : (
  852. <div className="scale-75">
  853. <TelegramLoginButton
  854. dataAuthUrl='/api/oauth/telegram/bind'
  855. botName={status.telegram_bot_name}
  856. />
  857. </div>
  858. )
  859. ) : (
  860. <Button disabled={true} size="small" className="!rounded-lg">
  861. {t('未启用')}
  862. </Button>
  863. )}
  864. </div>
  865. </div>
  866. </Card>
  867. {/* LinuxDO绑定 */}
  868. <Card
  869. className="!rounded-xl transition-shadow"
  870. bodyStyle={{ padding: '16px' }}
  871. shadows='hover'
  872. >
  873. <div className="flex items-center justify-between">
  874. <div className="flex items-center flex-1">
  875. <div className="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3">
  876. <SiLinux size={20} className="text-slate-600 dark:text-slate-300" />
  877. </div>
  878. <div className="flex-1 min-w-0">
  879. <div className="font-medium text-gray-900">{t('LinuxDO')}</div>
  880. <div className="text-sm text-gray-500 truncate">
  881. {userState.user && userState.user.linux_do_id !== ''
  882. ? userState.user.linux_do_id
  883. : t('未绑定')}
  884. </div>
  885. </div>
  886. </div>
  887. <Button
  888. type="primary"
  889. theme="outline"
  890. size="small"
  891. onClick={() => onLinuxDOOAuthClicked(status.linuxdo_client_id)}
  892. disabled={
  893. (userState.user && userState.user.linux_do_id !== '') ||
  894. !status.linuxdo_oauth
  895. }
  896. className="!rounded-lg"
  897. >
  898. {status.linuxdo_oauth ? t('绑定') : t('未启用')}
  899. </Button>
  900. </div>
  901. </Card>
  902. </div>
  903. </div>
  904. </TabPane>
  905. {/* 安全设置Tab */}
  906. <TabPane
  907. tab={
  908. <div className="flex items-center">
  909. <ShieldCheck size={16} className="mr-2" />
  910. {t('安全设置')}
  911. </div>
  912. }
  913. itemKey='security'
  914. >
  915. <div className="py-4">
  916. <div className="space-y-6">
  917. <Space vertical className='w-full'>
  918. {/* 系统访问令牌 */}
  919. <Card
  920. className="!rounded-xl w-full"
  921. bodyStyle={{ padding: '20px' }}
  922. shadows='hover'
  923. >
  924. <div className="flex flex-col sm:flex-row items-start sm:justify-between gap-4">
  925. <div className="flex items-start w-full sm:w-auto">
  926. <div className="w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0">
  927. <IconKey size="large" className="text-slate-600" />
  928. </div>
  929. <div className="flex-1">
  930. <Typography.Title heading={6} className="mb-1">
  931. {t('系统访问令牌')}
  932. </Typography.Title>
  933. <Typography.Text type="tertiary" className="text-sm">
  934. {t('用于API调用的身份验证令牌,请妥善保管')}
  935. </Typography.Text>
  936. {systemToken && (
  937. <div className="mt-3">
  938. <Input
  939. readonly
  940. value={systemToken}
  941. onClick={handleSystemTokenClick}
  942. size="large"
  943. className="!rounded-lg"
  944. prefix={<IconKey />}
  945. />
  946. </div>
  947. )}
  948. </div>
  949. </div>
  950. <Button
  951. type="primary"
  952. theme="solid"
  953. onClick={generateAccessToken}
  954. className="!rounded-lg !bg-slate-600 hover:!bg-slate-700 w-full sm:w-auto"
  955. icon={<IconKey />}
  956. >
  957. {systemToken ? t('重新生成') : t('生成令牌')}
  958. </Button>
  959. </div>
  960. </Card>
  961. {/* 密码管理 */}
  962. <Card
  963. className="!rounded-xl w-full"
  964. bodyStyle={{ padding: '20px' }}
  965. shadows='hover'
  966. >
  967. <div className="flex flex-col sm:flex-row items-start sm:justify-between gap-4">
  968. <div className="flex items-start w-full sm:w-auto">
  969. <div className="w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0">
  970. <IconLock size="large" className="text-slate-600" />
  971. </div>
  972. <div>
  973. <Typography.Title heading={6} className="mb-1">
  974. {t('密码管理')}
  975. </Typography.Title>
  976. <Typography.Text type="tertiary" className="text-sm">
  977. {t('定期更改密码可以提高账户安全性')}
  978. </Typography.Text>
  979. </div>
  980. </div>
  981. <Button
  982. type="primary"
  983. theme="solid"
  984. onClick={() => setShowChangePasswordModal(true)}
  985. className="!rounded-lg !bg-slate-600 hover:!bg-slate-700 w-full sm:w-auto"
  986. icon={<IconLock />}
  987. >
  988. {t('修改密码')}
  989. </Button>
  990. </div>
  991. </Card>
  992. {/* 两步验证设置 */}
  993. <TwoFASetting />
  994. {/* 危险区域 */}
  995. <Card
  996. className="!rounded-xl border-red-200 w-full"
  997. bodyStyle={{ padding: '20px' }}
  998. shadows='hover'
  999. >
  1000. <div className="flex flex-col sm:flex-row items-start sm:justify-between gap-4">
  1001. <div className="flex items-start w-full sm:w-auto">
  1002. <div className="w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0">
  1003. <IconDelete size="large" className="text-slate-600" />
  1004. </div>
  1005. <div>
  1006. <Typography.Title heading={6} className="mb-1 text-slate-700">
  1007. {t('删除账户')}
  1008. </Typography.Title>
  1009. <Typography.Text type="tertiary" className="text-sm">
  1010. {t('此操作不可逆,所有数据将被永久删除')}
  1011. </Typography.Text>
  1012. </div>
  1013. </div>
  1014. <Button
  1015. type="danger"
  1016. theme="solid"
  1017. onClick={() => setShowAccountDeleteModal(true)}
  1018. className="!rounded-lg w-full sm:w-auto !bg-slate-500 hover:!bg-slate-600"
  1019. icon={<IconDelete />}
  1020. >
  1021. {t('删除账户')}
  1022. </Button>
  1023. </div>
  1024. </Card>
  1025. </Space>
  1026. </div>
  1027. </div>
  1028. </TabPane>
  1029. {/* 通知设置Tab */}
  1030. <TabPane
  1031. tab={
  1032. <div className="flex items-center">
  1033. <Bell size={16} className="mr-2" />
  1034. {t('其他设置')}
  1035. </div>
  1036. }
  1037. itemKey='notification'
  1038. >
  1039. <div className="py-4">
  1040. <Tabs type='card' defaultActiveKey='notify' className="!rounded-lg">
  1041. <TabPane
  1042. tab={t('通知设置')}
  1043. itemKey='notify'
  1044. >
  1045. <div className="space-y-6">
  1046. {/* 通知方式选择 */}
  1047. <div className="bg-gray-50 rounded-xl">
  1048. <Typography.Text strong className="block mb-4 pt-4">{t('通知方式')}</Typography.Text>
  1049. <RadioGroup
  1050. value={notificationSettings.warningType}
  1051. onChange={(value) =>
  1052. handleNotificationSettingChange('warningType', value)
  1053. }
  1054. type="pureCard"
  1055. >
  1056. <Radio value='email' className="!p-4 !rounded-lg">
  1057. <div className="flex items-center">
  1058. <IconMail className="mr-2 text-slate-600" />
  1059. <div>
  1060. <div className="font-medium">{t('邮件通知')}</div>
  1061. <div className="text-sm text-gray-500">{t('通过邮件接收通知')}</div>
  1062. </div>
  1063. </div>
  1064. </Radio>
  1065. <Radio value='webhook' className="!p-4 !rounded-lg">
  1066. <div className="flex items-center">
  1067. <Webhook size={16} className="mr-2 text-slate-600" />
  1068. <div>
  1069. <div className="font-medium">{t('Webhook通知')}</div>
  1070. <div className="text-sm text-gray-500">{t('通过HTTP请求接收通知')}</div>
  1071. </div>
  1072. </div>
  1073. </Radio>
  1074. </RadioGroup>
  1075. </div>
  1076. {/* Webhook设置 */}
  1077. {notificationSettings.warningType === 'webhook' && (
  1078. <div className="space-y-4">
  1079. <div className="bg-white rounded-xl">
  1080. <Typography.Text strong className="block mb-3">{t('Webhook地址')}</Typography.Text>
  1081. <Input
  1082. value={notificationSettings.webhookUrl}
  1083. onChange={(val) =>
  1084. handleNotificationSettingChange('webhookUrl', val)
  1085. }
  1086. placeholder={t('请输入Webhook地址,例如: https://example.com/webhook')}
  1087. size="large"
  1088. className="!rounded-lg"
  1089. prefix={<Webhook size={16} className="m-2" />}
  1090. />
  1091. <div className="text-gray-500 text-sm mt-2">
  1092. {t('只支持https,系统将以 POST 方式发送通知,请确保地址可以接收 POST 请求')}
  1093. </div>
  1094. </div>
  1095. <div className="bg-white rounded-xl">
  1096. <Typography.Text strong className="block mb-3">{t('接口凭证(可选)')}</Typography.Text>
  1097. <Input
  1098. value={notificationSettings.webhookSecret}
  1099. onChange={(val) =>
  1100. handleNotificationSettingChange('webhookSecret', val)
  1101. }
  1102. placeholder={t('请输入密钥')}
  1103. size="large"
  1104. className="!rounded-lg"
  1105. prefix={<IconKey />}
  1106. />
  1107. <div className="text-gray-500 text-sm mt-2">
  1108. {t('密钥将以 Bearer 方式添加到请求头中,用于验证webhook请求的合法性')}
  1109. </div>
  1110. </div>
  1111. <div className="bg-slate-50 rounded-xl">
  1112. <div className="flex items-center justify-between cursor-pointer" onClick={() => setShowWebhookDocs(!showWebhookDocs)}>
  1113. <div className="flex items-center">
  1114. <Globe size={16} className="mr-2 text-slate-600" />
  1115. <Typography.Text strong className="text-slate-700">
  1116. {t('Webhook请求结构')}
  1117. </Typography.Text>
  1118. </div>
  1119. {showWebhookDocs ? <IconChevronUp /> : <IconChevronDown />}
  1120. </div>
  1121. <Collapsible isOpen={showWebhookDocs}>
  1122. <pre className="mt-4 bg-gray-800 text-gray-100 rounded-lg text-sm overflow-x-auto">
  1123. {`{
  1124. "type": "quota_exceed", // 通知类型
  1125. "title": "标题", // 通知标题
  1126. "content": "通知内容", // 通知内容,支持 {{value}} 变量占位符
  1127. "values": ["值1", "值2"], // 按顺序替换content中的 {{value}} 占位符
  1128. "timestamp": 1739950503 // 时间戳
  1129. }
  1130. 示例:
  1131. {
  1132. "type": "quota_exceed",
  1133. "title": "额度预警通知",
  1134. "content": "您的额度即将用尽,当前剩余额度为 {{value}}",
  1135. "values": ["$0.99"],
  1136. "timestamp": 1739950503
  1137. }`}
  1138. </pre>
  1139. </Collapsible>
  1140. </div>
  1141. </div>
  1142. )}
  1143. {/* 邮件设置 */}
  1144. {notificationSettings.warningType === 'email' && (
  1145. <div className="bg-white rounded-xl">
  1146. <Typography.Text strong className="block mb-3">{t('通知邮箱')}</Typography.Text>
  1147. <Input
  1148. value={notificationSettings.notificationEmail}
  1149. onChange={(val) =>
  1150. handleNotificationSettingChange('notificationEmail', val)
  1151. }
  1152. placeholder={t('留空则使用账号绑定的邮箱')}
  1153. size="large"
  1154. className="!rounded-lg"
  1155. prefix={<IconMail />}
  1156. />
  1157. <div className="text-gray-500 text-sm mt-2">
  1158. {t('设置用于接收额度预警的邮箱地址,不填则使用账号绑定的邮箱')}
  1159. </div>
  1160. </div>
  1161. )}
  1162. {/* 预警阈值 */}
  1163. <div className="bg-white rounded-xl">
  1164. <Typography.Text strong className="block mb-3">
  1165. {t('额度预警阈值')} {renderQuotaWithPrompt(notificationSettings.warningThreshold)}
  1166. </Typography.Text>
  1167. <AutoComplete
  1168. value={notificationSettings.warningThreshold}
  1169. onChange={(val) =>
  1170. handleNotificationSettingChange('warningThreshold', val)
  1171. }
  1172. size="large"
  1173. className="!rounded-lg w-full max-w-xs"
  1174. placeholder={t('请输入预警额度')}
  1175. data={[
  1176. { value: 100000, label: '0.2$' },
  1177. { value: 500000, label: '1$' },
  1178. { value: 1000000, label: '5$' },
  1179. { value: 5000000, label: '10$' },
  1180. ]}
  1181. prefix={<IconBell />}
  1182. />
  1183. <div className="text-gray-500 text-sm mt-2">
  1184. {t('当剩余额度低于此数值时,系统将通过选择的方式发送通知')}
  1185. </div>
  1186. </div>
  1187. </div>
  1188. </TabPane>
  1189. <TabPane
  1190. tab={t('价格设置')}
  1191. itemKey='price'
  1192. >
  1193. <div className="py-4">
  1194. <div className="space-y-4">
  1195. {/* 接受未设置价格模型 */}
  1196. <div className="bg-white rounded-xl">
  1197. <div className="flex items-start">
  1198. <div className="w-10 h-10 rounded-full bg-slate-100 flex items-center justify-center mt-1">
  1199. <Shield size={20} className="text-slate-600" />
  1200. </div>
  1201. <div className="flex-1">
  1202. <div className="flex items-center justify-between">
  1203. <div>
  1204. <Typography.Text strong className="block mb-2">
  1205. {t('接受未设置价格模型')}
  1206. </Typography.Text>
  1207. <div className="text-gray-500 text-sm">
  1208. {t('当模型没有设置价格时仍接受调用,仅当您信任该网站时使用,可能会产生高额费用')}
  1209. </div>
  1210. </div>
  1211. <Checkbox
  1212. checked={notificationSettings.acceptUnsetModelRatioModel}
  1213. onChange={(e) =>
  1214. handleNotificationSettingChange(
  1215. 'acceptUnsetModelRatioModel',
  1216. e.target.checked,
  1217. )
  1218. }
  1219. className="ml-4"
  1220. />
  1221. </div>
  1222. </div>
  1223. </div>
  1224. </div>
  1225. </div>
  1226. </div>
  1227. </TabPane>
  1228. <TabPane
  1229. tab={t('IP记录')}
  1230. itemKey='ip'
  1231. >
  1232. <div className="py-4">
  1233. <div className="bg-white rounded-xl">
  1234. <div className="flex items-start">
  1235. <div className="w-10 h-10 rounded-full bg-slate-100 flex items-center justify-center mt-1">
  1236. <ShieldCheck size={20} className="text-slate-600" />
  1237. </div>
  1238. <div className="flex-1">
  1239. <div className="flex items-center justify-between">
  1240. <div>
  1241. <Typography.Text strong className="block mb-2">
  1242. {t('记录请求与错误日志 IP')}
  1243. </Typography.Text>
  1244. <div className="text-gray-500 text-sm">
  1245. {t('开启后,仅“消费”和“错误”日志将记录您的客户端 IP 地址')}
  1246. </div>
  1247. </div>
  1248. <Checkbox
  1249. checked={notificationSettings.recordIpLog}
  1250. onChange={(e) =>
  1251. handleNotificationSettingChange(
  1252. 'recordIpLog',
  1253. e.target.checked,
  1254. )
  1255. }
  1256. className="ml-4"
  1257. />
  1258. </div>
  1259. </div>
  1260. </div>
  1261. </div>
  1262. </div>
  1263. </TabPane>
  1264. </Tabs>
  1265. <div className="mt-6 flex justify-end">
  1266. <Button
  1267. type='primary'
  1268. onClick={saveNotificationSettings}
  1269. size="large"
  1270. className="!rounded-lg !bg-slate-600 hover:!bg-slate-700"
  1271. icon={<IconSetting />}
  1272. >
  1273. {t('保存设置')}
  1274. </Button>
  1275. </div>
  1276. </div>
  1277. </TabPane>
  1278. </Tabs>
  1279. </div>
  1280. </Card>
  1281. </div>
  1282. </div>
  1283. {/* 邮箱绑定模态框 */}
  1284. <Modal
  1285. title={
  1286. <div className="flex items-center">
  1287. <IconMail className="mr-2 text-blue-500" />
  1288. {t('绑定邮箱地址')}
  1289. </div>
  1290. }
  1291. visible={showEmailBindModal}
  1292. onCancel={() => setShowEmailBindModal(false)}
  1293. onOk={bindEmail}
  1294. size={'small'}
  1295. centered={true}
  1296. maskClosable={false}
  1297. className="modern-modal"
  1298. >
  1299. <div className="space-y-4 py-4">
  1300. <div className="flex gap-3">
  1301. <Input
  1302. placeholder={t('输入邮箱地址')}
  1303. onChange={(value) => handleInputChange('email', value)}
  1304. name='email'
  1305. type='email'
  1306. size="large"
  1307. className="!rounded-lg flex-1"
  1308. prefix={<IconMail />}
  1309. />
  1310. <Button
  1311. onClick={sendVerificationCode}
  1312. disabled={disableButton || loading}
  1313. className="!rounded-lg"
  1314. type="primary"
  1315. theme="outline"
  1316. size='large'
  1317. >
  1318. {disableButton ? `${t('重新发送')} (${countdown})` : t('获取验证码')}
  1319. </Button>
  1320. </div>
  1321. <Input
  1322. placeholder={t('验证码')}
  1323. name='email_verification_code'
  1324. value={inputs.email_verification_code}
  1325. onChange={(value) =>
  1326. handleInputChange('email_verification_code', value)
  1327. }
  1328. size="large"
  1329. className="!rounded-lg"
  1330. prefix={<IconKey />}
  1331. />
  1332. {turnstileEnabled && (
  1333. <div className="flex justify-center">
  1334. <Turnstile
  1335. sitekey={turnstileSiteKey}
  1336. onVerify={(token) => {
  1337. setTurnstileToken(token);
  1338. }}
  1339. />
  1340. </div>
  1341. )}
  1342. </div>
  1343. </Modal>
  1344. {/* 微信绑定模态框 */}
  1345. <Modal
  1346. title={
  1347. <div className="flex items-center">
  1348. <SiWechat className="mr-2 text-green-500" size={20} />
  1349. {t('绑定微信账户')}
  1350. </div>
  1351. }
  1352. visible={showWeChatBindModal}
  1353. onCancel={() => setShowWeChatBindModal(false)}
  1354. footer={null}
  1355. size={'small'}
  1356. centered={true}
  1357. className="modern-modal"
  1358. >
  1359. <div className="space-y-4 py-4 text-center">
  1360. <Image src={status.wechat_qrcode} className="mx-auto" />
  1361. <div className="text-gray-600">
  1362. <p>{t('微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)')}</p>
  1363. </div>
  1364. <Input
  1365. placeholder={t('验证码')}
  1366. name='wechat_verification_code'
  1367. value={inputs.wechat_verification_code}
  1368. onChange={(v) =>
  1369. handleInputChange('wechat_verification_code', v)
  1370. }
  1371. size="large"
  1372. className="!rounded-lg"
  1373. prefix={<IconKey />}
  1374. />
  1375. <Button
  1376. type="primary"
  1377. theme="solid"
  1378. size='large'
  1379. onClick={bindWeChat}
  1380. className="!rounded-lg w-full !bg-slate-600 hover:!bg-slate-700"
  1381. icon={<SiWechat size={16} />}
  1382. >
  1383. {t('绑定')}
  1384. </Button>
  1385. </div>
  1386. </Modal>
  1387. {/* 账户删除模态框 */}
  1388. <Modal
  1389. title={
  1390. <div className="flex items-center">
  1391. <IconDelete className="mr-2 text-red-500" />
  1392. {t('删除账户确认')}
  1393. </div>
  1394. }
  1395. visible={showAccountDeleteModal}
  1396. onCancel={() => setShowAccountDeleteModal(false)}
  1397. onOk={deleteAccount}
  1398. size={'small'}
  1399. centered={true}
  1400. className="modern-modal"
  1401. >
  1402. <div className="space-y-4 py-4">
  1403. <Banner
  1404. type='danger'
  1405. description={t('您正在删除自己的帐户,将清空所有数据且不可恢复')}
  1406. closeIcon={null}
  1407. className="!rounded-lg"
  1408. />
  1409. <div>
  1410. <Typography.Text strong className="block mb-2 text-red-600">
  1411. {t('请输入您的用户名以确认删除')}
  1412. </Typography.Text>
  1413. <Input
  1414. placeholder={t('输入你的账户名{{username}}以确认删除', { username: ` ${userState?.user?.username} ` })}
  1415. name='self_account_deletion_confirmation'
  1416. value={inputs.self_account_deletion_confirmation}
  1417. onChange={(value) =>
  1418. handleInputChange('self_account_deletion_confirmation', value)
  1419. }
  1420. size="large"
  1421. className="!rounded-lg"
  1422. prefix={<IconUser />}
  1423. />
  1424. </div>
  1425. {turnstileEnabled && (
  1426. <div className="flex justify-center">
  1427. <Turnstile
  1428. sitekey={turnstileSiteKey}
  1429. onVerify={(token) => {
  1430. setTurnstileToken(token);
  1431. }}
  1432. />
  1433. </div>
  1434. )}
  1435. </div>
  1436. </Modal>
  1437. {/* 修改密码模态框 */}
  1438. <Modal
  1439. title={
  1440. <div className="flex items-center">
  1441. <IconLock className="mr-2 text-orange-500" />
  1442. {t('修改密码')}
  1443. </div>
  1444. }
  1445. visible={showChangePasswordModal}
  1446. onCancel={() => setShowChangePasswordModal(false)}
  1447. onOk={changePassword}
  1448. size={'small'}
  1449. centered={true}
  1450. className="modern-modal"
  1451. >
  1452. <div className="space-y-4 py-4">
  1453. <div>
  1454. <Typography.Text strong className="block mb-2">{t('原密码')}</Typography.Text>
  1455. <Input
  1456. name='original_password'
  1457. placeholder={t('请输入原密码')}
  1458. type='password'
  1459. value={inputs.original_password}
  1460. onChange={(value) =>
  1461. handleInputChange('original_password', value)
  1462. }
  1463. size="large"
  1464. className="!rounded-lg"
  1465. prefix={<IconLock />}
  1466. />
  1467. </div>
  1468. <div>
  1469. <Typography.Text strong className="block mb-2">{t('新密码')}</Typography.Text>
  1470. <Input
  1471. name='set_new_password'
  1472. placeholder={t('请输入新密码')}
  1473. type='password'
  1474. value={inputs.set_new_password}
  1475. onChange={(value) =>
  1476. handleInputChange('set_new_password', value)
  1477. }
  1478. size="large"
  1479. className="!rounded-lg"
  1480. prefix={<IconLock />}
  1481. />
  1482. </div>
  1483. <div>
  1484. <Typography.Text strong className="block mb-2">{t('确认新密码')}</Typography.Text>
  1485. <Input
  1486. name='set_new_password_confirmation'
  1487. placeholder={t('请再次输入新密码')}
  1488. type='password'
  1489. value={inputs.set_new_password_confirmation}
  1490. onChange={(value) =>
  1491. handleInputChange('set_new_password_confirmation', value)
  1492. }
  1493. size="large"
  1494. className="!rounded-lg"
  1495. prefix={<IconLock />}
  1496. />
  1497. </div>
  1498. {turnstileEnabled && (
  1499. <div className="flex justify-center">
  1500. <Turnstile
  1501. sitekey={turnstileSiteKey}
  1502. onVerify={(token) => {
  1503. setTurnstileToken(token);
  1504. }}
  1505. />
  1506. </div>
  1507. )}
  1508. </div>
  1509. </Modal>
  1510. </div>
  1511. );
  1512. };
  1513. export default PersonalSetting;